From 5d80270e1b37835e7129758caf333a0879c0bc93 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 12 Dec 2025 21:47:13 +0800 Subject: [PATCH 1/2] fix(orm): escape special characters in string search patterns --- packages/language/package.json | 4 -- .../src/client/crud/dialects/base-dialect.ts | 60 +++++++++++-------- .../src/client/crud/dialects/postgresql.ts | 16 ----- .../orm/src/client/crud/dialects/sqlite.ts | 16 ----- packages/orm/src/client/functions.ts | 13 ++-- tests/e2e/orm/client-api/filter.test.ts | 50 +++++++++++++--- tests/e2e/orm/client-api/json-filter.test.ts | 54 +++++++++++++++++ tests/e2e/orm/policy/policy-functions.test.ts | 33 ++++++++++ 8 files changed, 173 insertions(+), 73 deletions(-) diff --git a/packages/language/package.json b/packages/language/package.json index 9c6c371da..e58d67294 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -70,9 +70,5 @@ "glob": "^11.1.0", "langium-cli": "catalog:", "tmp": "catalog:" - }, - "volta": { - "node": "18.19.1", - "npm": "10.2.4" } } diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 00d0dca2d..30e330a40 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -541,7 +541,7 @@ export abstract class BaseCrudDialect { } else if (isTypeDef(this.schema, fieldDef.type)) { return this.buildTypedJsonFilter(receiver, filter, fieldDef.type, !!fieldDef.array); } else { - return this.true(); + throw createInvalidInputError(`Invalid JSON filter payload`); } } @@ -597,7 +597,7 @@ export abstract class BaseCrudDialect { // already handled break; default: - invariant(false, `Invalid JSON filter key: ${key}`); + throw createInvalidInputError(`Invalid JSON filter key: ${key}`); } } return this.and(...clauses); @@ -817,22 +817,15 @@ export abstract class BaseCrudDialect { continue; } + invariant(typeof value === 'string', `${key} value must be a string`); + + const escapedValue = this.escapeLikePattern(value); const condition = match(key) - .with('contains', () => - mode === 'insensitive' - ? this.eb(fieldRef, 'ilike', sql.val(`%${value}%`)) - : this.eb(fieldRef, 'like', sql.val(`%${value}%`)), - ) + .with('contains', () => this.buildStringLike(fieldRef, `%${escapedValue}%`, mode === 'insensitive')) .with('startsWith', () => - mode === 'insensitive' - ? this.eb(fieldRef, 'ilike', sql.val(`${value}%`)) - : this.eb(fieldRef, 'like', sql.val(`${value}%`)), - ) - .with('endsWith', () => - mode === 'insensitive' - ? this.eb(fieldRef, 'ilike', sql.val(`%${value}`)) - : this.eb(fieldRef, 'like', sql.val(`%${value}`)), + this.buildStringLike(fieldRef, `${escapedValue}%`, mode === 'insensitive'), ) + .with('endsWith', () => this.buildStringLike(fieldRef, `%${escapedValue}`, mode === 'insensitive')) .otherwise(() => { throw createInvalidInputError(`Invalid string filter key: ${key}`); }); @@ -846,6 +839,33 @@ export abstract class BaseCrudDialect { return this.and(...conditions); } + private buildJsonStringFilter( + receiver: Expression, + operation: 'string_contains' | 'string_starts_with' | 'string_ends_with', + value: string, + mode: 'default' | 'insensitive', + ) { + // build LIKE pattern based on operation, note that receiver is quoted + const escapedValue = this.escapeLikePattern(value); + const pattern = match(operation) + .with('string_contains', () => `"%${escapedValue}%"`) + .with('string_starts_with', () => `"${escapedValue}%"`) + .with('string_ends_with', () => `"%${escapedValue}"`) + .exhaustive(); + + return this.buildStringLike(receiver, pattern, mode === 'insensitive'); + } + + private escapeLikePattern(pattern: string) { + return pattern.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_'); + } + + private buildStringLike(receiver: Expression, pattern: string, insensitive: boolean) { + const { supportsILike } = this.getStringCasingBehavior(); + const op = insensitive && supportsILike ? 'ilike' : 'like'; + return sql`${receiver} ${sql.raw(op)} ${sql.val(pattern)} escape '\\'`; + } + private prepStringCasing( eb: ExpressionBuilder, value: unknown, @@ -1409,16 +1429,6 @@ export abstract class BaseCrudDialect { */ protected abstract buildJsonPathSelection(receiver: Expression, path: string | undefined): Expression; - /** - * Builds a JSON string filter expression. - */ - protected abstract buildJsonStringFilter( - receiver: Expression, - operation: 'string_contains' | 'string_starts_with' | 'string_ends_with', - value: string, - mode: 'default' | 'insensitive', - ): Expression; - /** * Builds a JSON array filter expression. */ diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index fd02d23c4..5b0c64510 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -462,22 +462,6 @@ export class PostgresCrudDialect extends BaseCrudDiale } } - protected override buildJsonStringFilter( - receiver: 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(); - - return this.eb(receiver, mode === 'insensitive' ? 'ilike' : 'like', sql.val(pattern)); - } - protected override buildJsonArrayFilter( lhs: Expression, operation: 'array_contains' | 'array_starts_with' | 'array_ends_with', diff --git a/packages/orm/src/client/crud/dialects/sqlite.ts b/packages/orm/src/client/crud/dialects/sqlite.ts index df8da4daa..677261983 100644 --- a/packages/orm/src/client/crud/dialects/sqlite.ts +++ b/packages/orm/src/client/crud/dialects/sqlite.ts @@ -368,22 +368,6 @@ export class SqliteCrudDialect extends BaseCrudDialect } } - protected override buildJsonStringFilter( - lhs: Expression, - operation: 'string_contains' | 'string_starts_with' | 'string_ends_with', - value: string, - _mode: 'default' | 'insensitive', - ) { - // JSON strings are quoted, so we need to add quotes to the pattern - const pattern = match(operation) - .with('string_contains', () => `"%${value}%"`) - .with('string_starts_with', () => `"${value}%"`) - .with('string_ends_with', () => `"%${value}"`) - .exhaustive(); - - return this.eb(lhs, 'like', sql.val(pattern)); - } - protected override buildJsonArrayFilter( lhs: Expression, operation: 'array_contains' | 'array_starts_with' | 'array_ends_with', diff --git a/packages/orm/src/client/functions.ts b/packages/orm/src/client/functions.ts index 3f1bc8060..5690a1e45 100644 --- a/packages/orm/src/client/functions.ts +++ b/packages/orm/src/client/functions.ts @@ -1,5 +1,5 @@ import { invariant, lowerCaseFirst, upperCaseFirst } from '@zenstackhq/common-helpers'; -import { sql, ValueNode, type BinaryOperator, type Expression, type ExpressionBuilder } from 'kysely'; +import { sql, ValueNode, type BinaryOperator, type Expression, type ExpressionBuilder, type SqlBool } from 'kysely'; import { match } from 'ts-pattern'; import type { ZModelFunction, ZModelFunctionContext } from './options'; @@ -53,13 +53,16 @@ const textMatch = ( op = 'like'; } + // escape special characters in search string + const escapedSearch = sql`REPLACE(REPLACE(REPLACE(CAST(${searchExpr} as text), '\\', '\\\\'), '%', '\\%'), '_', '\\_')`; + searchExpr = match(method) - .with('contains', () => eb.fn('CONCAT', [sql.lit('%'), sql`CAST(${searchExpr} as text)`, sql.lit('%')])) - .with('startsWith', () => eb.fn('CONCAT', [sql`CAST(${searchExpr} as text)`, sql.lit('%')])) - .with('endsWith', () => eb.fn('CONCAT', [sql.lit('%'), sql`CAST(${searchExpr} as text)`])) + .with('contains', () => eb.fn('CONCAT', [sql.lit('%'), escapedSearch, sql.lit('%')])) + .with('startsWith', () => eb.fn('CONCAT', [escapedSearch, sql.lit('%')])) + .with('endsWith', () => eb.fn('CONCAT', [sql.lit('%'), escapedSearch])) .exhaustive(); - return eb(fieldExpr, op, searchExpr); + return sql`${fieldExpr} ${sql.raw(op)} ${searchExpr} escape '\\'`; }; export const has: ZModelFunction = (eb, args) => { diff --git a/tests/e2e/orm/client-api/filter.test.ts b/tests/e2e/orm/client-api/filter.test.ts index a8cd56a90..ff040d728 100644 --- a/tests/e2e/orm/client-api/filter.test.ts +++ b/tests/e2e/orm/client-api/filter.test.ts @@ -7,7 +7,7 @@ describe('Client filter tests ', () => { let client: ClientContract; beforeEach(async () => { - client = (await createTestClient(schema)) as any; + client = await createTestClient(schema); }); afterEach(async () => { @@ -44,6 +44,7 @@ describe('Client filter tests ', () => { it('supports string filters', async () => { const user1 = await createUser('u1@test.com'); const user2 = await createUser('u2@test.com', { name: null }); + await createUser('%u3@test.com', { name: null }); // equals await expect(client.user.findFirst({ where: { id: user1.id } })).toResolveTruthy(); @@ -93,6 +94,16 @@ describe('Client filter tests ', () => { where: { email: { contains: 'test' } }, }), ).toResolveTruthy(); + await expect( + client.user.findFirst({ + where: { email: { contains: '%test' } }, + }), + ).toResolveNull(); + await expect( + client.user.findFirst({ + where: { email: { contains: '%u3' } }, + }), + ).toResolveTruthy(); await expect( client.user.findFirst({ where: { email: { contains: 'Test' } }, @@ -104,11 +115,21 @@ describe('Client filter tests ', () => { where: { email: { startsWith: 'u1' } }, }), ).toResolveTruthy(); + await expect( + client.user.findFirst({ + where: { email: { startsWith: '%u1' } }, + }), + ).toResolveNull(); await expect( client.user.findFirst({ where: { email: { startsWith: 'U1' } }, }), ).toResolveTruthy(); + await expect( + client.user.findFirst({ + where: { email: { startsWith: '%u3' } }, + }), + ).toResolveTruthy(); await expect( client.user.findFirst({ @@ -155,6 +176,21 @@ describe('Client filter tests ', () => { }), ).toResolveTruthy(); + await expect( + client.user.findFirst({ + where: { + email: { contains: '%u1', mode: 'insensitive' } as any, + }, + }), + ).toResolveNull(); + await expect( + client.user.findFirst({ + where: { + email: { contains: '%u3', mode: 'insensitive' } as any, + }, + }), + ).toResolveTruthy(); + await expect( client.user.findFirst({ where: { @@ -211,7 +247,7 @@ describe('Client filter tests ', () => { ).toResolveTruthy(); await expect( client.user.findFirst({ - where: { email: { notIn: ['u1@test.com', 'u2@test.com'] } }, + where: { email: { notIn: ['u1@test.com', 'u2@test.com', '%u3@test.com'] } }, }), ).toResolveFalsy(); await expect( @@ -225,22 +261,22 @@ describe('Client filter tests ', () => { client.user.findMany({ where: { email: { lt: 'a@test.com' } }, }), - ).toResolveWithLength(0); + ).toResolveWithLength(1); await expect( client.user.findMany({ where: { email: { lt: 'z@test.com' } }, }), - ).toResolveWithLength(2); + ).toResolveWithLength(3); await expect( client.user.findMany({ where: { email: { lte: 'u1@test.com' } }, }), - ).toResolveWithLength(1); + ).toResolveWithLength(2); await expect( client.user.findMany({ where: { email: { lte: 'u2@test.com' } }, }), - ).toResolveWithLength(2); + ).toResolveWithLength(3); await expect( client.user.findMany({ where: { email: { gt: 'a@test.com' } }, @@ -270,7 +306,7 @@ describe('Client filter tests ', () => { ).toResolveTruthy(); await expect( client.user.findFirst({ - where: { email: { contains: '3@' } }, + where: { email: { contains: '4@' } }, }), ).toResolveFalsy(); diff --git a/tests/e2e/orm/client-api/json-filter.test.ts b/tests/e2e/orm/client-api/json-filter.test.ts index 2961a8f21..acb03b04f 100644 --- a/tests/e2e/orm/client-api/json-filter.test.ts +++ b/tests/e2e/orm/client-api/json-filter.test.ts @@ -492,6 +492,9 @@ describe('Json filter tests', () => { await db.foo.create({ data: { data: { name: 'Bob Johnson', email: 'bob@example.net', tags: ['manager', 'typescript'] } }, }); + await db.foo.create({ + data: { data: { name: '%Foo', email: 'foo@example.com' } }, + }); // string_contains await expect( @@ -505,6 +508,17 @@ describe('Json filter tests', () => { }), ).resolves.toMatchObject({ data: { name: 'John Doe' } }); + await expect( + db.foo.findFirst({ + where: { + data: { + path: '$.name', + string_contains: '%Doe', // % should be treated as literal + }, + }, + }), + ).toResolveNull(); + await expect( db.foo.findFirst({ where: { @@ -576,6 +590,36 @@ describe('Json filter tests', () => { }, }), ).resolves.toMatchObject({ data: { name: 'Jane Smith' } }); + await expect( + db.foo.findFirst({ + where: { + data: { + path: '$.name', + string_starts_with: '%Jane', // % should be treated as literal + }, + }, + }), + ).toResolveNull(); + await expect( + db.foo.findFirst({ + where: { + data: { + path: '$.name', + string_starts_with: '%Smith', // % should be treated as literal + }, + }, + }), + ).toResolveNull(); + await expect( + db.foo.findFirst({ + where: { + data: { + path: '$.name', + string_starts_with: '%Foo', // % should be treated as literal + }, + }, + }), + ).resolves.toMatchObject({ data: { name: '%Foo' } }); // string_ends_with await expect( @@ -588,6 +632,16 @@ describe('Json filter tests', () => { }, }), ).resolves.toMatchObject({ data: { name: 'Bob Johnson' } }); + await expect( + db.foo.findFirst({ + where: { + data: { + path: '$.name', + string_ends_with: 'Johnson%', + }, + }, + }), + ).toResolveNull(); // Test with array index access await expect( diff --git a/tests/e2e/orm/policy/policy-functions.test.ts b/tests/e2e/orm/policy/policy-functions.test.ts index 6d70426e7..b48d3da3f 100644 --- a/tests/e2e/orm/policy/policy-functions.test.ts +++ b/tests/e2e/orm/policy/policy-functions.test.ts @@ -23,6 +23,23 @@ describe('policy functions tests', () => { await expect(db.foo.create({ data: { string: 'bac' } })).toResolveTruthy(); }); + it('escapes input for contains', async () => { + const db = await createPolicyTestClient( + ` + model Foo { + id String @id @default(cuid()) + string String + @@allow('all', contains(string, 'a%')) + } + `, + { debug: true }, + ); + + await expect(db.foo.create({ data: { string: 'ab' } })).toBeRejectedByPolicy(); + await expect(db.foo.create({ data: { string: 'a%' } })).toResolveTruthy(); + await expect(db.foo.create({ data: { string: 'a%b' } })).toResolveTruthy(); + }); + it('supports contains explicit case-sensitive', async () => { const db = await createPolicyTestClient( ` @@ -143,6 +160,22 @@ describe('policy functions tests', () => { await expect(anonDb.$setAuth({ id: 'user1', name: 'abc' }).foo.create({ data: {} })).toResolveTruthy(); }); + it('escapes input for startsWith', async () => { + const db = await createPolicyTestClient( + ` + model Foo { + id String @id @default(cuid()) + string String + @@allow('all', startsWith(string, '%a')) + } + `, + ); + + await expect(db.foo.create({ data: { string: 'ba' } })).toBeRejectedByPolicy(); + await expect(db.foo.create({ data: { string: '%a' } })).toResolveTruthy(); + await expect(db.foo.create({ data: { string: '%ab' } })).toResolveTruthy(); + }); + it('supports endsWith with field', async () => { const db = await createPolicyTestClient( ` From ca27f1098dd078c654470d0d2bc71542e274aa2d Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 12 Dec 2025 22:09:11 +0800 Subject: [PATCH 2/2] fix tests --- tests/e2e/orm/client-api/filter.test.ts | 39 ++++++++++++++++++------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/tests/e2e/orm/client-api/filter.test.ts b/tests/e2e/orm/client-api/filter.test.ts index ff040d728..f7774291b 100644 --- a/tests/e2e/orm/client-api/filter.test.ts +++ b/tests/e2e/orm/client-api/filter.test.ts @@ -44,7 +44,7 @@ describe('Client filter tests ', () => { it('supports string filters', async () => { const user1 = await createUser('u1@test.com'); const user2 = await createUser('u2@test.com', { name: null }); - await createUser('%u3@test.com', { name: null }); + await createUser('u3%@test.com', { name: null }); // equals await expect(client.user.findFirst({ where: { id: user1.id } })).toResolveTruthy(); @@ -101,9 +101,14 @@ describe('Client filter tests ', () => { ).toResolveNull(); await expect( client.user.findFirst({ - where: { email: { contains: '%u3' } }, + where: { email: { contains: 'u3%' } }, }), ).toResolveTruthy(); + await expect( + client.user.findFirst({ + where: { email: { contains: 'u3a' } }, + }), + ).toResolveNull(); await expect( client.user.findFirst({ where: { email: { contains: 'Test' } }, @@ -127,7 +132,12 @@ describe('Client filter tests ', () => { ).toResolveTruthy(); await expect( client.user.findFirst({ - where: { email: { startsWith: '%u3' } }, + where: { email: { startsWith: 'u3a' } }, + }), + ).toResolveNull(); + await expect( + client.user.findFirst({ + where: { email: { startsWith: 'u3%' } }, }), ).toResolveTruthy(); @@ -186,10 +196,17 @@ describe('Client filter tests ', () => { await expect( client.user.findFirst({ where: { - email: { contains: '%u3', mode: 'insensitive' } as any, + email: { contains: 'u3%', mode: 'insensitive' } as any, }, }), ).toResolveTruthy(); + await expect( + client.user.findFirst({ + where: { + email: { contains: 'u3a', mode: 'insensitive' } as any, + }, + }), + ).toResolveNull(); await expect( client.user.findFirst({ @@ -247,7 +264,7 @@ describe('Client filter tests ', () => { ).toResolveTruthy(); await expect( client.user.findFirst({ - where: { email: { notIn: ['u1@test.com', 'u2@test.com', '%u3@test.com'] } }, + where: { email: { notIn: ['u1@test.com', 'u2@test.com', 'u3%@test.com'] } }, }), ).toResolveFalsy(); await expect( @@ -261,7 +278,7 @@ describe('Client filter tests ', () => { client.user.findMany({ where: { email: { lt: 'a@test.com' } }, }), - ).toResolveWithLength(1); + ).toResolveWithLength(0); await expect( client.user.findMany({ where: { email: { lt: 'z@test.com' } }, @@ -271,17 +288,17 @@ describe('Client filter tests ', () => { client.user.findMany({ where: { email: { lte: 'u1@test.com' } }, }), - ).toResolveWithLength(2); + ).toResolveWithLength(1); await expect( client.user.findMany({ where: { email: { lte: 'u2@test.com' } }, }), - ).toResolveWithLength(3); + ).toResolveWithLength(2); await expect( client.user.findMany({ where: { email: { gt: 'a@test.com' } }, }), - ).toResolveWithLength(2); + ).toResolveWithLength(3); await expect( client.user.findMany({ where: { email: { gt: 'z@test.com' } }, @@ -291,12 +308,12 @@ describe('Client filter tests ', () => { client.user.findMany({ where: { email: { gte: 'u1@test.com' } }, }), - ).toResolveWithLength(2); + ).toResolveWithLength(3); await expect( client.user.findMany({ where: { email: { gte: 'u2@test.com' } }, }), - ).toResolveWithLength(1); + ).toResolveWithLength(2); // contains await expect(