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

Commit 293cf0d

Browse files
authored
fix(orm): escape special characters in string search patterns (#479)
* fix(orm): escape special characters in string search patterns * fix tests
1 parent 3e73e93 commit 293cf0d

File tree

8 files changed

+190
-73
lines changed

8 files changed

+190
-73
lines changed

packages/language/package.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,5 @@
7070
"glob": "^11.1.0",
7171
"langium-cli": "catalog:",
7272
"tmp": "catalog:"
73-
},
74-
"volta": {
75-
"node": "18.19.1",
76-
"npm": "10.2.4"
7773
}
7874
}

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

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,7 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
541541
} else if (isTypeDef(this.schema, fieldDef.type)) {
542542
return this.buildTypedJsonFilter(receiver, filter, fieldDef.type, !!fieldDef.array);
543543
} else {
544-
return this.true();
544+
throw createInvalidInputError(`Invalid JSON filter payload`);
545545
}
546546
}
547547

@@ -597,7 +597,7 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
597597
// already handled
598598
break;
599599
default:
600-
invariant(false, `Invalid JSON filter key: ${key}`);
600+
throw createInvalidInputError(`Invalid JSON filter key: ${key}`);
601601
}
602602
}
603603
return this.and(...clauses);
@@ -817,22 +817,15 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
817817
continue;
818818
}
819819

820+
invariant(typeof value === 'string', `${key} value must be a string`);
821+
822+
const escapedValue = this.escapeLikePattern(value);
820823
const condition = match(key)
821-
.with('contains', () =>
822-
mode === 'insensitive'
823-
? this.eb(fieldRef, 'ilike', sql.val(`%${value}%`))
824-
: this.eb(fieldRef, 'like', sql.val(`%${value}%`)),
825-
)
824+
.with('contains', () => this.buildStringLike(fieldRef, `%${escapedValue}%`, mode === 'insensitive'))
826825
.with('startsWith', () =>
827-
mode === 'insensitive'
828-
? this.eb(fieldRef, 'ilike', sql.val(`${value}%`))
829-
: this.eb(fieldRef, 'like', sql.val(`${value}%`)),
830-
)
831-
.with('endsWith', () =>
832-
mode === 'insensitive'
833-
? this.eb(fieldRef, 'ilike', sql.val(`%${value}`))
834-
: this.eb(fieldRef, 'like', sql.val(`%${value}`)),
826+
this.buildStringLike(fieldRef, `${escapedValue}%`, mode === 'insensitive'),
835827
)
828+
.with('endsWith', () => this.buildStringLike(fieldRef, `%${escapedValue}`, mode === 'insensitive'))
836829
.otherwise(() => {
837830
throw createInvalidInputError(`Invalid string filter key: ${key}`);
838831
});
@@ -846,6 +839,33 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
846839
return this.and(...conditions);
847840
}
848841

842+
private buildJsonStringFilter(
843+
receiver: Expression<any>,
844+
operation: 'string_contains' | 'string_starts_with' | 'string_ends_with',
845+
value: string,
846+
mode: 'default' | 'insensitive',
847+
) {
848+
// build LIKE pattern based on operation, note that receiver is quoted
849+
const escapedValue = this.escapeLikePattern(value);
850+
const pattern = match(operation)
851+
.with('string_contains', () => `"%${escapedValue}%"`)
852+
.with('string_starts_with', () => `"${escapedValue}%"`)
853+
.with('string_ends_with', () => `"%${escapedValue}"`)
854+
.exhaustive();
855+
856+
return this.buildStringLike(receiver, pattern, mode === 'insensitive');
857+
}
858+
859+
private escapeLikePattern(pattern: string) {
860+
return pattern.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
861+
}
862+
863+
private buildStringLike(receiver: Expression<any>, pattern: string, insensitive: boolean) {
864+
const { supportsILike } = this.getStringCasingBehavior();
865+
const op = insensitive && supportsILike ? 'ilike' : 'like';
866+
return sql<SqlBool>`${receiver} ${sql.raw(op)} ${sql.val(pattern)} escape '\\'`;
867+
}
868+
849869
private prepStringCasing(
850870
eb: ExpressionBuilder<any, any>,
851871
value: unknown,
@@ -1409,16 +1429,6 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
14091429
*/
14101430
protected abstract buildJsonPathSelection(receiver: Expression<any>, path: string | undefined): Expression<any>;
14111431

1412-
/**
1413-
* Builds a JSON string filter expression.
1414-
*/
1415-
protected abstract buildJsonStringFilter(
1416-
receiver: Expression<any>,
1417-
operation: 'string_contains' | 'string_starts_with' | 'string_ends_with',
1418-
value: string,
1419-
mode: 'default' | 'insensitive',
1420-
): Expression<SqlBool>;
1421-
14221432
/**
14231433
* Builds a JSON array filter expression.
14241434
*/

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

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -462,22 +462,6 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends BaseCrudDiale
462462
}
463463
}
464464

465-
protected override buildJsonStringFilter(
466-
receiver: Expression<any>,
467-
operation: 'string_contains' | 'string_starts_with' | 'string_ends_with',
468-
value: string,
469-
mode: 'default' | 'insensitive',
470-
) {
471-
// build LIKE pattern based on operation
472-
const pattern = match(operation)
473-
.with('string_contains', () => `"%${value}%"`)
474-
.with('string_starts_with', () => `"${value}%"`)
475-
.with('string_ends_with', () => `"%${value}"`)
476-
.exhaustive();
477-
478-
return this.eb(receiver, mode === 'insensitive' ? 'ilike' : 'like', sql.val(pattern));
479-
}
480-
481465
protected override buildJsonArrayFilter(
482466
lhs: Expression<any>,
483467
operation: 'array_contains' | 'array_starts_with' | 'array_ends_with',

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

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -368,22 +368,6 @@ export class SqliteCrudDialect<Schema extends SchemaDef> extends BaseCrudDialect
368368
}
369369
}
370370

371-
protected override buildJsonStringFilter(
372-
lhs: Expression<any>,
373-
operation: 'string_contains' | 'string_starts_with' | 'string_ends_with',
374-
value: string,
375-
_mode: 'default' | 'insensitive',
376-
) {
377-
// JSON strings are quoted, so we need to add quotes to the pattern
378-
const pattern = match(operation)
379-
.with('string_contains', () => `"%${value}%"`)
380-
.with('string_starts_with', () => `"${value}%"`)
381-
.with('string_ends_with', () => `"%${value}"`)
382-
.exhaustive();
383-
384-
return this.eb(lhs, 'like', sql.val(pattern));
385-
}
386-
387371
protected override buildJsonArrayFilter(
388372
lhs: Expression<any>,
389373
operation: 'array_contains' | 'array_starts_with' | 'array_ends_with',

packages/orm/src/client/functions.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { invariant, lowerCaseFirst, upperCaseFirst } from '@zenstackhq/common-helpers';
2-
import { sql, ValueNode, type BinaryOperator, type Expression, type ExpressionBuilder } from 'kysely';
2+
import { sql, ValueNode, type BinaryOperator, type Expression, type ExpressionBuilder, type SqlBool } from 'kysely';
33
import { match } from 'ts-pattern';
44
import type { ZModelFunction, ZModelFunctionContext } from './options';
55

@@ -53,13 +53,16 @@ const textMatch = (
5353
op = 'like';
5454
}
5555

56+
// escape special characters in search string
57+
const escapedSearch = sql`REPLACE(REPLACE(REPLACE(CAST(${searchExpr} as text), '\\', '\\\\'), '%', '\\%'), '_', '\\_')`;
58+
5659
searchExpr = match(method)
57-
.with('contains', () => eb.fn('CONCAT', [sql.lit('%'), sql`CAST(${searchExpr} as text)`, sql.lit('%')]))
58-
.with('startsWith', () => eb.fn('CONCAT', [sql`CAST(${searchExpr} as text)`, sql.lit('%')]))
59-
.with('endsWith', () => eb.fn('CONCAT', [sql.lit('%'), sql`CAST(${searchExpr} as text)`]))
60+
.with('contains', () => eb.fn('CONCAT', [sql.lit('%'), escapedSearch, sql.lit('%')]))
61+
.with('startsWith', () => eb.fn('CONCAT', [escapedSearch, sql.lit('%')]))
62+
.with('endsWith', () => eb.fn('CONCAT', [sql.lit('%'), escapedSearch]))
6063
.exhaustive();
6164

62-
return eb(fieldExpr, op, searchExpr);
65+
return sql<SqlBool>`${fieldExpr} ${sql.raw(op)} ${searchExpr} escape '\\'`;
6366
};
6467

6568
export const has: ZModelFunction<any> = (eb, args) => {

tests/e2e/orm/client-api/filter.test.ts

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ describe('Client filter tests ', () => {
77
let client: ClientContract<typeof schema>;
88

99
beforeEach(async () => {
10-
client = (await createTestClient(schema)) as any;
10+
client = await createTestClient(schema);
1111
});
1212

1313
afterEach(async () => {
@@ -44,6 +44,7 @@ describe('Client filter tests ', () => {
4444
it('supports string filters', async () => {
4545
const user1 = await createUser('u1@test.com');
4646
const user2 = await createUser('u2@test.com', { name: null });
47+
await createUser('u3%@test.com', { name: null });
4748

4849
// equals
4950
await expect(client.user.findFirst({ where: { id: user1.id } })).toResolveTruthy();
@@ -93,6 +94,21 @@ describe('Client filter tests ', () => {
9394
where: { email: { contains: 'test' } },
9495
}),
9596
).toResolveTruthy();
97+
await expect(
98+
client.user.findFirst({
99+
where: { email: { contains: '%test' } },
100+
}),
101+
).toResolveNull();
102+
await expect(
103+
client.user.findFirst({
104+
where: { email: { contains: 'u3%' } },
105+
}),
106+
).toResolveTruthy();
107+
await expect(
108+
client.user.findFirst({
109+
where: { email: { contains: 'u3a' } },
110+
}),
111+
).toResolveNull();
96112
await expect(
97113
client.user.findFirst({
98114
where: { email: { contains: 'Test' } },
@@ -104,11 +120,26 @@ describe('Client filter tests ', () => {
104120
where: { email: { startsWith: 'u1' } },
105121
}),
106122
).toResolveTruthy();
123+
await expect(
124+
client.user.findFirst({
125+
where: { email: { startsWith: '%u1' } },
126+
}),
127+
).toResolveNull();
107128
await expect(
108129
client.user.findFirst({
109130
where: { email: { startsWith: 'U1' } },
110131
}),
111132
).toResolveTruthy();
133+
await expect(
134+
client.user.findFirst({
135+
where: { email: { startsWith: 'u3a' } },
136+
}),
137+
).toResolveNull();
138+
await expect(
139+
client.user.findFirst({
140+
where: { email: { startsWith: 'u3%' } },
141+
}),
142+
).toResolveTruthy();
112143

113144
await expect(
114145
client.user.findFirst({
@@ -155,6 +186,28 @@ describe('Client filter tests ', () => {
155186
}),
156187
).toResolveTruthy();
157188

189+
await expect(
190+
client.user.findFirst({
191+
where: {
192+
email: { contains: '%u1', mode: 'insensitive' } as any,
193+
},
194+
}),
195+
).toResolveNull();
196+
await expect(
197+
client.user.findFirst({
198+
where: {
199+
email: { contains: 'u3%', mode: 'insensitive' } as any,
200+
},
201+
}),
202+
).toResolveTruthy();
203+
await expect(
204+
client.user.findFirst({
205+
where: {
206+
email: { contains: 'u3a', mode: 'insensitive' } as any,
207+
},
208+
}),
209+
).toResolveNull();
210+
158211
await expect(
159212
client.user.findFirst({
160213
where: {
@@ -211,7 +264,7 @@ describe('Client filter tests ', () => {
211264
).toResolveTruthy();
212265
await expect(
213266
client.user.findFirst({
214-
where: { email: { notIn: ['u1@test.com', 'u2@test.com'] } },
267+
where: { email: { notIn: ['u1@test.com', 'u2@test.com', 'u3%@test.com'] } },
215268
}),
216269
).toResolveFalsy();
217270
await expect(
@@ -230,7 +283,7 @@ describe('Client filter tests ', () => {
230283
client.user.findMany({
231284
where: { email: { lt: 'z@test.com' } },
232285
}),
233-
).toResolveWithLength(2);
286+
).toResolveWithLength(3);
234287
await expect(
235288
client.user.findMany({
236289
where: { email: { lte: 'u1@test.com' } },
@@ -245,7 +298,7 @@ describe('Client filter tests ', () => {
245298
client.user.findMany({
246299
where: { email: { gt: 'a@test.com' } },
247300
}),
248-
).toResolveWithLength(2);
301+
).toResolveWithLength(3);
249302
await expect(
250303
client.user.findMany({
251304
where: { email: { gt: 'z@test.com' } },
@@ -255,12 +308,12 @@ describe('Client filter tests ', () => {
255308
client.user.findMany({
256309
where: { email: { gte: 'u1@test.com' } },
257310
}),
258-
).toResolveWithLength(2);
311+
).toResolveWithLength(3);
259312
await expect(
260313
client.user.findMany({
261314
where: { email: { gte: 'u2@test.com' } },
262315
}),
263-
).toResolveWithLength(1);
316+
).toResolveWithLength(2);
264317

265318
// contains
266319
await expect(
@@ -270,7 +323,7 @@ describe('Client filter tests ', () => {
270323
).toResolveTruthy();
271324
await expect(
272325
client.user.findFirst({
273-
where: { email: { contains: '3@' } },
326+
where: { email: { contains: '4@' } },
274327
}),
275328
).toResolveFalsy();
276329

0 commit comments

Comments
 (0)