From 4a583a6244fc5670c8db21612dbebaf9777a48fe Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:13:44 -0600 Subject: [PATCH 1/3] fix(policy): compare relation foreign keys directly for `relation == auth()` Addresses pattern 3 of #2851. `relation == auth()` (and `relation == relation`) used to be rewritten to `relation.id == auth().id`, which compiled to a correlated subquery into the related table only to read back an id already stored in the foreign key column. When the relation is SQL-backed, to-one, and owns the foreign key, the terminal hop is now replaced with the foreign key field, e.g. `orgMember.user.id` -> `orgMember.userID` and `author == auth()` -> `authorID = $1` with no subquery at all. Compound ids map field by field. Relations that don't own the fk (or value-evaluated expressions such as auth()/binding value trees) keep the previous behavior. Co-Authored-By: Claude Fable 5.1 --- .../policy/src/expression-transformer.ts | 84 +++++++++- tests/regression/test/issue-2851.test.ts | 154 ++++++++++++++++++ 2 files changed, 231 insertions(+), 7 deletions(-) diff --git a/packages/plugins/policy/src/expression-transformer.ts b/packages/plugins/policy/src/expression-transformer.ts index 777fde082..4f727e335 100644 --- a/packages/plugins/policy/src/expression-transformer.ts +++ b/packages/plugins/policy/src/expression-transformer.ts @@ -368,14 +368,14 @@ export class ExpressionTransformer { const leftRelDef = this.getFieldDefFromFieldRef(expr.left, context); invariant(leftRelDef, 'failed to get relation field definition'); const idFields = QueryUtils.requireIdFields(this.schema, leftRelDef.type); - normalizedLeft = this.makeOrAppendMember(normalizedLeft, idFields[0]!); + normalizedLeft = this.appendIdOrForeignKey(normalizedLeft, idFields[0]!, context); } let normalizedRight: Expression = expr.right; if (this.isRelationField(expr.right, context)) { const rightRelDef = this.getFieldDefFromFieldRef(expr.right, context); invariant(rightRelDef, 'failed to get relation field definition'); const idFields = QueryUtils.requireIdFields(this.schema, rightRelDef.type); - normalizedRight = this.makeOrAppendMember(normalizedRight, idFields[0]!); + normalizedRight = this.appendIdOrForeignKey(normalizedRight, idFields[0]!, context); } return { normalizedLeft, normalizedRight }; } @@ -630,12 +630,13 @@ export class ExpressionTransformer { .map((f) => f.name); invariant(idFields.length > 0, 'auth type model must have at least one id field'); - // convert `auth() == other` into `auth().id == other.id` + // convert `auth() == other` into `auth().id == other.id`, or `auth().id == other's fk` + // when `other` is a to-one relation owning the foreign key const conditions = idFields.map((fieldName) => ExpressionUtils.binary( ExpressionUtils.member(authExpr, [fieldName]), '==', - this.makeOrAppendMember(other, fieldName), + this.appendIdOrForeignKey(other, fieldName, context), ), ); let result = this.buildAnd(conditions); @@ -654,6 +655,61 @@ export class ExpressionTransformer { } } + /** + * Appends `idField` to a relation expression, i.e. `relation` -> `relation.id`. When the relation + * is SQL-backed, to-one, and owns the foreign key referencing `idField`, the terminal hop is + * replaced with the foreign key column instead (`relation.id` -> `relationId`), which avoids a + * correlated subquery into the related table that would only read back the same value. + */ + private appendIdOrForeignKey( + relationExpr: Expression, + idField: string, + context: ExpressionTransformerContext, + ): Expression { + if (this.isSqlBackedRef(relationExpr, context)) { + const resolved = this.resolveFieldRef(relationExpr, context); + const relation = resolved?.fieldDef.relation; + if (relation && !resolved.fieldDef.array && relation.fields && relation.references) { + const idx = relation.references.indexOf(idField); + const fk = idx >= 0 ? relation.fields[idx] : undefined; + if (fk) { + return this.replaceLastMember(relationExpr, fk); + } + } + } + return this.makeOrAppendMember(relationExpr, idField); + } + + // replaces the terminal segment of a field/member expression with `fieldName` + private replaceLastMember(expr: Expression, fieldName: string): Expression { + if (ExpressionUtils.isMember(expr)) { + return ExpressionUtils.member(expr.receiver, [...expr.members.slice(0, -1), fieldName]); + } else { + invariant(ExpressionUtils.isField(expr), 'expected field or member expression'); + return ExpressionUtils.field(fieldName); + } + } + + // whether a field/member expression is compiled to SQL column references (as opposed to + // being evaluated against an in-memory value tree, e.g. `auth()` members or value bindings) + private isSqlBackedRef(expr: Expression, context: ExpressionTransformerContext): boolean { + if (ExpressionUtils.isField(expr)) { + return !context.contextValue; + } + if (ExpressionUtils.isMember(expr)) { + if (ExpressionUtils.isThis(expr.receiver)) { + return true; + } + if (ExpressionUtils.isField(expr.receiver)) { + return !context.contextValue; + } + if (ExpressionUtils.isBinding(expr.receiver)) { + return context.bindingScope?.[expr.receiver.name]?.value === undefined; + } + } + return false; + } + private transformValue(value: unknown, type: BuiltinType): OperationNode { if (value === true) { return trueNode(this.dialect); @@ -1135,6 +1191,15 @@ export class ExpressionTransformer { } private getFieldDefFromFieldRef(expr: Expression, context: ExpressionTransformerContext): FieldDef | undefined { + return this.resolveFieldRef(expr, context)?.fieldDef; + } + + // resolves a field/member expression to the terminal field's definition together with the + // model that declares it + private resolveFieldRef( + expr: Expression, + context: ExpressionTransformerContext, + ): { model: string; fieldDef: FieldDef } | undefined { // `this.foo` references belong to `thisType` (the outer model in collection-predicate // contexts); everything else uses `modelOrType`. const model = @@ -1142,21 +1207,26 @@ export class ExpressionTransformer { ? context.thisType : context.modelOrType; + const resolveField = (currModel: string, field: string) => { + const fieldDef = QueryUtils.getField(this.schema, currModel, field); + return fieldDef ? { model: currModel, fieldDef } : undefined; + }; + // walks a chain of member names from `startModel`, treating all but the last segment as // relation hops, and returns the terminal field's FieldDef; returns undefined if any // segment is missing or an intermediate hop is not a relation. - const walkRelationChain = (startModel: string, members: string[]): FieldDef | undefined => { + const walkRelationChain = (startModel: string, members: string[]) => { let currModel = startModel; for (let i = 0; i < members.length - 1; i++) { const hopDef = QueryUtils.getField(this.schema, currModel, members[i]!); if (!hopDef?.relation) return undefined; currModel = hopDef.type; } - return QueryUtils.getField(this.schema, currModel, members[members.length - 1]!); + return resolveField(currModel, members[members.length - 1]!); }; if (ExpressionUtils.isField(expr)) { - return QueryUtils.getField(this.schema, model, expr.field); + return resolveField(model, expr.field); } else if (ExpressionUtils.isMember(expr)) { if (ExpressionUtils.isThis(expr.receiver)) { // `this.<...>.field` chain rooted at the `this` model. diff --git a/tests/regression/test/issue-2851.test.ts b/tests/regression/test/issue-2851.test.ts index 9b92e5d32..7e7d69ffe 100644 --- a/tests/regression/test/issue-2851.test.ts +++ b/tests/regression/test/issue-2851.test.ts @@ -329,4 +329,158 @@ model Project { expect(query).not.toContain('count('); }); }); + + describe('relation == auth() uses the foreign key instead of a subquery', () => { + it('rewrites direct and chained to-one relations owning the foreign key', async () => { + const { db, sqls } = await createClient( + ` +model User { + id String @id @default(uuid()) @db.Uuid + orgMembers OrgMember[] + posts Post[] + @@allow('all', true) +} + +model OrgMember { + id String @id @default(uuid()) @db.Uuid + userID String @db.Uuid + user User @relation(fields: [userID], references: [id]) + teamMembers TeamMember[] + @@allow('all', true) +} + +model TeamMember { + id String @id @default(uuid()) @db.Uuid + orgMemberID String @db.Uuid + orgMember OrgMember @relation(fields: [orgMemberID], references: [id]) + @@allow('read', orgMember.user == auth()) +} + +model Post { + id String @id @default(uuid()) @db.Uuid + title String + authorID String? @db.Uuid + author User? @relation(fields: [authorID], references: [id]) + @@allow('read', author == auth()) + @@allow('update', this.author == auth()) + @@allow('delete', author != auth()) +} + `, + ); + + const rawDb = db.$unuseAll(); + const user1 = await rawDb.user.create({ data: {} }); + const user2 = await rawDb.user.create({ data: {} }); + const om1 = await rawDb.orgMember.create({ data: { userID: user1.id } }); + const om2 = await rawDb.orgMember.create({ data: { userID: user2.id } }); + const tm1 = await rawDb.teamMember.create({ data: { orgMemberID: om1.id } }); + await rawDb.teamMember.create({ data: { orgMemberID: om2.id } }); + const post1 = await rawDb.post.create({ data: { title: 'p1', authorID: user1.id } }); + await rawDb.post.create({ data: { title: 'p2', authorID: user2.id } }); + const post3 = await rawDb.post.create({ data: { title: 'p3', authorID: null } }); + + const authDb = db.$setAuth({ id: user1.id }); + + // chained: `orgMember.user == auth()` -> `orgMember.userID` + sqls.length = 0; + const teamMembers = await authDb.teamMember.findMany(); + expect(teamMembers.map((t: any) => t.id)).toEqual([tm1.id]); + let query = sqls.find((sql) => sql.includes('from "public"."TeamMember"')); + expect(query).toContain('"userID"'); + expect(query).not.toContain('from "public"."User"'); + + // direct: `author == auth()` -> `authorID` + sqls.length = 0; + const posts = await authDb.post.findMany(); + expect(posts.map((p: any) => p.id)).toEqual([post1.id]); + query = sqls.find((sql) => sql.includes('from "public"."Post"')); + expect(query).toContain('"Post"."authorID"'); + expect(query).not.toContain('from "public"."User"'); + // plain column comparison, no subquery at all + expect(query).not.toContain('(select'); + + // `this.author == auth()` -> `authorID` + sqls.length = 0; + const updated = await authDb.post.updateMany({ data: { title: 'updated' } }); + expect(updated.count).toBe(1); + query = sqls.find((sql) => sql.startsWith('update "public"."Post"')); + expect(query).not.toContain('from "public"."User"'); + + // `author != auth()` with null fk: the null-author post must not match (sql null semantics) + sqls.length = 0; + const deleted = await authDb.post.deleteMany(); + expect(deleted.count).toBe(1); + const remaining = await rawDb.post.findMany(); + expect(remaining.map((p: any) => p.id).sort()).toEqual([post1.id, post3.id].sort()); + }); + + it('rewrites compound id relations field by field', async () => { + const { db, sqls } = await createClient( + ` +model User { + tenantId String + localId String + docs Doc[] + @@id([tenantId, localId]) + @@allow('all', true) +} + +model Doc { + id String @id @default(uuid()) @db.Uuid + ownerTenantId String + ownerLocalId String + owner User @relation(fields: [ownerTenantId, ownerLocalId], references: [tenantId, localId]) + @@allow('read', owner == auth()) +} + `, + ); + + const rawDb = db.$unuseAll(); + await rawDb.user.create({ data: { tenantId: 't1', localId: 'u1' } }); + await rawDb.user.create({ data: { tenantId: 't1', localId: 'u2' } }); + const doc1 = await rawDb.doc.create({ data: { ownerTenantId: 't1', ownerLocalId: 'u1' } }); + await rawDb.doc.create({ data: { ownerTenantId: 't1', ownerLocalId: 'u2' } }); + + sqls.length = 0; + const docs = await db.$setAuth({ tenantId: 't1', localId: 'u1' }).doc.findMany(); + expect(docs.map((d: any) => d.id)).toEqual([doc1.id]); + const query = sqls.find((sql) => sql.includes('from "public"."Doc"')); + expect(query).toContain('"ownerTenantId"'); + expect(query).toContain('"ownerLocalId"'); + expect(query).not.toContain('from "public"."User"'); + }); + + it('falls back to a subquery when the relation does not own the foreign key', async () => { + const { db, sqls } = await createClient( + ` +model User { + id String @id @default(uuid()) @db.Uuid + settingsId String @unique @db.Uuid + settings Settings @relation(fields: [settingsId], references: [id]) + @@allow('all', true) +} + +model Settings { + id String @id @default(uuid()) @db.Uuid + user User? + @@allow('create', true) + @@allow('read', user == auth()) +} + `, + ); + + const rawDb = db.$unuseAll(); + const s1 = await rawDb.settings.create({ data: {} }); + const s2 = await rawDb.settings.create({ data: {} }); + const user1 = await rawDb.user.create({ data: { settingsId: s1.id } }); + await rawDb.user.create({ data: { settingsId: s2.id } }); + + sqls.length = 0; + const result = await db.$setAuth({ id: user1.id }).settings.findMany(); + expect(result.map((r: any) => r.id)).toEqual([s1.id]); + const query = sqls.find((sql) => sql.includes('from "public"."Settings"')); + // fk lives on User, so the related row must still be looked up + expect(query).toContain('from "public"."User"'); + }); + }); }); From 6a02e7f060bb13e17313b8cfef4c628583eb102a Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:23:59 -0600 Subject: [PATCH 2/3] fix(policy): resolve inherited scalar fields through the delegate base table in member chains The plain-field branch of the member-chain transform referenced inherited fields directly on the sub-type alias, producing "column does not exist" for chains like `post.ownerId` where `ownerId` is declared on a delegate base. This became reachable for `post.owner == auth()` via the fk rewrite and was already broken for direct inherited scalar access. Use the existing delegate base field lookup instead. Also adds a regression case comparing a relation with a collection-predicate value binding, covering the relation normalization path. Co-Authored-By: Claude Fable 5.1 --- .../policy/src/expression-transformer.ts | 7 +- tests/regression/test/issue-2851.test.ts | 119 ++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/packages/plugins/policy/src/expression-transformer.ts b/packages/plugins/policy/src/expression-transformer.ts index 4f727e335..c885b504c 100644 --- a/packages/plugins/policy/src/expression-transformer.ts +++ b/packages/plugins/policy/src/expression-transformer.ts @@ -963,7 +963,12 @@ export class ExpressionTransformer { invariant(i === members.length - 1, 'plain field access must be the last segment'); invariant(!currNode, 'plain field access must be the last segment'); - currNode = ReferenceNode.create(ColumnNode.create(member), TableNode.create(fromAlias)); + 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)); + } } } diff --git a/tests/regression/test/issue-2851.test.ts b/tests/regression/test/issue-2851.test.ts index 7e7d69ffe..03bbc9884 100644 --- a/tests/regression/test/issue-2851.test.ts +++ b/tests/regression/test/issue-2851.test.ts @@ -482,5 +482,124 @@ model Settings { // fk lives on User, so the related row must still be looked up expect(query).toContain('from "public"."User"'); }); + + it('rewrites only the SQL side when comparing a relation with a value binding', async () => { + const { db, sqls } = await createClient( + ` +model User { + id Int @id + assignments Assignment[] + @@allow('all', true) +} + +model Scope { + id Int @id + assignments Assignment[] + documents Document[] + @@allow('all', true) +} + +model Assignment { + id Int @id + userId Int + scopeId Int + user User @relation(fields: [userId], references: [id]) + scope Scope @relation(fields: [scopeId], references: [id]) + @@allow('all', true) +} + +model Document { + id Int @id + scopeId Int + scope Scope @relation(fields: [scopeId], references: [id]) + @@allow('create', true) + @@allow('read', auth().assignments?[a, a.scope == this.scope]) +} + `, + ); + + const rawDb = db.$unuseAll(); + await rawDb.scope.createMany({ data: [{ id: 1 }, { id: 2 }] }); + await rawDb.user.create({ data: { id: 1 } }); + await rawDb.assignment.create({ data: { id: 1, userId: 1, scopeId: 1 } }); + await rawDb.document.createMany({ + data: [ + { id: 10, scopeId: 1 }, + { id: 20, scopeId: 2 }, + ], + }); + + sqls.length = 0; + // `a.scope` is read from the auth value tree (as `scope.id`), `this.scope` becomes `scopeId` + const documents = await db + .$setAuth({ id: 1, assignments: [{ id: 1, scopeId: 1, scope: { id: 1 } }] }) + .document.findMany(); + expect(documents.map((d: any) => d.id)).toEqual([10]); + const query = sqls.find((sql) => sql.includes('from "public"."Document"')); + expect(query).toContain('"Document"."scopeId"'); + expect(query).not.toContain('from "public"."Scope"'); + }); + + it('resolves inherited relations and scalars of delegate sub-types through the base table', async () => { + const { db } = await createClient( + ` +model User { + id String @id @default(uuid()) @db.Uuid + contents Content[] + @@allow('all', true) +} + +model Content { + id String @id @default(uuid()) @db.Uuid + type String + ownerId String @db.Uuid + owner User @relation(fields: [ownerId], references: [id]) + @@delegate(type) +} + +model Post extends Content { + title String + comments Comment[] + likes Like[] + @@allow('create', true) + @@allow('read', owner == auth()) +} + +model Comment { + id String @id @default(uuid()) @db.Uuid + postId String @db.Uuid + post Post @relation(fields: [postId], references: [id]) + @@allow('create', true) + @@allow('read', post.owner == auth()) +} + +model Like { + id String @id @default(uuid()) @db.Uuid + postId String @db.Uuid + post Post @relation(fields: [postId], references: [id]) + @@allow('create', true) + @@allow('read', post.ownerId == auth().id) +} + `, + ); + + const rawDb = db.$unuseAll(); + const user1 = await rawDb.user.create({ data: {} }); + const user2 = await rawDb.user.create({ data: {} }); + const post1 = await rawDb.post.create({ data: { title: 'p1', ownerId: user1.id } }); + const post2 = await rawDb.post.create({ data: { title: 'p2', ownerId: user2.id } }); + const comment1 = await rawDb.comment.create({ data: { postId: post1.id } }); + await rawDb.comment.create({ data: { postId: post2.id } }); + const like1 = await rawDb.like.create({ data: { postId: post1.id } }); + await rawDb.like.create({ data: { postId: post2.id } }); + + const authDb = db.$setAuth({ id: user1.id }); + // direct inherited relation on the sub-type + expect((await authDb.post.findMany()).map((p: any) => p.id)).toEqual([post1.id]); + // chained inherited relation + expect((await authDb.comment.findMany()).map((c: any) => c.id)).toEqual([comment1.id]); + // chained inherited scalar + expect((await authDb.like.findMany()).map((l: any) => l.id)).toEqual([like1.id]); + }); }); }); From 21608d3052fb911c1a490f50cf97599c9d8f2408 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:48:55 -0600 Subject: [PATCH 3/3] test: cover relation == relation fk rewrite without auth() Co-Authored-By: Claude Fable 5.1 --- tests/regression/test/issue-2851.test.ts | 73 ++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/regression/test/issue-2851.test.ts b/tests/regression/test/issue-2851.test.ts index 03bbc9884..cc4c338cd 100644 --- a/tests/regression/test/issue-2851.test.ts +++ b/tests/regression/test/issue-2851.test.ts @@ -540,6 +540,79 @@ model Document { expect(query).not.toContain('from "public"."Scope"'); }); + it('rewrites both sides of relation == relation comparisons not involving auth()', async () => { + const { db, sqls } = await createClient( + ` +model User { + id String @id @default(uuid()) @db.Uuid + owned Task[] @relation('owner') + reviewed Task[] @relation('reviewer') + memberships Membership[] + @@allow('all', true) +} + +model Tenant { + id String @id @default(uuid()) @db.Uuid + memberships Membership[] + tasks Task[] + @@allow('all', true) +} + +model Membership { + id String @id @default(uuid()) @db.Uuid + userId String @db.Uuid + user User @relation(fields: [userId], references: [id]) + tenantId String @db.Uuid + tenant Tenant @relation(fields: [tenantId], references: [id]) + @@allow('all', true) +} + +model Task { + id String @id @default(uuid()) @db.Uuid + ownerId String @db.Uuid + owner User @relation('owner', fields: [ownerId], references: [id]) + reviewerId String @db.Uuid + reviewer User @relation('reviewer', fields: [reviewerId], references: [id]) + tenantId String @db.Uuid + tenant Tenant @relation(fields: [tenantId], references: [id]) + @@allow('create', true) + @@allow('read', this.owner == this.reviewer) + @@allow('delete', owner.memberships?[m, m.tenant == this.tenant]) +} + `, + ); + + const rawDb = db.$unuseAll(); + const user1 = await rawDb.user.create({ data: {} }); + const user2 = await rawDb.user.create({ data: {} }); + const tenant1 = await rawDb.tenant.create({ data: {} }); + const tenant2 = await rawDb.tenant.create({ data: {} }); + await rawDb.membership.create({ data: { userId: user1.id, tenantId: tenant1.id } }); + const selfReviewed = await rawDb.task.create({ + data: { ownerId: user1.id, reviewerId: user1.id, tenantId: tenant1.id }, + }); + await rawDb.task.create({ data: { ownerId: user1.id, reviewerId: user2.id, tenantId: tenant2.id } }); + + // `this.owner == this.reviewer` -> `ownerId = reviewerId` + sqls.length = 0; + const tasks = await db.task.findMany(); + expect(tasks.map((t: any) => t.id)).toEqual([selfReviewed.id]); + let query = sqls.find((sql) => sql.includes('from "public"."Task"')); + expect(query).toContain('"Task"."ownerId" = "Task"."reviewerId"'); + expect(query).not.toContain('from "public"."User"'); + + // binding relation vs `this` relation inside a collection predicate, both SQL-backed: + // `m.tenant == this.tenant` -> `m.tenantId = Task.tenantId` + sqls.length = 0; + // only the task in tenant1 has its owner as a member of the same tenant + const deleted = await db.task.deleteMany(); + expect(deleted.count).toBe(1); + expect(await rawDb.task.findUnique({ where: { id: selfReviewed.id } })).toBeNull(); + query = sqls.find((sql) => sql.startsWith('delete from "public"."Task"')); + expect(query).toContain('"tenantId" = "Task"."tenantId"'); + expect(query).not.toContain('from "public"."Tenant"'); + }); + it('resolves inherited relations and scalars of delegate sub-types through the base table', async () => { const { db } = await createClient( `