Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/orm/src/client/crud/operations/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
const r = await kysely.getExecutor().executeQuery(compiled);
result = r.rows as { $exists: number | boolean }[];
} catch (err) {
if (err instanceof ORMError) {
// already a structured error (e.g., raised by a plugin or wrapped by the executor)
throw err;
}
throw createDBQueryError(`Failed to execute query: ${err}`, err, compiled.sql, compiled.parameters);
}

Expand Down Expand Up @@ -212,6 +216,10 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
const r = await kysely.getExecutor().executeQuery(compiled);
result = r.rows;
} catch (err) {
if (err instanceof ORMError) {
// already a structured error (e.g., raised by a plugin or wrapped by the executor)
throw err;
}
throw createDBQueryError(`Failed to execute query: ${err}`, err, compiled.sql, compiled.parameters);
}

Expand Down
40 changes: 19 additions & 21 deletions packages/plugins/policy/src/expression-transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
falseNode,
isBeforeInvocation,
logicalNot,
rejectParameterizedComputedField,
trueNode,
} from './utils';

Expand Down Expand Up @@ -982,12 +983,7 @@ export class ExpressionTransformer<Schema extends SchemaDef> {
invariant(i === members.length - 1, 'plain field access must be the last segment');
invariant(!currNode, 'plain field access must be the last segment');

if (fieldDef.originModel && fieldDef.originModel !== fromModel) {
// field inherited from a delegate base model, look it up from the base table
currNode = this.buildDelegateBaseFieldSelect(fromModel, fromAlias, member, fieldDef.originModel);
} else {
currNode = ReferenceNode.create(ColumnNode.create(member), TableNode.create(fromAlias));
}
currNode = this.buildFieldRef(fromModel, member, fromAlias);
Comment thread
ymc9 marked this conversation as resolved.
Resolved
}
}

Expand Down Expand Up @@ -1126,29 +1122,30 @@ export class ExpressionTransformer<Schema extends SchemaDef> {
// if field comes from a delegate base model, we need to use the join alias
// of that base model

const tableName = context.alias ?? context.modelOrType;
return this.buildFieldRef(context.modelOrType, column, context.alias ?? context.modelOrType);
}

const fieldDef = QueryUtils.requireField(this.schema, context.modelOrType, column);
/**
* Builds a reference to a scalar field of `model` whose table is aliased as `modelAlias`.
* Handles regular columns, `@computed` fields (inlined via the dialect), and fields inherited
* from a delegate base model (looked up from the base table with a correlated subquery).
*/
private buildFieldRef(model: string, field: string, modelAlias: string): OperationNode {
const fieldDef = QueryUtils.requireField(this.schema, model, field);

if (fieldDef.computed) {
// `@computed` fields don't physically exist as columns — inline the
// computed expression the same way every other query path does via
// `dialect.fieldRef()`, instead of emitting a bare (nonexistent) column ref.
if (fieldDef.params) {
throw new Error(`parameterized computed field "${column}" cannot be used in a policy expression`);
}
return this.dialect.fieldRef(context.modelOrType, column, tableName).toOperationNode();
}
rejectParameterizedComputedField(fieldDef, model);

if (!fieldDef.originModel || fieldDef.originModel === context.modelOrType) {
return ReferenceNode.create(ColumnNode.create(column), TableNode.create(tableName));
if (!fieldDef.originModel || fieldDef.originModel === model) {
return fieldDef.computed
? this.dialect.fieldRef(model, field, modelAlias).toOperationNode()
: ReferenceNode.create(ColumnNode.create(field), TableNode.create(modelAlias));
}

// For inherited fields in delegate sub-types, use a correlated subquery against the
// base table. For create operations the VALUES table contains the sub-type's own
// fields and its id FK, so the subquery can look up the base record (already inserted
// in the same transaction) to resolve inherited field values correctly.
return this.buildDelegateBaseFieldSelect(context.modelOrType, tableName, column, fieldDef.originModel);
return this.buildDelegateBaseFieldSelect(model, modelAlias, field, fieldDef.originModel);
}

// convert transformer's binding scope to equivalent expression evaluator binding scope
Expand All @@ -1173,7 +1170,8 @@ export class ExpressionTransformer<Schema extends SchemaDef> {
kind: 'SelectQueryNode',
from: FromNode.create([TableNode.create(baseModel)]),
selections: [
SelectionNode.create(ReferenceNode.create(ColumnNode.create(field), TableNode.create(baseModel))),
// the base model owns the field, so a computed field is evaluated against the base table
SelectionNode.create(this.buildFieldRef(baseModel, field, baseModel) as SelectionNode['selection']),
],
where: WhereNode.create(
conjunction(
Expand Down
12 changes: 12 additions & 0 deletions packages/plugins/policy/src/policy-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
isBeforeInvocation,
isTrueNode,
logicalNot,
rejectParameterizedComputedField,
trueNode,
} from './utils';

Expand Down Expand Up @@ -736,6 +737,17 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf
if (fieldDef?.originModel) {
baseModelsToJoin.add(fieldDef.originModel);
}
if (fieldDef?.computed) {
// parameterized computed fields can't be evaluated without query-time args
rejectParameterizedComputedField(fieldDef, model);
// computed fields aren't physical columns, inline the computed expression
return SelectionNode.create(
AliasNode.create(
this.dialect.fieldRef(owningTable, f, owningTable).toOperationNode(),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
IdentifierNode.create(f),
),
);
}
return SelectionNode.create(ReferenceNode.create(ColumnNode.create(f), TableNode.create(owningTable)));
});

Expand Down
15 changes: 14 additions & 1 deletion packages/plugins/policy/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ORMError, ORMErrorReason, RejectedByPolicyReason, type BaseCrudDialect } from '@zenstackhq/orm';
import { ExpressionUtils, type Expression, type SchemaDef } from '@zenstackhq/orm/schema';
import { ExpressionUtils, type Expression, type FieldDef, type SchemaDef } from '@zenstackhq/orm/schema';
import type { OperationNode } from 'kysely';
import {
AliasNode,
Expand Down Expand Up @@ -191,3 +191,16 @@ export function createRejectedByPolicyError(
export function createUnsupportedError(message: string) {
return new ORMError(ORMErrorReason.NOT_SUPPORTED, message);
}

/**
* Throws a config error if the given field is a parameterized computed field. Such fields need
* query-time args, which policy expressions have no way to provide.
*/
export function rejectParameterizedComputedField(fieldDef: FieldDef, model: string) {
if (fieldDef.computed && fieldDef.params) {
throw new ORMError(
ORMErrorReason.CONFIG_ERROR,
`Parameterized computed field "${fieldDef.name}" of model "${model}" cannot be used in a policy expression`,
);
}
}
Loading
Loading