Skip to content

Commit dbb3474

Browse files
ymc9claude
andauthored
fix(orm): support DbNull/JsonNull/AnyNull for nullable typed JSON fields (#2552)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0495333 commit dbb3474

File tree

8 files changed

+257
-3
lines changed

8 files changed

+257
-3
lines changed

packages/orm/src/client/crud-types.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -827,7 +827,7 @@ type TypedJsonTypedFilter<
827827
| (Array extends true
828828
? ArrayTypedJsonFilter<Schema, TypeDefName, AllowedKinds>
829829
: NonArrayTypedJsonFilter<Schema, TypeDefName, AllowedKinds>)
830-
| (Optional extends true ? null : never)
830+
| (Optional extends true ? null | JsonNullValues : never)
831831
: {};
832832

833833
type ArrayTypedJsonFilter<
@@ -1375,14 +1375,24 @@ type ScalarFieldMutationPayload<
13751375
? ModelFieldIsOptional<Schema, Model, Field> extends true
13761376
? JsonValue | JsonNull | DbNull
13771377
: JsonValue | JsonNull
1378-
: MapModelFieldType<Schema, Model, Field>;
1378+
: IsTypedJsonField<Schema, Model, Field> extends true
1379+
? ModelFieldIsOptional<Schema, Model, Field> extends true
1380+
? MapModelFieldType<Schema, Model, Field> | JsonNull | DbNull
1381+
: MapModelFieldType<Schema, Model, Field>
1382+
: MapModelFieldType<Schema, Model, Field>;
13791383

13801384
type IsJsonField<
13811385
Schema extends SchemaDef,
13821386
Model extends GetModels<Schema>,
13831387
Field extends GetModelFields<Schema, Model>,
13841388
> = GetModelFieldType<Schema, Model, Field> extends 'Json' ? true : false;
13851389

1390+
type IsTypedJsonField<
1391+
Schema extends SchemaDef,
1392+
Model extends GetModels<Schema>,
1393+
Field extends GetModelFields<Schema, Model>,
1394+
> = GetModelFieldType<Schema, Model, Field> extends GetTypeDefs<Schema> ? true : false;
1395+
13861396
type CreateFKPayload<Schema extends SchemaDef, Model extends GetModels<Schema>> = OptionalWrap<
13871397
Schema,
13881398
Model,

packages/orm/src/client/crud/dialects/base-dialect.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,10 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
571571
}
572572

573573
if (isTypeDef(this.schema, fieldDef.type)) {
574+
if (payload instanceof DbNullClass || payload instanceof JsonNullClass || payload instanceof AnyNullClass) {
575+
// null sentinel passed directly (e.g. where: { field: DbNull }) — treat like { equals: sentinel }
576+
return this.buildJsonValueFilterClause(fieldRef, payload);
577+
}
574578
return this.buildJsonFilter(fieldRef, payload, fieldDef);
575579
}
576580

packages/orm/src/client/zod/factory.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -599,14 +599,41 @@ export class ZodSchemaFactory<
599599
candidates.push(this.makeJsonFilterSchema(contextModel, field, optional));
600600

601601
if (optional) {
602-
// allow null as well
602+
// allow null and null sentinel values
603603
candidates.push(z.null());
604+
candidates.push(z.instanceof(DbNullClass));
605+
candidates.push(z.instanceof(JsonNullClass));
606+
candidates.push(z.instanceof(AnyNullClass));
604607
}
605608

606609
// either plain json filter or field filters
607610
return z.union(candidates);
608611
}
609612

613+
// For optional typed JSON fields, allow DbNull, JsonNull, and null.
614+
// z.union doesn't work here because `z.any()` (returned by `makeScalarSchema`)
615+
// always wins, so we create a wrapper superRefine instead.
616+
private makeNullableTypedJsonMutationSchema(type: string, attributes?: readonly AttributeApplication[]) {
617+
const baseSchema = this.makeScalarSchema(type, attributes);
618+
return z
619+
.any()
620+
.superRefine((value, ctx) => {
621+
if (
622+
value instanceof DbNullClass ||
623+
value instanceof JsonNullClass ||
624+
value === null ||
625+
value === undefined
626+
) {
627+
return;
628+
}
629+
const parseResult = baseSchema.safeParse(value);
630+
if (!parseResult.success) {
631+
parseResult.error.issues.forEach((issue) => ctx.addIssue(issue as any));
632+
}
633+
})
634+
.optional();
635+
}
636+
610637
private isTypeDefType(type: string) {
611638
return this.schema.typeDefs && type in this.schema.typeDefs;
612639
}
@@ -1309,6 +1336,8 @@ export class ZodSchemaFactory<
13091336
if (fieldDef.type === 'Json') {
13101337
// DbNull for Json fields
13111338
fieldSchema = z.union([fieldSchema, z.instanceof(DbNullClass)]);
1339+
} else if (this.isTypeDefType(fieldDef.type)) {
1340+
fieldSchema = this.makeNullableTypedJsonMutationSchema(fieldDef.type, fieldDef.attributes);
13121341
} else {
13131342
fieldSchema = fieldSchema.nullable();
13141343
}
@@ -1667,6 +1696,8 @@ export class ZodSchemaFactory<
16671696
if (fieldDef.type === 'Json') {
16681697
// DbNull for Json fields
16691698
fieldSchema = z.union([fieldSchema, z.instanceof(DbNullClass)]);
1699+
} else if (this.isTypeDefType(fieldDef.type)) {
1700+
fieldSchema = this.makeNullableTypedJsonMutationSchema(fieldDef.type, fieldDef.attributes);
16701701
} else {
16711702
fieldSchema = fieldSchema.nullable();
16721703
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//////////////////////////////////////////////////////////////////////////////////////////////
2+
// DO NOT MODIFY THIS FILE //
3+
// This file is automatically generated by ZenStack CLI and should not be manually updated. //
4+
//////////////////////////////////////////////////////////////////////////////////////////////
5+
6+
/* eslint-disable */
7+
8+
import { type SchemaType as $Schema } from "./schema";
9+
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";
10+
import type { SimplifiedPlainResult as $Result, SelectIncludeOmit as $SelectIncludeOmit } from "@zenstackhq/orm";
11+
export type FooFindManyArgs = $FindManyArgs<$Schema, "Foo">;
12+
export type FooFindUniqueArgs = $FindUniqueArgs<$Schema, "Foo">;
13+
export type FooFindFirstArgs = $FindFirstArgs<$Schema, "Foo">;
14+
export type FooExistsArgs = $ExistsArgs<$Schema, "Foo">;
15+
export type FooCreateArgs = $CreateArgs<$Schema, "Foo">;
16+
export type FooCreateManyArgs = $CreateManyArgs<$Schema, "Foo">;
17+
export type FooCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Foo">;
18+
export type FooUpdateArgs = $UpdateArgs<$Schema, "Foo">;
19+
export type FooUpdateManyArgs = $UpdateManyArgs<$Schema, "Foo">;
20+
export type FooUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Foo">;
21+
export type FooUpsertArgs = $UpsertArgs<$Schema, "Foo">;
22+
export type FooDeleteArgs = $DeleteArgs<$Schema, "Foo">;
23+
export type FooDeleteManyArgs = $DeleteManyArgs<$Schema, "Foo">;
24+
export type FooCountArgs = $CountArgs<$Schema, "Foo">;
25+
export type FooAggregateArgs = $AggregateArgs<$Schema, "Foo">;
26+
export type FooGroupByArgs = $GroupByArgs<$Schema, "Foo">;
27+
export type FooWhereInput = $WhereInput<$Schema, "Foo">;
28+
export type FooSelect = $SelectInput<$Schema, "Foo">;
29+
export type FooInclude = $IncludeInput<$Schema, "Foo">;
30+
export type FooOmit = $OmitInput<$Schema, "Foo">;
31+
export type FooGetPayload<Args extends $SelectIncludeOmit<$Schema, "Foo", true>, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "Foo", Args, Options>;
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
//////////////////////////////////////////////////////////////////////////////////////////////
2+
// DO NOT MODIFY THIS FILE //
3+
// This file is automatically generated by ZenStack CLI and should not be manually updated. //
4+
//////////////////////////////////////////////////////////////////////////////////////////////
5+
6+
/* eslint-disable */
7+
8+
import { type SchemaType as $Schema } from "./schema";
9+
import type { ModelResult as $ModelResult, TypeDefResult as $TypeDefResult } from "@zenstackhq/orm";
10+
export type Foo = $ModelResult<$Schema, "Foo">;
11+
export type Metadata = $TypeDefResult<$Schema, "Metadata">;
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { AnyNull, DbNull, JsonNull } from '@zenstackhq/orm';
2+
import { createTestClient } from '@zenstackhq/testtools';
3+
import { describe, it, expect } from 'vitest';
4+
import { schema } from './schema';
5+
6+
// https://github.com/zenstackhq/zenstack/issues/2411
7+
// TypeScript errors with nullable custom JSON types when using DbNull/JsonNull/AnyNull
8+
9+
describe('Regression for issue #2411', () => {
10+
it('should accept DbNull/JsonNull/AnyNull for nullable typed JSON fields in create/update/find', async () => {
11+
const db = await createTestClient(schema);
12+
const metadata = { someInt: 1, someString: 'test' };
13+
14+
/* --------------------------------- CREATE --------------------------------- */
15+
16+
// metadata (non nullable) - these should cause TS errors
17+
// @ts-expect-error - should not be able to set a null value to the non nullable field
18+
await expect(db.foo.create({ data: { metadata: DbNull } })).rejects.toThrow();
19+
// @ts-expect-error - should not be able to set a null value to the non nullable field
20+
await expect(db.foo.create({ data: { metadata: JsonNull } })).rejects.toThrow();
21+
// @ts-expect-error - should not be able to set a null value to the non nullable field
22+
await expect(db.foo.create({ data: { metadata: AnyNull } })).rejects.toThrow();
23+
// @ts-expect-error - should not be able to set a null value to the non nullable field
24+
await expect(db.foo.create({ data: { metadata: null } })).rejects.toThrow();
25+
26+
await db.foo.create({ data: { metadata } }); // ✅ No typescript error
27+
28+
// optionalMetadata (nullable) - DbNull/JsonNull should NOT cause TS errors
29+
await db.foo.create({ data: { metadata, optionalMetadata: DbNull } });
30+
await db.foo.create({ data: { metadata, optionalMetadata: JsonNull } });
31+
// @ts-expect-error - AnyNull is not accepted for typed JSON fields (TS + runtime rejection)
32+
await expect(db.foo.create({ data: { metadata, optionalMetadata: AnyNull } })).rejects.toThrow();
33+
await db.foo.create({ data: { metadata, optionalMetadata: null } }); // ✅ No typescript error
34+
35+
/* --------------------------------- UPDATE --------------------------------- */
36+
37+
const firstFoo = await db.foo.findFirst();
38+
expect(firstFoo).not.toBeNull();
39+
const where = { id: firstFoo!.id };
40+
41+
// metadata (non nullable) - these should cause TS errors
42+
// @ts-expect-error - should not be able to set a null value to the non nullable field
43+
await expect(db.foo.update({ where, data: { metadata: DbNull } })).rejects.toThrow();
44+
// @ts-expect-error - should not be able to set a null value to the non nullable field
45+
await expect(db.foo.update({ where, data: { metadata: JsonNull } })).rejects.toThrow();
46+
// @ts-expect-error - should not be able to set a null value to the non nullable field
47+
await expect(db.foo.update({ where, data: { metadata: AnyNull } })).rejects.toThrow();
48+
// @ts-expect-error - should not be able to set a null value to the non nullable field
49+
await expect(db.foo.update({ where, data: { metadata: null } })).rejects.toThrow();
50+
51+
await db.foo.update({ where, data: { metadata } }); // ✅ No typescript error
52+
53+
// optionalMetadata (nullable) - DbNull/JsonNull should NOT cause TS errors
54+
await db.foo.update({ where, data: { metadata, optionalMetadata: DbNull } });
55+
await db.foo.update({ where, data: { metadata, optionalMetadata: JsonNull } });
56+
// @ts-expect-error - AnyNull is not accepted for typed JSON fields (TS + runtime rejection)
57+
await expect(db.foo.update({ where, data: { metadata, optionalMetadata: AnyNull } })).rejects.toThrow();
58+
await db.foo.update({ where, data: { metadata, optionalMetadata: null } }); // ✅ No typescript error
59+
60+
/* ---------------------------------- FIND ---------------------------------- */
61+
62+
// metadata (non nullable) - these should cause TS errors
63+
// @ts-expect-error - should not be able to filter by DbNull on a non nullable field
64+
void db.foo.findMany({ where: { metadata: DbNull } });
65+
// @ts-expect-error - should not be able to filter by JsonNull on a non nullable field
66+
void db.foo.findMany({ where: { metadata: JsonNull } });
67+
// @ts-expect-error - should not be able to filter by AnyNull on a non nullable field
68+
void db.foo.findMany({ where: { metadata: AnyNull } });
69+
// @ts-expect-error - should not be able to filter by null on a non nullable field
70+
void db.foo.findMany({ where: { metadata: null } });
71+
72+
// optionalMetadata (nullable) - these should NOT cause TS errors
73+
await db.foo.findMany({ where: { optionalMetadata: DbNull } });
74+
await db.foo.findMany({ where: { optionalMetadata: JsonNull } });
75+
await db.foo.findMany({ where: { optionalMetadata: AnyNull } });
76+
await db.foo.findMany({ where: { optionalMetadata: null } }); // ✅ No typescript error
77+
});
78+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//////////////////////////////////////////////////////////////////////////////////////////////
2+
// DO NOT MODIFY THIS FILE //
3+
// This file is automatically generated by ZenStack CLI and should not be manually updated. //
4+
//////////////////////////////////////////////////////////////////////////////////////////////
5+
6+
/* eslint-disable */
7+
8+
import { type SchemaDef, type AttributeApplication, type FieldDefault, ExpressionUtils } from "@zenstackhq/schema";
9+
export class SchemaType implements SchemaDef {
10+
provider = {
11+
type: "sqlite"
12+
} as const;
13+
models = {
14+
Foo: {
15+
name: "Foo",
16+
fields: {
17+
id: {
18+
name: "id",
19+
type: "String",
20+
id: true,
21+
attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }] as readonly AttributeApplication[],
22+
default: ExpressionUtils.call("cuid") as FieldDefault
23+
},
24+
createdAt: {
25+
name: "createdAt",
26+
type: "DateTime",
27+
attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }] as readonly AttributeApplication[],
28+
default: ExpressionUtils.call("now") as FieldDefault
29+
},
30+
updatedAt: {
31+
name: "updatedAt",
32+
type: "DateTime",
33+
updatedAt: true,
34+
attributes: [{ name: "@updatedAt" }] as readonly AttributeApplication[]
35+
},
36+
metadata: {
37+
name: "metadata",
38+
type: "Metadata",
39+
attributes: [{ name: "@json" }] as readonly AttributeApplication[]
40+
},
41+
optionalMetadata: {
42+
name: "optionalMetadata",
43+
type: "Metadata",
44+
optional: true,
45+
attributes: [{ name: "@json" }] as readonly AttributeApplication[]
46+
}
47+
},
48+
idFields: ["id"],
49+
uniqueFields: {
50+
id: { type: "String" }
51+
}
52+
}
53+
} as const;
54+
typeDefs = {
55+
Metadata: {
56+
name: "Metadata",
57+
fields: {
58+
someString: {
59+
name: "someString",
60+
type: "String"
61+
},
62+
someInt: {
63+
name: "someInt",
64+
type: "Int"
65+
}
66+
}
67+
}
68+
} as const;
69+
plugins = {};
70+
}
71+
export const schema = new SchemaType();
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
datasource db {
2+
provider = "sqlite"
3+
url = "file:./dev.db"
4+
}
5+
6+
type Metadata {
7+
someString String
8+
someInt Int
9+
}
10+
11+
model Foo {
12+
id String @id @default(cuid())
13+
createdAt DateTime @default(now())
14+
updatedAt DateTime @updatedAt
15+
16+
metadata Metadata @json
17+
optionalMetadata Metadata? @json
18+
}

0 commit comments

Comments
 (0)