Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getDelegateDescendantModels,
getManyToManyRelation,
isRelationField,
joinKeyRef,
requireField,
requireIdFields,
requireModel,
Expand Down Expand Up @@ -139,14 +140,17 @@ export abstract class LateralJoinDialectBase<Schema extends SchemaDef> extends B
const relationIds = requireIdFields(this.schema, relationModel);
invariant(parentIds.length === 1, 'many-to-many relation must have exactly one id field');
invariant(relationIds.length === 1, 'many-to-many relation must have exactly one id field');
// Use raw-alias refs so field access policies wrapping PK/FK in CASE WHEN NULL do not break the join.
const relationIdRef = joinKeyRef(this.schema, relationModel, relationModelAlias, relationIds[0]!);
const parentIdRef = joinKeyRef(this.schema, model, parentAlias, parentIds[0]!);
query = query.where((eb) =>
eb(
eb.ref(`${relationModelAlias}.${relationIds[0]}`),
eb.ref(relationIdRef),
'in',
eb
.selectFrom(m2m.joinTable)
.select(`${m2m.joinTable}.${m2m.otherFkName}`)
.whereRef(`${parentAlias}.${parentIds[0]}`, '=', `${m2m.joinTable}.${m2m.parentFkName}`),
.whereRef(parentIdRef, '=', `${m2m.joinTable}.${m2m.parentFkName}`),
),
);
} else {
Expand Down
22 changes: 15 additions & 7 deletions packages/orm/src/client/crud/dialects/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
getDelegateDescendantModels,
getManyToManyRelation,
getRelationForeignKeyFieldPairs,
joinKeyRef,
requireField,
requireIdFields,
requireModel,
Expand Down Expand Up @@ -359,32 +360,39 @@ export class SqliteCrudDialect<Schema extends SchemaDef> extends BaseCrudDialect
const relationIds = requireIdFields(this.schema, relationModel);
invariant(parentIds.length === 1, 'many-to-many relation must have exactly one id field');
invariant(relationIds.length === 1, 'many-to-many relation must have exactly one id field');
// Use raw-alias refs so field access policies wrapping PK/FK in CASE WHEN NULL do not break the join.
const relationIdRef = joinKeyRef(this.schema, relationModel, relationModelAlias, relationIds[0]!);
const parentIdRef = joinKeyRef(this.schema, model, parentAlias, parentIds[0]!);
selectModelQuery = selectModelQuery.where((eb) =>
eb(
eb.ref(`${relationModelAlias}.${relationIds[0]}`),
eb.ref(relationIdRef),
'in',
eb
.selectFrom(m2m.joinTable)
.select(`${m2m.joinTable}.${m2m.otherFkName}`)
.whereRef(`${parentAlias}.${parentIds[0]}`, '=', `${m2m.joinTable}.${m2m.parentFkName}`),
.whereRef(parentIdRef, '=', `${m2m.joinTable}.${m2m.parentFkName}`),
),
);
} else {
const { keyPairs, ownedByModel } = getRelationForeignKeyFieldPairs(this.schema, model, relationField);
keyPairs.forEach(({ fk, pk }) => {
if (ownedByModel) {
// the parent model owns the fk
// BelongsTo: model owns the FK, relation has the PK.
// Use raw alias only for the PK side. The FK side stays as a plain ref so that
// denying the FK intentionally hides the relation (the join evaluates to NULL).
selectModelQuery = selectModelQuery.whereRef(
`${relationModelAlias}.${pk}`,
joinKeyRef(this.schema, relationModel, relationModelAlias, pk),
'=',
`${parentAlias}.${fk}`,
);
} else {
// the relation side owns the fk
// HasMany: relation owns the FK, model has the PK.
// Use raw alias on both sides: the child's FK may be denied (to hide which parent
// it belongs to) but the parent must still be able to fetch its children.
selectModelQuery = selectModelQuery.whereRef(
`${relationModelAlias}.${fk}`,
joinKeyRef(this.schema, relationModel, relationModelAlias, fk),
'=',
`${parentAlias}.${pk}`,
joinKeyRef(this.schema, model, parentAlias, pk),
);
}
});
Expand Down
67 changes: 63 additions & 4 deletions packages/orm/src/client/query-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,54 @@ export function isTypeDef(schema: SchemaDef, type: string) {
return !!schema.typeDefs?.[type];
}

/**
* Prefix added to PK/FK columns in field-policy subqueries so join conditions
* can reference the raw (never-nulled) value even when the field itself is
* covered by a @deny rule that wraps it in CASE WHEN … THEN NULL.
*/
export const JOIN_KEY_RAW_PREFIX = '__zs_raw_';

/**
* Returns true when the field has at least one @deny or @allow attribute that applies to
* read operations AND whose condition is not a compile-time constant that collapses the
* filter to trivially true (i.e. @allow('read', true) or @deny('read', false)).
*
* Only in those cases does createSelectAllFieldsWithPolicies emit a CASE WHEN … THEN NULL
* expression together with a __zs_raw_* alias that joinKeyRef can reference.
*/
export function fieldHasConditionalReadPolicy(schema: SchemaDef, model: string, field: string): boolean {
const fieldDef = getField(schema, model, field);
return (
fieldDef?.attributes?.some((attr) => {
if (attr.name !== '@deny' && attr.name !== '@allow') return false;
const opArg = attr.args?.[0]?.value;
if (!ExpressionUtils.isLiteral(opArg) || typeof opArg.value !== 'string') return false;
const ops = opArg.value.split(',').map((v) => v.trim());
if (!ops.includes('all') && !ops.includes('read')) return false;
// Constant conditions that make buildFieldPolicyFilter return trueNode produce no
// CASE WHEN and therefore no raw alias – skip them.
const condArg = attr.args?.[1]?.value;
if (ExpressionUtils.isLiteral(condArg)) {
if (attr.name === '@allow' && condArg.value === true) return false;
if (attr.name === '@deny' && condArg.value === false) return false;
}
return true;
}) ?? false
);
}

/**
* Returns the column reference to use on the given side of a join condition.
* When the field has a non-trivial read policy the policy handler emits a raw alias
* (JOIN_KEY_RAW_PREFIX + fieldName) alongside the CASE WHEN expression so the join
* is not broken by the nulled value.
*/
export function joinKeyRef(schema: SchemaDef, model: string, tableAlias: string, field: string): string {
return fieldHasConditionalReadPolicy(schema, model, field)
? `${tableAlias}.${JOIN_KEY_RAW_PREFIX}${field}`
: `${tableAlias}.${field}`;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export function buildJoinPairs(
schema: SchemaDef,
model: string,
Expand All @@ -242,14 +290,25 @@ export function buildJoinPairs(
relationModelAlias: string,
): [string, string][] {
const { keyPairs, ownedByModel } = getRelationForeignKeyFieldPairs(schema, model, relationField);
const relationModel = requireField(schema, model, relationField).type;

return keyPairs.map(({ fk, pk }) => {
if (ownedByModel) {
// the parent model owns the fk
return [`${relationModelAlias}.${pk}`, `${modelAlias}.${fk}`];
// BelongsTo: model owns the FK, relation has the PK.
// Use raw alias only for the PK side. The FK side stays as a plain ref so that
// denying the FK intentionally hides the relation (the join evaluates to NULL).
return [
joinKeyRef(schema, relationModel, relationModelAlias, pk),
`${modelAlias}.${fk}`,
];
} else {
// the relation side owns the fk
return [`${relationModelAlias}.${fk}`, `${modelAlias}.${pk}`];
// HasMany: relation owns the FK, model has the PK.
// Use raw alias on both sides: the child's FK may be denied (to hide which parent it
// belongs to) but the parent must still be able to fetch its children.
return [
joinKeyRef(schema, relationModel, relationModelAlias, fk),
joinKeyRef(schema, model, modelAlias, pk),
];
}
});
}
Expand Down
13 changes: 13 additions & 0 deletions packages/plugins/policy/src/policy-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,19 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf
);
hasPolicies = hasPolicies || fieldHasPolicies;
selections.push(selection);

// When a PK or FK field is wrapped in CASE WHEN … THEN NULL by a field access policy,
// also expose the raw value under a stable alias so join conditions are not broken.
// Used for PK (HasMany parent) and FK (HasMany child). BelongsTo parent FK is kept as
// a plain ref intentionally: denying that FK is designed to hide the relation entirely.
if (fieldHasPolicies && (fieldDef.id || fieldDef.foreignKeyFor)) {
const rawAlias = `${QueryUtils.JOIN_KEY_RAW_PREFIX}${fieldDef.name}`;
selections.push(
SelectionNode.create(
AliasNode.create(ColumnNode.create(fieldDef.name), IdentifierNode.create(rawAlias)),
),
);
}
}

if (!hasPolicies) {
Expand Down
Loading
Loading