Skip to content

Commit b14fca2

Browse files
committed
feature(orm): Support RegExp directly as filter value + case-insensitive filter.
core: add escapeRegExp function
1 parent 76035ff commit b14fca2

File tree

10 files changed

+116
-18
lines changed

10 files changed

+116
-18
lines changed

packages/core/src/core.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,3 +710,10 @@ export function getCurrentFileName(): string {
710710
}
711711
return path;
712712
}
713+
714+
/**
715+
* Escape special characters in a regex string, so it can be used as a literal string.
716+
*/
717+
export function escapeRegExp(string: string): string {
718+
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
719+
}

packages/core/tests/core.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
changeClass,
55
collectForMicrotask,
66
createDynamicClass,
7+
escapeRegExp,
78
getClassName,
89
getClassTypeFromInstance,
910
getObjectKeysSize,
@@ -542,3 +543,15 @@ test('getParentClass', () => {
542543
expect(getParentClass(Admin)).toBe(User);
543544
expect(getParentClass(SuperAdmin)).toBe(Admin);
544545
});
546+
547+
test('escapeRegExp', () => {
548+
expect(escapeRegExp('a')).toBe('a');
549+
expect(escapeRegExp('a.')).toBe('a\\.');
550+
expect(escapeRegExp('a.\\')).toBe('a\\.\\\\');
551+
expect(escapeRegExp('a.\\b')).toBe('a\\.\\\\b');
552+
expect(escapeRegExp('a.\\b\\')).toBe('a\\.\\\\b\\\\');
553+
expect(escapeRegExp('a.\\b\\c')).toBe('a\\.\\\\b\\\\c');
554+
555+
expect(new RegExp('^' + escapeRegExp('a[.](c')).exec('a[.](c')![0]).toEqual('a[.](c');
556+
expect(new RegExp('^' + escapeRegExp('a[.](c')).exec('da[.](c')).toEqual(null);
557+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/*
2+
* Deepkit Framework
3+
* Copyright (C) 2021 Deepkit UG, Marc J. Schmidt
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the MIT License.
7+
*
8+
* You should have received a copy of the MIT License along with this program.
9+
*/
10+
11+
import { SQLFilterBuilder } from '@deepkit/sql';
12+
13+
export class MySQLSQLFilterBuilder extends SQLFilterBuilder {
14+
regexpComparator(lvalue: string, value: RegExp) {
15+
//mysql is per default case-sensitive, so we need to add the BINARY keyword
16+
if (value.flags.includes('i')) return `${lvalue} REGEXP ${this.bindParam(value.source)}`;
17+
return `BINARY ${lvalue} REGEXP ${this.bindParam(value.source)}`;
18+
}
19+
}

packages/mysql/src/mysql-platform.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010

1111
import { Pool } from 'mariadb';
1212
import { mySqlSerializer } from './mysql-serializer.js';
13-
import { isUUIDType, ReflectionKind, ReflectionProperty, Serializer, TypeNumberBrand } from '@deepkit/type';
13+
import { isUUIDType, ReflectionClass, ReflectionKind, ReflectionProperty, Serializer, TypeNumberBrand } from '@deepkit/type';
1414
import { Column, DefaultPlatform, IndexModel, isSet } from '@deepkit/sql';
1515
import { MysqlSchemaParser } from './mysql-schema-parser.js';
16+
import { MySQLSQLFilterBuilder } from './filter-builder.js';
1617

1718
export class MySQLPlatform extends DefaultPlatform {
1819
protected override defaultSqlType = 'longtext';
@@ -60,6 +61,10 @@ export class MySQLPlatform extends DefaultPlatform {
6061
this.addBinaryType('longblob');
6162
}
6263

64+
override createSqlFilterBuilder(schema: ReflectionClass<any>, tableName: string): MySQLSQLFilterBuilder {
65+
return new MySQLSQLFilterBuilder(schema, tableName, this.serializer, new this.placeholderStrategy, this);
66+
}
67+
6368
supportsSelectFor(): boolean {
6469
return true;
6570
}

packages/orm-integration/src/bookstore.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,28 @@ export const bookstoreTests = {
266266
database.disconnect();
267267
},
268268

269+
async regexp(databaseFactory: DatabaseFactory) {
270+
const database = await databaseFactory(entities);
271+
const peter = new User('Peter');
272+
const book1 = new Book(peter, 'Super book');
273+
const book2 = new Book(peter, 'super!');
274+
const book3 = new Book(peter, 'What if');
275+
await database.persist(book1, book2, book3);
276+
277+
{
278+
const books = await database.query(Book).filter({ title: /^Super/}).find();
279+
expect(books.length).toBe(1);
280+
expect(books[0].title).toBe('Super book');
281+
}
282+
283+
{
284+
const books = await database.query(Book).filter({ title: /^Super/i}).find();
285+
expect(books.length).toBe(2);
286+
expect(books[0].title).toBe('Super book');
287+
expect(books[1].title).toBe('super!');
288+
}
289+
},
290+
269291
async reference(databaseFactory: DatabaseFactory) {
270292
const database = await databaseFactory(entities);
271293

packages/orm/src/query-filter.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,11 @@ export function convertQueryFilter<T, K extends keyof T, Q extends FilterQuery<T
140140
}
141141

142142
if (property) {
143-
fieldValue = convertProperty(schema, property, filter[key], key, converter, fieldNamesMap, customMapping);
143+
if ((filter[key] as any) instanceof RegExp) {
144+
fieldValue = filter[key];
145+
} else {
146+
fieldValue = convertProperty(schema, property, filter[key], key, converter, fieldNamesMap, customMapping);
147+
}
144148
}
145149

146150
if (fieldValue !== undefined) {

packages/postgres/src/sql-filter-builder.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
import { SQLFilterBuilder } from '@deepkit/sql';
1212

1313
export class PostgreSQLFilterBuilder extends SQLFilterBuilder {
14-
regexpComparator() {
15-
return '~';
14+
regexpComparator(lvalue: string, value: RegExp) {
15+
if (value.flags.includes('i')) return `${lvalue} ~* ${this.bindParam(value.source)}`;
16+
return `${lvalue} ~ ${this.bindParam(value.source)}`;
1617
}
1718
}

packages/sql/src/sql-filter-builder.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,21 @@ export class SQLFilterBuilder {
3030
return 'IS NULL';
3131
}
3232

33-
regexpComparator() {
34-
return 'REGEXP';
33+
regexpComparator(lvalue: string, value: RegExp): string {
34+
return `${lvalue} REGEXP ${this.bindParam(value.source)}`;
3535
}
3636

3737
convert(filter: Filter): string {
3838
return this.conditions(filter, 'AND').trim();
3939
}
4040

41+
protected bindParam(value: any): string {
42+
this.params.push(value);
43+
return this.placeholderStrategy.getPlaceholder();
44+
}
45+
4146
/**
42-
* Normalizes values necessary for the conection driver to bind parameters for prepared statements.
47+
* Normalizes values necessary for the connection driver to bind parameters for prepared statements.
4348
* E.g. SQLite does not support boolean, so we convert boolean to number.
4449
*/
4550
protected bindValue(value: any): any {
@@ -88,7 +93,7 @@ export class SQLFilterBuilder {
8893
else if (comparison === 'in') cmpSign = 'IN';
8994
else if (comparison === 'nin') cmpSign = 'NOT IN';
9095
else if (comparison === 'like') cmpSign = 'LIKE';
91-
else if (comparison === 'regex') cmpSign = this.regexpComparator();
96+
else if (comparison === 'regex') return this.regexpComparator(this.quoteIdWithTable(fieldName), value);
9297
else throw new Error(`Comparator ${comparison} not supported.`);
9398

9499
const referenceValue = 'string' === typeof value && value[0] === '$';
@@ -147,6 +152,7 @@ export class SQLFilterBuilder {
147152

148153
if (i === '$exists') sql.push(this.platform.quoteValue(this.schema.hasProperty(i)));
149154
else if (i[0] === '$') sql.push(this.condition(fieldName, filter[i], i.substring(1)));
155+
else if (filter[i] instanceof RegExp) sql.push(this.condition(i, filter[i], 'regex'));
150156
else sql.push(this.condition(i, filter[i], 'eq'));
151157
}
152158

packages/sqlite/src/sql-filter-builder.sqlite.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,9 @@ export class SQLiteFilterBuilder extends SQLFilterBuilder {
1515
if (typeof value === 'boolean') return value ? 1 : 0;
1616
return super.bindValue(value);
1717
}
18+
19+
regexpComparator(lvalue: string, value: RegExp): string {
20+
let regex = value.flags + '::' + value.source; //will be decoded in sqlite-adapter
21+
return `${lvalue} REGEXP ${this.bindParam(regex)}`;
22+
}
1823
}

packages/sqlite/src/sqlite-adapter.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ export class SQLiteConnection extends SQLConnection {
126126
super(connectionPool, logger, transaction, stopwatch);
127127
this.db = new SQLiteConnection.DatabaseConstructor(this.dbPath);
128128
this.db.exec('PRAGMA foreign_keys=ON');
129+
this.db.function('regexp', { deterministic: true }, (regex, text) => {
130+
const splitter = regex.indexOf('::');
131+
if (splitter !== -1) {
132+
return new RegExp(regex.substring(splitter + 2), regex.substring(0, splitter)).test(text) ? 1 : 0;
133+
}
134+
return new RegExp(regex).test(text) ? 1 : 0;
135+
});
129136
}
130137

131138
async prepare(sql: string) {
@@ -302,7 +309,7 @@ export class SQLitePersistence extends SQLPersistence {
302309
for (let i = 0; i < changeSets.length; i++) {
303310
params.push(prepared.primaryKeys[i]);
304311
let pkValue = placeholderStrategy.getPlaceholder();
305-
valuesValues.push('(' + pkValue + ',' +valuesNames.map(name => {
312+
valuesValues.push('(' + pkValue + ',' + valuesNames.map(name => {
306313
params.push(prepared.values[name][i]);
307314
return placeholderStrategy.getPlaceholder();
308315
}).join(',') + ')');
@@ -338,21 +345,24 @@ export class SQLitePersistence extends SQLPersistence {
338345
await connection.exec(`DROP TABLE IF EXISTS _b`);
339346

340347
const sql = `
341-
CREATE TEMPORARY TABLE _b AS
342-
SELECT _.${prepared.originPkField}, ${selects.join(', ')}
343-
FROM (SELECT ${_rename.join(', ')} FROM (VALUES ${valuesValues.join(', ')})) as _
344-
INNER JOIN (SELECT ${_renameSet.join(', ')} FROM (VALUES ${valuesSetValues.join(', ')})) as _set ON (_.${prepared.originPkField} = _set.${prepared.originPkField})
348+
CREATE
349+
TEMPORARY TABLE _b AS
350+
SELECT _.${prepared.originPkField}, ${selects.join(', ')}
351+
FROM (SELECT ${_rename.join(', ')} FROM (VALUES ${valuesValues.join(', ')})) as _
352+
INNER JOIN (SELECT ${_renameSet.join(', ')} FROM (VALUES ${valuesSetValues.join(', ')})) as _
353+
set
354+
ON (_.${prepared.originPkField} = _ set.${prepared.originPkField})
345355
INNER JOIN ${prepared.tableName} as _origin ON (_origin.${prepared.pkField} = _.${prepared.originPkField});
346356
`;
347357

348358
await connection.run(sql, params);
349359

350360
const updateSql = `
351361
UPDATE
352-
${prepared.tableName}
362+
${prepared.tableName}
353363
SET ${prepared.setNames.join(', ')}
354364
FROM
355-
_b
365+
_b
356366
WHERE ${prepared.tableName}.${prepared.pkField} = _b.${prepared.originPkField};
357367
`;
358368
await connection.exec(updateSql);
@@ -414,9 +424,13 @@ export class SQLiteQueryResolver<T extends OrmEntity> extends SQLQueryResolver<T
414424

415425
try {
416426
await connection.exec(`DROP TABLE IF EXISTS _tmp_d`);
417-
await connection.run(`CREATE TEMPORARY TABLE _tmp_d as ${select.sql};`, select.params);
427+
await connection.run(`CREATE
428+
TEMPORARY TABLE _tmp_d as
429+
${select.sql};`, select.params);
418430

419-
const sql = `DELETE FROM ${tableName} WHERE ${tableName}.${pkField} IN (SELECT * FROM _tmp_d)`;
431+
const sql = `DELETE
432+
FROM ${tableName}
433+
WHERE ${tableName}.${pkField} IN (SELECT * FROM _tmp_d)`;
420434
await connection.run(sql);
421435
const rows = await connection.execAndReturnAll('SELECT * FROM _tmp_d');
422436

@@ -509,7 +523,9 @@ export class SQLiteQueryResolver<T extends OrmEntity> extends SQLQueryResolver<T
509523
try {
510524
await connection.exec(`DROP TABLE IF EXISTS _b;`);
511525

512-
const createBSQL = `CREATE TEMPORARY TABLE _b AS ${selectSQL.sql};`;
526+
const createBSQL = `CREATE
527+
TEMPORARY TABLE _b AS
528+
${selectSQL.sql};`;
513529
await connection.run(createBSQL, selectSQL.params);
514530

515531
await connection.run(sql);

0 commit comments

Comments
 (0)