Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions packages/orm/src/client/crud-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -827,7 +827,7 @@ type TypedJsonTypedFilter<
| (Array extends true
? ArrayTypedJsonFilter<Schema, TypeDefName, AllowedKinds>
: NonArrayTypedJsonFilter<Schema, TypeDefName, AllowedKinds>)
| (Optional extends true ? null : never)
| (Optional extends true ? null | JsonNullValues : never)
: {};

type ArrayTypedJsonFilter<
Expand Down Expand Up @@ -1375,14 +1375,24 @@ type ScalarFieldMutationPayload<
? ModelFieldIsOptional<Schema, Model, Field> extends true
? JsonValue | JsonNull | DbNull
: JsonValue | JsonNull
: MapModelFieldType<Schema, Model, Field>;
: IsTypedJsonField<Schema, Model, Field> extends true
? ModelFieldIsOptional<Schema, Model, Field> extends true
? MapModelFieldType<Schema, Model, Field> | JsonNull | DbNull
: MapModelFieldType<Schema, Model, Field>
: MapModelFieldType<Schema, Model, Field>;

type IsJsonField<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Field extends GetModelFields<Schema, Model>,
> = GetModelFieldType<Schema, Model, Field> extends 'Json' ? true : false;

type IsTypedJsonField<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Field extends GetModelFields<Schema, Model>,
> = GetModelFieldType<Schema, Model, Field> extends GetTypeDefs<Schema> ? true : false;

type CreateFKPayload<Schema extends SchemaDef, Model extends GetModels<Schema>> = OptionalWrap<
Schema,
Model,
Expand Down
4 changes: 4 additions & 0 deletions packages/orm/src/client/crud/dialects/base-dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,10 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
}

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);
}

Expand Down
33 changes: 32 additions & 1 deletion packages/orm/src/client/zod/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();
}
Expand Down
31 changes: 31 additions & 0 deletions tests/regression/test/issue-2411/input.ts
Original file line number Diff line number Diff line change
@@ -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<Args extends $SelectIncludeOmit<$Schema, "Foo", true>, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "Foo", Args, Options>;
11 changes: 11 additions & 0 deletions tests/regression/test/issue-2411/models.ts
Original file line number Diff line number Diff line change
@@ -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">;
78 changes: 78 additions & 0 deletions tests/regression/test/issue-2411/regression.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
71 changes: 71 additions & 0 deletions tests/regression/test/issue-2411/schema.ts
Original file line number Diff line number Diff line change
@@ -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();
18 changes: 18 additions & 0 deletions tests/regression/test/issue-2411/schema.zmodel
Original file line number Diff line number Diff line change
@@ -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
}
Loading