From 9cd50099c4c48b6ffca04666873ca9a8032e0111 Mon Sep 17 00:00:00 2001 From: Josh Gross Date: Wed, 23 Sep 2026 16:33:52 -0400 Subject: [PATCH] Remove parameter interpolation --- README.md | 41 +--- __tests__/index.test.ts | 90 ++------ __tests__/sanitization.test.ts | 385 --------------------------------- __tests__/text.test.ts | 9 +- package-lock.json | 17 -- package.json | 2 - src/index.ts | 91 ++++---- src/sanitization.ts | 381 -------------------------------- src/text.ts | 5 - 9 files changed, 73 insertions(+), 948 deletions(-) delete mode 100644 __tests__/sanitization.test.ts delete mode 100644 src/sanitization.ts diff --git a/README.md b/README.md index e0afe49..2f2cab1 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ const config = { } const conn = connect(config) -const results = await conn.execute('select 1 from dual where 1=?', [1]) +const results = await conn.execute('select 1 from dual') console.log(results) ``` @@ -78,8 +78,8 @@ const config = { const conn = connect(config) const results = await conn.transaction(async (tx) => { - const whenBranch = await tx.execute('INSERT INTO branches (database_id, name) VALUES (?, ?)', [42, "planetscale"]) - const whenCounter = await tx.execute('INSERT INTO slotted_counters(record_type, record_id, slot, count) VALUES (?, ?, RAND() * 100, 1) ON DUPLICATE KEY UPDATE count = count + 1', ['branch_count', 42]) + const whenBranch = await tx.execute(`INSERT INTO branches (database_id, name) VALUES (42, 'planetscale')`) + const whenCounter = await tx.execute(`INSERT INTO slotted_counters(record_type, record_id, slot, count) VALUES ('branch_count', 42, RAND() * 100, 1) ON DUPLICATE KEY UPDATE count = count + 1"` return [whenBranch, whenCounter] }) console.log(results) @@ -129,34 +129,16 @@ console.log(results) await disconnectAll() ``` -### Custom query parameter format function - -Query replacement parameters identified with `?` are replaced with escaped values. Named replacement parameters are supported with a colon prefix. -Placeholders inside string literals, quoted identifiers, and non-executable SQL comments are left unchanged. MySQL `/*! ... */` version comments contain executable SQL and are formatted accordingly. A version comment whose tokenization differs between supported Vitess releases throws an error. -Use `AS` before a quoted select-list alias (for example, `? AS 'value'`) so a quoted value cannot concatenate with the alias. +### Query parameters -```ts -const results1 = await conn.execute('select 1 from dual where 1=?', [42]) -const results2 = await conn.execute('select 1 from dual where 1=:id', { id: 42 }) -``` +`execute` does not interpolate query parameters. Pass it a complete SQL string. Calling `execute` with an array or a named-parameter object throws an error. -Providing a custom format function overrides the built-in placeholder parsing and escaping with an external library, like [`sqlstring`](https://github.com/mysqljs/sqlstring). -`sqlstring` replaces `?` characters inside strings and comments as well as value placeholders, so using it bypasses the context-aware behavior above. Only use a custom formatter whose placeholder rules are safe for your query templates. +Any values you include in a query must be escaped first, for example with [`sql-escaper`](https://github.com/mysqljs/sql-escaper): ```ts -import { connect } from '@planetscale/database' -import SqlString from 'sqlstring' +import { format } from 'sql-escaper' -const config = { - format: SqlString.format, - host: '', - username: '', - password: '' -} - -const conn = connect(config) -const results = await conn.execute('select 1 from dual where 1=?', [42]) -console.log(results) +const results = await conn.execute(format('select 1 from dual where 1=?', [42])) ``` ### Custom type casting function @@ -188,7 +170,6 @@ You can also pass a custom `cast` function to `execute`. If present, this will o ```ts const result = await conn.execute( 'SELECT userId, SUM(balance) AS balance FROM UserBalanceItem GROUP BY userId', - {}, { cast: (field, value) => { if (field.name === 'balance') { @@ -205,11 +186,11 @@ const result = await conn.execute( Rows can be returned as an object or an array of column values by passing an `as` option to `execute`. ```ts -const query = 'select 1 as one, 2 as two where 1=?' -const objects = conn.execute(query, [1], { as: 'object' }) +const query = 'select 1 as one, 2 as two' +const objects = conn.execute(query, { as: 'object' }) // objects.rows => [{one: '1', two: '2'}] -const arrays = conn.execute(query, [1], { as: 'array' }) +const arrays = conn.execute(query, { as: 'array' }) // arrays.rows => [['1', '2']] ``` diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 60fc3f8..6664b57 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -1,7 +1,6 @@ import assert from 'node:assert/strict' import { describe, test } from 'node:test' -import SqlString from 'sqlstring' -import { connect, format, hex, DatabaseError, UnknownError, type Cast } from '../dist/index' +import { Client, connect, hex, DatabaseError, UnknownError, type Cast } from '../dist/index' import { fetch, MockAgent, setGlobalDispatcher } from 'undici' import packageJSON from '../package.json' @@ -280,7 +279,7 @@ describe('execute', () => { }) const connection = connect(config) - const got = await connection.execute('SELECT 1 from dual;', null, { as: 'array' }) + const got = await connection.execute('SELECT 1 from dual;', { as: 'array' }) assert.deepStrictEqual(got, want) }) @@ -452,72 +451,29 @@ describe('execute', () => { } }) - test('it properly escapes query parameters', async () => { - const mockResponse = { - session: null, - result: { - fields: [{ name: ':vtg1', type: 'INT32' }], - rows: [{ lengths: ['1'], values: 'MQ==' }] - }, - timing: 1 - } - - const want = { - headers: [':vtg1'], - rows: [{ ':vtg1': 1 }], - types: { ':vtg1': 'INT32' }, - fields: [{ name: ':vtg1', type: 'INT32' }], - size: 1, - insertId: '0', - rowsAffected: 0, - statement: "SELECT 1 from dual where foo = 'bar';", - time: 1000 - } - - mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts: any) => { - const bodyObj = JSON.parse(opts.body.toString()) - assert.deepStrictEqual(bodyObj.query, want.statement) - return mockResponse - }) - + test('it rejects query parameters', async () => { const connection = connect(config) - const got = await connection.execute('SELECT ? from dual where foo = ?;', [1, 'bar']) + const message = /Query parameters are not supported/ - assert.deepStrictEqual(got, want) + await assert.rejects(connection.execute('SELECT ? from dual', [1] as any), message) + await assert.rejects(connection.execute('SELECT :id from dual', { id: 1 } as any), message) + await assert.rejects((connection.execute as any)('SELECT 1 from dual', null, { as: 'array' }), message) }) - test('it uses custom format function', async () => { - const mockResponse = { - session: null, - result: { - fields: [{ name: ':vtg1', type: 'INT32' }], - rows: [{ lengths: ['1'], values: 'MQ==' }] - }, - timing: 1 - } + test('it rejects query parameters in transactions and clients', async () => { + const message = /Query parameters are not supported/ - const want = { - headers: [':vtg1'], - types: { ':vtg1': 'INT32' }, - fields: [{ name: ':vtg1', type: 'INT32' }], - rows: [{ ':vtg1': 1 }], - size: 1, - insertId: '0', - rowsAffected: 0, - statement: 'select `login`, `email` from `users` where id = 42', - time: 1000 - } - - mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, (opts: any) => { - const bodyObj = JSON.parse(opts.body.toString()) - assert.deepStrictEqual(bodyObj.query, want.statement) - return mockResponse - }) + await assert.rejects(new Client(config).execute('SELECT ? from dual', [1] as any), message) - const connection = connect({ ...config, format: SqlString.format }) - const got = await connection.execute('select ?? from ?? where id = ?', [['login', 'email'], 'users', 42]) + // BEGIN and ROLLBACK + const mockResponse = { session: null, result: null, timing: 1 } + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, mockResponse) + mockPool.intercept({ path: EXECUTE_PATH, method: 'POST' }).reply(200, mockResponse) - assert.deepStrictEqual(got, want) + await assert.rejects( + connect(config).transaction((tx) => tx.execute('SELECT ? from dual', [1] as any)), + message + ) }) test('uses custom cast function', async () => { @@ -585,7 +541,7 @@ describe('execute', () => { const connInflate: Cast = (field, value) => (field.type === 'INT64' ? 'I am a biggish int' : value) const inflate: Cast = (field, value) => (field.type === 'INT64' ? BigInt(value as string) : value) const connection = connect({ ...config, cast: inflate }) - const got = await connection.execute('select 1 from dual', {}, { cast: connInflate }) + const got = await connection.execute('select 1 from dual', { cast: connInflate }) assert.deepStrictEqual(got, want) }) @@ -635,14 +591,6 @@ describe('refresh', () => { }) }) -describe('format', () => { - test('exports format function', () => { - const query = 'select 1 from user where id=?' - const expected = 'select 1 from user where id=42' - assert.deepStrictEqual(format(query, [42]), expected) - }) -}) - describe('hex', () => { test('exports hex function', () => { assert.deepStrictEqual(hex('\0'), '0x00') diff --git a/__tests__/sanitization.test.ts b/__tests__/sanitization.test.ts deleted file mode 100644 index 2429bb8..0000000 --- a/__tests__/sanitization.test.ts +++ /dev/null @@ -1,385 +0,0 @@ -import assert from 'node:assert/strict' -import { describe, test } from 'node:test' -import { format } from '../src/sanitization' - -describe('sanitization', () => { - describe('format', () => { - test('does no replacement for missing object key', () => { - const query = 'select 1 from user where id=:id' - assert.deepStrictEqual(format(query, {}), query) - }) - - test('replaces named parameters', () => { - const query = 'select 1 from user where state in (:state) and deleted_at=:deleted_at' - const expected = "select 1 from user where state in ('active', 'inactive') and deleted_at=true" - assert.deepStrictEqual(format(query, { state: ['active', 'inactive'], deleted_at: true }), expected) - }) - - test('replaces duplicate named parameters', () => { - const query = 'select 1 from user where id=:id or actor_id=:id' - const expected = 'select 1 from user where id=42 or actor_id=42' - assert.deepStrictEqual(format(query, { id: 42 }), expected) - }) - - test('does nothing with empty values list', () => { - const query = 'select 1 from user where id=?' - assert.deepStrictEqual(format(query, []), query) - }) - - test('does not replace positional placeholders inside quoted SQL', () => { - const query = 'select \'?\' as single_quote, "?" as double_quote, `?` as identifier, ? as value' - const expected = 'select \'?\' as single_quote, "?" as double_quote, `?` as identifier, 42 as value' - assert.deepStrictEqual(format(query, [42]), expected) - }) - - test('prevents a value from escaping through a question mark in a string literal', () => { - const query = "select secret from user where note='prefix?suffix' and username=?" - const expected = "select secret from user where note='prefix?suffix' and username=' OR 1=1 -- '" - assert.deepStrictEqual(format(query, [' OR 1=1 -- ']), expected) - }) - - test('honors escaped and doubled quote delimiters', () => { - const backslash = "select '\\'? still quoted', ? as value" - assert.deepStrictEqual(format(backslash, [1]), "select '\\'? still quoted', 1 as value") - - const doubledSingle = "select 'isn''t ?', ? as value" - assert.deepStrictEqual(format(doubledSingle, [2]), "select 'isn''t ?', 2 as value") - - const doubledDouble = 'select "a""?b", ? as value' - assert.deepStrictEqual(format(doubledDouble, [3]), 'select "a""?b", 3 as value') - - const doubledBacktick = 'select `a``?b`, ? as value' - assert.deepStrictEqual(format(doubledBacktick, [4]), 'select `a``?b`, 4 as value') - }) - - test('does not replace positional placeholders inside comments', () => { - const queries = [ - ['select 1 -- ?\nwhere id=?', 'select 1 -- ?\nwhere id=5'], - ['select 1 -- ?\rwhere id=?', 'select 1 -- ?\rwhere id=?'], - ['select 1 -- ?\r\nwhere id=?', 'select 1 -- ?\r\nwhere id=5'], - ['select 1 # ?\nwhere id=?', 'select 1 # ?\nwhere id=5'], - ['select 1 // ?\nwhere id=?', 'select 1 // ?\nwhere id=5'], - ['select /* ? */ ? from user', 'select /* ? */ 5 from user'] - ] - - for (const [query, expected] of queries) { - assert.deepStrictEqual(format(query, [5]), expected) - } - }) - - test('does not let Vitess // comment text desynchronize quoted SQL', () => { - const query = "select id from user // don't change this\nwhere note = '?' and id = ?" - const expected = "select id from user // don't change this\nwhere note = '?' and id = ' OR 1=1 -- '" - assert.deepStrictEqual(format(query, [' OR 1=1 -- ']), expected) - - const namedQuery = "select id from user // don't change this\nwhere note = ':id' and id = :id" - const namedExpected = "select id from user // don't change this\nwhere note = ':id' and id = ' OR 1=1 -- '" - assert.deepStrictEqual(format(namedQuery, { id: ' OR 1=1 -- ' }), namedExpected) - }) - - test('does not treat quotes in Vitess variable names as string delimiters', () => { - const attack = ' OR 1=1 -- ' - const query = "select @foo' from user where note = '?' and id = ?" - const expected = "select @foo' from user where note = '?' and id = ' OR 1=1 -- '" - assert.deepStrictEqual(format(query, [attack]), expected) - - const namedQuery = "select @@foo' from user where note = ':id' and id = :id" - const namedExpected = "select @@foo' from user where note = ':id' and id = ' OR 1=1 -- '" - assert.deepStrictEqual(format(namedQuery, { id: attack }), namedExpected) - - // Vitess consumes the first character after @ as part of the variable, - // even when that character would otherwise be a placeholder marker. - assert.deepStrictEqual(format('select @? as variable, ? as value', [42]), 'select @? as variable, 42 as value') - }) - - test('requires whitespace after two dashes to start a comment', () => { - const query = 'select 1--? + ?' - assert.deepStrictEqual(format(query, [2, 3]), 'select 1--2 + 3') - assert.deepStrictEqual(format('select 1--\f? + ?', [2, 3]), 'select 1--\f? + ?') - assert.deepStrictEqual(format('select 1--\v? + ?', [2, 3]), 'select 1--\v? + ?') - }) - - test('recognizes form feed and vertical tab after a double-dash comment marker', () => { - for (const whitespace of ['\f', '\v']) { - const positional = `select id from user --${whitespace} don't change this\nwhere note = 'prefix?suffix' and id = ?` - const positionalExpected = `select id from user --${whitespace} don't change this\nwhere note = 'prefix?suffix' and id = ' OR 1=1 -- '` - assert.deepStrictEqual(format(positional, [' OR 1=1 -- ']), positionalExpected) - - const named = `select id from user --${whitespace} don't change this\nwhere note = 'prefix:id-suffix' and id = :id` - const namedExpected = `select id from user --${whitespace} don't change this\nwhere note = 'prefix:id-suffix' and id = ' OR 1=1 -- '` - assert.deepStrictEqual(format(named, { id: ' OR 1=1 -- ' }), namedExpected) - } - }) - - test('does not replace placeholders inside optimizer hints', () => { - const query = 'select /*+ QB_NAME(?) */ ? as value' - const expected = "select /*+ QB_NAME(?) */ '*/ 99 as injected -- ' as value" - assert.deepStrictEqual(format(query, ['*/ 99 as injected -- ']), expected) - }) - - test('replaces placeholders in executable MySQL comments', () => { - const query = "select /*!50708 ? + 'literal?' + ? */ ?" - const expected = "select /*!50708 1 + 'literal?' + 2 */ 3" - assert.deepStrictEqual(format(query, [1, 2, 3]), expected) - }) - - test('does not let a value close an executable MySQL comment', () => { - const query = 'select /*!80000 ? as optional_value, */ ? as value' - const expected = "select /*!80000 '*' '/ 99 as injected -- ' as optional_value, */ 5 as value" - assert.deepStrictEqual(format(query, ['*/ 99 as injected -- ', 5]), expected) - - const named = 'select /*!80000 :optional as optional_value, */ :value as value' - const namedExpected = "select /*!80000 '*'' *' '/ payload' as optional_value, */ 5 as value" - assert.deepStrictEqual(format(named, { optional: "*' */ payload", value: 5 }), namedExpected) - - assert.deepStrictEqual( - format('select /*!80000 ? as optional_value */', ['a*/b*/c']), - "select /*!80000 'a*' '/b*' '/c' as optional_value */" - ) - assert.deepStrictEqual( - format('select /*!80000 ? as optional_value */', ['a/*b']), - "select /*!80000 'a/' '*b' as optional_value */" - ) - - assert.deepStrictEqual( - format('select id from user where id = ? /*!99999 and note = ? */', [42, '/*/ OR 1=1 -- ']), - "select id from user where id = 42 /*!99999 and note = '/' '*' '/ OR 1=1 -- ' */" - ) - assert.deepStrictEqual( - format('select id from user where id = ? /*!99999 and note = ? */', [42, '*/* OR 1=1 -- ']), - "select id from user where id = 42 /*!99999 and note = '*' '/' '* OR 1=1 -- ' */" - ) - }) - - test('rejects executable MySQL comments that Vitess versions tokenize differently', () => { - const ambiguous = [ - // Vitess 24 treats a quoted */ as part of the string, not the end of the comment. - "select id /*!99999 ' */ from user where note = '?' and id = ?", - "select /*!80000 '*/' as x, */ id from user where note like '%?%'", - 'select /*!80000 `a*/` */ ?', - 'select /*!80000 @`a*/` */ ?', - // Vitess 24 consumes one nested comment, including when skipping the body. - "select id /*!99999 ' /* */ from user where note = '? /* x */ y */' and id = ?", - 'select id from user where id = 1 /*!99999 /* hint */ and note = ? */', - 'select /*!99999 --/*/ ? */', - // Vitess 24 lets line comments run past */, and reads // as division. - 'select /*! 1 -- ignored */ + ?', - 'select /*!80000 1 # a */ + ?', - "select /*!80000 1 // it's\n */ + ?" - ] - - for (const query of ambiguous) { - assert.throws(() => format(query, ['*/ OR 1=1 -- ']), /Vitess versions parse differently/, query) - } - - assert.throws(() => format('select /*!80000 :id # a */ + :id', { id: 1 }), /Vitess versions parse differently/) - }) - - test('allows terminated line comments that Vitess versions parse identically', () => { - assert.deepStrictEqual( - format('select /*!80000 1 -- comment\n + ? */', [2]), - 'select /*!80000 1 -- comment\n + 2 */' - ) - assert.deepStrictEqual( - format('select /*!80000 1 # comment\n + ? */', [2]), - 'select /*!80000 1 # comment\n + 2 */' - ) - assert.deepStrictEqual(format('select /*!80000 ? # comment\n*/ + 1', [2]), 'select /*!80000 2 # comment\n*/ + 1') - assert.deepStrictEqual( - format('select /*!80000 ? -- comment\n*/ + 1', [2]), - 'select /*!80000 2 -- comment\n*/ + 1' - ) - }) - - test('separates values from executable-comment version prefixes and following tokens', () => { - assert.deepStrictEqual(format('select /*!? + */ 1', [100000]), 'select /*! 100000 + */ 1') - assert.deepStrictEqual(format('select /*!:value + */ 1', { value: 100000 }), 'select /*! 100000 + */ 1') - - assert.deepStrictEqual(format('select ?e1', [42]), 'select 42 e1') - assert.deepStrictEqual(format('select ?`e1`', ['value']), "select 'value' `e1`") - assert.deepStrictEqual(format('select _utf8?', ['value']), "select _utf8 'value'") - - // Adjacent string literals concatenate even with whitespace, so an - // explicit AS is required to preserve a quoted alias. - assert.throws(() => format("select ?'e1'", ['value']), /use AS before a quoted alias/) - assert.throws(() => format('select ?"e1"', ['value']), /use AS before a quoted alias/) - assert.throws(() => format("select ? 'e1'", ['value']), /use AS before a quoted alias/) - assert.throws(() => format("select ? /* comment */ 'e1'", ['value']), /use AS before a quoted alias/) - assert.throws(() => format("select ? -- comment\n 'e1'", ['value']), /use AS before a quoted alias/) - assert.deepStrictEqual(format("select ? AS 'e1'", ['value']), "select 'value' AS 'e1'") - }) - - test('separates values from a preceding variable token', () => { - // Vitess accepts quote characters in variable names, so `@v'...'` would - // make the value's opening quote part of the variable. - const attack = ' or 1=1 -- ' - assert.deepStrictEqual(format('select @v?', [attack]), "select @v ' or 1=1 -- '") - assert.deepStrictEqual(format('select @@x.y?', [attack]), "select @@x.y ' or 1=1 -- '") - assert.deepStrictEqual(format('select @`v`?', [1]), 'select @`v` 1') - assert.deepStrictEqual(format('select /*!80000 @v? */', [attack]), "select /*!80000 @v ' or 1=1 -- ' */") - assert.deepStrictEqual(format('select @v:id', { id: attack }), "select @v ' or 1=1 -- '") - - // Vitess consumes the character after @ or @@, even whitespace. - assert.deepStrictEqual(format('select @@ ?', [attack]), "select @@ ' or 1=1 -- '") - - // Values already separated from the variable are unchanged. - assert.deepStrictEqual(format('select @v = ?', [attack]), "select @v = ' or 1=1 -- '") - }) - - test('leaves placeholders in unterminated quoted SQL and comments unchanged', () => { - const quoted = "select 'unterminated ? and ?" - assert.deepStrictEqual(format(quoted, [1, 2]), quoted) - - const commented = 'select /* unterminated ? and ?' - assert.deepStrictEqual(format(commented, [1, 2]), commented) - }) - - test('does not replace named placeholders inside quoted SQL or comments', () => { - const query = 'select \':id\', " :id", `:id`, :id /* :id */' - const expected = 'select \':id\', " :id", `:id`, 42 /* :id */' - assert.deepStrictEqual(format(query, { id: 42 }), expected) - }) - - test('prevents a named value from escaping through a placeholder in a string literal', () => { - const query = "select secret from user where note='prefix:id-suffix' and username=:id" - const expected = "select secret from user where note='prefix:id-suffix' and username=' OR 1=1 -- '" - assert.deepStrictEqual(format(query, { id: ' OR 1=1 -- ' }), expected) - }) - - test('replaces named placeholders in executable MySQL comments', () => { - const query = "select /*!50708 :id + ':id' */ :id" - const expected = "select /*!50708 42 + ':id' */ 42" - assert.deepStrictEqual(format(query, { id: 42 }), expected) - }) - - test('formats as many values as given', () => { - const query = 'select 1 from user where id=? and deleted=?' - const expected = 'select 1 from user where id=42 and deleted=?' - assert.deepStrictEqual(format(query, [42]), expected) - }) - - test('formats number values', () => { - const query = 'select 1 from user where id=? and id2=?' - const expected = 'select 1 from user where id=12 and id2=42' - assert.deepStrictEqual(format(query, [12, 42]), expected) - }) - - test('formats bigint values', () => { - const query = 'select 1 from user where id=? and id2=? and id3=?' - const expected = 'select 1 from user where id=12 and id2=42 and id3=9223372036854775807' - assert.deepStrictEqual(format(query, [12n, 42n, 9223372036854775807n]), expected) - }) - - test('formats string values', () => { - const query = 'select 1 from user where state=?' - const expected = "select 1 from user where state='active'" - assert.deepStrictEqual(format(query, ['active']), expected) - }) - - test('formats null values', () => { - const query = 'update user set state=?, name=? where id=?' - const expected = 'update user set state=null, name=null where id=42' - assert.deepStrictEqual(format(query, [null, undefined, 42]), expected) - }) - - test('formats boolean values', () => { - const query = 'select 1 from user where active=? and deleted=?' - const expected = 'select 1 from user where active=true and deleted=false' - assert.deepStrictEqual(format(query, [true, false]), expected) - }) - - test('formats date values', () => { - const ts = Date.UTC(2022, 1, 8, 13, 15, 45) - const query = 'select 1 from user where created_at > ?' - const expected = "select 1 from user where created_at > '2022-02-08T13:15:45.000'" - assert.deepStrictEqual(format(query, [new Date(ts)]), expected) - }) - - test('formats array values', () => { - const query = 'select 1 from user where id > ? and state in (?)' - const expected = "select 1 from user where id > 42 and state in ('active', 'inactive')" - assert.deepStrictEqual(format(query, [42, ['active', 'inactive']]), expected) - }) - - test('rejects empty array values instead of deleting a placeholder', () => { - const query = 'select secret from user where id = 1--? AND tenant_id = 42' - assert.throws(() => format(query, [[]]), /empty arrays/) - assert.throws(() => format('select * from user where id in (:ids)', { ids: [] }), /empty arrays/) - assert.throws(() => format('select * from user where id in (?)', [[[]]]), /empty arrays/) - }) - - test('rejects non-finite numbers', () => { - const query = 'select 1 from user where id = ?' - assert.throws(() => format(query, [NaN]), /non-finite numbers/) - assert.throws(() => format(query, [Infinity]), /non-finite numbers/) - assert.throws(() => format(query, [-Infinity]), /non-finite numbers/) - }) - - test('formats objects with toString method', () => { - const state = { toString: () => 'active' } - const query = 'select 1 from user where state = ?' - const expected = "select 1 from user where state = 'active'" - assert.deepStrictEqual(format(query, [state]), expected) - }) - - test('formats empty Uint8Array', () => { - const query = 'select 1 from user where state = ?' - const expected = "select 1 from user where state = x''" - assert.deepStrictEqual(format(query, [new Uint8Array([])]), expected) - }) - - test('escapes double quotes', () => { - const query = 'select 1 from user where state = ?' - const expected = 'select 1 from user where state = \'\\"a\\"\'' - assert.deepStrictEqual(format(query, ['"a"']), expected) - }) - - test('escapes single quotes by doubling', () => { - const query = 'select 1 from user where state = ?' - assert.deepStrictEqual(format(query, ["'a'"]), "select 1 from user where state = '''a'''") - assert.deepStrictEqual(format(query, ["' OR 1=1 -- "]), "select 1 from user where state = ''' OR 1=1 -- '") - }) - - test('escapes new lines', () => { - const query = 'select 1 from user where state = ?' - const expected = "select 1 from user where state = '\\na\\n'" - assert.deepStrictEqual(format(query, ['\na\n']), expected) - }) - - test('escapes carriage returns', () => { - const query = 'select 1 from user where state = ?' - const expected = "select 1 from user where state = '\\ra\\r'" - assert.deepStrictEqual(format(query, ['\ra\r']), expected) - }) - - test('escapes tabs', () => { - const query = 'select 1 from user where state = ?' - const expected = "select 1 from user where state = '\\ta\\t'" - assert.deepStrictEqual(format(query, ['\ta\t']), expected) - }) - - test('escapes back slashes', () => { - const query = 'select 1 from user where state = ?' - const expected = "select 1 from user where state = '\\\\a\\\\'" - assert.deepStrictEqual(format(query, ['\\a\\']), expected) - }) - - test('escapes null byte', () => { - const query = 'select 1 from user where state = ?' - const expected = "select 1 from user where state = '\\0a\\0'" - assert.deepStrictEqual(format(query, ['\0a\0']), expected) - }) - - test('escapes back space', () => { - const query = 'select 1 from user where state = ?' - const expected = "select 1 from user where state = '\\ba\\b'" - assert.deepStrictEqual(format(query, ['\ba\b']), expected) - }) - - test('escapes control-z', () => { - const query = 'select 1 from user where state = ?' - const expected = "select 1 from user where state = '\\Za\\Z'" - assert.deepStrictEqual(format(query, ['\x1aa\x1a']), expected) - }) - }) -}) diff --git a/__tests__/text.test.ts b/__tests__/text.test.ts index 56db85a..c5c3fe0 100644 --- a/__tests__/text.test.ts +++ b/__tests__/text.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { describe, test } from 'node:test' -import { decodeUtf8, hex, uint8Array, uint8ArrayToHex } from '../src/text' +import { decodeUtf8, hex, uint8Array } from '../src/text' describe('text', () => { describe('decodeUtf8', () => { @@ -41,11 +41,4 @@ describe('text', () => { assert.deepStrictEqual(uint8Array('Å'), new Uint8Array([197])) }) }) - - describe('uint8ArrayToHex', () => { - test('converts an array of 8-bit unsigned integers to hex', () => { - assert.deepStrictEqual(uint8ArrayToHex(new Uint8Array([])), "x''") - assert.deepStrictEqual(uint8ArrayToHex(new Uint8Array([197])), "x'c5'") - }) - }) }) diff --git a/package-lock.json b/package-lock.json index b6db14c..19cc4f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,14 +10,12 @@ "license": "Apache-2.0", "devDependencies": { "@types/node": "^22.0.0", - "@types/sqlstring": "^2.3.0", "@typescript-eslint/eslint-plugin": "^6.1.0", "@typescript-eslint/parser": "^6.1.0", "eslint": "^8.45.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^5.0.0", "prettier": "^3.0.0", - "sqlstring": "^2.3.3", "tsx": "^4.22.4", "typescript": "^5.1.6", "undici": "^8.3.0" @@ -643,12 +641,6 @@ "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==", "dev": true }, - "node_modules/@types/sqlstring": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@types/sqlstring/-/sqlstring-2.3.0.tgz", - "integrity": "sha512-kMFecDYYFk/f5fljO0UFrSPwU1JxY4mIjX6ic7MHv5nD6sEd3NYLoWcOV/3s6Drs7RHdCwTQdD5NdgVl0I2zzg==", - "dev": true - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.1.0.tgz", @@ -2435,15 +2427,6 @@ "node": ">=8" } }, - "node_modules/sqlstring": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", - "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", diff --git a/package.json b/package.json index 45bba21..531d8a2 100644 --- a/package.json +++ b/package.json @@ -53,14 +53,12 @@ "homepage": "https://github.com/planetscale/database-js#readme", "devDependencies": { "@types/node": "^22.0.0", - "@types/sqlstring": "^2.3.0", "@typescript-eslint/eslint-plugin": "^6.1.0", "@typescript-eslint/parser": "^6.1.0", "eslint": "^8.45.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^5.0.0", "prettier": "^3.0.0", - "sqlstring": "^2.3.3", "tsx": "^4.22.4", "typescript": "^5.1.6", "undici": "^8.3.0" diff --git a/src/index.ts b/src/index.ts index 3dc0c60..815f00d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,5 @@ import { cast } from './cast.js' export { cast } from './cast.js' -import { format } from './sanitization.js' -export { format } from './sanitization.js' export { hex } from './text.js' import { Version } from './version.js' @@ -76,7 +74,6 @@ type Res = { } export type Cast = typeof cast -type Format = typeof format export interface Config { url?: string @@ -84,7 +81,6 @@ export interface Config { password?: string host?: string fetch?: Fetch - format?: Format cast?: Cast } @@ -128,8 +124,6 @@ interface QueryResult { type ExecuteAs = 'array' | 'object' -type ExecuteArgs = Record | any[] | null - type ExecuteOptions = T extends 'array' ? { as?: 'object'; cast?: Cast } : T extends 'object' @@ -147,22 +141,15 @@ export class Client { return this.connection().transaction(fn) } - async execute>( - query: string, - args?: ExecuteArgs, - options?: ExecuteOptions<'object'> - ): Promise> - async execute>( - query: string, - args: ExecuteArgs, - options: ExecuteOptions<'array'> - ): Promise> + async execute>(query: string, options?: ExecuteOptions<'object'>): Promise> + async execute>(query: string, options: ExecuteOptions<'array'>): Promise> async execute | Row<'array'>>( query: string, - args: ExecuteArgs = null, - options: any = { as: 'object' } + options?: any, + ...unsupported: unknown[] ): Promise> { - return this.connection().execute(query, args, options) + options = executeOptions(options, unsupported) + return this.connection().execute(query, options) } connection(): Connection { @@ -179,23 +166,39 @@ class Tx { this.conn = conn } - async execute>( - query: string, - args?: ExecuteArgs, - options?: ExecuteOptions<'object'> - ): Promise> - async execute>( - query: string, - args: ExecuteArgs, - options: ExecuteOptions<'array'> - ): Promise> + async execute>(query: string, options?: ExecuteOptions<'object'>): Promise> + async execute>(query: string, options: ExecuteOptions<'array'>): Promise> async execute | Row<'array'>>( query: string, - args: ExecuteArgs = null, - options: any = { as: 'object' } + options?: any, + ...unsupported: unknown[] ): Promise> { - return this.conn.execute(query, args, options) + options = executeOptions(options, unsupported) + return this.conn.execute(query, options) + } +} + +const optionKeys = ['as', 'cast'] + +// Catch callers still using the removed execute(query, args, options) form, +// which would otherwise run the query with its placeholders unreplaced. +function executeOptions(options: unknown, unsupported: unknown[]): { as?: ExecuteAs; cast?: Cast } { + if (options == null) { + options = {} + } + + const isOptions = + typeof options === 'object' && + !Array.isArray(options) && + Object.keys(options as object).every((key) => optionKeys.includes(key)) + + if (unsupported.length > 0 || !isOptions) { + throw new Error( + 'Query parameters are not supported: pass a complete SQL string and options to execute(query, options)' + ) } + + return options as { as?: ExecuteAs; cast?: Cast } } function protocol(protocol: string): string { @@ -250,28 +253,18 @@ export class Connection { await this.createSession() } - async execute>( - query: string, - args?: ExecuteArgs, - options?: ExecuteOptions<'object'> - ): Promise> - async execute>( - query: string, - args: ExecuteArgs, - options: ExecuteOptions<'array'> - ): Promise> + async execute>(query: string, options?: ExecuteOptions<'object'>): Promise> + async execute>(query: string, options: ExecuteOptions<'array'>): Promise> async execute | Row<'array'>>( query: string, - args: ExecuteArgs = null, - options: any = { as: 'object' } + options?: any, + ...unsupported: unknown[] ): Promise> { + options = executeOptions(options, unsupported) const url = new URL('/psdb.v1alpha1.Database/Execute', this.url) - const formatter = this.config.format || format - const sql = args ? formatter(query, args) : query - const saved = await postJSON(this.config, this.fetch, url, { - query: sql, + query, session: this.session }) @@ -313,7 +306,7 @@ export class Connection { rowsAffected, insertId, size: rows.length, - statement: sql, + statement: query, time: timingSeconds * 1000 } } diff --git a/src/sanitization.ts b/src/sanitization.ts deleted file mode 100644 index 4c2a1a7..0000000 --- a/src/sanitization.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { uint8ArrayToHex } from './text.js' - -type Stringable = { toString: () => string } -type Value = null | undefined | number | boolean | string | Array | Date | Stringable - -export function format(query: string, values: Record | any[]): string { - return Array.isArray(values) ? replacePosition(query, values) : replaceNamed(query, values) -} - -function replacePosition(query: string, values: Value[]): string { - let index = 0 - return replacePlaceholders(query, '?', (position, inExecutableComment) => { - if (index >= values.length) { - return - } - - return { end: position + 1, value: sanitize(values[index++], inExecutableComment) } - }) -} - -function replaceNamed(query: string, values: Record): string { - return replacePlaceholders(query, ':', (position, inExecutableComment) => { - let end = position + 1 - while (end < query.length && isWordChar(query.charCodeAt(end))) { - end++ - } - - if (end === position + 1) { - return - } - - const name = query.slice(position + 1, end) - return hasOwn(values, name) ? { end, value: sanitize(values[name], inExecutableComment) } : undefined - }) -} - -type Replacement = { end: number; value: string } - -function replacePlaceholders( - query: string, - marker: '?' | ':', - replacementAt: (position: number, inExecutableComment: boolean) => Replacement | undefined -): string { - let position = 0 - let copyFrom = 0 - let result = '' - let executableCommentEnd = -1 - let variableEnd = -1 - - while (position < query.length) { - const char = query[position] - - if (position === executableCommentEnd) { - executableCommentEnd = -1 - position += 2 - continue - } - - const inExecutableComment = executableCommentEnd !== -1 - - // Locate the version comment's closing delimiter before tokenizing its - // executable SQL body. Vitess 22 and 23 end the comment at the first raw - // closing delimiter. Vitess 24 tokenizes the body inline instead: a quoted - // `*/` does not close it, one nested /* */ comment is consumed, and line - // comments run past `*/`. Reject bodies where those rules disagree rather - // than guess which one the server uses. - if (!inExecutableComment && char === '/' && query[position + 1] === '*' && query[position + 2] === '!') { - const end = query.indexOf('*/', position + 3) - if (end === -1) { - position = query.length - continue - } - // A disabled Vitess 24 version comment tracks raw nested openers even - // inside tokens, while Vitess 23 stops at their first closing delimiter. - // Checking starts before the closing `*` so overlapping `/*/` is rejected. - const nestedComment = query.indexOf('/*', position + 3) - if (nestedComment !== -1 && nestedComment < end) { - throw ambiguousExecutableComment() - } - executableCommentEnd = end - position += 3 - continue - } - - const contextEnd = inExecutableComment ? executableCommentEnd : query.length - - // Vitess permits quote characters inside @user and @@system variable - // tokens. Skip the complete token before interpreting quotes or comments. - if (char === '@') { - position = tokenEnd(skipVariable(query, position, contextEnd), contextEnd, inExecutableComment) - variableEnd = position - continue - } - - if (char === "'" || char === '"' || char === '`') { - position = tokenEnd(skipQuoted(query, position, char, contextEnd), contextEnd, inExecutableComment) - continue - } - - if (inExecutableComment && isLineComment(query, position)) { - const end = skipLineComment(query, position + (char === '#' ? 1 : 2), contextEnd) - // Vitess 24 reads // as division inside a version comment. # and -- - // agree across versions only when their comment ends before */. - const reachesBoundary = end === contextEnd && query[contextEnd - 1] !== '\n' - if ((char === '/' && query[position + 1] === '/') || reachesBoundary) { - throw ambiguousExecutableComment() - } - position = end - continue - } - - if (char === '#') { - position = skipLineComment(query, position + 1, contextEnd) - continue - } - - // Vitess accepts `//` as a line comment in addition to MySQL's `#` and `-- ` forms. - if (char === '/' && query[position + 1] === '/') { - position = skipLineComment(query, position + 2, contextEnd) - continue - } - - if (char === '-' && query[position + 1] === '-' && isDashComment(query, position + 2)) { - position = skipLineComment(query, position + 2, contextEnd) - continue - } - - if (char === '/' && query[position + 1] === '*') { - if (inExecutableComment) { - throw ambiguousExecutableComment() - } - position = skipBlockComment(query, position + 2, contextEnd) - continue - } - - const replacement = char === marker ? replacementAt(position, inExecutableComment) : undefined - if (replacement) { - const next = nextTokenChar(query, replacement.end) - if (replacement.value.endsWith("'") && (next === "'" || next === '"')) { - throw new Error('Cannot format query: use AS before a quoted alias that follows a quoted value') - } - // Keep the value distinct from adjacent tokens. This prevents quote - // characters joining variable names and digits becoming version tags. - const previousIsToken = position > 0 && isVariableNameChar(query.charCodeAt(position - 1)) - const startsExecutableComment = inExecutableComment && query.startsWith('/*!', position - 3) - const separatorBefore = position === variableEnd || startsExecutableComment || previousIsToken ? ' ' : '' - const separatorAfter = needsSeparatorAfter(query, replacement.end) ? ' ' : '' - result += query.slice(copyFrom, position) + separatorBefore + replacement.value + separatorAfter - copyFrom = replacement.end - position = replacement.end - continue - } - - position++ - } - - return copyFrom === 0 ? query : result + query.slice(copyFrom) -} - -// Resolve an unterminated token (-1) to the end of its context. Inside an -// executable comment, Vitess versions disagree on where such a token ends. -function tokenEnd(end: number, limit: number, inExecutableComment: boolean): number { - if (end !== -1) return end - if (inExecutableComment) throw ambiguousExecutableComment() - return limit -} - -function ambiguousExecutableComment(): Error { - return new Error('Cannot format query: Vitess versions parse differently inside this /*! */ comment') -} - -// Returns -1 when the variable name runs into the limit. -function skipVariable(query: string, position: number, limit: number): number { - let end = position + 1 - if (query[end] === '@') end++ - if (end >= limit) return -1 - - if (query[end] === '`') { - return skipQuoted(query, end, '`', limit) - } - - // Vitess always consumes the first character after @, then accepts letters, - // digits, _, $, dots, and quote characters in the rest of the variable name. - end++ - while (end < limit && isVariableNameChar(query.charCodeAt(end))) { - end++ - } - return end -} - -// Returns -1 when the quoted token is not closed before the limit. -function skipQuoted(query: string, position: number, quote: string, limit: number): number { - const backslashEscapes = quote !== '`' - - for (let i = position + 1; i < limit; i++) { - if (backslashEscapes && query[i] === '\\') { - i++ - continue - } - - if (query[i] === quote) { - if (query[i + 1] === quote) { - i++ - continue - } - return i + 1 - } - } - - return -1 -} - -function skipLineComment(query: string, position: number, limit: number): number { - const newline = query.indexOf('\n', position) - return newline === -1 || newline >= limit ? limit : newline + 1 -} - -function skipBlockComment(query: string, position: number, limit: number): number { - const end = query.indexOf('*/', position) - return end === -1 || end >= limit ? limit : end + 2 -} - -function isLineComment(query: string, position: number): boolean { - const char = query[position] - const next = query[position + 1] - return ( - char === '#' || - (char === '/' && next === '/') || - (char === '-' && next === '-' && isDashComment(query, position + 2)) - ) -} - -function isDashComment(query: string, position: number): boolean { - return position >= query.length || ' \t\n\r\f\v'.includes(query[position]) -} - -function isWordChar(code: number): boolean { - // Preserve the ASCII `\w` behavior of the previous named-placeholder regexp. - return (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || code === 95 -} - -function isVariableNameChar(code: number): boolean { - return isWordChar(code) || code === 36 || code === 46 || code === 39 || code === 34 || code === 96 -} - -function needsSeparatorAfter(query: string, position: number): boolean { - if (position >= query.length) return false - const char = query[position] - return isVariableNameChar(query.charCodeAt(position)) || char === '?' || char === ':' -} - -function nextTokenChar(query: string, position: number): string | undefined { - while (position < query.length) { - while (position < query.length && ' \t\n\r'.includes(query[position])) position++ - - const char = query[position] - if (char === '#') { - position = skipLineComment(query, position + 1, query.length) - continue - } - if (char === '/' && query[position + 1] === '/') { - position = skipLineComment(query, position + 2, query.length) - continue - } - if (char === '-' && query[position + 1] === '-' && isDashComment(query, position + 2)) { - position = skipLineComment(query, position + 2, query.length) - continue - } - if (char === '/' && query[position + 1] === '*' && query[position + 2] !== '!') { - position = skipBlockComment(query, position + 2, query.length) - continue - } - return char - } -} - -function hasOwn(obj: unknown, name: string): boolean { - return Object.prototype.hasOwnProperty.call(obj, name) -} - -function sanitize(value: Value, inExecutableComment = false): string { - if (value == null) { - return 'null' - } - - if (typeof value === 'number') { - if (!Number.isFinite(value)) { - throw new Error('Cannot format query: non-finite numbers are not valid SQL values') - } - return String(value) - } - - if (typeof value === 'bigint') { - return String(value) - } - - if (typeof value === 'boolean') { - return value ? 'true' : 'false' - } - - if (typeof value === 'string') { - return quote(value, inExecutableComment) - } - - if (Array.isArray(value)) { - if (value.length === 0) { - throw new Error('Cannot format query: empty arrays are not valid SQL values') - } - return value.map((item) => sanitize(item, inExecutableComment)).join(', ') - } - - if (value instanceof Date) { - return quote(value.toISOString().slice(0, -1), inExecutableComment) - } - - if (value instanceof Uint8Array) { - return uint8ArrayToHex(value) - } - - return quote(value.toString(), inExecutableComment) -} - -function quote(text: string, inExecutableComment: boolean): string { - let escaped = escape(text) - if (inExecutableComment) { - // Split block-comment delimiters across adjacent string literals so an - // interpolated value cannot alter the surrounding version comment. MySQL - // and Vitess concatenate adjacent string literals into the original value. - escaped = splitCommentDelimiters(escaped) - } - return `'${escaped}'` -} - -function splitCommentDelimiters(text: string): string { - let result = '' - - for (let i = 0; i < text.length; i++) { - const char = text[i] - result += char - - const next = text[i + 1] - if ((char === '/' && next === '*') || (char === '*' && next === '/')) { - // Close and reopen the literal between both characters. Checking every - // adjacent pair also handles overlapping inputs such as /*/ and */*. - result += "' '" - } - } - - return result -} - -const re = /[\0\b\n\r\t\x1a\\"']/g - -function escape(text: string): string { - return text.replace(re, replacement) -} - -function replacement(text: string): string { - switch (text) { - case '"': - return '\\"' - case "'": - return "''" - case '\n': - return '\\n' - case '\r': - return '\\r' - case '\t': - return '\\t' - case '\\': - return '\\\\' - case '\0': - return '\\0' - case '\b': - return '\\b' - case '\x1a': - return '\\Z' - default: - return '' - } -} diff --git a/src/text.ts b/src/text.ts index 222ff27..dd5a44f 100644 --- a/src/text.ts +++ b/src/text.ts @@ -13,11 +13,6 @@ export function uint8Array(text: string): Uint8Array { return Uint8Array.from(bytes(text)) } -export function uint8ArrayToHex(uint8: Uint8Array): string { - const digits = Array.from(uint8).map((i) => i.toString(16).padStart(2, '0')) - return `x'${digits.join('')}'` -} - function bytes(text: string): number[] { return text.split('').map((c) => c.charCodeAt(0)) }