diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 6505491a6..d29822209 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -827,7 +827,7 @@ type TypedJsonTypedFilter< | (Array extends true ? ArrayTypedJsonFilter : NonArrayTypedJsonFilter) - | (Optional extends true ? null : never) + | (Optional extends true ? null | JsonNullValues : never) : {}; type ArrayTypedJsonFilter< @@ -1375,7 +1375,11 @@ type ScalarFieldMutationPayload< ? ModelFieldIsOptional extends true ? JsonValue | JsonNull | DbNull : JsonValue | JsonNull - : MapModelFieldType; + : IsTypedJsonField extends true + ? ModelFieldIsOptional extends true + ? MapModelFieldType | JsonNull | DbNull + : MapModelFieldType + : MapModelFieldType; type IsJsonField< Schema extends SchemaDef, @@ -1383,6 +1387,12 @@ type IsJsonField< Field extends GetModelFields, > = GetModelFieldType extends 'Json' ? true : false; +type IsTypedJsonField< + Schema extends SchemaDef, + Model extends GetModels, + Field extends GetModelFields, +> = GetModelFieldType extends GetTypeDefs ? true : false; + type CreateFKPayload> = OptionalWrap< Schema, Model, diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index c6b4fe72d..2ae0bb7af 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -571,6 +571,10 @@ export abstract class BaseCrudDialect { } if (isTypeDef(this.schema, fieldDef.type)) { + if (payload instanceof DbNullClass || payload instanceof JsonNullClass || payload instanceof AnyNullClass) { + // null sentinel passed directly (e.g. where: { field: DbNull }) — treat like { equals: sentinel } + return this.buildJsonValueFilterClause(fieldRef, payload); + } return this.buildJsonFilter(fieldRef, payload, fieldDef); } diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 7166f42f7..d584c3271 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -599,14 +599,41 @@ export class ZodSchemaFactory< candidates.push(this.makeJsonFilterSchema(contextModel, field, optional)); if (optional) { - // allow null as well + // allow null and null sentinel values candidates.push(z.null()); + candidates.push(z.instanceof(DbNullClass)); + candidates.push(z.instanceof(JsonNullClass)); + candidates.push(z.instanceof(AnyNullClass)); } // either plain json filter or field filters return z.union(candidates); } + // For optional typed JSON fields, allow DbNull, JsonNull, and null. + // z.union doesn't work here because `z.any()` (returned by `makeScalarSchema`) + // always wins, so we create a wrapper superRefine instead. + private makeNullableTypedJsonMutationSchema(type: string, attributes?: readonly AttributeApplication[]) { + const baseSchema = this.makeScalarSchema(type, attributes); + return z + .any() + .superRefine((value, ctx) => { + if ( + value instanceof DbNullClass || + value instanceof JsonNullClass || + value === null || + value === undefined + ) { + return; + } + const parseResult = baseSchema.safeParse(value); + if (!parseResult.success) { + parseResult.error.issues.forEach((issue) => ctx.addIssue(issue as any)); + } + }) + .optional(); + } + private isTypeDefType(type: string) { return this.schema.typeDefs && type in this.schema.typeDefs; } @@ -1309,6 +1336,8 @@ export class ZodSchemaFactory< if (fieldDef.type === 'Json') { // DbNull for Json fields fieldSchema = z.union([fieldSchema, z.instanceof(DbNullClass)]); + } else if (this.isTypeDefType(fieldDef.type)) { + fieldSchema = this.makeNullableTypedJsonMutationSchema(fieldDef.type, fieldDef.attributes); } else { fieldSchema = fieldSchema.nullable(); } @@ -1667,6 +1696,8 @@ export class ZodSchemaFactory< if (fieldDef.type === 'Json') { // DbNull for Json fields fieldSchema = z.union([fieldSchema, z.instanceof(DbNullClass)]); + } else if (this.isTypeDefType(fieldDef.type)) { + fieldSchema = this.makeNullableTypedJsonMutationSchema(fieldDef.type, fieldDef.attributes); } else { fieldSchema = fieldSchema.nullable(); } diff --git a/tests/regression/test/issue-2411/input.ts b/tests/regression/test/issue-2411/input.ts new file mode 100644 index 000000000..74cd690b9 --- /dev/null +++ b/tests/regression/test/issue-2411/input.ts @@ -0,0 +1,31 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaType as $Schema } from "./schema"; +import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, ExistsArgs as $ExistsArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; +import type { SimplifiedPlainResult as $Result, SelectIncludeOmit as $SelectIncludeOmit } from "@zenstackhq/orm"; +export type FooFindManyArgs = $FindManyArgs<$Schema, "Foo">; +export type FooFindUniqueArgs = $FindUniqueArgs<$Schema, "Foo">; +export type FooFindFirstArgs = $FindFirstArgs<$Schema, "Foo">; +export type FooExistsArgs = $ExistsArgs<$Schema, "Foo">; +export type FooCreateArgs = $CreateArgs<$Schema, "Foo">; +export type FooCreateManyArgs = $CreateManyArgs<$Schema, "Foo">; +export type FooCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Foo">; +export type FooUpdateArgs = $UpdateArgs<$Schema, "Foo">; +export type FooUpdateManyArgs = $UpdateManyArgs<$Schema, "Foo">; +export type FooUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Foo">; +export type FooUpsertArgs = $UpsertArgs<$Schema, "Foo">; +export type FooDeleteArgs = $DeleteArgs<$Schema, "Foo">; +export type FooDeleteManyArgs = $DeleteManyArgs<$Schema, "Foo">; +export type FooCountArgs = $CountArgs<$Schema, "Foo">; +export type FooAggregateArgs = $AggregateArgs<$Schema, "Foo">; +export type FooGroupByArgs = $GroupByArgs<$Schema, "Foo">; +export type FooWhereInput = $WhereInput<$Schema, "Foo">; +export type FooSelect = $SelectInput<$Schema, "Foo">; +export type FooInclude = $IncludeInput<$Schema, "Foo">; +export type FooOmit = $OmitInput<$Schema, "Foo">; +export type FooGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "Foo", Args, Options>; diff --git a/tests/regression/test/issue-2411/models.ts b/tests/regression/test/issue-2411/models.ts new file mode 100644 index 000000000..ff2c2aa4d --- /dev/null +++ b/tests/regression/test/issue-2411/models.ts @@ -0,0 +1,11 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaType as $Schema } from "./schema"; +import type { ModelResult as $ModelResult, TypeDefResult as $TypeDefResult } from "@zenstackhq/orm"; +export type Foo = $ModelResult<$Schema, "Foo">; +export type Metadata = $TypeDefResult<$Schema, "Metadata">; diff --git a/tests/regression/test/issue-2411/regression.test.ts b/tests/regression/test/issue-2411/regression.test.ts new file mode 100644 index 000000000..88823c819 --- /dev/null +++ b/tests/regression/test/issue-2411/regression.test.ts @@ -0,0 +1,78 @@ +import { AnyNull, DbNull, JsonNull } from '@zenstackhq/orm'; +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, it, expect } from 'vitest'; +import { schema } from './schema'; + +// https://github.com/zenstackhq/zenstack/issues/2411 +// TypeScript errors with nullable custom JSON types when using DbNull/JsonNull/AnyNull + +describe('Regression for issue #2411', () => { + it('should accept DbNull/JsonNull/AnyNull for nullable typed JSON fields in create/update/find', async () => { + const db = await createTestClient(schema); + const metadata = { someInt: 1, someString: 'test' }; + + /* --------------------------------- CREATE --------------------------------- */ + + // metadata (non nullable) - these should cause TS errors + // @ts-expect-error - should not be able to set a null value to the non nullable field + await expect(db.foo.create({ data: { metadata: DbNull } })).rejects.toThrow(); + // @ts-expect-error - should not be able to set a null value to the non nullable field + await expect(db.foo.create({ data: { metadata: JsonNull } })).rejects.toThrow(); + // @ts-expect-error - should not be able to set a null value to the non nullable field + await expect(db.foo.create({ data: { metadata: AnyNull } })).rejects.toThrow(); + // @ts-expect-error - should not be able to set a null value to the non nullable field + await expect(db.foo.create({ data: { metadata: null } })).rejects.toThrow(); + + await db.foo.create({ data: { metadata } }); // ✅ No typescript error + + // optionalMetadata (nullable) - DbNull/JsonNull should NOT cause TS errors + await db.foo.create({ data: { metadata, optionalMetadata: DbNull } }); + await db.foo.create({ data: { metadata, optionalMetadata: JsonNull } }); + // @ts-expect-error - AnyNull is not accepted for typed JSON fields (TS + runtime rejection) + await expect(db.foo.create({ data: { metadata, optionalMetadata: AnyNull } })).rejects.toThrow(); + await db.foo.create({ data: { metadata, optionalMetadata: null } }); // ✅ No typescript error + + /* --------------------------------- UPDATE --------------------------------- */ + + const firstFoo = await db.foo.findFirst(); + expect(firstFoo).not.toBeNull(); + const where = { id: firstFoo!.id }; + + // metadata (non nullable) - these should cause TS errors + // @ts-expect-error - should not be able to set a null value to the non nullable field + await expect(db.foo.update({ where, data: { metadata: DbNull } })).rejects.toThrow(); + // @ts-expect-error - should not be able to set a null value to the non nullable field + await expect(db.foo.update({ where, data: { metadata: JsonNull } })).rejects.toThrow(); + // @ts-expect-error - should not be able to set a null value to the non nullable field + await expect(db.foo.update({ where, data: { metadata: AnyNull } })).rejects.toThrow(); + // @ts-expect-error - should not be able to set a null value to the non nullable field + await expect(db.foo.update({ where, data: { metadata: null } })).rejects.toThrow(); + + await db.foo.update({ where, data: { metadata } }); // ✅ No typescript error + + // optionalMetadata (nullable) - DbNull/JsonNull should NOT cause TS errors + await db.foo.update({ where, data: { metadata, optionalMetadata: DbNull } }); + await db.foo.update({ where, data: { metadata, optionalMetadata: JsonNull } }); + // @ts-expect-error - AnyNull is not accepted for typed JSON fields (TS + runtime rejection) + await expect(db.foo.update({ where, data: { metadata, optionalMetadata: AnyNull } })).rejects.toThrow(); + await db.foo.update({ where, data: { metadata, optionalMetadata: null } }); // ✅ No typescript error + + /* ---------------------------------- FIND ---------------------------------- */ + + // metadata (non nullable) - these should cause TS errors + // @ts-expect-error - should not be able to filter by DbNull on a non nullable field + void db.foo.findMany({ where: { metadata: DbNull } }); + // @ts-expect-error - should not be able to filter by JsonNull on a non nullable field + void db.foo.findMany({ where: { metadata: JsonNull } }); + // @ts-expect-error - should not be able to filter by AnyNull on a non nullable field + void db.foo.findMany({ where: { metadata: AnyNull } }); + // @ts-expect-error - should not be able to filter by null on a non nullable field + void db.foo.findMany({ where: { metadata: null } }); + + // optionalMetadata (nullable) - these should NOT cause TS errors + await db.foo.findMany({ where: { optionalMetadata: DbNull } }); + await db.foo.findMany({ where: { optionalMetadata: JsonNull } }); + await db.foo.findMany({ where: { optionalMetadata: AnyNull } }); + await db.foo.findMany({ where: { optionalMetadata: null } }); // ✅ No typescript error + }); +}); diff --git a/tests/regression/test/issue-2411/schema.ts b/tests/regression/test/issue-2411/schema.ts new file mode 100644 index 000000000..ea2f16394 --- /dev/null +++ b/tests/regression/test/issue-2411/schema.ts @@ -0,0 +1,71 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, type AttributeApplication, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema"; +export class SchemaType implements SchemaDef { + provider = { + type: "sqlite" + } as const; + models = { + Foo: { + name: "Foo", + fields: { + id: { + name: "id", + type: "String", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("cuid") as FieldDefault + }, + createdAt: { + name: "createdAt", + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[], + default: ExpressionUtils.call("now") as FieldDefault + }, + updatedAt: { + name: "updatedAt", + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[] + }, + metadata: { + name: "metadata", + type: "Metadata", + attributes: [{ name: "@json" }] as readonly AttributeApplication[] + }, + optionalMetadata: { + name: "optionalMetadata", + type: "Metadata", + optional: true, + attributes: [{ name: "@json" }] as readonly AttributeApplication[] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "String" } + } + } + } as const; + typeDefs = { + Metadata: { + name: "Metadata", + fields: { + someString: { + name: "someString", + type: "String" + }, + someInt: { + name: "someInt", + type: "Int" + } + } + } + } as const; + plugins = {}; +} +export const schema = new SchemaType(); diff --git a/tests/regression/test/issue-2411/schema.zmodel b/tests/regression/test/issue-2411/schema.zmodel new file mode 100644 index 000000000..c56239d42 --- /dev/null +++ b/tests/regression/test/issue-2411/schema.zmodel @@ -0,0 +1,18 @@ +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} + +type Metadata { + someString String + someInt Int +} + +model Foo { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + metadata Metadata @json + optionalMetadata Metadata? @json +}