diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index bea3db5ba..42b0b8fb3 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -1153,7 +1153,13 @@ export class ZodSchemaFactory< fields[field] = this.makeRelationSelectIncludeSchema(model, field, options).optional(); } } else { - fields[field] = z.boolean().optional(); + if (this.options.allowQueryTimeOmitOverride === false && this.isFieldOmittedByConfig(model, field)) { + // when query-time omit override is disallowed, an omitted field cannot be + // un-omitted by explicitly selecting it, so only allow `false` + fields[field] = z.literal(false).optional(); + } else { + fields[field] = z.boolean().optional(); + } } } @@ -1261,6 +1267,24 @@ export class ZodSchemaFactory< return result; } + /** + * Determines whether a field is configured to be omitted at the schema or client-options level + * (query-level omit is excluded as it's mutually exclusive with `select`). + */ + private isFieldOmittedByConfig(model: string, field: string): boolean { + // options-level omit + const omitConfig = + (this.options.omit as Record | undefined)?.[lowerCaseFirst(model)] ?? + (this.options.omit as Record | undefined)?.[model]; + if (omitConfig && typeof omitConfig[field] === 'boolean') { + return omitConfig[field]; + } + + // schema-level omit + const fieldDef = requireField(this.schema, model, field); + return !!fieldDef.omit; + } + @cache() private makeOmitSchema(model: string) { const fields: Record = {}; diff --git a/tests/regression/test/issue-2671.test.ts b/tests/regression/test/issue-2671.test.ts new file mode 100644 index 000000000..69d0a9dff --- /dev/null +++ b/tests/regression/test/issue-2671.test.ts @@ -0,0 +1,59 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2671 +describe('Regression for issue 2671', () => { + const schema = ` +model User { + id String @id @default(cuid()) + email String @unique + passwordHash String @omit +} + `; + + it('allows selecting omitted fields when override is allowed (default)', async () => { + const db = await createTestClient(schema); + + await db.user.create({ + data: { email: 'user1@test.com', passwordHash: 'secret-hash' }, + }); + + // by default query-time override is allowed, so explicit select returns the field + const selected = await db.user.findFirst({ + where: { email: 'user1@test.com' }, + select: { passwordHash: true }, + }); + expect(selected?.passwordHash).toBe('secret-hash'); + }); + + it('forbids selecting omitted fields when allowQueryTimeOmitOverride is false', async () => { + const base = await createTestClient(schema); + const db = base.$setOptions({ ...base.$options, allowQueryTimeOmitOverride: false }); + + await db.user.create({ + data: { email: 'user1@test.com', passwordHash: 'secret-hash' }, + }); + + // explicitly selecting the omitted field must be rejected + await expect( + db.user.findFirst({ + where: { email: 'user1@test.com' }, + select: { passwordHash: true }, + }), + ).toBeRejectedByValidation(); + + // selecting non-omitted fields still works + const ok = await db.user.findFirst({ + where: { email: 'user1@test.com' }, + select: { email: true }, + }); + expect(ok).toEqual({ email: 'user1@test.com' }); + + // explicitly excluding the omitted field via select is fine + const excluded = await db.user.findFirst({ + where: { email: 'user1@test.com' }, + select: { email: true, passwordHash: false }, + }); + expect(excluded).toEqual({ email: 'user1@test.com' }); + }); +});