Skip to content

Commit f3843a3

Browse files
ymc9Copilot
andauthored
feat(orm): implement JSON null values and equality filter (#464)
* feat(orm): implement JSON null values and equality filter * Update packages/orm/src/client/crud/validator/index.ts Co-authored-by: Copilot <[email protected]> * Update packages/orm/src/utils/type-utils.ts Co-authored-by: Copilot <[email protected]> * address PR comments * speed up test type-checking --------- Co-authored-by: Copilot <[email protected]>
1 parent d3bfc9d commit f3843a3

39 files changed

+559
-101
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
2626
# What's ZenStack
2727

28+
> Read full documentation at 👉🏻 https://zenstack.dev/v3.
29+
2830
ZenStack is a TypeScript database toolkit for developing full-stack or backend Node.js/Bun applications. It provides a unified data modeling and access solution with the following features:
2931

3032
- A modern schema-first ORM that's compatible with [Prisma](https://github.com/prisma/prisma)'s schema and API

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

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import type {
3333
} from '../schema';
3434
import type {
3535
AtLeast,
36+
JsonNullValues,
37+
JsonValue,
3638
MapBaseType,
3739
NonEmptyArray,
3840
NullableIf,
@@ -44,6 +46,7 @@ import type {
4446
WrapType,
4547
XOR,
4648
} from '../utils/type-utils';
49+
import type { DbNull, JsonNull } from './null-values';
4750
import type { ClientOptions } from './options';
4851
import type { ToKyselySchema } from './query-builder';
4952

@@ -359,7 +362,7 @@ type PrimitiveFilter<T extends string, Nullable extends boolean, WithAggregation
359362
: T extends 'Bytes'
360363
? BytesFilter<Nullable, WithAggregations>
361364
: T extends 'Json'
362-
? 'Not implemented yet' // TODO: Json filter
365+
? JsonFilter
363366
: never;
364367

365368
type CommonPrimitiveFilter<
@@ -452,6 +455,11 @@ export type BooleanFilter<Nullable extends boolean, WithAggregations extends boo
452455
}
453456
: {}));
454457

458+
export type JsonFilter = {
459+
equals?: JsonValue | JsonNullValues;
460+
not?: JsonValue | JsonNullValues;
461+
};
462+
455463
export type SortOrder = 'asc' | 'desc';
456464
export type NullsOrder = 'first' | 'last';
457465

@@ -772,20 +780,34 @@ type CreateScalarPayload<Schema extends SchemaDef, Model extends GetModels<Schem
772780
}
773781
>;
774782

775-
// For unknown reason toplevel `Simplify` can't simplify this type, so we added an extra layer
776-
// to make it work
777783
type ScalarCreatePayload<
778784
Schema extends SchemaDef,
779785
Model extends GetModels<Schema>,
780786
Field extends ScalarFields<Schema, Model, false>,
781-
> = Simplify<
782-
| MapModelFieldType<Schema, Model, Field>
787+
> =
788+
| ScalarFieldMutationPayload<Schema, Model, Field>
783789
| (FieldIsArray<Schema, Model, Field> extends true
784790
? {
785791
set?: MapModelFieldType<Schema, Model, Field>;
786792
}
787-
: never)
788-
>;
793+
: never);
794+
795+
type ScalarFieldMutationPayload<
796+
Schema extends SchemaDef,
797+
Model extends GetModels<Schema>,
798+
Field extends GetModelFields<Schema, Model>,
799+
> =
800+
IsJsonField<Schema, Model, Field> extends true
801+
? ModelFieldIsOptional<Schema, Model, Field> extends true
802+
? JsonValue | JsonNull | DbNull
803+
: JsonValue | JsonNull
804+
: MapModelFieldType<Schema, Model, Field>;
805+
806+
type IsJsonField<
807+
Schema extends SchemaDef,
808+
Model extends GetModels<Schema>,
809+
Field extends GetModelFields<Schema, Model>,
810+
> = GetModelFieldType<Schema, Model, Field> extends 'Json' ? true : false;
789811

790812
type CreateFKPayload<Schema extends SchemaDef, Model extends GetModels<Schema>> = OptionalWrap<
791813
Schema,
@@ -932,7 +954,7 @@ type ScalarUpdatePayload<
932954
Model extends GetModels<Schema>,
933955
Field extends NonRelationFields<Schema, Model>,
934956
> =
935-
| MapModelFieldType<Schema, Model, Field>
957+
| ScalarFieldMutationPayload<Schema, Model, Field>
936958
| (Field extends NumericFields<Schema, Model>
937959
? {
938960
set?: NullableIf<number, ModelFieldIsOptional<Schema, Model, Field>>;

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

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
StringFilter,
1616
} from '../../crud-types';
1717
import { createConfigError, createInvalidInputError, createNotSupportedError } from '../../errors';
18+
import { AnyNullClass, DbNullClass, JsonNullClass } from '../../null-values';
1819
import type { ClientOptions } from '../../options';
1920
import {
2021
aggregate,
@@ -499,24 +500,50 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
499500
return this.buildEnumFilter(fieldRef, fieldDef, payload);
500501
}
501502

502-
return (
503-
match(fieldDef.type as BuiltinType)
504-
.with('String', () => this.buildStringFilter(fieldRef, payload))
505-
.with(P.union('Int', 'Float', 'Decimal', 'BigInt'), (type) =>
506-
this.buildNumberFilter(fieldRef, type, payload),
507-
)
508-
.with('Boolean', () => this.buildBooleanFilter(fieldRef, payload))
509-
.with('DateTime', () => this.buildDateTimeFilter(fieldRef, payload))
510-
.with('Bytes', () => this.buildBytesFilter(fieldRef, payload))
511-
// TODO: JSON filters
512-
.with('Json', () => {
513-
throw createNotSupportedError('JSON filters are not supported yet');
514-
})
515-
.with('Unsupported', () => {
516-
throw createInvalidInputError(`Unsupported field cannot be used in filters`);
517-
})
518-
.exhaustive()
519-
);
503+
return match(fieldDef.type as BuiltinType)
504+
.with('String', () => this.buildStringFilter(fieldRef, payload))
505+
.with(P.union('Int', 'Float', 'Decimal', 'BigInt'), (type) =>
506+
this.buildNumberFilter(fieldRef, type, payload),
507+
)
508+
.with('Boolean', () => this.buildBooleanFilter(fieldRef, payload))
509+
.with('DateTime', () => this.buildDateTimeFilter(fieldRef, payload))
510+
.with('Bytes', () => this.buildBytesFilter(fieldRef, payload))
511+
.with('Json', () => this.buildJsonFilter(fieldRef, payload))
512+
.with('Unsupported', () => {
513+
throw createInvalidInputError(`Unsupported field cannot be used in filters`);
514+
})
515+
.exhaustive();
516+
}
517+
518+
private buildJsonFilter(lhs: Expression<any>, payload: any): any {
519+
const clauses: Expression<SqlBool>[] = [];
520+
invariant(payload && typeof payload === 'object', 'Json filter payload must be an object');
521+
for (const [key, value] of Object.entries(payload)) {
522+
switch (key) {
523+
case 'equals': {
524+
clauses.push(this.buildJsonValueFilterClause(lhs, value));
525+
break;
526+
}
527+
case 'not': {
528+
clauses.push(this.eb.not(this.buildJsonValueFilterClause(lhs, value)));
529+
break;
530+
}
531+
}
532+
}
533+
return this.and(...clauses);
534+
}
535+
536+
private buildJsonValueFilterClause(lhs: Expression<any>, value: unknown) {
537+
if (value instanceof DbNullClass) {
538+
return this.eb(lhs, 'is', null);
539+
} else if (value instanceof JsonNullClass) {
540+
return this.eb.and([this.eb(lhs, '=', 'null'), this.eb(lhs, 'is not', null)]);
541+
} else if (value instanceof AnyNullClass) {
542+
// AnyNull matches both DB NULL and JSON null
543+
return this.eb.or([this.eb(lhs, 'is', null), this.eb(lhs, '=', 'null')]);
544+
} else {
545+
return this.buildLiteralFilter(lhs, 'Json', value);
546+
}
520547
}
521548

522549
private buildLiteralFilter(lhs: Expression<any>, type: BuiltinType, rhs: unknown) {

packages/orm/src/client/crud/dialects/postgresql.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { BuiltinType, FieldDef, GetModels, SchemaDef } from '../../../schem
1414
import { DELEGATE_JOINED_FIELD_PREFIX } from '../../constants';
1515
import type { FindArgs } from '../../crud-types';
1616
import { createInternalError } from '../../errors';
17+
import { AnyNullClass, DbNullClass, JsonNullClass } from '../../null-values';
1718
import type { ClientOptions } from '../../options';
1819
import {
1920
buildJoinPairs,
@@ -25,7 +26,6 @@ import {
2526
requireModel,
2627
} from '../../query-utils';
2728
import { BaseCrudDialect } from './base-dialect';
28-
2929
export class PostgresCrudDialect<Schema extends SchemaDef> extends BaseCrudDialect<Schema> {
3030
private isoDateSchema = z.iso.datetime({ local: true, offset: true });
3131

@@ -42,6 +42,15 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends BaseCrudDiale
4242
return value;
4343
}
4444

45+
// Handle special null classes for JSON fields
46+
if (value instanceof JsonNullClass) {
47+
return 'null';
48+
} else if (value instanceof DbNullClass) {
49+
return null;
50+
} else if (value instanceof AnyNullClass) {
51+
invariant(false, 'should not reach here: AnyNull is not a valid input value');
52+
}
53+
4554
if (Array.isArray(value)) {
4655
if (type === 'Json' && !forArrayField) {
4756
// node-pg incorrectly handles array values passed to non-array JSON fields,

packages/orm/src/client/crud/dialects/sqlite.ts

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { BuiltinType, FieldDef, GetModels, SchemaDef } from '../../../schem
1313
import { DELEGATE_JOINED_FIELD_PREFIX } from '../../constants';
1414
import type { FindArgs } from '../../crud-types';
1515
import { createInternalError } from '../../errors';
16+
import { AnyNullClass, DbNullClass, JsonNullClass } from '../../null-values';
1617
import {
1718
getDelegateDescendantModels,
1819
getManyToManyRelation,
@@ -33,27 +34,35 @@ export class SqliteCrudDialect<Schema extends SchemaDef> extends BaseCrudDialect
3334
return value;
3435
}
3536

37+
// Handle special null classes for JSON fields
38+
if (value instanceof JsonNullClass) {
39+
return 'null';
40+
} else if (value instanceof DbNullClass) {
41+
return null;
42+
} else if (value instanceof AnyNullClass) {
43+
invariant(false, 'should not reach here: AnyNull is not a valid input value');
44+
}
45+
46+
if (type === 'Json' || (this.schema.typeDefs && type in this.schema.typeDefs)) {
47+
// JSON data should be stringified
48+
return JSON.stringify(value);
49+
}
50+
3651
if (Array.isArray(value)) {
3752
return value.map((v) => this.transformPrimitive(v, type, false));
3853
} else {
39-
if (this.schema.typeDefs && type in this.schema.typeDefs) {
40-
// typed JSON field
41-
return JSON.stringify(value);
42-
} else {
43-
return match(type)
44-
.with('Boolean', () => (value ? 1 : 0))
45-
.with('DateTime', () =>
46-
value instanceof Date
47-
? value.toISOString()
48-
: typeof value === 'string'
49-
? new Date(value).toISOString()
50-
: value,
51-
)
52-
.with('Decimal', () => (value as Decimal).toString())
53-
.with('Bytes', () => Buffer.from(value as Uint8Array))
54-
.with('Json', () => JSON.stringify(value))
55-
.otherwise(() => value);
56-
}
54+
return match(type)
55+
.with('Boolean', () => (value ? 1 : 0))
56+
.with('DateTime', () =>
57+
value instanceof Date
58+
? value.toISOString()
59+
: typeof value === 'string'
60+
? new Date(value).toISOString()
61+
: value,
62+
)
63+
.with('Decimal', () => (value as Decimal).toString())
64+
.with('Bytes', () => Buffer.from(value as Uint8Array))
65+
.otherwise(() => value);
5766
}
5867
}
5968

0 commit comments

Comments
 (0)