diff --git a/packages/js/src/utils.ts b/packages/js/src/utils.ts index 207841d..9665bdc 100644 --- a/packages/js/src/utils.ts +++ b/packages/js/src/utils.ts @@ -32,7 +32,7 @@ export function likePatternToRegexp(pattern: string, flags: '' | 'i' = ''): RegE } if (escaped) { - throw new SyntaxError('LIKE pattern must not end with an escape character'); + throw new Error('LIKE pattern must not end with an escape character'); } return new RegExp(`^${source}$`, `u${flags}`); diff --git a/packages/sql/README.md b/packages/sql/README.md index 6bfe4d2..498d08c 100644 --- a/packages/sql/README.md +++ b/packages/sql/README.md @@ -93,7 +93,8 @@ console.log(replacements) // [5, 10] | `regex` | Dialect-specific regular expression comparison with the pattern bound | | `and`, `or` | Parenthesized conditions joined by `and` or `or` | | `not`, `nor` | Negated groups joined by `and` or `or`, respectively | -| `some`, `none`, `every` | Correlated relation subqueries | +| `like`, `ilike` | Bound LIKE patterns with dialect feature configuration (see [LIKE patterns](#like-patterns)) | +| `some` (also `is`), `none` (also `isNot`), `every` | Correlated relation subqueries | The exported function for `in` is named `within`. The relation functions are exported as `someRelation`, `noneRelation`, and `everyRelation`; `allInterpreters` @@ -194,6 +195,47 @@ relatedRelationField }`, whose fields can likewise be matching arrays. Relation metadata describes joins and column mappings. For domain-specific SQL, provide a custom composer and implement its `relation` handler. +### LIKE patterns + +The named `like` and `ilike` interpreters are included in `allInterpreters`. +Both accept `FieldCondition` with a SQL LIKE pattern: `%` and `_` are wildcards, and backslash is the escape character. For example, +``new FieldCondition('like', 'name', String.raw`A\_%`)`` matches names beginning with literal `A_`. Non-string patterns and trailing unpaired backslashes throw during interpretation. + +`like` renders `field LIKE pattern ESCAPE ...`. `ilike` uses native ILIKE for PostgreSQL. Other supported dialects downcase column and value, this may have an impact on query performance because in this case database server will not be able to utilize indexes. + +The escape clause and bracket handling come from `features.like`, including when interpreting `ilike`. Brackets are escaped in MSSQL to ensure behavior matches across databases and JavaScript runtime. + +### Dialect features + +Different database servers implement RegExp support differently. For such use cases, `@ucast/sql` supports dialect options specification. + +It consists from `features`, escaping and bound param behavior. All this can be customized per interpretation or per integration. + +For example, PostgreSQL support ILIKE natively, but MySQL determines case sensetivity based on column collation. RegExp matching also implemented differently. + +To account all this small differences, `@ucast/sql` has `features` option which is used to determine which dialect specific interpreter to choose during interpretation. + +> Check [dialects.ts](./src/dialects.ts) for details. + +For example, to override native `ilike` of PostgreSQL with downcase strategy, it's enough to update its feature description: + +```ts +import { FieldCondition } from '@ucast/core'; +import { allInterpreters, createSqlInterpreter, pg } from '@ucast/sql'; + +const interpret = createSqlInterpreter(allInterpreters); +const [sql, params] = interpret(new FieldCondition('ilike', 'name', 'Ab%'), { + ...pg, + features: { ...pg.features, ilike: { use: 'lower' } }, +}); +// SQL: LOWER("name") LIKE LOWER($1) ESCAPE E'\\' +// params: ['Ab%'] +``` + +Drizzle accepts feature overrides directly in its factory configuration, since escaping and param bounding is done by drizzle. + +Custom composers must expose `features` property which is later can be used by dialect specific interpreter. + ### SQL composers `createSqlInterpreter` creates a composer for each interpretation call. The default @@ -219,20 +261,11 @@ implementation. The factory receives the SQL query options and returns a | `sql` | Compose fragments with a tagged template | | `raw(sql)` | Create a fragment from trusted SQL syntax | | `join(fragments, separator)` | Join fragments using a fragment separator | -| `regexp(ignoreCase)` | Return the dialect's regex operator and comparator | | `relation(name)` | Return a related `composer` and `subquery(predicate)` | +| `features` | Dialect feature configuration for regex and LIKE rendering | To return a different result shape, implement the optional -`result(fragment)` method on the composer and specify its return type as the -second generic argument. Without `result`, the interpreter returns the fragment -directly. - -`createDefaultSqlComposer(options)` is exported for extending -the built-in string composer. A custom `relation` handler can create a child with -`createDefaultSqlComposer(childOptions, parentComposer)` to share parameter binding. -The interpreter compiles the related predicate before calling `subquery(predicate)`. -Place that predicate before additional bound filters in the generated SQL so -positional placeholders follow parameter order. +`result(fragment)` method on the composer and specify its return type as the second generic argument. Without `result`, the interpreter returns the fragment directly. ### Custom interpreter @@ -248,10 +281,7 @@ const condition = new FieldCondition('eq', 'x', true); interpret(condition, pg); ``` -To add a custom operator, create a function that receives a condition and a -composer and returns a SQL fragment. A third argument provides -`interpret(condition, composer)` for recursively interpreting child conditions. -Let's create an operator that adds a condition on `publishedAt`: +To add a custom operator, create a function that receives a condition and a composer and returns an SQL fragment. A third argument provides `interpret(condition, composer)` for recursively interpreting child conditions. Let's create an operator that adds a condition on `publishedAt`: ```ts import { DocumentCondition } from '@ucast/core'; @@ -277,8 +307,11 @@ console.log(sql); // "publishedAt" >= $1 console.log(params); // [new Date()] ``` -Use `param` for values and `field` for identifiers; reserve `raw` for trusted SQL -syntax. If condition names differ from registered operator names, pass +Use `param` for values and `field` for identifiers; reserve `raw` for trusted SQL syntax. + +**Important:** `raw` renderes string as-is and may cause SQL injections! So, ensure that what you pass in `raw` is safe. + +If condition names differ from registered operator names, pass `getInterpreterName(condition, options)` in the `createSqlInterpreter` options. ## Integrations @@ -382,7 +415,7 @@ const condition = new FieldCondition('some', 'posts', db.select().from(authors).where(interpret(condition, 'authors')); ``` -Here, Drizzle infers the `authors.posts` join from `posts.author`. Direct joins, inferred inverse joins, composite keys, named self relations, and nestedrelations are supported. Each nested condition uses the related table's columns and encoders. +Here, Drizzle infers the `authors.posts` join from `posts.author`. Direct joins, inferred inverse joins, composite keys, named self relations, and nested relations are supported. Each nested condition uses the related table's columns and encoders. | Operator | Meaning | | --- | --- | @@ -412,7 +445,7 @@ Pass an optional second argument to select or extend the interpreter set. These operators come from `@ucast/sql`: ```ts -import { eq , lt } from '@ucast/sql'; +import { eq, lt } from '@ucast/sql'; const interpretSelected = createDrizzleInterpreter( { schema: { authors } }, @@ -421,7 +454,7 @@ const interpretSelected = createDrizzleInterpreter( ``` Values are bound parameters. Comparisons and membership conditions use the -resolved column's encoder; regular-expression patterns and modulo operands do not. Direct Drizzle `SQL` objects as condition values are rejected because Drizzle would otherwise render them as executable syntax. Use native Drizzle predicates or custom interpreters to compose SQL expressions. +resolved column's encoder; regular-expression patterns, LIKE patterns, and modulo operands do not. Direct Drizzle `SQL` objects as condition values are rejected because Drizzle would otherwise render them as executable syntax. Use native Drizzle predicates or custom interpreters to compose SQL expressions. #### Drizzle 1.x relation declarations @@ -453,87 +486,8 @@ Predefined relation filters restrict the row set for all relation operators: Drizzle 0.x applies casing when rendering a predicate; the same predicate can be rendered with different dialect casing settings. In 1.0.0 release candidates use native table construction casing, such as `sqliteTableCreator(name => name, 'snake_case')`. -The interpreter accepts neither a casing nor a dialect option. - -### [Sequelize](https://sequelize.org/) - -For custom relation SQL, pass `{ createSqlComposer }` as the second argument to -`createSequelizeInterpreter` and implement the composer's `relation` handler. - -```js -import { interpret } from '@ucast/sql/sequelize'; -import { CompoundCondition, FieldCondition } from '@ucast/core'; -import { Model, Sequelize, DataTypes } from 'sequelize'; - -const sequelize = new Sequelize('sqlite::memory:'); - -class User extends Model {} - -User.init({ - name: { type: DataTypes.STRING }, - blocked: { type: DataTypes.BOOLEAN }, - lastLoggedIn: { type: DataTypes.DATE }, -}, { sequelize }); - -const condition = new CompoundCondition('and', [ - new FieldCondition('eq', 'blocked', false), - new FieldCondition('lt', 'lastLoggedIn', new Date(Date.now() - 24 * 3600 * 1000)), -]); - -const queryOptions = interpret(condition, User, { alias: User.name }); -// Pass both the SQL literal and its bound replacements to Sequelize. -const users = await User.findAll(queryOptions); -``` - -`interpret(condition, Model, options?)` returns `{ where, replacements }`. -The optional third argument accepts `alias` and `replacementMode` (`'numeric'`, -the default positional `?` placeholders, or `'named'`). Named placeholders use -an interpreter-local call counter; consumers combining predicates own replacement -ordering and name collisions across interpreter instances. -The default alias is `Model.tableName`; pass the query's model alias explicitly -when it differs, as with `User.findAll()` above. - -### [Objection.js](https://vincit.github.io/objection.js/) - -The adapter translates model properties and relations with -`objectionToSqlMetadata(Model)`. To customize that mapping, pass -`{ createSqlComposer }` as the second argument to `createObjectionInterpreter`. -The factory receives query options including the model's `SqlEntityMetadata`; -pass customized metadata to `createDefaultSqlComposer`. For custom relation SQL, -implement the composer's `relation` handler. - -```js -import { interpret } from '@ucast/sql/objection'; -import { CompoundCondition, FieldCondition } from '@ucast/core'; -import { Model } from 'objection'; -import Knex from 'knex'; - -Model.knex(Knex({ client: 'pg' })); - -class User extends Model { - static tableName = 'users'; -} - -const condition = new CompoundCondition('and', [ - new FieldCondition('eq', 'blocked', false), - new FieldCondition('lt', 'lastLoggedIn', Date.now() - 24 * 3600 * 1000), -]); - -// the next code produces: -// User.query() -// .where('blocked', false) -// .where('lastLoggedIn', '<', Date.now() - 24 * 3600 * 1000) -const query = User.query().where(interpret(condition, User)); -``` - -`interpret(condition, Model, options?)` returns an Objection `RawBuilder` with -bound values. Pass it to `.where()` or `.andWhere()` to compose it with other -conditions. The model supplies the dialect and property/relation metadata. -For aliased queries, pass the same alias explicitly: - -```js -const query = User.query().alias('u').where(interpret(condition, User, { alias: 'u' })); -``` +The interpreter accepts no casing option. Its optional `features` configuration +overrides dialect feature entries; native Drizzle renders identifiers and parameters. ### [MikroORM](https://mikro-orm.io/) @@ -673,6 +627,85 @@ async function main() { main().catch(console.error); ``` +### [Sequelize](https://sequelize.org/) + +For custom relation SQL, pass `{ createSqlComposer }` as the second argument to +`createSequelizeInterpreter` and implement the composer's `relation` handler. + +```js +import { interpret } from '@ucast/sql/sequelize'; +import { CompoundCondition, FieldCondition } from '@ucast/core'; +import { Model, Sequelize, DataTypes } from 'sequelize'; + +const sequelize = new Sequelize('sqlite::memory:'); + +class User extends Model {} + +User.init({ + name: { type: DataTypes.STRING }, + blocked: { type: DataTypes.BOOLEAN }, + lastLoggedIn: { type: DataTypes.DATE }, +}, { sequelize }); + +const condition = new CompoundCondition('and', [ + new FieldCondition('eq', 'blocked', false), + new FieldCondition('lt', 'lastLoggedIn', new Date(Date.now() - 24 * 3600 * 1000)), +]); + +const queryOptions = interpret(condition, User, { alias: User.name }); +// Pass both the SQL literal and its bound replacements to Sequelize. +const users = await User.findAll(queryOptions); +``` + +`interpret(condition, Model, options?)` returns `{ where, replacements }`. +The optional third argument accepts `alias` and `replacementMode` (`'numeric'`, +the default positional `?` placeholders, or `'named'`). Named placeholders use +an interpreter-local call counter; consumers combining predicates own replacement +ordering and name collisions across interpreter instances. +The default alias is `Model.tableName`; pass the query's model alias explicitly +when it differs, as with `User.findAll()` above. + +### [Objection.js](https://vincit.github.io/objection.js/) + +The adapter translates model properties and relations with +`objectionToSqlMetadata(Model)`. To customize that mapping, pass +`{ createSqlComposer }` as the second argument to `createObjectionInterpreter`. +The factory receives query options including the model's `SqlEntityMetadata`; +pass customized metadata to `createDefaultSqlComposer`. For custom relation SQL, +implement the composer's `relation` handler. + +```js +import { interpret } from '@ucast/sql/objection'; +import { CompoundCondition, FieldCondition } from '@ucast/core'; +import { Model } from 'objection'; +import Knex from 'knex'; + +Model.knex(Knex({ client: 'pg' })); + +class User extends Model { + static tableName = 'users'; +} + +const condition = new CompoundCondition('and', [ + new FieldCondition('eq', 'blocked', false), + new FieldCondition('lt', 'lastLoggedIn', Date.now() - 24 * 3600 * 1000), +]); + +// the next code produces: +// User.query() +// .where('blocked', false) +// .where('lastLoggedIn', '<', Date.now() - 24 * 3600 * 1000) +const query = User.query().where(interpret(condition, User)); +``` + +`interpret(condition, Model, options?)` returns an Objection `RawBuilder` with +bound values. Pass it to `.where()` or `.andWhere()` to compose it with other +conditions. The model supplies the dialect and property/relation metadata. +For aliased queries, pass the same alias explicitly: + +```js +const query = User.query().alias('u').where(interpret(condition, User, { alias: 'u' })); +``` ## TypeScript Support Written in TypeScript and supports type inference for supported ORMs. diff --git a/packages/sql/spec/drizzle-orm/sharedExamples.ts b/packages/sql/spec/drizzle-orm/sharedExamples.ts index 5d435f7..bd71fbc 100644 --- a/packages/sql/spec/drizzle-orm/sharedExamples.ts +++ b/packages/sql/spec/drizzle-orm/sharedExamples.ts @@ -5,7 +5,8 @@ import Database from 'better-sqlite3' import { allInterpreters, eq, type SqlOperator } from '../../src/index.ts' import type { - DrizzleInterpreterOptions, DrizzleQueryOptions, + DrizzleInterpreterOptions, + DrizzleQueryOptions, } from '../../src/lib/drizzle-orm/base.ts' export interface RelationDefinition { @@ -65,6 +66,24 @@ export function includeDrizzleInterpreterExamples({ }) const setup = () => setupExample(encoded) + it('uses native LIKE rules with mapped fields and unencoded bound patterns', () => { + const { render } = setup() + const query = render(new FieldCondition('like', 'displayName', String.raw`a\%`)) + expect(query.sql).to.equal('"users"."display_name" LIKE ? ESCAPE char(92)') + expect(query.params).to.deep.equal([String.raw`a\%`]) + const insensitive = render(new FieldCondition('ilike', 'code', 'Ab%')) + expect(insensitive.sql).to.equal('LOWER("users"."code") LIKE LOWER(?) ESCAPE char(92)') + expect(insensitive.params).to.deep.equal(['Ab%']) + const pgUsers = pg.pgSchema('app').table('users', { name: pg.text('name') }) + const myUsers = mysql.mysqlTable('users', { name: mysql.text('name') }) + const interpret = createDrizzleInterpreter({ schema: { pgUsers, myUsers } }) + const condition = new FieldCondition('ilike', 'name', 'Ab%') + expect(new pg.PgDialect().sqlToQuery(interpret(condition, 'pgUsers'))) + .to.include({ sql: String.raw`"app"."users"."name" ILIKE $1 ESCAPE E'\\'` }) + expect(new mysql.MySqlDialect().sqlToQuery(interpret(condition, 'myUsers'))) + .to.include({ sql: 'LOWER(`users`.`name`) LIKE LOWER(?) ESCAPE char(92)' }) + }) + const cases: [string, unknown, string, unknown[]][] = [ ['eq', 18, '= ?', [18]], ['ne', 18, '<> ?', [18]], @@ -317,6 +336,35 @@ export function includeDrizzleInterpreterExamples({ } }) + it('executes LIKE operators in nested relations with mapped columns and overrides', () => { + const { connection, db, schema, users, names, interpret } = setupRelationExamples() + try { + const condition = some('posts', new FieldCondition('ilike', 'title', 'pub%')) + expect(names(condition)).to.deep.equal(['Bob']) + expect(names(some('posts', some('author', new FieldCondition('like', 'name', 'B%'))))) + .to.deep.equal(['Bob']) + const query = setup().dialect.sqlToQuery(interpret(condition, 'users')) + expect(query.sql).to.contain('LOWER("users_ucast_0"."post_title") LIKE LOWER(?) ESCAPE char(92)') + const custom = createDrizzleInterpreter({ + schema, + features: { like: { use: 'native', escapeCharacter: String.raw`'\'` } }, + }) + const predicate = custom(condition, 'users') + expect(setup().dialect.sqlToQuery(predicate).sql).to.contain(String.raw`"post_title") LIKE LOWER(?) ESCAPE '\'`) + expect(db.select({ name: users.name }).from(users).where(predicate).all()) + .to.deep.equal([{ name: 'Bob' }]) + const transform: SqlOperator> = (condition, { sql, field, param }) => + sql`upper(${field(condition.field)}) like upper(${param(condition.value)}) escape '\\'` + const transformed = createDrizzleInterpreter( + { schema }, { ...allInterpreters, ilike: transform } + ) + expect(db.select({ name: users.name }).from(users).where(transformed(condition, 'users')).all()) + .to.deep.equal([{ name: 'Bob' }]) + } finally { + connection.close() + } + }) + it('composes custom operators inside relations', () => { const { connection, db, schema, users } = setupRelationExamples() try { diff --git a/packages/sql/spec/interpreters.spec.ts b/packages/sql/spec/interpreters.spec.ts index 82e6904..db509cc 100644 --- a/packages/sql/spec/interpreters.spec.ts +++ b/packages/sql/spec/interpreters.spec.ts @@ -18,6 +18,9 @@ import { nor, mod, regex, + ilike, + like, + sqlite, pg, oracle, mysql, @@ -276,11 +279,313 @@ describe('Condition Interpreter', () => { expect(params).to.deep.equal([condition.value.source]) }) - it('throws exception for MSSQL as it does not support REGEXP', () => { + const regexpLikeCases: [RegExp, string][] = [ + [/pattern/, 'c'], + [/pattern/i, 'i'], + [/^pattern$/m, 'cm'], + [/a.b/s, 'cs'], + [/^a.b$/ims, 'ims'], + [/pattern/guy, 'c'], + ] + + regexpLikeCases.forEach(([pattern, flags]) => { + it(`generates MSSQL REGEXP_LIKE for ${pattern} with flags ${flags}`, () => { + const [sql, params] = interpret(new Field('regex', 'email', pattern), mssql) + + expect(sql).to.equal('REGEXP_LIKE([email], ?, ?)') + expect(params).to.deep.equal([pattern.source, flags]) + }) + }) + + it('binds MSSQL regex patterns without changing the expression or its state', () => { + const pattern = /O'Reilly\d+|--/gi + pattern.lastIndex = 4 + const condition = new Field('regex', 'displayName', pattern) + const [sql, params] = interpret(condition, { + ...mssql, + alias: 'users', + metadata: { toColumnName: () => 'display_name' }, + }) + + expect(sql).to.equal('REGEXP_LIKE([users].[display_name], ?, ?)') + expect(params).to.deep.equal([String.raw`O'Reilly\d+|--`, 'i']) + expect(condition.value).to.equal(pattern) + expect(pattern.lastIndex).to.equal(4) + }) + + it('preserves MSSQL parameter ordering when regex predicates are composed and negated', () => { + const interpret = createSqlInterpreter({ eq, regex, and, not }) + const condition = new CompoundCondition('and', [ + new Field('eq', 'active', true), + new CompoundCondition('not', [new Field('regex', 'email', /@example\.com$/i)]), + new Field('regex', 'name', /^Ada/), + ]) + const [sql, params] = interpret(condition, { + ...mssql, + paramPlaceholder: index => `@p${index}`, + }) + + expect(sql).to.equal( + '([active] = @p1 and not (REGEXP_LIKE([email], @p2, @p3)) ' + + 'and REGEXP_LIKE([name], @p4, @p5))' + ) + expect(params).to.deep.equal([true, String.raw`@example\.com$`, 'i', '^Ada', 'c']) + }) + + it('uses a configured regexp strategy for MSSQL', () => { const condition = new Field('regex', 'email', /@/) - expect(() => { - interpret(condition, { ...mssql }) - }).to.throw(/"regexp" operator is not supported in MSSQL/) + const [sql, params] = interpret(condition, { + ...mssql, + features: { ...mssql.features, regex: { use: 'regexp' } }, + }) + + expect(sql).to.equal('[email] regexp ? = 1') + expect(params).to.deep.equal([condition.value.source]) + }) + }) + + describe('like', () => { + const interpret = createSqlInterpreter({ like }) + + it('uses PostgreSQL placeholders and an explicit backslash escape', () => { + const [sql, params] = interpret(new Field('like', 'name', 'Alice%'), pg) + + expect(sql).to.equal(String.raw`"name" LIKE $1 ESCAPE E'\\'`) + expect(params).to.deep.equal(['Alice%']) + }) + + it('uses MySQL identifiers and CHAR(92) as the escape character', () => { + const [sql, params] = interpret(new Field('like', 'name', 'Alice%'), mysql) + + expect(sql).to.equal('`name` LIKE ? ESCAPE char(92)') + expect(params).to.deep.equal(['Alice%']) + }) + + it('uses SQLite identifiers and positional parameters', () => { + const [sql, params] = interpret(new Field('like', 'name', 'Alice%'), sqlite) + + expect(sql).to.equal('"name" LIKE ? ESCAPE char(92)') + expect(params).to.deep.equal(['Alice%']) + }) + + it('uses Oracle placeholders and a backslash escape literal', () => { + const [sql, params] = interpret(new Field('like', 'name', 'Alice%'), oracle) + + expect(sql).to.equal(String.raw`"name" LIKE $1 ESCAPE '\'`) + expect(params).to.deep.equal(['Alice%']) + }) + + it('uses MSSQL identifiers and a backslash escape literal', () => { + const [sql, params] = interpret(new Field('like', 'name', 'Alice%'), mssql) + + expect(sql).to.equal(String.raw`[name] LIKE ? ESCAPE '\'`) + expect(params).to.deep.equal(['Alice%']) + }) + + it('preserves percent and underscore wildcards in the bound pattern', () => { + const [sql, params] = interpret(new Field('like', 'name', 'A_ice%'), pg) + + expect(sql).to.equal(String.raw`"name" LIKE $1 ESCAPE E'\\'`) + expect(params).to.deep.equal(['A_ice%']) + }) + + it('preserves an escaped underscore followed by a wildcard', () => { + const [sql, params] = interpret(new Field('like', 'name', String.raw`Alice\_%`), pg) + + expect(sql).to.equal(String.raw`"name" LIKE $1 ESCAPE E'\\'`) + expect(params).to.deep.equal([String.raw`Alice\_%`]) + }) + + it('preserves an escaped percent followed by a wildcard', () => { + const [sql, params] = interpret(new Field('like', 'discount', String.raw`50\%%`), pg) + + expect(sql).to.equal(String.raw`"discount" LIKE $1 ESCAPE E'\\'`) + expect(params).to.deep.equal([String.raw`50\%%`]) + }) + + it('accepts an empty pattern', () => { + const [sql, params] = interpret(new Field('like', 'name', ''), pg) + + expect(sql).to.equal(String.raw`"name" LIKE $1 ESCAPE E'\\'`) + expect(params).to.deep.equal(['']) + }) + + it('accepts a pattern containing only a wildcard', () => { + const [sql, params] = interpret(new Field('like', 'name', '%'), pg) + + expect(sql).to.equal(String.raw`"name" LIKE $1 ESCAPE E'\\'`) + expect(params).to.deep.equal(['%']) + }) + + it('accepts a trailing escaped backslash', () => { + const [sql, params] = interpret(new Field('like', 'path', String.raw`docs\\`), pg) + + expect(sql).to.equal(String.raw`"path" LIKE $1 ESCAPE E'\\'`) + expect(params).to.deep.equal([String.raw`docs\\`]) + }) + + it('leaves regular-expression metacharacters unchanged', () => { + const [sql, params] = interpret(new Field('like', 'name', 'a.b+[cd](e)?^$'), pg) + + expect(sql).to.equal(String.raw`"name" LIKE $1 ESCAPE E'\\'`) + expect(params).to.deep.equal(['a.b+[cd](e)?^$']) + }) + + it('binds quotes and SQL-looking text as a value', () => { + const [sql, params] = interpret(new Field('like', 'name', "O'Reilly'; --%"), pg) + + expect(sql).to.equal(String.raw`"name" LIKE $1 ESCAPE E'\\'`) + expect(params).to.deep.equal(["O'Reilly'; --%"]) + }) + + it('preserves line breaks in the pattern', () => { + const [sql, params] = interpret(new Field('like', 'name', 'first\nsecond%'), pg) + + expect(sql).to.equal(String.raw`"name" LIKE $1 ESCAPE E'\\'`) + expect(params).to.deep.equal(['first\nsecond%']) + }) + + it('escapes opening brackets for MSSQL without changing the condition', () => { + const condition = new Field('like', 'name', '[Alice]%') + Object.freeze(condition) + const [sql, params] = interpret(condition, mssql) + + expect(sql).to.equal(String.raw`[name] LIKE ? ESCAPE '\'`) + expect(params).to.deep.equal([String.raw`\[Alice]%`]) + expect(condition.value).to.equal('[Alice]%') + }) + + it('preserves an already escaped MSSQL opening bracket', () => { + const [sql, params] = interpret(new Field('like', 'name', String.raw`\[Alice]%`), mssql) + + expect(sql).to.equal(String.raw`[name] LIKE ? ESCAPE '\'`) + expect(params).to.deep.equal([String.raw`\[Alice]%`]) + }) + + it('escapes each opening bracket in an MSSQL pattern', () => { + const [sql, params] = interpret(new Field('like', 'name', '[A][B]%'), mssql) + + expect(sql).to.equal(String.raw`[name] LIKE ? ESCAPE '\'`) + expect(params).to.deep.equal([String.raw`\[A]\[B]%`]) + }) + + it('escapes an MSSQL opening bracket after an escaped backslash', () => { + const [sql, params] = interpret(new Field('like', 'name', String.raw`\\[Alice]%`), mssql) + + expect(sql).to.equal(String.raw`[name] LIKE ? ESCAPE '\'`) + expect(params).to.deep.equal([String.raw`\\\[Alice]%`]) + }) + + it('leaves a standalone MSSQL closing bracket unchanged', () => { + const [sql, params] = interpret(new Field('like', 'name', ']Alice%'), mssql) + + expect(sql).to.equal(String.raw`[name] LIKE ? ESCAPE '\'`) + expect(params).to.deep.equal([']Alice%']) + }) + + it('uses mapped, aliased columns and a configured escape expression', () => { + const [sql, params] = interpret(new Field('like', 'displayName', 'Alice%'), { + ...pg, + alias: 'users', + metadata: { toColumnName: () => 'display_name' }, + features: { ...pg.features, like: { use: 'native', escapeCharacter: 'char(92)' } }, + }) + + expect(sql).to.equal('"users"."display_name" LIKE $1 ESCAPE char(92)') + expect(params).to.deep.equal(['Alice%']) + }) + + it('rejects a null pattern', () => { + const condition = new Field('like', 'name', null) + + expect(() => interpret(condition, pg)).to.throw('LIKE pattern must be a string') + }) + + it('rejects an undefined pattern', () => { + const condition = new Field('like', 'name', undefined) + + expect(() => interpret(condition, pg)).to.throw('LIKE pattern must be a string') + }) + + it('rejects a numeric pattern', () => { + const condition = new Field('like', 'name', 42) + + expect(() => interpret(condition, pg)).to.throw('LIKE pattern must be a string') + }) + + it('rejects a RegExp instead of treating it as a LIKE pattern', () => { + const condition = new Field('like', 'name', /Alice/) + + expect(() => interpret(condition, pg)).to.throw('LIKE pattern must be a string') + }) + + it('rejects an array of patterns', () => { + const condition = new Field('like', 'name', ['Alice%']) + + expect(() => interpret(condition, pg)).to.throw('LIKE pattern must be a string') + }) + + it('rejects a trailing unpaired backslash', () => { + const condition = new Field('like', 'name', 'Alice\\') + + expect(() => interpret(condition, pg)) + .to.throw('LIKE pattern must not end with an unpaired backslash') + }) + + it('rejects an unpaired backslash after an escaped backslash', () => { + const condition = new Field('like', 'name', 'Alice\\\\\\') + + expect(() => interpret(condition, pg)) + .to.throw('LIKE pattern must not end with an unpaired backslash') + }) + }) + + describe('ilike', () => { + const interpret = createSqlInterpreter({ ilike }) + const cases: [string, SqlQueryOptions, string][] = [ + ['MySQL', mysql, 'LOWER(`name`) LIKE LOWER(?) ESCAPE char(92)'], + ['SQLite', sqlite, 'LOWER("name") LIKE LOWER(?) ESCAPE char(92)'], + ['Oracle', oracle, String.raw`LOWER("name") LIKE LOWER($1) ESCAPE '\'`], + ['MSSQL', mssql, String.raw`LOWER([name]) LIKE LOWER(?) ESCAPE '\'`], + ] + + cases.forEach(([dialect, options, expectedSql]) => { + it(`lowercases both operands in SQL for ${dialect} and binds the original pattern`, () => { + const condition = new Field('ilike', 'name', String.raw`Ab\_%`) + const [sql, params] = interpret(condition, options) + + expect(sql).to.equal(expectedSql) + expect(params).to.deep.equal([String.raw`Ab\_%`]) + expect(condition.value).to.equal(String.raw`Ab\_%`) + }) + }) + + it('uses native ILIKE for PostgreSQL', () => { + const [sql, params] = interpret(new Field('ilike', 'name', 'Ab%'), pg) + + expect(sql).to.equal(String.raw`"name" ILIKE $1 ESCAPE E'\\'`) + expect(params).to.deep.equal(['Ab%']) + }) + + it('applies the lower strategy override to mapped, aliased columns', () => { + const [sql, params] = interpret(new Field('ilike', 'displayName', 'Ab%'), { + ...pg, + features: { ...pg.features, ilike: { use: 'lower' } }, + alias: 'users', + metadata: { toColumnName: () => 'display_name' }, + }) + + expect(sql).to.equal(String.raw`LOWER("users"."display_name") LIKE LOWER($1) ESCAPE E'\\'`) + expect(params).to.deep.equal(['Ab%']) + }) + + it('preserves MSSQL bracket escaping before database case folding', () => { + const condition = new Field('ilike', 'name', String.raw`Ab[CD]\_%`) + const [sql, params] = interpret(condition, mssql) + + expect(sql).to.equal(String.raw`LOWER([name]) LIKE LOWER(?) ESCAPE '\'`) + expect(params).to.deep.equal([String.raw`Ab\[CD]\_%`]) + expect(condition.value).to.equal(String.raw`Ab[CD]\_%`) }) }) @@ -578,7 +883,7 @@ describe('Condition Interpreter', () => { param: () => ({ text: '?' }), manyParams: () => { throw new Error('not used') }, relation: () => { throw new Error('not used') }, - regexp: pg.regexp, + features: pg.features, raw: text => ({ text }), join: (fragments, separator) => ({ text: fragments.map(fragment => fragment.text).join(separator.text), diff --git a/packages/sql/src/dialects.ts b/packages/sql/src/dialects.ts index 347d89c..8e399a7 100644 --- a/packages/sql/src/dialects.ts +++ b/packages/sql/src/dialects.ts @@ -1,96 +1,106 @@ -const POSIX_REGEXP = Object.freeze({ - true: { - operator: '~*', - comparator: '', - }, - false: { - operator: '~', - comparator: '' - } -} as const); - -function posixRegex(ignoreCase: boolean) { - const type = ignoreCase ? 'true' : 'false'; - return POSIX_REGEXP[type]; -} - -const REGEXP = Object.freeze({ - operator: 'regexp', - comparator: ' = 1' -}); -function regexp() { - return REGEXP; -} - const questionPlaceholder = () => '?'; const $indexPlaceholder = (index: number) => `$${index}`; export const oracle = { - regexp: posixRegex, + features: { + regex: { use: 'posix' }, + like: { use: 'native' }, + ilike: { use: 'lower' } + }, paramPlaceholder: $indexPlaceholder, escapeIdentifier: (name: string) => `"${name}"`, -}; -export const pg = { ...oracle }; +} satisfies DialectOptions; +export const pg = { + ...oracle, + features: { + regex: { + use: 'posix', + }, + like: { use: 'native', escapeCharacter: "E'\\\\'" }, + ilike: { use: 'native' } + }, +} satisfies DialectOptions; export const mysql = { - regexp, + features: { + regex: { use: 'regexp' }, + like: { use: 'native', escapeCharacter: `char(${'\\'.charCodeAt(0)})` }, + ilike: { use: 'lower' } + }, paramPlaceholder: questionPlaceholder, escapeIdentifier: (name: string) => `\`${name}\``, -}; +} satisfies DialectOptions; export const sqlite = { ...mysql, + features: { + regex: { use: 'regexp' }, + like: { ...mysql.features.like }, + ilike: { use: 'lower' } + }, escapeIdentifier: (name: string) => `"${name}"`, -}; +} satisfies DialectOptions; export const mssql = { - regexp() { - throw new Error('"regexp" operator is not supported in MSSQL'); + features: { + regex: { use: 'regexpLike' }, + like: { use: 'native', supportsBrackets: true }, + ilike: { use: 'lower', supportsBrackets: true } }, paramPlaceholder: questionPlaceholder, escapeIdentifier: (name: string) => `[${name}]`, +} satisfies DialectOptions; + +export type DialectOperators = { + regex: { + use: 'regexp' | 'posix' | 'regexpLike'; + } + like: { + use: 'native'; + supportsBrackets?: boolean; + escapeCharacter?: string; + } + ilike: { + use: 'native' | 'lower'; + supportsBrackets?: boolean; + escapeCharacter?: string; + } }; export interface DialectOptions { - regexp(ignoreCase: boolean): { operator: string; comparator: string; } - escapeIdentifier(name: string, relationName?: string): string - paramPlaceholder(index: number): string + features: DialectOperators; + escapeIdentifier(name: string): string; + paramPlaceholder(index: number): string; } -export type SupportedDialects = 'mssql' | -'postgres' | -'pg' | -'postgresql' | -'oracle' | -'oracledb' | -'mysql' | -'mysql2' | -'mariadb' | -'better-sqlite3' | -'sqlite3' | -'sqlite'; +export type SupportedDialects = + | 'mssql' + | 'postgres' + | 'pg' + | 'postgresql' + | 'oracle' + | 'oracledb' + | 'mysql' + | 'mysql2' + | 'mariadb' + | 'better-sqlite3' + | 'sqlite3' + | 'sqlite'; type Dialects = Record; export function createDialects>(options: T): Dialects { - const mssqlOptions = { - ...mssql, + const configure = (dialect: DialectOptions) => ({ + ...dialect, ...options, - }; - const pgOptions = { - ...pg, - ...options, - }; - const oracleOptions = { - ...oracle, - ...options, - }; - const mysqlOptions = { - ...mysql, - ...options, - }; - const sqliteOptions = { - ...sqlite, - ...options, - }; + features: { + ...dialect.features, + ...options.features, + }, + }); + const mssqlOptions = configure(mssql); + const pgOptions = configure(pg); + const oracleOptions = configure(oracle); + const mysqlOptions = configure(mysql); + const sqliteOptions = configure(sqlite); return { mssql: mssqlOptions, diff --git a/packages/sql/src/interpreter.ts b/packages/sql/src/interpreter.ts index 3dc9704..f4ddf23 100644 --- a/packages/sql/src/interpreter.ts +++ b/packages/sql/src/interpreter.ts @@ -3,7 +3,8 @@ import { type FieldCondition, type InterpreterOptions } from '@ucast/core'; -import { type DialectOptions } from './dialects.ts'; +import { type DialectOperators, type DialectOptions } from './dialects.ts'; +import { hasOwn } from './utils.ts'; export function createSqlInterpreter< F = string, @@ -43,10 +44,10 @@ export interface SqlInterpreterOptions { } export interface SqlComposer { + features: Readonly; field(field: string): F; param(value: FieldCondition | unknown): F; manyParams(value: FieldCondition | unknown[]): F; - regexp: DialectOptions['regexp']; sql(strings: TemplateStringsArray, ...values: unknown[]): F; raw(sql: string): F; join(fragments: F[], separator: F): F; @@ -62,7 +63,7 @@ export interface SqlComposition extends SqlComposer { result?(fragment: F): R } -export interface SqlQueryOptions extends Required { +export interface SqlQueryOptions extends DialectOptions { alias?: string; metadata?: SqlEntityMetadata; } @@ -138,7 +139,7 @@ function createStringComposer( return result; }), join: joinStrings, - regexp: options.regexp, + features: options.features, relation(name) { const relation = options.metadata?.resolveRelation?.(name); if (!relation) { @@ -214,3 +215,28 @@ export type SqlOperator = ( composer: SqlComposer, context: SqlInterpretationContext, ) => F; + +export function dialectSpecificOperator( + name: T, + implementations: Record>, + options?: { + validate?(value: C['value']): void; + } +): SqlOperator { + return (condition, composer, context) => { + options?.validate?.(condition.value); + const selections = composer.features; + const maybeImpl = selections && hasOwn(selections, name) + ? implementations[selections[name].use as keyof typeof implementations] + : null; + const implementation = typeof maybeImpl === 'function' + ? maybeImpl + : undefined; + + if (implementation) { + return implementation(condition, composer, context); + } + + throw new Error(`SQL composer does not support the "${name}" dialect capability${typeof selections?.[name] === 'string' ? ` (unknown strategy "${selections[name]}")` : ''}`); + }; +} diff --git a/packages/sql/src/interpreters.ts b/packages/sql/src/interpreters.ts index 84fccff..ac96d3c 100644 --- a/packages/sql/src/interpreters.ts +++ b/packages/sql/src/interpreters.ts @@ -5,6 +5,7 @@ import { type Comparable } from '@ucast/core'; import { + dialectSpecificOperator, type SqlOperator, } from './interpreter.ts'; @@ -50,14 +51,65 @@ export const mod: SqlOperator> = ( condition, { sql, field, param } ) => sql`mod(${field(condition.field)}, ${param(condition.value[0])}) = ${param(condition.value[1])}`; -export const regex: SqlOperator> = ( +export const regex = dialectSpecificOperator, 'regex'>('regex', { + posix(condition, { sql, field, param, raw }) { + const op = condition.value.ignoreCase ? '~*' : '~'; + return sql`${field(condition.field)} ${raw(op)} ${param(condition.value.source)}`; + }, + regexp(condition, { sql, field, param }) { + return sql`${field(condition.field)} regexp ${param(condition.value.source)} = 1`; + }, + regexpLike(condition, { sql, field, param }) { + const pattern = condition.value; + const flags = (pattern.ignoreCase ? 'i' : 'c') + + (pattern.multiline ? 'm' : '') + + (pattern.dotAll ? 's' : ''); + return sql`REGEXP_LIKE(${field(condition.field)}, ${param(pattern.source)}, ${param(flags)})`; + } +}); + +function validateLikePattern(pattern: string) { + if (typeof pattern !== 'string') throw new Error('LIKE pattern must be a string'); + let trailingSlashes = 0; + for (let i = pattern.length - 1; i >= 0 && pattern[i] === '\\'; i--) { + trailingSlashes++; + } + if (trailingSlashes % 2 !== 0) { + throw new Error('LIKE pattern must not end with an unpaired backslash'); + } +} + +const escapeBrackets = (value: string) => value.replace(/\\[\s\S]|\[/g, match => match === '[' ? '\\[' : match); +const DEFAULT_LIKE_ESCAPE_CHAR = "'\\'"; +export const like: SqlOperator> = ( condition, - { sql, field, param, regexp, raw } + { sql, field, param, raw, features } ) => { - const regexpDetails = regexp(condition.value.ignoreCase); - return sql`${field(condition.field)} ${raw(regexpDetails.operator)} ${param(condition.value.source)}${raw(regexpDetails.comparator)}`; + validateLikePattern(condition.value); + const escapeChar = features.like.escapeCharacter ?? DEFAULT_LIKE_ESCAPE_CHAR; + const pattern = features.like.supportsBrackets + ? escapeBrackets(condition.value) + : condition.value; + return sql`${field(condition.field)} LIKE ${param(pattern)} ESCAPE ${raw(escapeChar)}`; }; +export const ilike = dialectSpecificOperator, 'ilike'>('ilike', { + native(condition, { sql, field, param, raw, features }) { + const escapeChar = features.like.escapeCharacter ?? DEFAULT_LIKE_ESCAPE_CHAR; + const pattern = features.like.supportsBrackets + ? escapeBrackets(condition.value) + : condition.value; + return sql`${field(condition.field)} ILIKE ${param(pattern)} ESCAPE ${raw(escapeChar)}`; + }, + lower(condition, { sql, field, param, raw, features }) { + const escapeChar = features.like.escapeCharacter ?? DEFAULT_LIKE_ESCAPE_CHAR; + const pattern = features.like.supportsBrackets + ? escapeBrackets(condition.value) + : condition.value; + return sql`LOWER(${field(condition.field)}) LIKE LOWER(${param(pattern)}) ESCAPE ${raw(escapeChar)}`; + } +}, { validate: validateLikePattern }); + function compoundOperator(combinator: 'and' | 'or', isInverted = false) { const separator = ` ${combinator} `; return ((node, composer, { interpret }) => { diff --git a/packages/sql/src/lib/drizzle-orm/base.ts b/packages/sql/src/lib/drizzle-orm/base.ts index 9323928..100ffa0 100644 --- a/packages/sql/src/lib/drizzle-orm/base.ts +++ b/packages/sql/src/lib/drizzle-orm/base.ts @@ -1,15 +1,13 @@ import { Condition, type FieldCondition } from '@ucast/core'; import { createSqlInterpreter, mysql, pg, sqlite, - type SqlComposer, type SqlOperator, type SqlQueryOptions, + type SqlComposer, type SqlOperator, type SqlQueryOptions, type DialectOptions, } from '../../index.ts'; - -export const hasOwn = Object.hasOwn ?? ((hasOwnProperty) => - (object: object, key: PropertyKey) => hasOwnProperty(object, key) -)(Object.prototype.hasOwnProperty.call.bind(Object.prototype.hasOwnProperty)); +import { hasOwn } from "../../utils.ts"; export interface DrizzleInterpreterOptions> { schema: TSchema; + features?: Partial; } export type TableKeys = { @@ -56,11 +54,13 @@ export function createGetEntityName(entityKind: symbol) { export interface RelationAdapter { tables: Record; resolveRelation( - tableKey: string, name: string + tableKey: string, + name: string ): DrizzleRelationMetadata | undefined; } -interface ComposerOptions extends Pick { +interface ComposerOptions { + features: SqlQueryOptions['features']; table: TTable; tableKey: string; family: string; @@ -84,7 +84,10 @@ export interface DrizzleOperations } export function createBaseDrizzleInterpreter< - TTable, TColumn extends TChunk, TSQL extends TChunk, TChunk + TTable, + TColumn extends TChunk, + TSQL extends TChunk, + TChunk >( operations: DrizzleOperations ) { @@ -92,7 +95,10 @@ export function createBaseDrizzleInterpreter< function createInterpreter>( operators: Record>, - adapter: RelationAdapter + adapter: RelationAdapter, + dialectOverrides?: { + features?: Partial + } ) { const interpret = createSqlInterpreter>(operators, { createSqlComposer: createDrizzleSqlComposer, @@ -115,7 +121,7 @@ export function createBaseDrizzleInterpreter< let index = 0; const dialect = SUPPORTED_TABLE_TYPES[family as keyof typeof SUPPORTED_TABLE_TYPES]; return interpret(condition, { - regexp: dialect.regexp, + features: { ...dialect.features, ...dialectOverrides?.features }, family, table: queryTable, tableKey, @@ -133,6 +139,7 @@ export function createBaseDrizzleInterpreter< return sql.param(value, encoder); }; return { + features: options.features, field: name => column(name) ?? sql`${sql.identifier(getTableName(options.table))}.${sql.identifier(name)}`, param(conditionOrValue) { @@ -151,7 +158,6 @@ export function createBaseDrizzleInterpreter< sql, raw: sql.raw, join: sql.join, - regexp: options.regexp, relation(name) { const relation = options.resolve(name, options.tableKey); if (!relation) throw new Error(`Relation metadata for "${getTableName(options.table)}.${name}" not found`); diff --git a/packages/sql/src/lib/drizzle-orm/v0.x.ts b/packages/sql/src/lib/drizzle-orm/v0.x.ts index d0d0787..8985b10 100644 --- a/packages/sql/src/lib/drizzle-orm/v0.x.ts +++ b/packages/sql/src/lib/drizzle-orm/v0.x.ts @@ -21,12 +21,13 @@ import { allInterpreters, type SqlOperator } from '../../index.ts'; import { createBaseDrizzleInterpreter, createGetEntityName, - hasOwn, SUPPORTED_TABLE_TYPES, type DrizzleInterpreterOptions as BaseDrizzleInterpreterOptions, type DrizzleRelationMetadata as BaseDrizzleRelationMetadata, type DrizzleInterpreter as BaseDrizzleInterpreter, } from './base.ts'; +import { hasOwn } from "../../utils.ts"; + export type { DrizzleQueryOptions } from './base.ts'; export type DrizzleInterpreterOptions> @@ -49,7 +50,7 @@ export function createDrizzleInterpreter options: DrizzleInterpreterOptions, operators: Record> = allInterpreters ): DrizzleInterpreter { - return createInterpreter(operators, extractRelations(options.schema)); + return createInterpreter(operators, extractRelations(options.schema), options); } function extractRelations(schema: Record) { diff --git a/packages/sql/src/lib/drizzle-orm/v1.x.ts b/packages/sql/src/lib/drizzle-orm/v1.x.ts index b7c91ca..7857235 100644 --- a/packages/sql/src/lib/drizzle-orm/v1.x.ts +++ b/packages/sql/src/lib/drizzle-orm/v1.x.ts @@ -7,11 +7,13 @@ import { Relation, relationToSQL, type TableRelationalConfig } from "drizzle-orm import { aliasedTable } from 'drizzle-orm-v1/alias'; import { allInterpreters, type SqlOperator } from '../../index.ts'; import { - createBaseDrizzleInterpreter, createGetEntityName, hasOwn, + createBaseDrizzleInterpreter, createGetEntityName, type DrizzleInterpreterOptions as BaseDrizzleInterpreterOptions, type DrizzleRelationMetadata as BaseDrizzleRelationMetadata, type DrizzleInterpreter as BaseDrizzleInterpreter, } from './base.ts'; +import { hasOwn } from "../../utils.ts"; + export type { DrizzleQueryOptions } from './base.ts'; export type DrizzleInterpreterOptions> @@ -93,7 +95,8 @@ export function createDrizzleInterpreter } return createInterpreter(operators, { tables, - resolveRelation: (key, name) => hasOwn(metadata[key] || {}, name) + resolveRelation: (key, name) => + typeof metadata[key] === "object" && metadata[key] && hasOwn(metadata[key], name) ? metadata[key][name] : undefined, - }); + }, options); } diff --git a/packages/sql/src/utils.ts b/packages/sql/src/utils.ts new file mode 100644 index 0000000..db842ed --- /dev/null +++ b/packages/sql/src/utils.ts @@ -0,0 +1,3 @@ +export const hasOwn = Object.hasOwn ?? ((hasOwnProperty) => + (object: object, key: PropertyKey) => hasOwnProperty(object, key) +)(Object.prototype.hasOwnProperty.call.bind(Object.prototype.hasOwnProperty));