From caef5a86f5e5ea5a4c3b48e990e1d2cf10dbeef3 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:51:27 -0700 Subject: [PATCH 1/2] fix(orm): keep omitted fields in derived relation subquery so nested joins and ordering work When a to-many relation requires a derived subquery (orderBy/skip/take/cursor/distinct), omitted fields (query-level, client-level, or schema-level) were dropped from the inner select. The outer aggregation still referenced them for nested relation joins (PK/FK) and for ORDER BY, causing "column does not exist" errors. The inner subquery now selects all fields; omission is handled by the outer JSON projection as before. Fixes #2830 Co-Authored-By: Claude Fable 5.1 --- .../src/client/crud/dialects/base-dialect.ts | 23 ++-- tests/regression/test/issue-2830.test.ts | 108 ++++++++++++++++++ 2 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 tests/regression/test/issue-2830.test.ts diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 4210f99cf..75709f0b4 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -1431,11 +1431,19 @@ export abstract class BaseCrudDialect { query: SelectQueryBuilder, omit: Record | undefined | null, modelAlias: string, + options?: { + /** + * When false, query-level, client-level, and schema-level omit settings are all + * ignored and every field is selected. + */ + applyOmit?: boolean; + }, ) { let result = query; + const applyOmit = options?.applyOmit ?? true; for (const fieldDef of getModelFields(this.schema, model, { inherited: true, computed: true })) { - if (this.shouldOmitField(omit, model, fieldDef.name)) { + if (applyOmit && this.shouldOmitField(omit, model, fieldDef.name)) { continue; } // parameterized computed fields can't be auto-selected — they require @@ -1453,7 +1461,7 @@ export abstract class BaseCrudDialect { result = result.select(() => { const jsonObject: Record> = {}; for (const fieldDef of getModelFields(this.schema, subModel.name, { computed: true })) { - if (this.shouldOmitField(omit, subModel.name, fieldDef.name)) { + if (applyOmit && this.shouldOmitField(omit, subModel.name, fieldDef.name)) { continue; } // parameterized computed fields require query-time args; not auto-selected @@ -1498,12 +1506,11 @@ export abstract class BaseCrudDialect { let subQuery = this.buildSelectModel(model, subQueryAlias); if (selectAllFields) { - subQuery = this.buildSelectAllFields( - model, - subQuery, - typeof payload === 'object' ? payload?.omit : undefined, - subQueryAlias, - ); + // omission (query-level, client-level, or schema-level) is intentionally not + // applied here: this select feeds a derived subquery whose columns are needed by + // nested relation joins (PK/FK fields) and by ordering of the aggregated result, + // and the outer JSON object projection handles omission on its own + subQuery = this.buildSelectAllFields(model, subQuery, undefined, subQueryAlias, { applyOmit: false }); } if (payload && typeof payload === 'object') { diff --git a/tests/regression/test/issue-2830.test.ts b/tests/regression/test/issue-2830.test.ts new file mode 100644 index 000000000..36565a915 --- /dev/null +++ b/tests/regression/test/issue-2830.test.ts @@ -0,0 +1,108 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2830 +describe('Regression for issue #2830', () => { + const schema = ` +model Parent { + id Int @id @default(autoincrement()) + name String + children Child[] +} + +model Child { + id Int @id @default(autoincrement()) + position Int + parent Parent @relation(fields: [parentId], references: [id]) + parentId Int + grandchildren Grandchild[] +} + +model Grandchild { + id Int @id @default(autoincrement()) + name String + child Child @relation(fields: [childId], references: [id]) + childId Int +} +`; + + async function seed(db: any) { + await db.parent.create({ + data: { + name: 'p1', + children: { + create: [ + { position: 2, grandchildren: { create: [{ name: 'g2' }] } }, + { position: 1, grandchildren: { create: [{ name: 'g1a' }, { name: 'g1b' }] } }, + ], + }, + }, + }); + } + + it('keeps nested includes working when the PK is omitted on an ordered relation', async () => { + const db = await createTestClient(schema); + await seed(db); + + const result = await db.parent.findMany({ + include: { + children: { + omit: { id: true }, + orderBy: { position: 'asc' }, + include: { grandchildren: true }, + }, + }, + }); + + expect(result).toHaveLength(1); + const children = result[0]!.children; + expect(children.map((c: any) => c.position)).toEqual([1, 2]); + expect(children[0]).not.toHaveProperty('id'); + expect(children[0]!.grandchildren.map((g: any) => g.name).sort()).toEqual(['g1a', 'g1b']); + expect(children[1]!.grandchildren.map((g: any) => g.name)).toEqual(['g2']); + // grandchildren are not affected by the parent-level omit + expect(children[0]!.grandchildren[0]).toHaveProperty('id'); + }); + + it('keeps nested includes working when the FK is omitted on a paginated relation', async () => { + const db = await createTestClient(schema); + await seed(db); + + const result = await db.parent.findMany({ + include: { + children: { + omit: { id: true, parentId: true }, + orderBy: { position: 'desc' }, + take: 1, + include: { grandchildren: { omit: { childId: true } } }, + }, + }, + }); + + expect(result[0]!.children).toHaveLength(1); + expect(result[0]!.children[0]!.position).toBe(2); + expect(result[0]!.children[0]).not.toHaveProperty('id'); + expect(result[0]!.children[0]).not.toHaveProperty('parentId'); + expect(result[0]!.children[0]!.grandchildren).toEqual([expect.objectContaining({ name: 'g2' })]); + expect(result[0]!.children[0]!.grandchildren[0]).not.toHaveProperty('childId'); + }); + + it('respects schema-level @omit on an ordered relation with nested include', async () => { + const db = await createTestClient(schema.replace('position Int', 'position Int @omit')); + await seed(db); + + const result = await db.parent.findMany({ + include: { + children: { + orderBy: { position: 'asc' }, + include: { grandchildren: true }, + }, + }, + }); + const children = result[0]!.children; + expect(children).toHaveLength(2); + expect(children[0]).not.toHaveProperty('position'); + expect(children[0]!.grandchildren).toHaveLength(2); + expect(children[1]!.grandchildren).toHaveLength(1); + }); +}); From 60f7f75715e06c16edea7b3547bcc329bfe7ff79 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:14:43 -0700 Subject: [PATCH 2/2] fix(orm): keep applying omission to delegate-descendant JSON in derived subquery Descendant fields are never referenced by joins or ordering, and the packed JSON is copied to the result as-is, so omission must still be applied there. Co-Authored-By: Claude Fable 5.1 --- .../src/client/crud/dialects/base-dialect.ts | 7 ++- tests/regression/test/issue-2830.test.ts | 49 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 75709f0b4..e3e852ed2 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -1434,7 +1434,10 @@ export abstract class BaseCrudDialect { options?: { /** * When false, query-level, client-level, and schema-level omit settings are all - * ignored and every field is selected. + * ignored for the model's own fields and every field is selected. Omission is + * still applied to the JSON packed from delegate descendants, since those fields + * are never referenced by joins or ordering and the packed JSON is copied to + * the final result as-is. */ applyOmit?: boolean; }, @@ -1461,7 +1464,7 @@ export abstract class BaseCrudDialect { result = result.select(() => { const jsonObject: Record> = {}; for (const fieldDef of getModelFields(this.schema, subModel.name, { computed: true })) { - if (applyOmit && this.shouldOmitField(omit, subModel.name, fieldDef.name)) { + if (this.shouldOmitField(omit, subModel.name, fieldDef.name)) { continue; } // parameterized computed fields require query-time args; not auto-selected diff --git a/tests/regression/test/issue-2830.test.ts b/tests/regression/test/issue-2830.test.ts index 36565a915..287ea9bcd 100644 --- a/tests/regression/test/issue-2830.test.ts +++ b/tests/regression/test/issue-2830.test.ts @@ -105,4 +105,53 @@ model Grandchild { expect(children[0]!.grandchildren).toHaveLength(2); expect(children[1]!.grandchildren).toHaveLength(1); }); + + it('still omits delegate-descendant fields on an ordered relation', async () => { + const db = await createTestClient( + ` +model Parent { + id Int @id @default(autoincrement()) + name String + items Item[] +} + +model Item { + id Int @id @default(autoincrement()) + position Int + kind String + parent Parent @relation(fields: [parentId], references: [id]) + parentId Int + @@delegate(kind) +} + +model SecretItem extends Item { + secret String @omit + public String +} +`, + ); + const parent = await db.parent.create({ data: { name: 'p1' } }); + await db.secretItem.create({ + data: { parentId: parent.id, position: 2, secret: 's2', public: 'pub2' }, + }); + await db.secretItem.create({ + data: { parentId: parent.id, position: 1, secret: 's1', public: 'pub1' }, + }); + + const result = await db.parent.findMany({ + include: { + items: { + omit: { id: true }, + orderBy: { position: 'asc' }, + }, + }, + }); + + const items = result[0]!.items; + expect(items.map((i: any) => i.position)).toEqual([1, 2]); + expect(items[0]).toMatchObject({ kind: 'SecretItem', public: 'pub1' }); + // schema-level @omit on the delegate descendant must still be honored + expect(items[0]).not.toHaveProperty('secret'); + expect(items[1]).not.toHaveProperty('secret'); + }); });