diff --git a/packages/builder/package.json b/packages/builder/package.json index e27ce863..5f7a4d62 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -1,7 +1,7 @@ { "name": "@sqb/builder", "description": "Extensible multi-dialect SQL query builder written with TypeScript", - "version": "6.0.2", + "version": "6.0.3", "author": "Panates", "private": false, "license": "Apache-2.0", diff --git a/packages/connect/package.json b/packages/connect/package.json index 37ecab82..e6485c4b 100644 --- a/packages/connect/package.json +++ b/packages/connect/package.json @@ -1,7 +1,7 @@ { "name": "@sqb/connect", "description": "Multi-dialect database connection framework written with TypeScript", - "version": "6.0.2", + "version": "6.0.3", "author": "Panates", "license": "Apache-2.0", "scripts": { @@ -44,7 +44,7 @@ "postgrejs": "^2.23.1" }, "peerDependencies": { - "@sqb/builder": "^6.0.2", + "@sqb/builder": "^6.0.3", "reflect-metadata": "^0.2.2" }, "type": "module", diff --git a/packages/mariadb-dialect/package.json b/packages/mariadb-dialect/package.json index 48584e59..d2ac99db 100644 --- a/packages/mariadb-dialect/package.json +++ b/packages/mariadb-dialect/package.json @@ -1,7 +1,7 @@ { "name": "@sqb/mariadb-dialect", "description": "SQB serialization extension for MariaDB database", - "version": "6.0.2", + "version": "6.0.3", "author": "Panates", "license": "Apache-2.0", "scripts": { @@ -24,7 +24,7 @@ "tslib": "^2.8.1" }, "peerDependencies": { - "@sqb/builder": "^6.0.2" + "@sqb/builder": "^6.0.3" }, "devDependencies": { "expect": "^30.4.1" diff --git a/packages/mariadb/package.json b/packages/mariadb/package.json index d1b989ee..f4644c55 100644 --- a/packages/mariadb/package.json +++ b/packages/mariadb/package.json @@ -1,7 +1,7 @@ { "name": "@sqb/mariadb", "description": "SQB adapter for the mariadb driver", - "version": "6.0.2", + "version": "6.0.3", "author": "Panates", "license": "Apache-2.0", "scripts": { @@ -28,9 +28,9 @@ "mariadb": "^3.5.3" }, "peerDependencies": { - "@sqb/builder": "^6.0.2", - "@sqb/connect": "^6.0.2", - "@sqb/mariadb-dialect": "^6.0.2", + "@sqb/builder": "^6.0.3", + "@sqb/connect": "^6.0.3", + "@sqb/mariadb-dialect": "^6.0.3", "mariadb": "^3.5.3" }, "type": "module", diff --git a/packages/migrator/package.json b/packages/migrator/package.json index 214e2501..a75719eb 100644 --- a/packages/migrator/package.json +++ b/packages/migrator/package.json @@ -1,7 +1,7 @@ { "name": "@sqb/migrator", "description": "Database migrator for SQB", - "version": "6.0.2", + "version": "6.0.3", "author": "Panates", "license": "Apache-2.0", "scripts": { @@ -28,9 +28,9 @@ "tslib": "^2.8.1" }, "peerDependencies": { - "@sqb/builder": "^6.0.2", - "@sqb/connect": "^6.0.2", - "@sqb/postgres": "^6.0.2" + "@sqb/builder": "^6.0.3", + "@sqb/connect": "^6.0.3", + "@sqb/postgres": "^6.0.3" }, "devDependencies": { "expect": "^30.4.1", diff --git a/packages/mssql-dialect/package.json b/packages/mssql-dialect/package.json index 211392da..4dfe0342 100644 --- a/packages/mssql-dialect/package.json +++ b/packages/mssql-dialect/package.json @@ -1,7 +1,7 @@ { "name": "@sqb/mssql-dialect", "description": "SQB serialization extension for MS-SQL database", - "version": "6.0.2", + "version": "6.0.3", "author": "Panates", "license": "Apache-2.0", "scripts": { @@ -24,7 +24,7 @@ "tslib": "^2.8.1" }, "peerDependencies": { - "@sqb/builder": "^6.0.2" + "@sqb/builder": "^6.0.3" }, "devDependencies": { "expect": "^30.4.1" diff --git a/packages/mssql/package.json b/packages/mssql/package.json index ab20062d..1ae960ad 100644 --- a/packages/mssql/package.json +++ b/packages/mssql/package.json @@ -1,7 +1,7 @@ { "name": "@sqb/mssql", "description": "SQB adapter for the mssql (tedious) driver", - "version": "6.0.2", + "version": "6.0.3", "author": "Panates", "license": "Apache-2.0", "scripts": { @@ -30,9 +30,9 @@ "mssql": "^12.7.0" }, "peerDependencies": { - "@sqb/builder": "^6.0.2", - "@sqb/connect": "^6.0.2", - "@sqb/mssql-dialect": "^6.0.2", + "@sqb/builder": "^6.0.3", + "@sqb/connect": "^6.0.3", + "@sqb/mssql-dialect": "^6.0.3", "mssql": "^11.0.0 || ^12.0.0" }, "type": "module", diff --git a/packages/mssql/src/mssql-connection.ts b/packages/mssql/src/mssql-connection.ts index 51ad825a..afbe1dff 100644 --- a/packages/mssql/src/mssql-connection.ts +++ b/packages/mssql/src/mssql-connection.ts @@ -39,7 +39,7 @@ const typeNameMap: Record = { Image: { dataType: 'IMAGE', jsType: 'Buffer' }, }; -const NAMED_PARAM_PATTERN = / *:([a-zA-Z_]+)/; +const NAMED_PARAM_PATTERN = /^( *):([a-zA-Z_]\w*)$/; export class MssqlConnection implements Adapter.Connection { private intlcon?: ConnectionPool; @@ -228,11 +228,26 @@ export class MssqlConnection implements Adapter.Connection { }); let token: string | null; let out = ''; - let m; + // quotes:true hands back an entire string/double-quoted-identifier + // literal as one token, so a ":name" occurring inside one never + // matches the anchored NAMED_PARAM_PATTERN at all. T-SQL's [bracket] + // identifier quoting isn't a "quote" character the tokenizer knows + // about though (brackets:false), so "[col:name]" comes back as three + // separate tokens ("[col", ":name", "]") - track bracket state + // explicitly so a ":name" inside one isn't mistaken for a param. + let inBracket = false; while ((token = tokenizer.next())) { - m = NAMED_PARAM_PATTERN.exec(token); - if (m) { - token = '@' + m[1]; + const trimmed = token.trim(); + if (inBracket) { + if (trimmed.includes(']')) inBracket = false; + } else if (trimmed.startsWith('[')) { + inBracket = true; + } else { + const m = NAMED_PARAM_PATTERN.exec(token); + if (m) { + const [, leading, name] = m; + token = leading + '@' + name; + } } out += token; } diff --git a/packages/mssql/test/mssql-connection.spec.ts b/packages/mssql/test/mssql-connection.spec.ts new file mode 100644 index 00000000..70447e45 --- /dev/null +++ b/packages/mssql/test/mssql-connection.spec.ts @@ -0,0 +1,60 @@ +import type { QueryRequest } from '@sqb/connect'; +import { expect } from 'expect'; +import { MssqlConnection } from '../src/mssql-connection.js'; + +describe('mssql:MssqlConnection', () => { + const connection = new MssqlConnection({} as any); + + describe('_normalizeNamedParams', () => { + function normalize(sql: string, params?: Record) { + const request: QueryRequest = { sql, params }; + (connection as any)._normalizeNamedParams(request); + return request; + } + + it('should replace a named param with "@name"', () => { + const r = normalize('select :a as a', { a: 1 }); + expect(r.sql).toStrictEqual('select @a as a'); + }); + + it('should replace multiple distinct params', () => { + const r = normalize('select :a, :b', { a: 1, b: 2 }); + expect(r.sql).toStrictEqual('select @a, @b'); + }); + + it('should replace every occurrence of a repeated param', () => { + const r = normalize('select :a, :b, :a', { a: 1, b: 2 }); + expect(r.sql).toStrictEqual('select @a, @b, @a'); + }); + + it('should leave sql untouched when there is no named param', () => { + const r = normalize('select 1'); + expect(r.sql).toStrictEqual('select 1'); + }); + + it('should not rewrite the params object itself', () => { + const params = { a: 1 }; + const r = normalize('select :a', params); + expect(r.params).toBe(params); + }); + + it('should not treat ":name" inside a string literal as a param', () => { + const r = normalize("select :a as a, 'literal :notparam text' as lit", { + a: 1, + }); + expect(r.sql).toStrictEqual( + "select @a as a, 'literal :notparam text' as lit", + ); + }); + + it('should not treat ":name" inside a double-quoted identifier as a param', () => { + const r = normalize('select :a, "col:name" from t', { a: 1 }); + expect(r.sql).toStrictEqual('select @a, "col:name" from t'); + }); + + it('should not treat ":name" inside a bracket-quoted identifier as a param', () => { + const r = normalize('select :a, [col:name] from t', { a: 1 }); + expect(r.sql).toStrictEqual('select @a, [col:name] from t'); + }); + }); +}); diff --git a/packages/mysql-dialect/package.json b/packages/mysql-dialect/package.json index eb2fb498..37d9a347 100644 --- a/packages/mysql-dialect/package.json +++ b/packages/mysql-dialect/package.json @@ -1,7 +1,7 @@ { "name": "@sqb/mysql-dialect", "description": "SQB serialization extension for MySQL database", - "version": "6.0.2", + "version": "6.0.3", "author": "Panates", "license": "Apache-2.0", "scripts": { @@ -24,7 +24,7 @@ "tslib": "^2.8.1" }, "peerDependencies": { - "@sqb/builder": "^6.0.2" + "@sqb/builder": "^6.0.3" }, "devDependencies": { "expect": "^30.4.1" diff --git a/packages/mysql/package.json b/packages/mysql/package.json index 4b655c1d..7ba7fc63 100644 --- a/packages/mysql/package.json +++ b/packages/mysql/package.json @@ -1,7 +1,7 @@ { "name": "@sqb/mysql", "description": "SQB adapter for the mysql2 driver", - "version": "6.0.2", + "version": "6.0.3", "author": "Panates", "license": "Apache-2.0", "scripts": { @@ -28,9 +28,9 @@ "mysql2": "^3.24.2" }, "peerDependencies": { - "@sqb/builder": "^6.0.2", - "@sqb/connect": "^6.0.2", - "@sqb/mysql-dialect": "^6.0.2", + "@sqb/builder": "^6.0.3", + "@sqb/connect": "^6.0.3", + "@sqb/mysql-dialect": "^6.0.3", "mysql2": "^3.24.2" }, "type": "module", diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index e3f3f953..a1b94c90 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -1,7 +1,7 @@ { "name": "@sqb/nestjs", "description": "Nestjs module for data connection using SQB", - "version": "6.0.2", + "version": "6.0.3", "author": "Panates", "license": "Apache-2.0", "scripts": { @@ -39,8 +39,8 @@ "peerDependencies": { "@nestjs/common": ">=7.4.0", "@nestjs/core": ">=7.4.0", - "@sqb/builder": "^6.0.2", - "@sqb/connect": "^6.0.2", + "@sqb/builder": "^6.0.3", + "@sqb/connect": "^6.0.3", "reflect-metadata": "^0.2.2", "rxjs": ">=6.6.0" }, diff --git a/packages/oracle-dialect/package.json b/packages/oracle-dialect/package.json index e47336be..62c27314 100644 --- a/packages/oracle-dialect/package.json +++ b/packages/oracle-dialect/package.json @@ -1,7 +1,7 @@ { "name": "@sqb/oracle-dialect", "description": "SQB serialization extension for Oracle database", - "version": "6.0.2", + "version": "6.0.3", "author": "Panates", "license": "Apache-2.0", "scripts": { @@ -25,7 +25,7 @@ "tslib": "^2.8.1" }, "peerDependencies": { - "@sqb/builder": "^6.0.2" + "@sqb/builder": "^6.0.3" }, "devDependencies": { "expect": "^30.4.1" diff --git a/packages/oracle/package.json b/packages/oracle/package.json index e6221cb6..f1291f1a 100644 --- a/packages/oracle/package.json +++ b/packages/oracle/package.json @@ -1,7 +1,7 @@ { "name": "@sqb/oracle", "description": "SQB serialization extension for Oracle database", - "version": "6.0.2", + "version": "6.0.3", "author": "Panates", "license": "Apache-2.0", "scripts": { @@ -24,9 +24,9 @@ "tslib": "^2.8.1" }, "peerDependencies": { - "@sqb/builder": "^6.0.2", - "@sqb/connect": "^6.0.2", - "@sqb/oracle-dialect": "^6.0.2", + "@sqb/builder": "^6.0.3", + "@sqb/connect": "^6.0.3", + "@sqb/oracle-dialect": "^6.0.3", "oracledb": ">= 6.4.0" }, "devDependencies": { diff --git a/packages/postgres-dialect/package.json b/packages/postgres-dialect/package.json index e791b7d0..3788194a 100644 --- a/packages/postgres-dialect/package.json +++ b/packages/postgres-dialect/package.json @@ -1,7 +1,7 @@ { "name": "@sqb/postgres-dialect", "description": "SQB serialization extension for PostgreSQL database", - "version": "6.0.2", + "version": "6.0.3", "author": "Panates", "license": "Apache-2.0", "scripts": { @@ -24,7 +24,7 @@ "tslib": "^2.8.1" }, "peerDependencies": { - "@sqb/builder": "^6.0.2" + "@sqb/builder": "^6.0.3" }, "devDependencies": { "expect": "^30.4.1" diff --git a/packages/postgres/package.json b/packages/postgres/package.json index 1e48e80c..cf7cc4c7 100644 --- a/packages/postgres/package.json +++ b/packages/postgres/package.json @@ -1,7 +1,7 @@ { "name": "@sqb/postgres", "description": "SQB serialization extension for PostgreSQL database", - "version": "6.0.2", + "version": "6.0.3", "author": "Panates", "license": "Apache-2.0", "scripts": { @@ -30,9 +30,9 @@ "postgrejs": "^2.22.2" }, "peerDependencies": { - "@sqb/builder": "^6.0.2", - "@sqb/connect": "^6.0.2", - "@sqb/postgres-dialect": "^6.0.2", + "@sqb/builder": "^6.0.3", + "@sqb/connect": "^6.0.3", + "@sqb/postgres-dialect": "^6.0.3", "postgrejs": ">=2.15.1 <3.0.0" }, "type": "module", diff --git a/packages/postgres/src/pg-connection.ts b/packages/postgres/src/pg-connection.ts index ced16961..1e24d677 100644 --- a/packages/postgres/src/pg-connection.ts +++ b/packages/postgres/src/pg-connection.ts @@ -28,7 +28,7 @@ const SqbDataTypToOIDMap = { [DataType.GUID]: [DataTypeOIDs.uuid, DataTypeOIDs._uuid], }; -const NAMED_PARAM_PATTERN = / *:([a-zA-Z_]+)/; +const NAMED_PARAM_PATTERN = /^( *):([a-zA-Z_]\w*)$/; export class PgConnection implements Adapter.Connection { private intlcon?: Connection; @@ -189,29 +189,39 @@ export class PgConnection implements Adapter.Connection { let out = ''; let namedParams: Map | undefined; let params = request.params; - let m; + // Tracks the previous raw token so a "::" type-cast can be recognized: + // the tokenizer splits it into a lone ":" token followed by a ":name" + // token, which looks exactly like a named param unless we know the + // ":" right before it isn't part of a legitimate delimiter. quotes:true + // hands back an entire string/quoted-identifier literal as one token + // (e.g. "'literal :notparam text'"), so a ":name" occurring inside one + // never matches NAMED_PARAM_PATTERN at all - the tokenizer always + // isolates a genuine ":name" reference as its own token (only ever + // preceded by whitespace merged into the same token), so there's + // nothing else in the token to rule it out with. + let prevToken = ''; while ((token = tokenizer.next())) { - m = NAMED_PARAM_PATTERN.exec(token); - if (m) { + const m = NAMED_PARAM_PATTERN.exec(token); + if (m && prevToken !== ':') { + const [, leading, k] = m; if (!namedParams) { if (typeof params !== 'object' || Array.isArray(params)) throw new Error('"params" should be an key, value object'); namedParams = new Map(); } - const k = m[1]; - let index = namedParams.size + 1; - if (namedParams.has(k)) { - index = namedParams.get(k).index; - } else { - const v = params[k]; - if (v != undefined) - namedParams.set(k, { - index: namedParams.size + 1, - value: v, - }); + // Register the param (even if it has no matching value) as soon as + // it's first seen, so its index is reserved right away - deferring + // registration until a value is found let a later, unrelated param + // silently reuse the same index instead. + let entry = namedParams.get(k); + if (!entry) { + entry = { index: namedParams.size + 1, value: params[k] }; + namedParams.set(k, entry); } - token = `$${index}`; + token = leading + `$${entry.index}`; } + const trimmed = token.trim(); + if (trimmed) prevToken = trimmed; out += token; } if (namedParams) { diff --git a/packages/postgres/test/pg-connection.spec.ts b/packages/postgres/test/pg-connection.spec.ts new file mode 100644 index 00000000..6bfe90f3 --- /dev/null +++ b/packages/postgres/test/pg-connection.spec.ts @@ -0,0 +1,109 @@ +import type { QueryRequest } from '@sqb/connect'; +import { expect } from 'expect'; +import { PgConnection } from '../src/pg-connection.js'; + +describe('postgres:PgConnection', () => { + const connection = new PgConnection({} as any); + + describe('_normalizeNamedParams', () => { + function normalize(sql: string, params?: any) { + const request: QueryRequest = { sql, params }; + (connection as any)._normalizeNamedParams(request); + return request; + } + + it('should replace a named param with a positional one', () => { + const r = normalize('select :a as a', { a: 1 }); + expect(r.sql).toStrictEqual('select $1 as a'); + expect(r.params).toStrictEqual([1]); + }); + + it('should reuse the same index for a repeated param', () => { + const r = normalize('select :a, :b, :a', { a: 1, b: 2 }); + expect(r.sql).toStrictEqual('select $1, $2, $1'); + expect(r.params).toStrictEqual([1, 2]); + }); + + it('should not treat ":name" inside a string literal as a param', () => { + const r = normalize("select :a as a, 'literal :notparam text' as lit", { + a: 1, + }); + expect(r.sql).toStrictEqual( + "select $1 as a, 'literal :notparam text' as lit", + ); + expect(r.params).toStrictEqual([1]); + }); + + it('should not treat the second ":" of a "::" cast as a param', () => { + const r = normalize('select :a::text as a', { a: 1 }); + expect(r.sql).toStrictEqual('select $1::text as a'); + expect(r.params).toStrictEqual([1]); + }); + + it('should not treat ":name" inside a quoted identifier as a param', () => { + const r = normalize('select :a, "col:name" from t', { a: 1 }); + expect(r.sql).toStrictEqual('select $1, "col:name" from t'); + expect(r.params).toStrictEqual([1]); + }); + + it('should leave an array-slice expression untouched', () => { + const r = normalize('select arr[2:4], :a', { a: 1 }); + expect(r.sql).toStrictEqual('select arr[2:4], $1'); + expect(r.params).toStrictEqual([1]); + }); + + it('should not confuse a cast on a string literal with a param', () => { + const r = normalize("select :a, '2024-01-01'::date as d", { a: 1 }); + expect(r.sql).toStrictEqual("select $1, '2024-01-01'::date as d"); + expect(r.params).toStrictEqual([1]); + }); + + it('should handle a chained (double) cast', () => { + const r = normalize('select :a, x::text::varchar as v', { a: 1 }); + expect(r.sql).toStrictEqual('select $1, x::text::varchar as v'); + expect(r.params).toStrictEqual([1]); + }); + + it('should not let a shorter param name match a prefix of a longer one', () => { + const r = normalize('select :a, :ab', { a: 1, ab: 2 }); + expect(r.sql).toStrictEqual('select $1, $2'); + expect(r.params).toStrictEqual([1, 2]); + }); + + it('should assign indexes by order of appearance in the SQL, not in the params object', () => { + const r = normalize('select :b, :a', { a: 1, b: 2 }); + expect(r.sql).toStrictEqual('select $1, $2'); + expect(r.params).toStrictEqual([2, 1]); + }); + + it('should treat param names as case-sensitive', () => { + const r = normalize('select :a, :A', { a: 1, A: 2 }); + expect(r.sql).toStrictEqual('select $1, $2'); + expect(r.params).toStrictEqual([1, 2]); + }); + + it('should leave sql and params untouched when there is no named param', () => { + const r = normalize('select 1'); + expect(r.sql).toStrictEqual('select 1'); + expect(r.params).toBeUndefined(); + }); + + it('should not throw for a "::" cast when params is not an object', () => { + expect(() => normalize('select x::text')).not.toThrow(); + const r = normalize('select x::text'); + expect(r.sql).toStrictEqual('select x::text'); + }); + + it('should throw if params is not a key/value object but sql has a named param', () => { + expect(() => normalize('select :a', [1] as any)).toThrow( + '"params" should be an key, value object', + ); + }); + + it('should assign each named param its own index and value, even when one of them has no matching value', () => { + const r = normalize('select :a, :missing, :b', { a: 1, b: 2 }); + expect(r.sql).toStrictEqual('select $1, $2, $3'); + expect(r.params).toStrictEqual([1, undefined, 2]); + }); + }); +}); diff --git a/packages/sqlite-dialect/package.json b/packages/sqlite-dialect/package.json index 2e2b0e8c..9c4229dc 100644 --- a/packages/sqlite-dialect/package.json +++ b/packages/sqlite-dialect/package.json @@ -1,7 +1,7 @@ { "name": "@sqb/sqlite-dialect", "description": "SQB serialization extension for SQLite database", - "version": "6.0.2", + "version": "6.0.3", "author": "Panates", "license": "Apache-2.0", "scripts": { @@ -24,7 +24,7 @@ "tslib": "^2.8.1" }, "peerDependencies": { - "@sqb/builder": "^6.0.2" + "@sqb/builder": "^6.0.3" }, "devDependencies": { "expect": "^30.4.1" diff --git a/packages/sqlite/package.json b/packages/sqlite/package.json index 2e078fee..de0ae885 100644 --- a/packages/sqlite/package.json +++ b/packages/sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@sqb/sqlite", "description": "SQB adapter for Node.js and Bun's native SQLite drivers", - "version": "6.0.2", + "version": "6.0.3", "author": "Panates", "license": "Apache-2.0", "scripts": { @@ -27,9 +27,9 @@ "expect": "^30.4.1" }, "peerDependencies": { - "@sqb/builder": "^6.0.2", - "@sqb/connect": "^6.0.2", - "@sqb/sqlite-dialect": "^6.0.2" + "@sqb/builder": "^6.0.3", + "@sqb/connect": "^6.0.3", + "@sqb/sqlite-dialect": "^6.0.3" }, "type": "module", "module": "./index.js", diff --git a/packages/sqljs/package.json b/packages/sqljs/package.json index 6c24b83e..8ff8db97 100644 --- a/packages/sqljs/package.json +++ b/packages/sqljs/package.json @@ -1,7 +1,7 @@ { "name": "@sqb/sqljs", "description": "SQB serialization extension for sql.js driver", - "version": "6.0.2", + "version": "6.0.3", "author": "Panates", "license": "Apache-2.0", "scripts": { @@ -30,9 +30,9 @@ "sql.js": "^1.14.2" }, "peerDependencies": { - "@sqb/builder": "^6.0.2", - "@sqb/connect": "^6.0.2", - "@sqb/sqlite-dialect": "^6.0.2", + "@sqb/builder": "^6.0.3", + "@sqb/connect": "^6.0.3", + "@sqb/sqlite-dialect": "^6.0.3", "sql.js": "^1.14.2" }, "type": "module",