Skip to content
This repository was archived by the owner on Mar 1, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 8 additions & 0 deletions packages/orm/src/client/crud-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,8 +458,16 @@ export type BooleanFilter<Nullable extends boolean, WithAggregations extends boo
: {}));

export type JsonFilter = {
path?: string[];
equals?: JsonValue | JsonNullValues;
not?: JsonValue | JsonNullValues;
string_contains?: string;
string_starts_with?: string;
string_ends_with?: string;
mode?: 'default' | 'insensitive';
array_contains?: JsonValue;
array_starts_with?: JsonValue;
array_ends_with?: JsonValue;
};

// TODO: extra typedef filtering
Expand Down
76 changes: 74 additions & 2 deletions packages/orm/src/client/crud/dialects/base-dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,14 +524,49 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
private buildJsonFilter(lhs: Expression<any>, payload: any): any {
const clauses: Expression<SqlBool>[] = [];
invariant(payload && typeof payload === 'object', 'Json filter payload must be an object');

const path = payload.path && Array.isArray(payload.path) ? payload.path : [];
const receiver = this.buildJsonPathSelection(lhs, path, 'json');
const stringReceiver = this.buildJsonPathSelection(lhs, path, 'string');

const mode = payload.mode ?? 'default';
invariant(mode === 'default' || mode === 'insensitive', 'Invalid JSON filter mode');

for (const [key, value] of Object.entries(payload)) {
switch (key) {
case 'equals': {
clauses.push(this.buildJsonValueFilterClause(lhs, value));
clauses.push(this.buildJsonValueFilterClause(receiver, value));
break;
}
case 'not': {
clauses.push(this.eb.not(this.buildJsonValueFilterClause(lhs, value)));
clauses.push(this.eb.not(this.buildJsonValueFilterClause(receiver, value)));
break;
}
case 'string_contains': {
invariant(typeof value === 'string', 'string_contains value must be a string');
clauses.push(this.buildJsonStringFilter(stringReceiver, key, value, mode));
break;
}
case 'string_starts_with': {
invariant(typeof value === 'string', 'string_starts_with value must be a string');
clauses.push(this.buildJsonStringFilter(stringReceiver, key, value, mode));
break;
}
case 'string_ends_with': {
invariant(typeof value === 'string', 'string_ends_with value must be a string');
clauses.push(this.buildJsonStringFilter(stringReceiver, key, value, mode));
break;
}
case 'array_contains': {
clauses.push(this.buildJsonArrayFilter(receiver, key, value));
break;
}
case 'array_starts_with': {
clauses.push(this.buildJsonArrayFilter(receiver, key, value));
break;
}
case 'array_ends_with': {
clauses.push(this.buildJsonArrayFilter(receiver, key, value));
break;
}
Comment thread
ymc9 marked this conversation as resolved.
}
Expand All @@ -552,6 +587,24 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
}
}

private buildJsonStringFilter(
lhs: Expression<any>,
operation: 'string_contains' | 'string_starts_with' | 'string_ends_with',
value: string,
mode: 'default' | 'insensitive',
) {
// build LIKE pattern based on operation
const pattern = match(operation)
.with('string_contains', () => `%${value}%`)
.with('string_starts_with', () => `${value}%`)
.with('string_ends_with', () => `%${value}`)
.exhaustive();

// use appropriate operator based on database capabilities
const { supportsILike } = this.getStringCasingBehavior();
return this.eb(lhs, mode === 'insensitive' && supportsILike ? 'ilike' : 'like', sql.val(pattern));
}

private buildLiteralFilter(lhs: Expression<any>, type: BuiltinType, rhs: unknown) {
return this.eb(lhs, '=', rhs !== null && rhs !== undefined ? this.transformPrimitive(rhs, type, false) : rhs);
}
Expand Down Expand Up @@ -1245,5 +1298,24 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
*/
abstract getStringCasingBehavior(): { supportsILike: boolean; likeCaseSensitive: boolean };

/**
* Builds a JSON path selection expression.
* @param asType 'string' | 'json', when 'string', the result is stripped with text quotes if it's a string
*/
protected abstract buildJsonPathSelection(
receiver: Expression<any>,
path: string[],
asType: 'string' | 'json',
): Expression<any>;

/**
* Builds a JSON array filter expression.
*/
protected abstract buildJsonArrayFilter(
lhs: Expression<any>,
operation: 'array_contains' | 'array_starts_with' | 'array_ends_with',
value: unknown,
): Expression<SqlBool>;

// #endregion
}
45 changes: 45 additions & 0 deletions packages/orm/src/client/crud/dialects/postgresql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type ExpressionWrapper,
type RawBuilder,
type SelectQueryBuilder,
type SqlBool,
} from 'kysely';
import { match } from 'ts-pattern';
import z from 'zod';
Expand Down Expand Up @@ -453,6 +454,50 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends BaseCrudDiale
}
}

protected override buildJsonPathSelection(receiver: Expression<any>, path: [], asType: 'string' | 'json') {
Comment thread
ymc9 marked this conversation as resolved.
Outdated
if (path.length > 0) {
const pathValues = path.map((p: string) => this.eb.val(p));
if (asType === 'string') {
// use `jsonb_extract_path_text` to get string values without quotes
return this.eb.fn('jsonb_extract_path_text', [receiver, ...pathValues]);
} else {
return this.eb.fn('jsonb_extract_path', [receiver, ...pathValues]);
}
} else {
// if we're selecting the JSON root, we'll have to resort to `trim` when selecting as string
// to remove the quotes
if (asType === 'string') {
return sql`trim(both '"' from ${receiver}::text)`;
} else {
return receiver;
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

protected override buildJsonArrayFilter(
lhs: Expression<any>,
operation: 'array_contains' | 'array_starts_with' | 'array_ends_with',
value: unknown,
) {
return match(operation)
.with('array_contains', () => sql<SqlBool>`${lhs} @> ${sql.lit(JSON.stringify([value]))}::jsonb`)
.with('array_starts_with', () =>
this.eb(
this.eb.fn('jsonb_extract_path', [lhs, this.eb.val('0')]),
'=',
this.transformPrimitive(value, 'Json', false),
),
)
.with('array_ends_with', () =>
this.eb(
this.eb.fn('jsonb_extract_path', [lhs, sql`(jsonb_array_length(${lhs}) - 1)::text`]),
'=',
this.transformPrimitive(value, 'Json', false),
),
)
.exhaustive();
}
Comment thread
ymc9 marked this conversation as resolved.

override get supportInsertWithDefault() {
return true;
}
Expand Down
41 changes: 41 additions & 0 deletions packages/orm/src/client/crud/dialects/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,47 @@ export class SqliteCrudDialect<Schema extends SchemaDef> extends BaseCrudDialect
);
}

protected override buildJsonPathSelection(
receiver: Expression<any>,
path: string[],
_asType: 'string' | 'json',
): Expression<any> {
if (path.length === 0) {
return receiver;
}

// build a JSON path from the path segments
// array indices should use bracket notation: $.a[0].b instead of $.a.0.b
const jsonPath =
'$' +
path
.map((p) => {
// check if the segment is a numeric array index
if (/^\d+$/.test(p)) {
return `[${p}]`;
}
return `.${p}`;
})
.join('');
return this.eb.fn('json_extract', [receiver, this.eb.val(jsonPath)]);
}

protected override buildJsonArrayFilter(
lhs: Expression<any>,
operation: 'array_contains' | 'array_starts_with' | 'array_ends_with',
value: unknown,
) {
return match(operation)
.with('array_contains', () => sql<any>`EXISTS (SELECT 1 FROM json_each(${lhs}) WHERE value = ${value})`)
.with('array_starts_with', () =>
this.eb(this.eb.fn('json_extract', [lhs, this.eb.val('$[0]')]), '=', value),
)
.with('array_ends_with', () =>
this.eb(sql`json_extract(${lhs}, '$[' || (json_array_length(${lhs}) - 1) || ']')`, '=', value),
)
.exhaustive();
}

override get supportsUpdateWithLimit() {
return false;
}
Expand Down
10 changes: 9 additions & 1 deletion packages/orm/src/client/crud/validator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,9 +608,17 @@ export class InputValidator<Schema extends SchemaDef> {

private makeJsonFilterSchema(optional: boolean) {
const valueSchema = this.makeJsonValueSchema(optional, true);
return z.object({
return z.strictObject({
path: z.string().array().optional(),
equals: valueSchema.optional(),
not: valueSchema.optional(),
string_contains: z.string().optional(),
string_starts_with: z.string().optional(),
string_ends_with: z.string().optional(),
mode: this.makeStringModeSchema().optional(),
Comment thread
ymc9 marked this conversation as resolved.
array_contains: valueSchema.optional(),
array_starts_with: valueSchema.optional(),
array_ends_with: valueSchema.optional(),
});
}

Expand Down
1 change: 1 addition & 0 deletions packages/testtools/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export async function createTestClient(

if (options?.debug) {
console.log(`Work directory: ${workDir}`);
console.log(`Database name: ${dbName}`);
_options.log = testLogger;
}

Expand Down
Loading
Loading