diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 4210f99cf..e3e852ed2 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -1431,11 +1431,22 @@ 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 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; + }, ) { 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 @@ -1498,12 +1509,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..287ea9bcd --- /dev/null +++ b/tests/regression/test/issue-2830.test.ts @@ -0,0 +1,157 @@ +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); + }); + + 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'); + }); +});