Skip to content

Commit f3f126c

Browse files
Azzerty23claude
andcommitted
refactor(client): consolidate direct-read bypass into read/readUnique via direct flag
Remove the requiresUpdatePreloadBypassReadPolicy dialect flag and the duplicate readUniqueDirect method. All non-RETURNING dialects now use the direct=true path in read/readUnique, which routes connection acquisition through the outer executor while bypassing onKyselyQuery interceptors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent eca01e1 commit f3f126c

4 files changed

Lines changed: 43 additions & 66 deletions

File tree

packages/orm/src/client/crud/dialects/base-dialect.ts

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -81,19 +81,6 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
8181
*/
8282
abstract get insertIgnoreMethod(): 'onConflict' | 'ignore';
8383

84-
/**
85-
* Whether the pre-load SELECT (used to resolve entity IDs before a top-level UPDATE on
86-
* non-RETURNING dialects) must bypass the read-policy filter.
87-
*
88-
* MySQL pre-loads entity IDs before running an UPDATE. If the row is read-denied the
89-
* pre-load returns null and the UPDATE never runs, masking update-deny error codes.
90-
* Setting this to true makes the pre-load use `executeQueryDirect`, which skips
91-
* `onKyselyQuery` interceptors (including the read policy).
92-
*/
93-
get requiresUpdatePreloadBypassReadPolicy(): boolean {
94-
return false;
95-
}
96-
9784
// #endregion
9885

9986
// #region value transformation
@@ -183,9 +170,7 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
183170
effectiveOrderBy &&
184171
enumerate(effectiveOrderBy).some((ob: any) => typeof ob === 'object' && '_fuzzyRelevance' in ob)
185172
) {
186-
throw createNotSupportedError(
187-
'cursor pagination cannot be combined with "_fuzzyRelevance" ordering',
188-
);
173+
throw createNotSupportedError('cursor pagination cannot be combined with "_fuzzyRelevance" ordering');
189174
}
190175
result = this.buildCursorFilter(
191176
model,
@@ -1683,7 +1668,10 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
16831668
'fuzzy filter must be an object with at least a "search" field',
16841669
);
16851670
const raw = value as Record<string, unknown>;
1686-
invariant(typeof raw['search'] === 'string' && raw['search'].length > 0, 'fuzzy.search must be a non-empty string');
1671+
invariant(
1672+
typeof raw['search'] === 'string' && raw['search'].length > 0,
1673+
'fuzzy.search must be a non-empty string',
1674+
);
16871675
const mode = raw['mode'] ?? 'simple';
16881676
invariant(
16891677
mode === 'simple' || mode === 'word' || mode === 'strictWord',

packages/orm/src/client/crud/dialects/mysql.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,6 @@ export class MySqlCrudDialect<Schema extends SchemaDef> extends LateralJoinDiale
5858
return 'ignore' as const;
5959
}
6060

61-
override get requiresUpdatePreloadBypassReadPolicy(): boolean {
62-
return true;
63-
}
64-
6561
// #endregion
6662

6763
// #region value transformation

packages/orm/src/client/crud/operations/base.ts

Lines changed: 34 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
292292
kysely: AnyKysely,
293293
model: string,
294294
args: FindArgs<Schema, GetModels<Schema>, any, true> | undefined,
295+
direct = false,
295296
): Promise<any[]> {
296297
// table
297298
let query = this.dialect.buildSelectModel(model, model);
@@ -317,11 +318,23 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
317318

318319
query = query.modifyEnd(this.makeContextComment({ model, operation: 'read' }));
319320

320-
let result: any[] = [];
321321
const compiled = kysely.getExecutor().compileQuery(query.toOperationNode(), createQueryId());
322+
323+
let result: any[] = [];
322324
try {
323-
const r = await kysely.getExecutor().executeQuery(compiled);
324-
result = r.rows;
325+
if (direct) {
326+
// Bypass onKyselyQuery interceptors (e.g. policy plugin) so read-denied rows
327+
// are still reachable. Uses the outer executor for connection acquisition so
328+
// the query runs within an active transaction when applicable.
329+
const zenExecutor = (this.client as any).kyselyProps.executor as ZenStackQueryExecutor;
330+
const r = await kysely
331+
.getExecutor()
332+
.provideConnection((connection) => zenExecutor.executeQueryDirect(compiled, connection));
333+
result = r.rows;
334+
} else {
335+
const r = await kysely.getExecutor().executeQuery(compiled);
336+
result = r.rows;
337+
}
325338
} catch (err) {
326339
// Re-throw ORMErrors (e.g. policy violations with custom error codes) as-is
327340
// to avoid wrapping them in a generic DBQueryError and losing their type/code.
@@ -332,8 +345,13 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
332345
return result;
333346
}
334347

335-
protected async readUnique(kysely: AnyKysely, model: string, args: FindArgs<Schema, GetModels<Schema>, any, true>) {
336-
const result = await this.read(kysely, model, { ...args, take: 1 });
348+
protected async readUnique(
349+
kysely: AnyKysely,
350+
model: string,
351+
args: FindArgs<Schema, GetModels<Schema>, any, true>,
352+
direct = false,
353+
) {
354+
const result = await this.read(kysely, model, { ...args, take: 1 }, direct);
337355
return result[0] ?? null;
338356
}
339357

@@ -1199,19 +1217,13 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
11991217
// For non-RETURNING dialects that require it (e.g. MySQL), the pre-load SELECT must
12001218
// bypass the read policy so that read-denied rows are still reachable and the UPDATE
12011219
// can run, allowing its own policy error codes to be surfaced.
1202-
const bypassReadPolicyForPreload =
1203-
!this.dialect.supportsReturning && !fromRelation && this.dialect.requiresUpdatePreloadBypassReadPolicy;
1220+
const bypassReadPolicyForPreload = !this.dialect.supportsReturning && !fromRelation;
12041221

12051222
// lazily load the entity to be updated
12061223
let thisEntity: any;
12071224
const loadThisEntity = async () => {
12081225
if (thisEntity === undefined) {
1209-
thisEntity = bypassReadPolicyForPreload
1210-
? await this.readUniqueDirect(kysely, model, {
1211-
where: origWhere,
1212-
select: this.makeIdSelect(model),
1213-
} as any)
1214-
: ((await this.getEntityIds(kysely, model, origWhere)) ?? null);
1226+
thisEntity = (await this.getEntityIds(kysely, model, origWhere, bypassReadPolicyForPreload)) ?? null;
12151227
if (!thisEntity && throwIfNotFound) {
12161228
throw createNotFoundError(model);
12171229
}
@@ -2542,38 +2554,16 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
25422554
}
25432555

25442556
// Given a unique filter of a model, load the entity and return its id fields
2545-
private getEntityIds(kysely: AnyKysely, model: string, uniqueFilter: any) {
2546-
return this.readUnique(kysely, model, {
2547-
where: uniqueFilter,
2548-
select: this.makeIdSelect(model),
2549-
});
2550-
}
2551-
2552-
// Like readUnique but bypasses onKyselyQuery interceptors (e.g. policy plugin).
2553-
// Used for the MySQL update pre-load so read-denied rows are still reachable.
2554-
private async readUniqueDirect(
2555-
kysely: AnyKysely,
2556-
model: string,
2557-
args: FindArgs<Schema, GetModels<Schema>, any, true>,
2558-
): Promise<any | null> {
2559-
let query = this.dialect.buildSelectModel(model, model);
2560-
const argsWithTake = { ...args, take: 1 };
2561-
query = this.dialect.buildFilterSortTake(model, argsWithTake, query, model);
2562-
if ('select' in args && args.select) {
2563-
query = this.buildFieldSelection(model, query, args.select, model);
2564-
} else {
2565-
query = this.dialect.buildSelectAllFields(model, query, (args as any)?.omit, model);
2566-
}
2567-
const queryNode = query.toOperationNode();
2568-
// In a transaction, kysely.getExecutor() is Kysely's wrapper — not ZenStackQueryExecutor.
2569-
// Route connection acquisition through the outer executor; compile and execute on the base one.
2570-
const outerExecutor = kysely.getExecutor();
2571-
const zenExecutor = (this.client as any).kyselyProps.executor as ZenStackQueryExecutor;
2572-
const compiled = zenExecutor.compileQuery(queryNode, createQueryId());
2573-
const r = await outerExecutor.provideConnection((connection) =>
2574-
zenExecutor.executeQueryDirect(compiled, connection),
2557+
private getEntityIds(kysely: AnyKysely, model: string, uniqueFilter: any, direct = false) {
2558+
return this.readUnique(
2559+
kysely,
2560+
model,
2561+
{
2562+
where: uniqueFilter,
2563+
select: this.makeIdSelect(model),
2564+
},
2565+
direct,
25752566
);
2576-
return r.rows[0] ?? null;
25772567
}
25782568

25792569
// Given multiple unique filters, load all matching entities and return their id fields in one query

packages/orm/src/client/executor/zenstack-query-executor.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -678,7 +678,10 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie
678678
/**
679679
* Execute a compiled query on `connection`, bypassing all `onKyselyQuery` plugin interceptors.
680680
*/
681-
async executeQueryDirect(compiledQuery: CompiledQuery, connection: DatabaseConnection): Promise<QueryResult<unknown>> {
681+
async executeQueryDirect(
682+
compiledQuery: CompiledQuery,
683+
connection: DatabaseConnection,
684+
): Promise<QueryResult<unknown>> {
682685
return this.internalExecuteQuery(compiledQuery.query, connection, compiledQuery.queryId);
683686
}
684687

0 commit comments

Comments
 (0)