diff --git a/packages/core/src/eval.ts b/packages/core/src/eval.ts index d88e69b7..3e4fff25 100644 --- a/packages/core/src/eval.ts +++ b/packages/core/src/eval.ts @@ -126,6 +126,7 @@ export namespace Eval { concat: Multi regex(x: Term, y: RegExp): Expr regex(x: Term, y: Term, flags?: string): Expr + startsWith(x: Term, y: Term): Expr // logical / bitwise and: Multi & Multi & Multi @@ -250,6 +251,7 @@ operators.$nin = ([value, array], data) => { // string Eval.concat = multary('concat', (args, data) => args.map(arg => executeEval(data, arg)).join(''), Type.String) Eval.regex = multary('regex', ([value, regex, flags], data) => makeRegExp(executeEval(data, regex), flags).test(executeEval(data, value)), Type.Boolean) +Eval.startsWith = comparator('startsWith', (str, prefix) => str.startsWith(prefix)) // logical / bitwise Eval.and = multary('and', (args, data) => { diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index fb2336c5..5d394936 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -34,6 +34,7 @@ export namespace Query { // regexp $regex?: Extract $regexFor?: Extract + $startsWith?: Extract // bitwise $bitsAllClear?: Extract @@ -99,6 +100,7 @@ const queryOperators: QueryOperators = { // regexp $regex: (query, data) => makeRegExp(query).test(data), $regexFor: (query, data) => typeof query === 'string' ? makeRegExp(data).test(query) : makeRegExp(data, query.flags).test(query.input), + $startsWith: (query, data) => data.startsWith(query), // bitwise $bitsAllSet: (query, data) => (query & data) === query, diff --git a/packages/mongo/src/builder.ts b/packages/mongo/src/builder.ts index 5056f71d..aa4fefa7 100644 --- a/packages/mongo/src/builder.ts +++ b/packages/mongo/src/builder.ts @@ -168,6 +168,16 @@ export class Builder { }, }), + $startsWith: ([str, prefix], group) => { + const input = this.eval(str, group) + if (typeof prefix === 'string') { + const escaped = '^' + String(prefix).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&') + return { $regexMatch: { input, regex: { $literal: escaped } } } + } + const p = this.eval(prefix, group) + return { $eq: [{ $substrCP: [input, 0, { $strLenCP: p }] }, p] } + }, + $length: (arg, group) => ({ $size: this.eval(arg, group) }), $nin: (arg, group) => ({ $not: { $in: arg.map(val => this.eval(val, group)) } }), @@ -315,6 +325,8 @@ export class Builder { }, }, }) + } else if (prop === '$startsWith') { + return { $regex: '^' + String(query[prop]).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&') } } else if (prop === '$exists') { if (query[prop]) return { $ne: null } else return null diff --git a/packages/mysql/src/builder.ts b/packages/mysql/src/builder.ts index 704b48d7..1620a95f 100644 --- a/packages/mysql/src/builder.ts +++ b/packages/mysql/src/builder.ts @@ -34,6 +34,21 @@ export class MySQLBuilder extends Builder { super(driver, tables) this._dbTimezone = compat.timezone ?? 'SYSTEM' + this.queryOperators.$startsWith = (key, value) => { + const escaped = String(value).replace(/[!%_]/g, '!$&') + return `${key} LIKE BINARY ${this.escape(escaped + '%')} ESCAPE '!'` + } + + this.evalOperators.$startsWith = ([key, prefix]) => { + const k = this.parseEval(key) + if (typeof prefix === 'string') { + const escaped = String(prefix).replace(/[!%_]/g, '!$&') + return `${k} LIKE BINARY ${this.escape(escaped + '%')} ESCAPE '!'` + } + const p = this.parseEval(prefix) + return `${k} LIKE BINARY concat(replace(replace(replace(${p}, '!', '!!'), '%', '!%'), '_', '!_'), '%') ESCAPE '!'` + } + if (this.compat.mysql57 || this.compat.maria) { this.queryOperators.$regexFor = (key, value) => typeof value === 'string' ? `${this.escape(value)} ${this.compat.ci ? 'collate utf8mb4_bin' : ''} regexp ${key}` : `${this.escape(value.input)} ${(!!value.flags?.includes('i') === !!this.compat.ci) diff --git a/packages/postgres/src/builder.ts b/packages/postgres/src/builder.ts index d81ee9a3..ddff385b 100644 --- a/packages/postgres/src/builder.ts +++ b/packages/postgres/src/builder.ts @@ -34,6 +34,10 @@ export class PostgresBuilder extends Builder { ...this.queryOperators, $regexFor: (key, value) => typeof value === 'string' ? `${this.escape(value)} ~ ${key}` : `${this.escape(value.input)} ${value.flags?.includes('i') ? '~*' : '~'} ${key}`, + $startsWith: (key, value) => { + const escaped = String(value).replace(/[!%_]/g, '!$&') + return `${key} LIKE ${this.escape(escaped + '%')} ESCAPE '!'` + }, $size: (key, value) => { if (this.isJsonQuery(key)) { return `${this.jsonLength(key)} = ${this.escape(value)}` @@ -59,6 +63,15 @@ export class PostgresBuilder extends Builder { $regex: ([key, value, flags]) => `(${this.parseEval(key)} ${ (flags?.includes('i') || (value instanceof RegExp && value.flags.includes('i'))) ? '~*' : '~' } ${this.parseEval(value)})`, + $startsWith: ([key, prefix]) => { + const k = this.parseEval(key) + if (typeof prefix === 'string') { + const escaped = String(prefix).replace(/[!%_]/g, '!$&') + return `${k} LIKE ${this.escape(escaped + '%')} ESCAPE '!'` + } + const p = this.parseEval(prefix) + return `${k} LIKE concat(replace(replace(replace(${p}, '!', '!!'), '%', '!%'), '_', '!_'), '%') ESCAPE '!'` + }, // number $add: (args) => `(${args.map(arg => this.parseEval(arg, 'double precision')).join(' + ')})`, diff --git a/packages/sqlite/src/builder.ts b/packages/sqlite/src/builder.ts index 2b4f9d18..16999361 100644 --- a/packages/sqlite/src/builder.ts +++ b/packages/sqlite/src/builder.ts @@ -14,12 +14,26 @@ export class SQLiteBuilder extends Builder { : value.flags?.includes('i') ? `regexp2(${key}, ${this.escape(value.input)}, 'i')` : `${this.escape(value.input)} regexp ${key}` + this.queryOperators.$startsWith = (key, value) => { + const escaped = String(value).replace(/[*?[]/g, '[$&]') + return `${key} GLOB ${this.escape(escaped + '*')}` + } + this.evalOperators.$if = (args) => `iif(${args.map(arg => this.parseEval(arg)).join(', ')})` this.evalOperators.$regex = ([key, value, flags]) => (flags?.includes('i') || (value instanceof RegExp && value.flags?.includes('i'))) ? `regexp2(${this.parseEval(value)}, ${this.parseEval(key)}, ${this.escape(flags ?? (value as any).flags)})` : `regexp(${this.parseEval(value)}, ${this.parseEval(key)})` this.evalOperators.$concat = (args) => `(${args.map(arg => this.parseEval(arg)).join('||')})` + this.evalOperators.$startsWith = ([key, prefix]) => { + const k = this.parseEval(key) + if (typeof prefix === 'string') { + const escaped = String(prefix).replace(/[*?[]/g, '[$&]') + return `${k} GLOB ${this.escape(escaped + '*')}` + } + const p = this.parseEval(prefix) + return `${k} GLOB replace(replace(replace(${p}, '[', '[[]'), '*', '[*]'), '?', '[?]') || '*'` + } this.evalOperators.$modulo = ([left, right]) => `modulo(${this.parseEval(left)}, ${this.parseEval(right)})` this.evalOperators.$log = ([left, right]) => isNullable(right) ? `ln(${this.parseEval(left)})` diff --git a/packages/tests/src/query.ts b/packages/tests/src/query.ts index 35d9fa6c..76d49d1e 100644 --- a/packages/tests/src/query.ts +++ b/packages/tests/src/query.ts @@ -11,6 +11,7 @@ interface Foo { date?: Date time?: Date regex?: string + pattern?: string } declare module '@cordisjs/plugin-database' { @@ -31,6 +32,7 @@ function QueryOperators(database: Database) { date: 'date', time: 'time', regex: 'string', + pattern: 'string', }, { autoInc: true, }) @@ -275,6 +277,60 @@ namespace QueryOperators { }) } + export const text = function Text(database: Database) { + beforeAll(async () => { + await database.remove('temp1', {}) + await database.create('temp1', { text: 'hello world', pattern: 'hello' }) + await database.create('temp1', { text: '100%juice', pattern: '100%' }) + await database.create('temp1', { text: 'a_b', pattern: 'a_' }) + await database.create('temp1', { text: '!important', pattern: '!' }) + await database.create('temp1', { text: '!%_test', pattern: '!%_' }) + await database.create('temp1', { text: 'Hello World', pattern: 'Hello' }) + await database.create('temp1', { text: '*.log', pattern: '*' }) + await database.create('temp1', { text: '?query', pattern: '?' }) + await database.create('temp1', { text: '[tag]', pattern: '[' }) + await database.create('temp1', { text: '.hidden', pattern: '.' }) + await database.create('temp1', { text: '^anchor', pattern: '^' }) + await database.create('temp1', { text: '$money', pattern: '$' }) + }) + + it('$.startsWith', async () => { + await expect(database.get('temp1', row => $.startsWith(row.text, 'hello'))).eventually.to.have.length(1) + await expect(database.get('temp1', row => $.startsWith(row.text, '100%'))).eventually.to.have.length(1) + await expect(database.get('temp1', row => $.startsWith(row.text, 'a_'))).eventually.to.have.length(1) + await expect(database.get('temp1', row => $.startsWith(row.text, '!'))).eventually.to.have.length(2) + await expect(database.get('temp1', row => $.startsWith(row.text, '!%_'))).eventually.to.have.length(1) + await expect(database.get('temp1', row => $.startsWith(row.text, 'Hello'))).eventually.to.have.length(1) + await expect(database.get('temp1', row => $.startsWith(row.text, 'hello'))).eventually.to.have.length(1) + await expect(database.get('temp1', row => $.startsWith(row.text, '.'))).eventually.to.have.length(1) + await expect(database.get('temp1', row => $.startsWith(row.text, '^'))).eventually.to.have.length(1) + await expect(database.get('temp1', row => $.startsWith(row.text, '$'))).eventually.to.have.length(1) + await expect(database.get('temp1', row => $.startsWith(row.text, '*'))).eventually.to.have.length(1) + await expect(database.get('temp1', row => $.startsWith(row.text, '?'))).eventually.to.have.length(1) + await expect(database.get('temp1', row => $.startsWith(row.text, '['))).eventually.to.have.length(1) + await expect(database.get('temp1', row => $.startsWith(row.text, 'nomatch'))).eventually.to.have.length(0) + await expect(database.get('temp1', row => $.startsWith(row.text, row.pattern))).eventually.to.have.length(12) + await expect(database.get('temp1', row => $.startsWith('hello world', row.pattern))).eventually.to.have.length(1) + }) + + it('$startsWith', async () => { + await expect(database.get('temp1', { text: { $startsWith: 'hello' } })).eventually.to.have.length(1) + await expect(database.get('temp1', { text: { $startsWith: '100%' } })).eventually.to.have.length(1) + await expect(database.get('temp1', { text: { $startsWith: 'a_' } })).eventually.to.have.length(1) + await expect(database.get('temp1', { text: { $startsWith: '!' } })).eventually.to.have.length(2) + await expect(database.get('temp1', { text: { $startsWith: '!%_' } })).eventually.to.have.length(1) + await expect(database.get('temp1', { text: { $startsWith: 'Hello' } })).eventually.to.have.length(1) + await expect(database.get('temp1', { text: { $startsWith: 'hello' } })).eventually.to.have.length(1) + await expect(database.get('temp1', { text: { $startsWith: '.' } })).eventually.to.have.length(1) + await expect(database.get('temp1', { text: { $startsWith: '^' } })).eventually.to.have.length(1) + await expect(database.get('temp1', { text: { $startsWith: '$' } })).eventually.to.have.length(1) + await expect(database.get('temp1', { text: { $startsWith: '*' } })).eventually.to.have.length(1) + await expect(database.get('temp1', { text: { $startsWith: '?' } })).eventually.to.have.length(1) + await expect(database.get('temp1', { text: { $startsWith: '[' } })).eventually.to.have.length(1) + await expect(database.get('temp1', { text: { $startsWith: 'nomatch' } })).eventually.to.have.length(0) + }) + } + export const bitwise = function Bitwise(database: Database) { beforeAll(async () => { await database.remove('temp1', {})