Skip to content
This repository was archived by the owner on Mar 1, 2026. It is now read-only.

Commit ebf98a5

Browse files
authored
feat(orm): implement json filters (#472)
1 parent 02dec0c commit ebf98a5

12 files changed

Lines changed: 678 additions & 92 deletions

File tree

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@
8686
- [x] Custom table name
8787
- [x] Custom field name
8888
- [x] Global omit
89-
- [ ] DbNull vs JsonNull
89+
- [x] DbNull vs JsonNull
9090
- [ ] Migrate to tsdown
9191
- [x] @default validation
9292
- [x] Benchmark

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,8 +458,16 @@ export type BooleanFilter<Nullable extends boolean, WithAggregations extends boo
458458
: {}));
459459

460460
export type JsonFilter = {
461+
path?: string[];
461462
equals?: JsonValue | JsonNullValues;
462463
not?: JsonValue | JsonNullValues;
464+
string_contains?: string;
465+
string_starts_with?: string;
466+
string_ends_with?: string;
467+
mode?: 'default' | 'insensitive';
468+
array_contains?: JsonValue;
469+
array_starts_with?: JsonValue;
470+
array_ends_with?: JsonValue;
463471
};
464472

465473
// TODO: extra typedef filtering

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

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -524,16 +524,57 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
524524
private buildJsonFilter(lhs: Expression<any>, payload: any): any {
525525
const clauses: Expression<SqlBool>[] = [];
526526
invariant(payload && typeof payload === 'object', 'Json filter payload must be an object');
527+
528+
const path = payload.path && Array.isArray(payload.path) ? payload.path : [];
529+
const receiver = this.buildJsonPathSelection(lhs, path, 'json');
530+
const stringReceiver = this.buildJsonPathSelection(lhs, path, 'string');
531+
532+
const mode = payload.mode ?? 'default';
533+
invariant(mode === 'default' || mode === 'insensitive', 'Invalid JSON filter mode');
534+
527535
for (const [key, value] of Object.entries(payload)) {
528536
switch (key) {
529537
case 'equals': {
530-
clauses.push(this.buildJsonValueFilterClause(lhs, value));
538+
clauses.push(this.buildJsonValueFilterClause(receiver, value));
531539
break;
532540
}
533541
case 'not': {
534-
clauses.push(this.eb.not(this.buildJsonValueFilterClause(lhs, value)));
542+
clauses.push(this.eb.not(this.buildJsonValueFilterClause(receiver, value)));
543+
break;
544+
}
545+
case 'string_contains': {
546+
invariant(typeof value === 'string', 'string_contains value must be a string');
547+
clauses.push(this.buildJsonStringFilter(stringReceiver, key, value, mode));
548+
break;
549+
}
550+
case 'string_starts_with': {
551+
invariant(typeof value === 'string', 'string_starts_with value must be a string');
552+
clauses.push(this.buildJsonStringFilter(stringReceiver, key, value, mode));
553+
break;
554+
}
555+
case 'string_ends_with': {
556+
invariant(typeof value === 'string', 'string_ends_with value must be a string');
557+
clauses.push(this.buildJsonStringFilter(stringReceiver, key, value, mode));
558+
break;
559+
}
560+
case 'array_contains': {
561+
clauses.push(this.buildJsonArrayFilter(receiver, key, value));
562+
break;
563+
}
564+
case 'array_starts_with': {
565+
clauses.push(this.buildJsonArrayFilter(receiver, key, value));
566+
break;
567+
}
568+
case 'array_ends_with': {
569+
clauses.push(this.buildJsonArrayFilter(receiver, key, value));
535570
break;
536571
}
572+
case 'path':
573+
case 'mode':
574+
// already handled
575+
break;
576+
default:
577+
invariant(false, `Invalid JSON filter key: ${key}`);
537578
}
538579
}
539580
return this.and(...clauses);
@@ -552,6 +593,24 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
552593
}
553594
}
554595

596+
private buildJsonStringFilter(
597+
lhs: Expression<any>,
598+
operation: 'string_contains' | 'string_starts_with' | 'string_ends_with',
599+
value: string,
600+
mode: 'default' | 'insensitive',
601+
) {
602+
// build LIKE pattern based on operation
603+
const pattern = match(operation)
604+
.with('string_contains', () => `%${value}%`)
605+
.with('string_starts_with', () => `${value}%`)
606+
.with('string_ends_with', () => `%${value}`)
607+
.exhaustive();
608+
609+
// use appropriate operator based on database capabilities
610+
const { supportsILike } = this.getStringCasingBehavior();
611+
return this.eb(lhs, mode === 'insensitive' && supportsILike ? 'ilike' : 'like', sql.val(pattern));
612+
}
613+
555614
private buildLiteralFilter(lhs: Expression<any>, type: BuiltinType, rhs: unknown) {
556615
return this.eb(lhs, '=', rhs !== null && rhs !== undefined ? this.transformPrimitive(rhs, type, false) : rhs);
557616
}
@@ -1245,5 +1304,24 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
12451304
*/
12461305
abstract getStringCasingBehavior(): { supportsILike: boolean; likeCaseSensitive: boolean };
12471306

1307+
/**
1308+
* Builds a JSON path selection expression.
1309+
* @param asType 'string' | 'json', when 'string', the result is stripped with text quotes if it's a string
1310+
*/
1311+
protected abstract buildJsonPathSelection(
1312+
receiver: Expression<any>,
1313+
path: string[],
1314+
asType: 'string' | 'json',
1315+
): Expression<any>;
1316+
1317+
/**
1318+
* Builds a JSON array filter expression.
1319+
*/
1320+
protected abstract buildJsonArrayFilter(
1321+
lhs: Expression<any>,
1322+
operation: 'array_contains' | 'array_starts_with' | 'array_ends_with',
1323+
value: unknown,
1324+
): Expression<SqlBool>;
1325+
12481326
// #endregion
12491327
}

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
type ExpressionWrapper,
88
type RawBuilder,
99
type SelectQueryBuilder,
10+
type SqlBool,
1011
} from 'kysely';
1112
import { match } from 'ts-pattern';
1213
import z from 'zod';
@@ -453,6 +454,50 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends BaseCrudDiale
453454
}
454455
}
455456

457+
protected override buildJsonPathSelection(receiver: Expression<any>, path: string[], asType: 'string' | 'json') {
458+
if (path.length > 0) {
459+
const pathValues = path.map((p: string) => this.eb.val(p));
460+
if (asType === 'string') {
461+
// use `jsonb_extract_path_text` to get string values without quotes
462+
return this.eb.fn('jsonb_extract_path_text', [receiver, ...pathValues]);
463+
} else {
464+
return this.eb.fn('jsonb_extract_path', [receiver, ...pathValues]);
465+
}
466+
} else {
467+
// if we're selecting the JSON root, we'll have to resort to `trim` when selecting as string
468+
// to remove the quotes
469+
if (asType === 'string') {
470+
return sql`trim(both '"' from ${receiver}::text)`;
471+
} else {
472+
return receiver;
473+
}
474+
}
475+
}
476+
477+
protected override buildJsonArrayFilter(
478+
lhs: Expression<any>,
479+
operation: 'array_contains' | 'array_starts_with' | 'array_ends_with',
480+
value: unknown,
481+
) {
482+
return match(operation)
483+
.with('array_contains', () => sql<SqlBool>`${lhs} @> ${sql.val(JSON.stringify([value]))}::jsonb`)
484+
.with('array_starts_with', () =>
485+
this.eb(
486+
this.eb.fn('jsonb_extract_path', [lhs, this.eb.val('0')]),
487+
'=',
488+
this.transformPrimitive(value, 'Json', false),
489+
),
490+
)
491+
.with('array_ends_with', () =>
492+
this.eb(
493+
this.eb.fn('jsonb_extract_path', [lhs, sql`(jsonb_array_length(${lhs}) - 1)::text`]),
494+
'=',
495+
this.transformPrimitive(value, 'Json', false),
496+
),
497+
)
498+
.exhaustive();
499+
}
500+
456501
override get supportInsertWithDefault() {
457502
return true;
458503
}

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,47 @@ export class SqliteCrudDialect<Schema extends SchemaDef> extends BaseCrudDialect
359359
);
360360
}
361361

362+
protected override buildJsonPathSelection(
363+
receiver: Expression<any>,
364+
path: string[],
365+
_asType: 'string' | 'json',
366+
): Expression<any> {
367+
if (path.length === 0) {
368+
return receiver;
369+
}
370+
371+
// build a JSON path from the path segments
372+
// array indices should use bracket notation: $.a[0].b instead of $.a.0.b
373+
const jsonPath =
374+
'$' +
375+
path
376+
.map((p) => {
377+
// check if the segment is a numeric array index
378+
if (/^\d+$/.test(p)) {
379+
return `[${p}]`;
380+
}
381+
return `.${p}`;
382+
})
383+
.join('');
384+
return this.eb.fn('json_extract', [receiver, this.eb.val(jsonPath)]);
385+
}
386+
387+
protected override buildJsonArrayFilter(
388+
lhs: Expression<any>,
389+
operation: 'array_contains' | 'array_starts_with' | 'array_ends_with',
390+
value: unknown,
391+
) {
392+
return match(operation)
393+
.with('array_contains', () => sql<any>`EXISTS (SELECT 1 FROM json_each(${lhs}) WHERE value = ${value})`)
394+
.with('array_starts_with', () =>
395+
this.eb(this.eb.fn('json_extract', [lhs, this.eb.val('$[0]')]), '=', value),
396+
)
397+
.with('array_ends_with', () =>
398+
this.eb(sql`json_extract(${lhs}, '$[' || (json_array_length(${lhs}) - 1) || ']')`, '=', value),
399+
)
400+
.exhaustive();
401+
}
402+
362403
override get supportsUpdateWithLimit() {
363404
return false;
364405
}

packages/orm/src/client/crud/validator/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -608,9 +608,17 @@ export class InputValidator<Schema extends SchemaDef> {
608608

609609
private makeJsonFilterSchema(optional: boolean) {
610610
const valueSchema = this.makeJsonValueSchema(optional, true);
611-
return z.object({
611+
return z.strictObject({
612+
path: z.string().array().optional(),
612613
equals: valueSchema.optional(),
613614
not: valueSchema.optional(),
615+
string_contains: z.string().optional(),
616+
string_starts_with: z.string().optional(),
617+
string_ends_with: z.string().optional(),
618+
mode: this.makeStringModeSchema().optional(),
619+
array_contains: valueSchema.optional(),
620+
array_starts_with: valueSchema.optional(),
621+
array_ends_with: valueSchema.optional(),
614622
});
615623
}
616624

packages/testtools/src/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ export async function createTestClient(
157157

158158
if (options?.debug) {
159159
console.log(`Work directory: ${workDir}`);
160+
console.log(`Database name: ${dbName}`);
160161
_options.log = testLogger;
161162
}
162163

0 commit comments

Comments
 (0)