From 3454be88630b7cad99bbab9cdd5b9d0e80eed817 Mon Sep 17 00:00:00 2001 From: elias <14819853+eliasthecactus@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:08:32 +0200 Subject: [PATCH 1/2] Add WHERE detection for UPDATE and DELETE UPDATE and DELETE results get a where flag, set when the statement has a WHERE of its own. A WHERE in a subquery or a CTE does not count, nor does one in a string, a comment or a quoted identifier, and LIMIT or TOP is not a WHERE. This lets a client warn before an UPDATE or DELETE that changes every row. --- README.md | 15 +++ src/defines.ts | 2 + src/index.ts | 1 + src/parser.ts | 17 ++- src/tokenizer.ts | 1 + test/identifier/single-statement.spec.ts | 128 +++++++++++++++++++++++ test/parser/single-statements.spec.ts | 100 ++++++++++++++++-- 7 files changed, 255 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d8e7bd5..29b2a0c 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,21 @@ console.log(statements); 1. `strict (bool)`: allow disable strict mode which will ignore unknown types *(default=true)* 2. `dialect (string)`: Specify your database dialect, values: `generic`, `mysql`, `oracle`, `psql`, `sqlite`, `mssql`, `bigquery` and `dynamodb`. *(default=generic)* +### Statement flags + +Some statement types carry flags about their own clauses. A clause inside a subquery or a CTE does not count. + +- `limit (bool)`, `offset (bool)`: on `SELECT`, whether it has a `LIMIT` (or `FETCH`, or `TOP` for `mssql`) and an `OFFSET`. +- `where (bool)`: on `UPDATE` and `DELETE`, whether it has a `WHERE`. A `LIMIT` or `TOP` does not count, so `where: false` means the statement is not filtered by a condition. + +```js +identify('DELETE FROM users WHERE id IN (SELECT id FROM bans WHERE active)'); +// [{ type: 'DELETE', executionType: 'MODIFICATION', where: true, ... }] + +identify('UPDATE users SET score = (SELECT max(score) FROM games WHERE won)'); +// [{ type: 'UPDATE', executionType: 'MODIFICATION', where: false, ... }] +``` + ### DynamoDB (PartiQL) DynamoDB exposes a [PartiQL](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ql-reference.html)-compatible diff --git a/src/defines.ts b/src/defines.ts index c2d4ba0..954e786 100644 --- a/src/defines.ts +++ b/src/defines.ts @@ -153,6 +153,7 @@ export interface IdentifyResult { columns: ColumnReference[]; limit?: boolean; offset?: boolean; + where?: boolean; } export interface Statement { @@ -170,6 +171,7 @@ export interface Statement { columns: ColumnReference[]; limit?: boolean; offset?: boolean; + where?: boolean; isCte?: boolean; } diff --git a/src/index.ts b/src/index.ts index e4288d8..ce2ee61 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,7 @@ export function identify(query: string, options: IdentifyOptions = {}): Identify }; if (statement.limit !== undefined) result.limit = statement.limit; if (statement.offset !== undefined) result.offset = statement.offset; + if (statement.where !== undefined) result.where = statement.where; return result; }); } diff --git a/src/parser.ts b/src/parser.ts index 5bfd6b0..75ff31e 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -572,6 +572,7 @@ function createUpdateStatementParser(options: ParseOptions) { }, add: (token) => { statement.type = 'UPDATE'; + statement.where = false; if (statement.start < 0) { statement.start = token.start; } @@ -595,6 +596,7 @@ function createDeleteStatementParser(options: ParseOptions) { }, add: (token) => { statement.type = 'DELETE'; + statement.where = false; if (statement.start < 0) { statement.start = token.start; } @@ -1173,14 +1175,25 @@ function stateMachineStatementParser( statement.parameters.push(token.value); } - if (statement.type === 'SELECT' && token.value === '(') { + if (statement.type?.match(/^(SELECT|UPDATE|DELETE)$/) && token.value === '(') { parensDepth++; } - if (statement.type === 'SELECT' && token.value === ')') { + if (statement.type?.match(/^(SELECT|UPDATE|DELETE)$/) && token.value === ')') { parensDepth--; } + // Only a WHERE of the statement itself limits the rows it changes, not one in a + // subquery. A CTE's never reaches this parser. + if ( + (statement.type === 'UPDATE' || statement.type === 'DELETE') && + token.type === 'keyword' && + token.value.toUpperCase() === 'WHERE' && + parensDepth === 0 + ) { + statement.where = true; + } + if ( statement.type === 'SELECT' && token.type === 'keyword' && diff --git a/src/tokenizer.ts b/src/tokenizer.ts index d0265e6..fc38e05 100644 --- a/src/tokenizer.ts +++ b/src/tokenizer.ts @@ -93,6 +93,7 @@ const KEYWORDS = [ 'FETCH', 'TOP', 'ALL', + 'WHERE', ]; const SNOWFLAKE_KEYWORDS = [ diff --git a/test/identifier/single-statement.spec.ts b/test/identifier/single-statement.spec.ts index c5b7f7e..d904616 100644 --- a/test/identifier/single-statement.spec.ts +++ b/test/identifier/single-statement.spec.ts @@ -1101,6 +1101,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + where: true, }, ]; @@ -1121,6 +1122,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + where: true, }, ]; expect(actual).to.eql(expected); @@ -1138,6 +1140,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + where: true, }, ]; @@ -1817,6 +1820,131 @@ describe('identifier', () => { }); }); + describe('WHERE detection', () => { + it('should set where=true for UPDATE with WHERE', () => { + const [result] = identify('UPDATE users SET active = 0 WHERE id = 1'); + expect(result.where).to.equal(true); + }); + + it('should set where=false for UPDATE without WHERE', () => { + const [result] = identify('UPDATE users SET active = 0'); + expect(result.where).to.equal(false); + }); + + it('should set where=true for DELETE with WHERE', () => { + const [result] = identify('DELETE FROM users WHERE id = 1'); + expect(result.where).to.equal(true); + }); + + it('should set where=false for DELETE without WHERE', () => { + const [result] = identify('DELETE FROM users'); + expect(result.where).to.equal(false); + }); + + it('should detect WHERE case-insensitively', () => { + const [result] = identify('delete from users where id = 1'); + expect(result.where).to.equal(true); + }); + + it('should set where=true for WHERE followed by RETURNING', () => { + const [result] = identify('DELETE FROM users WHERE id = 1 RETURNING *', { + dialect: 'psql', + }); + expect(result.where).to.equal(true); + }); + + it('should not set where on statements other than UPDATE and DELETE', () => { + const [select] = identify('SELECT * FROM users WHERE id = 1'); + const [insert] = identify('INSERT INTO users (id) SELECT id FROM old WHERE id > 1'); + expect(select.where).to.equal(undefined); + expect(insert.where).to.equal(undefined); + }); + + it('should not treat WHERE inside a string literal as a real WHERE', () => { + const [result] = identify("UPDATE users SET note = 'WHERE id = 1'"); + expect(result.where).to.equal(false); + }); + + it('should not treat WHERE inside a comment as a real WHERE', () => { + const [line] = identify('DELETE FROM users -- WHERE id = 1'); + const [block] = identify('DELETE FROM users /* WHERE id = 1 */'); + expect(line.where).to.equal(false); + expect(block.where).to.equal(false); + }); + + it('should not treat a quoted identifier named where as a real WHERE', () => { + const [result] = identify('UPDATE users SET "where" = 1', { dialect: 'psql' }); + expect(result.where).to.equal(false); + }); + + it('should not count LIMIT or TOP as a WHERE', () => { + const [limit] = identify('DELETE FROM users LIMIT 10', { dialect: 'mysql' }); + const [top] = identify('DELETE TOP (10) FROM users', { dialect: 'mssql' }); + expect(limit.where).to.equal(false); + expect(top.where).to.equal(false); + }); + + it('should set where=true for a multi-table DELETE with WHERE for mysql', () => { + const [result] = identify( + 'DELETE u FROM users u JOIN bans b ON b.user_id = u.id WHERE b.active = 1', + { dialect: 'mysql' }, + ); + expect(result.where).to.equal(true); + }); + + it('should set where=true for DELETE ... USING with WHERE for psql', () => { + const [result] = identify('DELETE FROM users USING bans WHERE bans.user_id = users.id', { + dialect: 'psql', + }); + expect(result.where).to.equal(true); + }); + + describe('top-level only (ignoring subqueries and CTEs)', () => { + it('should not treat a WHERE in a subquery in SET as a real WHERE', () => { + const [result] = identify( + 'UPDATE users SET score = (SELECT max(score) FROM games WHERE games.user_id = 1)', + ); + expect(result.where).to.equal(false); + }); + + it('should not treat a WHERE in a subquery in FROM as a real WHERE for psql', () => { + const [result] = identify( + 'UPDATE users SET score = s.score FROM (SELECT user_id, score FROM games WHERE won) s', + { dialect: 'psql' }, + ); + expect(result.where).to.equal(false); + }); + + it('should set where=true when the WHERE itself holds a subquery', () => { + const [result] = identify( + 'DELETE FROM users WHERE id IN (SELECT user_id FROM bans WHERE active = 1)', + ); + expect(result.where).to.equal(true); + }); + + it('should not treat a WHERE in a CTE as a real WHERE', () => { + const [result] = identify( + 'WITH old AS (SELECT id FROM users WHERE created < 1) DELETE FROM users', + { dialect: 'psql' }, + ); + expect(result.where).to.equal(false); + }); + + it('should set where=true for a DELETE after a CTE with its own WHERE', () => { + const [result] = identify( + 'WITH old AS (SELECT id FROM users WHERE created < 1) DELETE FROM users WHERE id IN (SELECT id FROM old)', + { dialect: 'psql' }, + ); + expect(result.where).to.equal(true); + }); + }); + + it('should detect WHERE for each statement on its own', () => { + const results = identify('DELETE FROM a; DELETE FROM b WHERE id = 1; UPDATE c SET x = (1)'); + expect(results.map((result) => result.where)).to.eql([false, true, false]); + }); + }); + describe('SELECT INTO detection', () => { it('should identify SELECT INTO as SELECT_INTO for mssql', () => { const [result] = identify('SELECT * INTO NewTable FROM SourceTable', { diff --git a/test/parser/single-statements.spec.ts b/test/parser/single-statements.spec.ts index 1a7dd6c..63813d8 100644 --- a/test/parser/single-statements.spec.ts +++ b/test/parser/single-statements.spec.ts @@ -623,6 +623,7 @@ describe('parser', () => { parameters: [], tables: [], columns: [], + where: true, }, ], tokens: [ @@ -634,8 +635,20 @@ describe('parser', () => { }, { type: 'unknown', - value: " Persons SET Name = 'John' WHERE PersonID = 1", + value: " Persons SET Name = 'John' ", start: 6, + end: 32, + }, + { + type: 'keyword', + value: 'WHERE', + start: 33, + end: 37, + }, + { + type: 'unknown', + value: ' PersonID = 1', + start: 38, end: 50, }, { @@ -669,6 +682,7 @@ describe('parser', () => { parameters: [], tables: [], columns: [], + where: true, }, ], tokens: [ @@ -692,8 +706,20 @@ describe('parser', () => { }, { type: 'unknown', - value: ' Persons WHERE PersonID = 1', + value: ' Persons ', start: 11, + end: 19, + }, + { + type: 'keyword', + value: 'WHERE', + start: 20, + end: 24, + }, + { + type: 'unknown', + value: ' PersonID = 1', + start: 25, end: 37, }, { @@ -841,8 +867,20 @@ describe('parser', () => { }, { type: 'unknown', - value: ' a where x = ', + value: ' a ', start: 13, + end: 15, + }, + { + type: 'keyword', + value: 'where', + start: 16, + end: 20, + }, + { + type: 'unknown', + value: ' x = ', + start: 21, end: 25, }, { @@ -887,8 +925,20 @@ describe('parser', () => { }, { type: 'unknown', - value: ' a where x = ', + value: ' a ', start: 13, + end: 15, + }, + { + type: 'keyword', + value: 'where', + start: 16, + end: 20, + }, + { + type: 'unknown', + value: ' x = ', + start: 21, end: 25, }, { @@ -933,8 +983,20 @@ describe('parser', () => { }, { type: 'unknown', - value: ' a where x = ', + value: ' a ', start: 13, + end: 15, + }, + { + type: 'keyword', + value: 'where', + start: 16, + end: 20, + }, + { + type: 'unknown', + value: ' x = ', + start: 21, end: 25, }, { @@ -991,8 +1053,20 @@ describe('parser', () => { }, { type: 'unknown', - value: ' a where x = ', + value: ' a ', start: 13, + end: 15, + }, + { + type: 'keyword', + value: 'where', + start: 16, + end: 20, + }, + { + type: 'unknown', + value: ' x = ', + start: 21, end: 25, }, { @@ -1075,8 +1149,20 @@ describe('parser', () => { }, { type: 'unknown', - value: ' a where x = ', + value: ' a ', start: 13, + end: 15, + }, + { + type: 'keyword', + value: 'where', + start: 16, + end: 20, + }, + { + type: 'unknown', + value: ' x = ', + start: 21, end: 25, }, { From 126a89e8e261d18b7c2d9fcdd3e9f56ead7adecb Mon Sep 17 00:00:00 2001 From: elias <14819853+eliasthecactus@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:02:58 +0200 Subject: [PATCH 2/2] Fix false WHERE matches from strings, comments and names - mysql, bigquery, snowflake: handle backslash escapes in strings - psql: handle backslash escapes in E'...' strings - oracle: handle q'[...]' and nq'[...]' strings - psql, mssql: handle nested block comments - don't treat a word after . or @ as a keyword (t.where, @where, t.limit) - set where=false when another statement follows without a semicolon (mssql) - README: clarify what the where flag means and that CTEs aren't checked --- README.md | 6 +- src/parser.ts | 46 +++++++-- src/tokenizer.ts | 98 +++++++++++++++++-- test/identifier/single-statement.spec.ts | 118 +++++++++++++++++++++++ test/tokenizer/index.spec.ts | 107 ++++++++++++++++++++ 5 files changed, 357 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 29b2a0c..cf99b89 100644 --- a/README.md +++ b/README.md @@ -155,10 +155,12 @@ console.log(statements); ### Statement flags -Some statement types carry flags about their own clauses. A clause inside a subquery or a CTE does not count. +Some statement types carry flags about their own clauses. Only the statement's own clauses count, not ones inside a subquery or a CTE. - `limit (bool)`, `offset (bool)`: on `SELECT`, whether it has a `LIMIT` (or `FETCH`, or `TOP` for `mssql`) and an `OFFSET`. -- `where (bool)`: on `UPDATE` and `DELETE`, whether it has a `WHERE`. A `LIMIT` or `TOP` does not count, so `where: false` means the statement is not filtered by a condition. +- `where (bool)`: on `UPDATE` and `DELETE`, whether the statement has its own `WHERE` clause. The condition itself isn't checked, so `WHERE 1=1` still gives `true`, and a `DELETE` that is only filtered by a `JOIN` gives `false`. A `LIMIT` or `TOP` doesn't count either. + +An `UPDATE` or `DELETE` inside a CTE (`WITH d AS (DELETE FROM a ...) ...`) isn't checked at all and doesn't affect the flag of the outer statement. ```js identify('DELETE FROM users WHERE id IN (SELECT id FROM bans WHERE active)'); diff --git a/src/parser.ts b/src/parser.ts index 75ff31e..9aa2adf 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -14,6 +14,22 @@ import type { import { ColumnParser } from './column-parser'; import { TableParser } from './table-parser'; +// statement types whose LIMIT/OFFSET/WHERE flags only count outside parentheses +const PARENS_TRACKED_TYPES = new Set(['SELECT', 'UPDATE', 'DELETE']); + +// keywords that can't appear at the top level of an UPDATE or DELETE, only start a new statement +const NEXT_STATEMENT_KEYWORDS = new Set([ + 'SELECT', + 'INSERT', + 'UPDATE', + 'DELETE', + 'MERGE', + 'TRUNCATE', + 'CREATE', + 'ALTER', + 'DROP', +]); + interface StatementParser { addToken: (token: Token, nextToken: Token) => void; getStatement: () => Statement; @@ -1026,6 +1042,7 @@ function stateMachineStatementParser( let openBlocks = 0; let parensDepth = 0; + let sawNextStatement = false; const columnParser = new ColumnParser(dialect); const tableParser = new TableParser(dialect); @@ -1175,23 +1192,32 @@ function stateMachineStatementParser( statement.parameters.push(token.value); } - if (statement.type?.match(/^(SELECT|UPDATE|DELETE)$/) && token.value === '(') { - parensDepth++; - } - - if (statement.type?.match(/^(SELECT|UPDATE|DELETE)$/) && token.value === ')') { - parensDepth--; + if (statement.type && PARENS_TRACKED_TYPES.has(statement.type)) { + if (token.value === '(') { + parensDepth++; + } else if (token.value === ')') { + parensDepth--; + } } - // Only a WHERE of the statement itself limits the rows it changes, not one in a - // subquery. A CTE's never reaches this parser. if ( (statement.type === 'UPDATE' || statement.type === 'DELETE') && token.type === 'keyword' && - token.value.toUpperCase() === 'WHERE' && parensDepth === 0 ) { - statement.where = true; + const keyword = token.value.toUpperCase(); + // Without a semicolon between them (fine in mssql), the next statement ends up in + // this one, and its WHERE would be counted as ours. Don't trust the flag then. + // INSERT( and TRUNCATE( are mysql functions, not statements. + if (NEXT_STATEMENT_KEYWORDS.has(keyword) && nextToken.value !== '(') { + sawNextStatement = true; + statement.where = false; + } + // Only a WHERE of the statement itself limits the rows it changes, not one in a + // subquery. A CTE's never reaches this parser. + if (keyword === 'WHERE' && !sawNextStatement) { + statement.where = true; + } } if ( diff --git a/src/tokenizer.ts b/src/tokenizer.ts index fc38e05..4ff5ecb 100644 --- a/src/tokenizer.ts +++ b/src/tokenizer.ts @@ -116,6 +116,13 @@ const INDIVIDUALS: Record = { ';': 'semicolon', }; +const ORACLE_QUOTE_CLOSINGS: Record = { + '[': ']', + '{': '}', + '<': '>', + '(': ')', +}; + const ENDTOKENS: Record = { '"': '"', "'": "'", @@ -139,11 +146,21 @@ export function scanToken( } if (isCommentBlock(ch, state)) { - return scanCommentBlock(state); + return scanCommentBlock(state, dialect); } if (isString(ch, dialect) && ch !== null) { - return scanString(state, ENDTOKENS[ch]); + return scanString(state, ENDTOKENS[ch], hasBackslashEscapes(dialect)); + } + + // Postgres escape string: E'It\'s' + if (dialect === 'psql' && (ch === 'E' || ch === 'e') && peek(state) === "'") { + read(state); + return scanString(state, "'", true); + } + + if (dialect === 'oracle' && isOracleQuotedString(ch, state)) { + return scanOracleQuotedString(state); } if (isParameter(ch, state, paramTypes)) { @@ -297,10 +314,15 @@ function scanDollarQuotedString(state: State): Token { }; } -function scanString(state: State, endToken: Char): Token { +function scanString(state: State, endToken: Char, backslashEscapes = false): Token { let nextChar: Char; do { nextChar = read(state); + // supporting backslash escaping: 'str\'ing' + if (backslashEscapes && nextChar === '\\') { + read(state); + continue; + } // supporting double quote escaping: 'str''ing' if (nextChar === endToken) { if (peek(state) === endToken) { @@ -333,6 +355,23 @@ function getCustomParam(state: State, paramTypes: ParamTypes): string | null | u return matches ? matches[0] : null; } +// Oracle alternative quoting: q'[It's]', nq'{It's}', q'!It's!' +function scanOracleQuotedString(state: State): Token { + const delimiterPos = state.input.indexOf("'", state.start) + 1; + const delimiter = state.input[delimiterPos]; + const closing = delimiter ? (ORACLE_QUOTE_CLOSINGS[delimiter] ?? delimiter) + "'" : null; + const end = closing ? state.input.indexOf(closing, delimiterPos + 1) : -1; + state.position = end === -1 ? state.input.length - 1 : end + 1; + + const value = state.input.slice(state.start, state.position + 1); + return { + type: 'string', + value, + start: state.start, + end: state.start + value.length - 1, + }; +} + function scanParameter(state: State, dialect: Dialect, paramTypes: ParamTypes): Token { const curCh = state.input[state.start]; const nextChar = peek(state); @@ -411,14 +450,25 @@ function scanParameter(state: State, dialect: Dialect, paramTypes: ParamTypes): }; } -function scanCommentBlock(state: State): Token { +function scanCommentBlock(state: State, dialect: Dialect): Token { let nextChar: Char = ''; let prevChar: Char; + // Postgres and SQL Server allow nested block comments: /* a /* b */ c */ + const nested = dialect === 'psql' || dialect === 'mssql'; + let depth = 1; do { prevChar = nextChar; nextChar = read(state); - } while ((prevChar || '') + (nextChar || '') !== '*/' && nextChar !== null); + const pair = (prevChar || '') + (nextChar || ''); + if (pair === '*/') { + depth--; + nextChar = depth > 0 ? '' : nextChar; + } else if (nested && pair === '/*') { + depth++; + nextChar = ''; + } + } while (depth > 0 && nextChar !== null); if (nextChar !== null && nextChar !== '/') { unread(state); @@ -477,7 +527,7 @@ function scanWord(state: State, dialect: Dialect): Token { } const value = state.input.slice(state.start, state.position + 1); - if (!isKeyword(value, dialect)) { + if (!isKeyword(value, dialect) || isQualifiedName(state)) { return skipWord(state, value); } @@ -526,10 +576,39 @@ function isWhitespace(ch: Char): boolean { return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; } +function isInlineSpace(ch: Char): boolean { + return ch === ' ' || ch === '\t'; +} + function isAlphaNumeric(ch: Char): boolean { return ch !== null && /\w/.test(ch); } +// a column or variable named like a keyword: t.where, t . where, @where +function isQualifiedName(state: State): boolean { + const { input } = state; + let i = state.start - 1; + if (input[i] === '@') { + return true; + } + // stay on the same line, so a line comment ending in a dot (-- done.\nDELETE ...) doesn't count + while (i >= 0 && isInlineSpace(input[i])) i--; + if (input[i] !== '.') { + return false; + } + + // a number with a trailing dot (1. FROM t) is not a qualifier + let end = i - 1; + while (end >= 0 && isInlineSpace(input[end])) end--; + let begin = end; + while (begin >= 0 && isAlphaNumeric(input[begin])) begin--; + return !/^\d+$/.test(input.slice(begin + 1, end + 1)); +} + +function hasBackslashEscapes(dialect: Dialect): boolean { + return ['mysql', 'bigquery', 'snowflake'].includes(dialect); +} + function isString(ch: Char, dialect: Dialect): boolean { const stringStart: Char[] = dialect === 'mysql' ? ["'", '"'] : ["'"]; return stringStart.includes(ch); @@ -580,6 +659,13 @@ function isDollarQuotedString(state: State): boolean { return /^\$[\w]*\$/.exec(state.input.slice(state.start)) !== null; } +function isOracleQuotedString(ch: Char, state: State): boolean { + if (ch !== 'q' && ch !== 'Q' && ch !== 'n' && ch !== 'N') { + return false; + } + return /^n?q'/i.test(state.input.slice(state.start, state.start + 3)); +} + function isQuotedIdentifier(ch: Char, dialect: Dialect): boolean { const startQuoteChars: Char[] = getStartQuotes(dialect); return startQuoteChars.includes(ch); diff --git a/test/identifier/single-statement.spec.ts b/test/identifier/single-statement.spec.ts index d904616..e1b3d3e 100644 --- a/test/identifier/single-statement.spec.ts +++ b/test/identifier/single-statement.spec.ts @@ -1757,6 +1757,11 @@ describe('identifier', () => { expect(result.limit).to.equal(false); }); + it('should not treat a column named limit as a real limit', () => { + const [result] = identify('SELECT t.limit FROM t', { dialect: 'psql' }); + expect(result.limit).to.equal(false); + }); + describe('top-level only (ignoring subqueries and CTEs)', () => { it('should not set limit=true when LIMIT only appears in a subquery in FROM', () => { const [result] = identify('SELECT * FROM (SELECT * FROM tab1 LIMIT 10) AS sub'); @@ -1939,6 +1944,119 @@ describe('identifier', () => { }); }); + describe('keyword-like text that is not a WHERE', () => { + it('should not treat WHERE after a backslash-escaped quote as a real WHERE for mysql', () => { + const [single] = identify("UPDATE posts SET body = 'It\\'s where we live'", { + dialect: 'mysql', + }); + const [double] = identify('UPDATE posts SET body = "It\\"s where we live"', { + dialect: 'mysql', + }); + expect(single.where).to.equal(false); + expect(double.where).to.equal(false); + }); + + it('should not treat WHERE in an E string as a real WHERE for psql', () => { + const [result] = identify("UPDATE posts SET body = E'It\\'s where we live'", { + dialect: 'psql', + }); + expect(result.where).to.equal(false); + }); + + it('should still see the WHERE after a plain string ending in a backslash for psql', () => { + const [result] = identify("UPDATE files SET path = 'C:\\' WHERE id = 1", { + dialect: 'psql', + }); + expect(result.where).to.equal(true); + }); + + it('should not treat WHERE in a q-quoted string as a real WHERE for oracle', () => { + const [result] = identify("UPDATE posts SET body = q'[It's where we live]'", { + dialect: 'oracle', + }); + expect(result.where).to.equal(false); + }); + + it('should not treat WHERE in a nested block comment as a real WHERE', () => { + const [psql] = identify('DELETE FROM users /* a /* b */ WHERE id = 1 */', { + dialect: 'psql', + }); + const [mssql] = identify('DELETE FROM users /* a /* b */ WHERE id = 1 */', { + dialect: 'mssql', + }); + expect(psql.where).to.equal(false); + expect(mssql.where).to.equal(false); + }); + + it('should not treat a column named where as a real WHERE', () => { + const [psql] = identify('UPDATE t SET x = t.where', { dialect: 'psql' }); + const [mysql] = identify('UPDATE t SET x = t.where', { dialect: 'mysql' }); + expect(psql.where).to.equal(false); + expect(mysql.where).to.equal(false); + }); + + it('should not treat a variable named where as a real WHERE for mssql', () => { + const [result] = identify('UPDATE t SET x = @where', { dialect: 'mssql' }); + expect(result.where).to.equal(false); + }); + + it('should not treat a column named where with spaces around the dot as a real WHERE', () => { + const [result] = identify('UPDATE t SET a = u. where FROM u', { dialect: 'psql' }); + expect(result.where).to.equal(false); + }); + + it('should still see the WHERE after a number with a trailing dot', () => { + const [result] = identify('UPDATE t SET a = 1. WHERE id = 1', { dialect: 'psql' }); + expect(result.where).to.equal(true); + }); + + it('should still see a DELETE after a line comment ending in a dot', () => { + const [result] = identify('-- clean up.\nDELETE FROM users', { dialect: 'psql' }); + expect(result.type).to.equal('DELETE'); + expect(result.where).to.equal(false); + }); + + ['bigquery', 'snowflake'].forEach((dialect) => { + it(`should not let a backslash-escaped quote hide the next statement for ${dialect}`, () => { + const results = identify("DELETE FROM t WHERE name = 'O\\'Brien'; DELETE FROM audit", { + dialect: dialect as Dialect, + }); + expect(results.map((result) => result.where)).to.eql([true, false]); + }); + }); + }); + + describe('statements without a semicolon between them', () => { + it('should not count the WHERE of the next statement for mssql', () => { + const [result] = identify('DELETE FROM users\nSELECT * FROM logs WHERE id = 1', { + dialect: 'mssql', + }); + expect(result.where).to.equal(false); + }); + + it('should set where=false when an unfiltered statement follows for mssql', () => { + const [result] = identify('UPDATE a SET x = 1 WHERE id = 1\nDELETE FROM b', { + dialect: 'mssql', + }); + expect(result.where).to.equal(false); + }); + + it('should still see the WHERE after a function named like a statement for mysql', () => { + const [result] = identify( + "UPDATE t SET price = TRUNCATE(price, 2), name = INSERT(name, 1, 1, 'x') WHERE id = 1", + { dialect: 'mysql' }, + ); + expect(result.where).to.equal(true); + }); + + it('should still see the WHERE after a table hint for mssql', () => { + const [result] = identify('DELETE FROM t WITH (TABLOCK) WHERE id = 1', { + dialect: 'mssql', + }); + expect(result.where).to.equal(true); + }); + }); + it('should detect WHERE for each statement on its own', () => { const results = identify('DELETE FROM a; DELETE FROM b WHERE id = 1; UPDATE c SET x = (1)'); expect(results.map((result) => result.where)).to.eql([false, true, false]); diff --git a/test/tokenizer/index.spec.ts b/test/tokenizer/index.spec.ts index 74762fa..d5bf374 100644 --- a/test/tokenizer/index.spec.ts +++ b/test/tokenizer/index.spec.ts @@ -281,6 +281,113 @@ describe('scan', () => { expect(actual).to.eql(expected); }); + describe('backslash escapes in strings', () => { + it('scans backslash-escaped quotes for mysql', () => { + expect(scanToken(initState("'It\\'s' x"), 'mysql').value).to.eql("'It\\'s'"); + expect(scanToken(initState('"a\\"b" x'), 'mysql').value).to.eql('"a\\"b"'); + expect(scanToken(initState("'C:\\\\' x"), 'mysql').value).to.eql("'C:\\\\'"); + }); + + it('scans E strings with backslash-escaped quotes for psql', () => { + expect(scanToken(initState("E'It\\'s' x"), 'psql')).to.eql({ + type: 'string', + value: "E'It\\'s'", + start: 0, + end: 7, + }); + expect(scanToken(initState("e'It\\'s' x"), 'psql').value).to.eql("e'It\\'s'"); + }); + + it('does not treat backslashes as escapes in plain strings for psql', () => { + expect(scanToken(initState("'C:\\' x"), 'psql').value).to.eql("'C:\\'"); + }); + + ['bigquery', 'snowflake'].forEach((dialect) => { + it(`scans backslash-escaped quotes for ${dialect}`, () => { + expect(scanToken(initState("'It\\'s' x"), dialect as Dialect).value).to.eql("'It\\'s'"); + }); + }); + + it('does not treat backslashes as escapes for generic', () => { + expect(scanToken(initState("'C:\\' x")).value).to.eql("'C:\\'"); + }); + }); + + describe('oracle q-quoted strings', () => { + it('scans q-quoted strings with bracket delimiters', () => { + expect(scanToken(initState("q'[It's]' x"), 'oracle')).to.eql({ + type: 'string', + value: "q'[It's]'", + start: 0, + end: 8, + }); + expect(scanToken(initState("Q'{It's}' x"), 'oracle').value).to.eql("Q'{It's}'"); + expect(scanToken(initState("q'' x"), 'oracle').value).to.eql("q''"); + expect(scanToken(initState("q'(It's)' x"), 'oracle').value).to.eql("q'(It's)'"); + }); + + it('scans q-quoted strings with any other delimiter', () => { + expect(scanToken(initState("q'!It's!' x"), 'oracle').value).to.eql("q'!It's!'"); + }); + + it('scans nq-quoted strings', () => { + expect(scanToken(initState("nq'[It's]' x"), 'oracle').value).to.eql("nq'[It's]'"); + }); + + it('does not scan q-quoted strings for other dialects', () => { + expect(scanToken(initState("q'[It's]'"), 'psql').value).to.eql('q'); + }); + }); + + describe('nested block comments', () => { + ['psql', 'mssql'].forEach((dialect) => { + it(`scans nested block comments for ${dialect}`, () => { + const comment = '/* a /* b */ c */'; + expect(scanToken(initState(`${comment} x`), dialect as Dialect)).to.eql({ + type: 'comment-block', + value: comment, + start: 0, + end: 16, + }); + }); + }); + + it('does not scan nested block comments for mysql', () => { + expect(scanToken(initState('/* a /* b */ c */'), 'mysql').value).to.eql('/* a /* b */'); + }); + }); + + describe('keywords used as names', () => { + const scanAt = (input: string, start: number, dialect: Dialect) => + scanToken({ input, start, end: input.length - 1, position: start - 1 }, dialect); + + it('does not scan a keyword after a dot as a keyword', () => { + expect(scanAt('t.where', 2, 'psql')).to.eql({ + type: 'unknown', + value: 'where', + start: 2, + end: 6, + }); + expect(scanAt('t.limit', 2, 'mysql').type).to.eql('unknown'); + }); + + it('does not scan a keyword after @ as a keyword', () => { + expect(scanAt('@where', 1, 'mssql').type).to.eql('unknown'); + }); + + it('does not scan a keyword after a dot with spaces around it as a keyword', () => { + expect(scanAt('t . where', 4, 'psql').type).to.eql('unknown'); + }); + + it('still scans a keyword after a number with a trailing dot', () => { + expect(scanAt('1. where', 3, 'psql').type).to.eql('keyword'); + }); + + it('still scans a keyword after a line comment ending in a dot', () => { + expect(scanAt('-- done.\nwhere', 9, 'psql').type).to.eql('keyword'); + }); + }); + describe('tokenizing parameters', () => { describe('tokenizing just parameter starting character', () => { [