From 8b1f23108624c6ca3309bf7ff4ad7838841bc463 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:42:49 +0800 Subject: [PATCH 1/3] feat(orm): implement json filters --- packages/orm/src/client/crud-types.ts | 8 + .../src/client/crud/dialects/base-dialect.ts | 76 ++- .../src/client/crud/dialects/postgresql.ts | 45 ++ .../orm/src/client/crud/dialects/sqlite.ts | 41 ++ .../orm/src/client/crud/validator/index.ts | 10 +- packages/testtools/src/client.ts | 1 + tests/e2e/orm/client-api/json-filter.test.ts | 533 +++++++++++++++--- tests/e2e/orm/schemas/json/input.ts | 40 +- tests/e2e/orm/schemas/json/models.ts | 2 +- tests/e2e/orm/schemas/json/schema.ts | 4 +- tests/e2e/orm/schemas/json/schema.zmodel | 2 +- 11 files changed, 671 insertions(+), 91 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 87bcdf312..eafe35f26 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -458,8 +458,16 @@ export type BooleanFilter { private buildJsonFilter(lhs: Expression, payload: any): any { const clauses: Expression[] = []; 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; } } @@ -552,6 +587,24 @@ export abstract class BaseCrudDialect { } } + private buildJsonStringFilter( + lhs: Expression, + 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, type: BuiltinType, rhs: unknown) { return this.eb(lhs, '=', rhs !== null && rhs !== undefined ? this.transformPrimitive(rhs, type, false) : rhs); } @@ -1245,5 +1298,24 @@ export abstract class BaseCrudDialect { */ 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, + path: string[], + asType: 'string' | 'json', + ): Expression; + + /** + * Builds a JSON array filter expression. + */ + protected abstract buildJsonArrayFilter( + lhs: Expression, + operation: 'array_contains' | 'array_starts_with' | 'array_ends_with', + value: unknown, + ): Expression; + // #endregion } diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index fe67b8701..b76fa8168 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -7,6 +7,7 @@ import { type ExpressionWrapper, type RawBuilder, type SelectQueryBuilder, + type SqlBool, } from 'kysely'; import { match } from 'ts-pattern'; import z from 'zod'; @@ -453,6 +454,50 @@ export class PostgresCrudDialect extends BaseCrudDiale } } + protected override buildJsonPathSelection(receiver: Expression, path: [], asType: 'string' | 'json') { + 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; + } + } + } + + protected override buildJsonArrayFilter( + lhs: Expression, + operation: 'array_contains' | 'array_starts_with' | 'array_ends_with', + value: unknown, + ) { + return match(operation) + .with('array_contains', () => sql`${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(); + } + override get supportInsertWithDefault() { return true; } diff --git a/packages/orm/src/client/crud/dialects/sqlite.ts b/packages/orm/src/client/crud/dialects/sqlite.ts index 7bc0489ad..d434e0d6f 100644 --- a/packages/orm/src/client/crud/dialects/sqlite.ts +++ b/packages/orm/src/client/crud/dialects/sqlite.ts @@ -359,6 +359,47 @@ export class SqliteCrudDialect extends BaseCrudDialect ); } + protected override buildJsonPathSelection( + receiver: Expression, + path: string[], + _asType: 'string' | 'json', + ): Expression { + 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, + operation: 'array_contains' | 'array_starts_with' | 'array_ends_with', + value: unknown, + ) { + return match(operation) + .with('array_contains', () => sql`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; } diff --git a/packages/orm/src/client/crud/validator/index.ts b/packages/orm/src/client/crud/validator/index.ts index b0d19d60c..578dde676 100644 --- a/packages/orm/src/client/crud/validator/index.ts +++ b/packages/orm/src/client/crud/validator/index.ts @@ -608,9 +608,17 @@ export class InputValidator { 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(), + array_contains: valueSchema.optional(), + array_starts_with: valueSchema.optional(), + array_ends_with: valueSchema.optional(), }); } diff --git a/packages/testtools/src/client.ts b/packages/testtools/src/client.ts index 1defaeaff..cd6be7472 100644 --- a/packages/testtools/src/client.ts +++ b/packages/testtools/src/client.ts @@ -157,6 +157,7 @@ export async function createTestClient( if (options?.debug) { console.log(`Work directory: ${workDir}`); + console.log(`Database name: ${dbName}`); _options.log = testLogger; } diff --git a/tests/e2e/orm/client-api/json-filter.test.ts b/tests/e2e/orm/client-api/json-filter.test.ts index 82eb336ad..2fce98f64 100644 --- a/tests/e2e/orm/client-api/json-filter.test.ts +++ b/tests/e2e/orm/client-api/json-filter.test.ts @@ -1,23 +1,21 @@ -import { createTestClient } from '@zenstackhq/testtools'; -import { describe, it, expect } from 'vitest'; +import { AnyNull, DbNull, JsonNull } from '@zenstackhq/orm'; +import { createTestClient, getTestDbProvider } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; import { schema } from '../schemas/json/schema'; import { schema as typedJsonSchema } from '../schemas/typed-json/schema'; -import { JsonNull, DbNull, AnyNull } from '@zenstackhq/orm'; describe('Json filter tests', () => { it('works with simple equality filter', async () => { const db = await createTestClient(schema); - await db.plainJson.create({ data: { data: { hello: 'world' } } }); + await db.foo.create({ data: { data: { hello: 'world' } } }); - await expect( - db.plainJson.findFirst({ where: { data: { equals: { hello: 'world' } } } }), - ).resolves.toMatchObject({ + await expect(db.foo.findFirst({ where: { data: { equals: { hello: 'world' } } } })).resolves.toMatchObject({ data: { hello: 'world' }, }); - await expect(db.plainJson.findFirst({ where: { data: { not: { hello: 'foo' } } } })).resolves.toMatchObject({ + await expect(db.foo.findFirst({ where: { data: { not: { hello: 'foo' } } } })).resolves.toMatchObject({ data: { hello: 'world' }, }); - await expect(db.plainJson.findFirst({ where: { data: { not: { hello: 'world' } } } })).toResolveNull(); + await expect(db.foo.findFirst({ where: { data: { not: { hello: 'world' } } } })).toResolveNull(); }); it('distinguishes between JsonNull and DbNull', async () => { @@ -25,19 +23,19 @@ describe('Json filter tests', () => { // Create records with different null types // Record 1: data contains JSON null, data1 is DB NULL (unset) - const rec1 = await db.plainJson.create({ data: { data: JsonNull } }); + const rec1 = await db.foo.create({ data: { data: JsonNull } }); // Record 2: data contains object, data1 explicitly set to JSON null - const rec2 = await db.plainJson.create({ data: { data: { foo: 'bar' }, data1: JsonNull } }); + const rec2 = await db.foo.create({ data: { data: { foo: 'bar' }, data1: JsonNull } }); // Record 3: data contains object, data1 is DB NULL (unset) - const rec3 = await db.plainJson.create({ data: { data: { hello: 'world' }, data1: DbNull } }); + const rec3 = await db.foo.create({ data: { data: { hello: 'world' }, data1: DbNull } }); // Record 4: data contains object, data1 explicitly set to an object - const rec4 = await db.plainJson.create({ data: { data: { test: 'value' }, data1: { key: 'value' } } }); + const rec4 = await db.foo.create({ data: { data: { test: 'value' }, data1: { key: 'value' } } }); // Test JsonNull - should match JSON null value in data field - const jsonNullResults = await db.plainJson.findMany({ + const jsonNullResults = await db.foo.findMany({ where: { data: { equals: JsonNull } }, }); expect(jsonNullResults).toHaveLength(1); @@ -45,7 +43,7 @@ describe('Json filter tests', () => { expect(jsonNullResults[0]?.id).toBe(rec1.id); // Test JsonNull in data1 field - const jsonNullData1Results = await db.plainJson.findMany({ + const jsonNullData1Results = await db.foo.findMany({ where: { data1: { equals: JsonNull } }, }); expect(jsonNullData1Results).toHaveLength(1); // Only record 2 has data1 as JSON null @@ -53,21 +51,21 @@ describe('Json filter tests', () => { expect(jsonNullData1Results[0]?.id).toBe(rec2.id); // Test NOT JsonNull - should exclude JSON null records - const notJsonNull = await db.plainJson.findMany({ + const notJsonNull = await db.foo.findMany({ where: { data: { not: JsonNull } }, }); expect(notJsonNull).toHaveLength(3); // Should exclude the JsonNull record expect(notJsonNull.map((r) => r.id).sort()).toEqual([rec2.id, rec3.id, rec4.id].sort()); // Test data1 with actual value - "not JsonNull" should match DB NULL and actual objects - const data1NotJsonNull = await db.plainJson.findMany({ + const data1NotJsonNull = await db.foo.findMany({ where: { data1: { not: JsonNull } }, }); // Records 1, 3 have DB NULL, record 4 has an object - all should match "not JsonNull" expect(data1NotJsonNull.length).toBe(3); // Test DbNull - should match database NULL values - const dbNullResults = await db.plainJson.findMany({ + const dbNullResults = await db.foo.findMany({ where: { data1: { equals: DbNull } }, }); // Records 1 and 3 have data1 as DB NULL @@ -75,7 +73,7 @@ describe('Json filter tests', () => { expect(dbNullResults.map((r) => r.id).sort()).toEqual([rec1.id, rec3.id].sort()); // Test AnyNull - should match both JSON null and DB NULL - const anyNullResults = await db.plainJson.findMany({ + const anyNullResults = await db.foo.findMany({ where: { data1: { equals: AnyNull } }, }); // Records 1, 2, and 3: rec1 (DB NULL), rec2 (JSON null), rec3 (DB NULL) @@ -84,101 +82,81 @@ describe('Json filter tests', () => { // invalid input // @ts-expect-error - await expect(db.plainJson.create({ data: { data: null } })).toBeRejectedByValidation(); + await expect(db.foo.create({ data: { data: null } })).toBeRejectedByValidation(); // @ts-expect-error - await expect(db.plainJson.create({ data: { data: DbNull } })).toBeRejectedByValidation(); + await expect(db.foo.create({ data: { data: DbNull } })).toBeRejectedByValidation(); // @ts-expect-error - await expect(db.plainJson.create({ data: { data1: null } })).toBeRejectedByValidation(); + await expect(db.foo.create({ data: { data1: null } })).toBeRejectedByValidation(); // @ts-expect-error - await expect(db.plainJson.update({ where: { id: rec1.id }, data: { data: null } })).toBeRejectedByValidation(); + await expect(db.foo.update({ where: { id: rec1.id }, data: { data: null } })).toBeRejectedByValidation(); await expect( // @ts-expect-error - db.plainJson.update({ where: { id: rec1.id }, data: { data: DbNull } }), + db.foo.update({ where: { id: rec1.id }, data: { data: DbNull } }), ).toBeRejectedByValidation(); // @ts-expect-error - await expect(db.plainJson.update({ where: { id: rec1.id }, data: { data1: null } })).toBeRejectedByValidation(); + await expect(db.foo.update({ where: { id: rec1.id }, data: { data1: null } })).toBeRejectedByValidation(); }); it('works with updates', async () => { const db = await createTestClient(schema); - const rec = await db.plainJson.create({ data: { data: { hello: 'world' }, data1: 'data1' } }); + const rec = await db.foo.create({ data: { data: { hello: 'world' }, data1: 'data1' } }); // Update to JSON null - await db.plainJson.update({ + await db.foo.update({ where: { id: rec.id }, data: { data: JsonNull }, }); - await expect(db.plainJson.findUnique({ where: { id: rec.id } })).resolves.toMatchObject({ + await expect(db.foo.findUnique({ where: { id: rec.id } })).resolves.toMatchObject({ data: null, }); // Update to DB null - await db.plainJson.update({ + await db.foo.update({ where: { id: rec.id }, data: { data1: DbNull }, }); - await expect(db.plainJson.findUnique({ where: { id: rec.id } })).resolves.toMatchObject({ + await expect(db.foo.findUnique({ where: { id: rec.id } })).resolves.toMatchObject({ data1: null, }); // Update to actual object - await db.plainJson.update({ + await db.foo.update({ where: { id: rec.id }, data: { data: { updated: 'value' }, data1: { another: 'value' } }, }); - await expect(db.plainJson.findUnique({ where: { id: rec.id } })).resolves.toMatchObject({ + await expect(db.foo.findUnique({ where: { id: rec.id } })).resolves.toMatchObject({ data: { updated: 'value' }, data1: { another: 'value' }, }); }); - it('works with JSON arrays', async () => { - const db = await createTestClient( - ` -model PlainJson { - id Int @id @default(autoincrement()) - data Json[] -} -`, - { provider: 'postgresql' }, - ); - - await expect(db.plainJson.create({ data: { data: [{ a: 1 }, { b: 2 }] } })).resolves.toMatchObject({ - data: [{ a: 1 }, { b: 2 }], - }); - await expect(db.plainJson.create({ data: { data: { set: [{ a: 1 }, { b: 2 }] } } })).resolves.toMatchObject({ - data: [{ a: 1 }, { b: 2 }], - }); - await expect(db.plainJson.create({ data: { data: DbNull } })).toBeRejectedByValidation(); - }); - it('works with JSON objects containing null values', async () => { const db = await createTestClient(schema); // Create a record with an object containing a null property value - const rec1 = await db.plainJson.create({ data: { data: { key: null } } }); + const rec1 = await db.foo.create({ data: { data: { key: null } } }); expect(rec1.data).toEqual({ key: null }); // Create a record with nested object containing null values - const rec2 = await db.plainJson.create({ data: { data: { outer: { inner: null }, valid: 'value' } } }); + const rec2 = await db.foo.create({ data: { data: { outer: { inner: null }, valid: 'value' } } }); expect(rec2.data).toEqual({ outer: { inner: null }, valid: 'value' }); // Query with equality filter for object with null value - await expect(db.plainJson.findFirst({ where: { data: { equals: { key: null } } } })).resolves.toMatchObject({ + await expect(db.foo.findFirst({ where: { data: { equals: { key: null } } } })).resolves.toMatchObject({ id: rec1.id, data: { key: null }, }); // Query with equality filter for nested object with null value await expect( - db.plainJson.findFirst({ where: { data: { equals: { outer: { inner: null }, valid: 'value' } } } }), + db.foo.findFirst({ where: { data: { equals: { outer: { inner: null }, valid: 'value' } } } }), ).resolves.toMatchObject({ id: rec2.id, data: { outer: { inner: null }, valid: 'value' }, }); // Query with not filter for object with null value - const notResults = await db.plainJson.findMany({ + const notResults = await db.foo.findMany({ where: { data: { not: { key: null } } }, }); expect(notResults.find((r) => r.id === rec1.id)).toBeUndefined(); @@ -189,15 +167,15 @@ model PlainJson { const db = await createTestClient(schema); // Create a record with an array containing null values - const rec1 = await db.plainJson.create({ data: { data: [1, null, 3] } }); + const rec1 = await db.foo.create({ data: { data: [1, null, 3] } }); expect(rec1.data).toEqual([1, null, 3]); // Create a record with an array of objects including null - const rec2 = await db.plainJson.create({ data: { data: [{ a: 1 }, null, { b: 2 }] } }); + const rec2 = await db.foo.create({ data: { data: [{ a: 1 }, null, { b: 2 }] } }); expect(rec2.data).toEqual([{ a: 1 }, null, { b: 2 }]); // Create a record with nested arrays containing null - const rec3 = await db.plainJson.create({ + const rec3 = await db.foo.create({ data: { data: [ [1, null], @@ -211,21 +189,21 @@ model PlainJson { ]); // Query with equality filter for array with null value - await expect(db.plainJson.findFirst({ where: { data: { equals: [1, null, 3] } } })).resolves.toMatchObject({ + await expect(db.foo.findFirst({ where: { data: { equals: [1, null, 3] } } })).resolves.toMatchObject({ id: rec1.id, data: [1, null, 3], }); // Query with equality filter for array of objects with null await expect( - db.plainJson.findFirst({ where: { data: { equals: [{ a: 1 }, null, { b: 2 }] } } }), + db.foo.findFirst({ where: { data: { equals: [{ a: 1 }, null, { b: 2 }] } } }), ).resolves.toMatchObject({ id: rec2.id, data: [{ a: 1 }, null, { b: 2 }], }); // Query with not filter for array with null value - const notResults = await db.plainJson.findMany({ + const notResults = await db.foo.findMany({ where: { data: { not: [1, null, 3] } }, }); expect(notResults.find((r) => r.id === rec1.id)).toBeUndefined(); @@ -234,7 +212,7 @@ model PlainJson { }); it('works with filtering typed JSON fields', async () => { - const db = await createTestClient(typedJsonSchema, { debug: true }); + const db = await createTestClient(typedJsonSchema); const alice = await db.user.create({ data: { profile: { name: 'Alice', age: 25, jobs: [] } }, @@ -249,4 +227,431 @@ model PlainJson { db.user.findFirst({ where: { profile: { not: { name: 'Alice', age: 20 } } } }), ).resolves.toMatchObject(alice); }); + + it('works with path selection equality filter', async () => { + const db = await createTestClient(schema); + const createData = { a: { b: { c: 42 } }, x: [{ value: 1 }, { value: 2 }] }; + await db.foo.create({ + data: { data: createData }, + }); + + await expect( + db.foo.findFirst({ + where: { + data: { + path: [], + equals: createData, + }, + }, + }), + ).toResolveTruthy(); + + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['a'], + equals: createData['a'], + }, + }, + }), + ).toResolveTruthy(); + + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['a', 'b'], + equals: createData['a']['b'], + }, + }, + }), + ).toResolveTruthy(); + + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['a', 'z'], + equals: createData['a']['b'], + }, + }, + }), + ).toResolveNull(); + + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['x'], + equals: createData['x'], + }, + }, + }), + ).toResolveTruthy(); + + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['x', '1'], + equals: createData['x'][1], + }, + }, + }), + ).toResolveTruthy(); + + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['x', '0'], + equals: createData['x'][1], + }, + }, + }), + ).toResolveNull(); + }); + + it('works with path selection string filters', async () => { + const db = await createTestClient(schema); + + await db.foo.create({ + data: { data: { name: 'John Doe', email: 'john@example.com', tags: ['developer', 'typescript'] } }, + }); + await db.foo.create({ + data: { data: { name: 'Jane Smith', email: 'jane@test.org', tags: ['designer', 'ui'] } }, + }); + await db.foo.create({ + data: { data: { name: 'Bob Johnson', email: 'bob@example.net', tags: ['manager', 'typescript'] } }, + }); + + // string_contains + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['name'], + string_contains: 'Doe', + }, + }, + }), + ).resolves.toMatchObject({ data: { name: 'John Doe' } }); + + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['name'], + string_contains: 'doe', + mode: 'insensitive', // case insensitive + }, + }, + }), + ).resolves.toMatchObject({ data: { name: 'John Doe' } }); + + const provider = getTestDbProvider(); + if (provider === 'postgresql') { + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['name'], + string_contains: 'doe', + }, + }, + }), + ).toResolveNull(); // case sensitive + + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['name'], + string_contains: 'doe', + mode: 'default', + }, + }, + }), + ).toResolveNull(); // case sensitive + } + + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['bar'], // non-existing path + string_contains: 'Doe', + }, + }, + }), + ).toResolveNull(); + + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['name'], + string_contains: 'NonExistent', + }, + }, + }), + ).toResolveNull(); + + // string_starts_with + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['name'], + string_starts_with: 'Jane', + }, + }, + }), + ).resolves.toMatchObject({ data: { name: 'Jane Smith' } }); + + // string_ends_with + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['name'], + string_ends_with: 'Johnson', + }, + }, + }), + ).resolves.toMatchObject({ data: { name: 'Bob Johnson' } }); + + // Test with array index access + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['tags', '0'], + string_contains: 'velop', + }, + }, + }), + ).resolves.toMatchObject({ data: { tags: ['developer', 'typescript'] } }); + + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['tags', '1'], + string_starts_with: 'type', + }, + }, + }), + ).resolves.toMatchObject({ data: { tags: ['developer', 'typescript'] } }); + + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['tags', '2'], + string_starts_with: 'type', + }, + }, + }), + ).toResolveNull(); + }); + + it('works with string filter for top-level string values', async () => { + const db = await createTestClient(schema); + + await db.foo.createMany({ + data: [{ data: 'Hello World' }], + }); + + await expect( + db.foo.findFirst({ + where: { + data: { + string_contains: 'World', + }, + }, + }), + ).resolves.toMatchObject({ data: 'Hello World' }); + + await expect( + db.foo.findFirst({ + where: { + data: { + path: [], + string_contains: 'World', + }, + }, + }), + ).resolves.toMatchObject({ data: 'Hello World' }); + + await expect( + db.foo.findFirst({ + where: { + data: { + string_contains: 'Foo', + }, + }, + }), + ).toResolveNull(); + }); + + it('works with path selection array filter', async () => { + const db = await createTestClient(schema); + + await db.foo.create({ + data: { + data: { + tags: ['typescript', 'react', 'node'], + numbers: [1, 2, 3, 4, 5], + nested: { items: ['alpha', 'beta', 'gamma'] }, + }, + }, + }); + await db.foo.create({ + data: { + data: { + tags: ['python', 'django', 'flask'], + numbers: [10, 20, 30], + nested: { items: ['delta', 'epsilon'] }, + }, + }, + }); + await db.foo.create({ + data: { + data: { + tags: ['java', 'spring'], + numbers: [100, 200], + nested: { items: ['zeta'] }, + }, + }, + }); + + // array_contains - check if array contains a specific value + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['tags'], + array_contains: 'react', + }, + }, + }), + ).resolves.toMatchObject({ data: { tags: ['typescript', 'react', 'node'] } }); + + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['numbers'], + array_contains: 20, + }, + }, + }), + ).resolves.toMatchObject({ data: { numbers: [10, 20, 30] } }); + + // array_contains with nested path + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['nested', 'items'], + array_contains: 'beta', + }, + }, + }), + ).resolves.toMatchObject({ data: { nested: { items: ['alpha', 'beta', 'gamma'] } } }); + + // array_starts_with - check if array starts with a specific value + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['tags'], + array_starts_with: 'typescript', + }, + }, + }), + ).resolves.toMatchObject({ data: { tags: ['typescript', 'react', 'node'] } }); + + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['numbers'], + array_starts_with: 1, + }, + }, + }), + ).resolves.toMatchObject({ data: { numbers: [1, 2, 3, 4, 5] } }); + + // array_ends_with - check if array ends with a specific value + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['tags'], + array_ends_with: 'node', + }, + }, + }), + ).resolves.toMatchObject({ data: { tags: ['typescript', 'react', 'node'] } }); + + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['numbers'], + array_ends_with: 30, + }, + }, + }), + ).resolves.toMatchObject({ data: { numbers: [10, 20, 30] } }); + + // Negative tests - should not match + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['tags'], + array_contains: 'rust', + }, + }, + }), + ).toResolveNull(); + + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['tags'], + array_starts_with: 'react', + }, + }, + }), + ).toResolveNull(); + + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['numbers'], + array_ends_with: 100, + }, + }, + }), + ).toResolveNull(); + + // Test with non-existent path + await expect( + db.foo.findFirst({ + where: { + data: { + path: ['nonexistent'], + array_contains: 'anything', + }, + }, + }), + ).toResolveNull(); + }); }); diff --git a/tests/e2e/orm/schemas/json/input.ts b/tests/e2e/orm/schemas/json/input.ts index 3ee54f670..9564b7f27 100644 --- a/tests/e2e/orm/schemas/json/input.ts +++ b/tests/e2e/orm/schemas/json/input.ts @@ -8,23 +8,23 @@ import { type SchemaType as $Schema } from "./schema"; import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, 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, ClientOptions as $ClientOptions } from "@zenstackhq/orm"; import type { SimplifiedModelResult as $SimplifiedModelResult, SelectIncludeOmit as $SelectIncludeOmit } from "@zenstackhq/orm"; -export type PlainJsonFindManyArgs = $FindManyArgs<$Schema, "PlainJson">; -export type PlainJsonFindUniqueArgs = $FindUniqueArgs<$Schema, "PlainJson">; -export type PlainJsonFindFirstArgs = $FindFirstArgs<$Schema, "PlainJson">; -export type PlainJsonCreateArgs = $CreateArgs<$Schema, "PlainJson">; -export type PlainJsonCreateManyArgs = $CreateManyArgs<$Schema, "PlainJson">; -export type PlainJsonCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "PlainJson">; -export type PlainJsonUpdateArgs = $UpdateArgs<$Schema, "PlainJson">; -export type PlainJsonUpdateManyArgs = $UpdateManyArgs<$Schema, "PlainJson">; -export type PlainJsonUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "PlainJson">; -export type PlainJsonUpsertArgs = $UpsertArgs<$Schema, "PlainJson">; -export type PlainJsonDeleteArgs = $DeleteArgs<$Schema, "PlainJson">; -export type PlainJsonDeleteManyArgs = $DeleteManyArgs<$Schema, "PlainJson">; -export type PlainJsonCountArgs = $CountArgs<$Schema, "PlainJson">; -export type PlainJsonAggregateArgs = $AggregateArgs<$Schema, "PlainJson">; -export type PlainJsonGroupByArgs = $GroupByArgs<$Schema, "PlainJson">; -export type PlainJsonWhereInput = $WhereInput<$Schema, "PlainJson">; -export type PlainJsonSelect = $SelectInput<$Schema, "PlainJson">; -export type PlainJsonInclude = $IncludeInput<$Schema, "PlainJson">; -export type PlainJsonOmit = $OmitInput<$Schema, "PlainJson">; -export type PlainJsonGetPayload, Options extends $ClientOptions<$Schema> = $ClientOptions<$Schema>> = $SimplifiedModelResult<$Schema, "PlainJson", Options, Args>; +export type FooFindManyArgs = $FindManyArgs<$Schema, "Foo">; +export type FooFindUniqueArgs = $FindUniqueArgs<$Schema, "Foo">; +export type FooFindFirstArgs = $FindFirstArgs<$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, Options extends $ClientOptions<$Schema> = $ClientOptions<$Schema>> = $SimplifiedModelResult<$Schema, "Foo", Options, Args>; diff --git a/tests/e2e/orm/schemas/json/models.ts b/tests/e2e/orm/schemas/json/models.ts index de1787342..24ea716d8 100644 --- a/tests/e2e/orm/schemas/json/models.ts +++ b/tests/e2e/orm/schemas/json/models.ts @@ -7,4 +7,4 @@ import { type SchemaType as $Schema } from "./schema"; import { type ModelResult as $ModelResult } from "@zenstackhq/orm"; -export type PlainJson = $ModelResult<$Schema, "PlainJson">; +export type Foo = $ModelResult<$Schema, "Foo">; diff --git a/tests/e2e/orm/schemas/json/schema.ts b/tests/e2e/orm/schemas/json/schema.ts index d8be5cd75..5c254e03f 100644 --- a/tests/e2e/orm/schemas/json/schema.ts +++ b/tests/e2e/orm/schemas/json/schema.ts @@ -11,8 +11,8 @@ const _schema = { type: "sqlite" }, models: { - PlainJson: { - name: "PlainJson", + Foo: { + name: "Foo", fields: { id: { name: "id", diff --git a/tests/e2e/orm/schemas/json/schema.zmodel b/tests/e2e/orm/schemas/json/schema.zmodel index 4a981b60d..95e42dbdd 100644 --- a/tests/e2e/orm/schemas/json/schema.zmodel +++ b/tests/e2e/orm/schemas/json/schema.zmodel @@ -2,7 +2,7 @@ datasource db { provider = "sqlite" } -model PlainJson { +model Foo { id Int @id @default(autoincrement()) data Json data1 Json? From a1f2b86d6dbec361841bfb9e655c0ce29e450782 Mon Sep 17 00:00:00 2001 From: Yiming Cao Date: Mon, 8 Dec 2025 15:49:42 +0800 Subject: [PATCH 2/3] Update packages/orm/src/client/crud/dialects/postgresql.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/orm/src/client/crud/dialects/postgresql.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index b76fa8168..b060c851f 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -454,7 +454,7 @@ export class PostgresCrudDialect extends BaseCrudDiale } } - protected override buildJsonPathSelection(receiver: Expression, path: [], asType: 'string' | 'json') { + protected override buildJsonPathSelection(receiver: Expression, path: string[], asType: 'string' | 'json') { if (path.length > 0) { const pathValues = path.map((p: string) => this.eb.val(p)); if (asType === 'string') { From 65b887cbc090f3195ee735a18af586b3b1d53d55 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:54:13 +0800 Subject: [PATCH 3/3] address PR comments --- TODO.md | 2 +- packages/orm/src/client/crud/dialects/base-dialect.ts | 6 ++++++ packages/orm/src/client/crud/dialects/postgresql.ts | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index 9628cc16f..0bf6c3509 100644 --- a/TODO.md +++ b/TODO.md @@ -86,7 +86,7 @@ - [x] Custom table name - [x] Custom field name - [x] Global omit - - [ ] DbNull vs JsonNull + - [x] DbNull vs JsonNull - [ ] Migrate to tsdown - [x] @default validation - [x] Benchmark diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 36de0ef25..baec52569 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -569,6 +569,12 @@ export abstract class BaseCrudDialect { clauses.push(this.buildJsonArrayFilter(receiver, key, value)); break; } + case 'path': + case 'mode': + // already handled + break; + default: + invariant(false, `Invalid JSON filter key: ${key}`); } } return this.and(...clauses); diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index b060c851f..527d9996c 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -480,7 +480,7 @@ export class PostgresCrudDialect extends BaseCrudDiale value: unknown, ) { return match(operation) - .with('array_contains', () => sql`${lhs} @> ${sql.lit(JSON.stringify([value]))}::jsonb`) + .with('array_contains', () => sql`${lhs} @> ${sql.val(JSON.stringify([value]))}::jsonb`) .with('array_starts_with', () => this.eb( this.eb.fn('jsonb_extract_path', [lhs, this.eb.val('0')]),