From 8df7518b28d54e0257311e2fc22a6365803e1772 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 22:10:36 +0000 Subject: [PATCH 1/4] Add MySQL DELIMITER support for stored procedure scripts (#66) Recognize the MySQL/MariaDB `DELIMITER ` client directive as its own statement and use the new value as the terminator for subsequent statements. This unblocks editing and splitting stored-procedure scripts in tools like Beekeeper Studio where DELIMITER is the standard idiom for CREATE PROCEDURE/FUNCTION/TRIGGER bodies that contain inner `;` terminators. The tokenizer now accepts a `delimiter` parameter and emits a `semicolon` token for arbitrary symbol delimiters (`$`, `$$`, `//`, etc.). The parser tracks the current delimiter and applies a new one after a DELIMITER statement flushes. Only enabled for the mysql dialect. --- README.md | 2 + src/defines.ts | 2 + src/parser.ts | 126 ++++++++++++++++++++- src/tokenizer.ts | 39 ++++++- test/identifier/multiple-statement.spec.ts | 70 ++++++++++++ test/index.spec.ts | 2 +- test/parser/multiple-statements.spec.ts | 124 ++++++++++++++++++++ test/tokenizer/index.spec.ts | 32 ++++++ 8 files changed, 386 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d1bc4dd..9e637b6 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ For the show statements, please refer to the [MySQL Docs about SHOW Statements]( * ALTER_INDEX * ALTER_PROCEDURE * ANON_BLOCK (BigQuery and Oracle dialects only) +* DELIMITER (MySQL dialect only — sets the statement terminator used by the + client for subsequent statements, e.g. `DELIMITER $$` / `DELIMITER ;`) * SHOW_BINARY (MySQL and generic dialects only) * SHOW_BINLOG (MySQL and generic dialects only) * SHOW_CHARACTER (MySQL and generic dialects only) diff --git a/src/defines.ts b/src/defines.ts index 5fc2f15..d8cdb62 100644 --- a/src/defines.ts +++ b/src/defines.ts @@ -75,6 +75,7 @@ export type StatementType = | 'COMMIT' | 'ROLLBACK' | 'ANON_BLOCK' + | 'DELIMITER' | 'UNKNOWN'; export type ExecutionType = @@ -142,6 +143,7 @@ export interface Statement { tables: TableReference[]; columns: ColumnReference[]; isCte?: boolean; + newDelimiter?: string; } export interface ConcreteStatement extends Statement { diff --git a/src/parser.ts b/src/parser.ts index c180c78..c8ea21e 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -95,6 +95,7 @@ export const EXECUTION_TYPES: Record = { ROLLBACK: 'TRANSACTION', UNKNOWN: 'UNKNOWN', ANON_BLOCK: 'ANON_BLOCK', + DELIMITER: 'MODIFICATION', }; const statementsWithEnds = [ @@ -132,11 +133,11 @@ function createInitialStatement(): Statement { }; } -function nextNonWhitespaceToken(state: State, dialect: Dialect): Token { +function nextNonWhitespaceToken(state: State, dialect: Dialect, delimiter: string): Token { let token: Token; do { state = initState({ prevState: state }); - token = scanToken(state, dialect); + token = scanToken(state, dialect, undefined, delimiter); } while (token.type === 'whitespace'); return token; } @@ -163,6 +164,7 @@ export function parse( let prevState: State = topLevelState; let statementParser: StatementParser | null = null; + let currentDelimiter = ';'; const cteState: { isCte: boolean; asSeen: boolean; @@ -183,8 +185,8 @@ export function parse( while (prevState.position < topLevelState.end) { const tokenState = initState({ prevState }); - const token = scanToken(tokenState, dialect, paramTypes); - const nextToken = nextNonWhitespaceToken(tokenState, dialect); + const token = scanToken(tokenState, dialect, paramTypes, currentDelimiter); + const nextToken = nextNonWhitespaceToken(tokenState, dialect, currentDelimiter); if (!statementParser) { // ignore blank tokens before the start of a CTE / not part of a statement @@ -279,8 +281,15 @@ export function parse( const statement = statementParser.getStatement(); if (statement.endStatement) { statementParser.flush(); - statement.end = token.end; + if (statement.type !== 'DELIMITER') { + // DELIMITER sets its own `end` to the last delimiter-value char + // (end-of-line is not included in the statement text). + statement.end = token.end; + } topLevelStatement.body.push(statement as ConcreteStatement); + if (statement.type === 'DELIMITER' && statement.newDelimiter) { + currentDelimiter = statement.newDelimiter; + } statementParser = null; } } @@ -293,6 +302,9 @@ export function parse( if (!statement.endStatement) { statement.end = topLevelStatement.end; topLevelStatement.body.push(statement as ConcreteStatement); + if (statement.type === 'DELIMITER' && statement.newDelimiter) { + currentDelimiter = statement.newDelimiter; + } } } @@ -366,6 +378,11 @@ function createStatementParserByToken( return createBlockStatementParser(options); } break; + case 'DELIMITER': + if (options.dialect === 'mysql') { + return createDelimiterStatementParser(); + } + break; default: break; } @@ -796,6 +813,103 @@ function createRollbackStatementParser(options: ParseOptions) { return stateMachineStatementParser(statement, steps, options); } +function createDelimiterStatementParser(): StatementParser { + const statement: Statement = { + start: -1, + end: 0, + type: 'DELIMITER', + executionType: 'MODIFICATION', + parameters: [], + tables: [], + columns: [], + }; + + let delimiterStart: number | undefined; + let lastMeaningfulEnd: number | undefined; + let lastMeaningfulValue: string | undefined; + let finalized = false; + + const captureDelimiter = () => { + if (finalized || delimiterStart === undefined || lastMeaningfulEnd === undefined) { + return; + } + let raw = lastMeaningfulValue ?? ''; + // Strip matching surrounding quotes (e.g. DELIMITER "//", DELIMITER '//'). + if (raw.length >= 2 && (raw[0] === '"' || raw[0] === "'") && raw[raw.length - 1] === raw[0]) { + raw = raw.slice(1, -1); + } + if (raw.length > 0) { + statement.newDelimiter = raw; + } + statement.end = lastMeaningfulEnd; + finalized = true; + }; + + return { + getStatement() { + return statement; + }, + + flush() { + // Reached EOF without seeing a newline — capture whatever we have. + // We intentionally do not set `endStatement` here so that the parse() + // trailing-statement branch still pushes this statement to the body. + captureDelimiter(); + }, + + addToken(token: Token) { + if (statement.endStatement) { + return; + } + + if (statement.start < 0) { + // first token is the DELIMITER keyword + if (token.type === 'keyword' && token.value.toUpperCase() === 'DELIMITER') { + statement.start = token.start; + } + return; + } + + const endStatement = () => { + captureDelimiter(); + // Truthy sentinel that tells parse() to flush this statement. + statement.endStatement = '\n'; + if (lastMeaningfulEnd === undefined) { + // DELIMITER keyword with no value; end the statement just before + // the terminating whitespace/comment token. + statement.end = token.start - 1; + } + }; + + if (token.type === 'whitespace') { + if (/[\r\n]/.test(token.value)) { + endStatement(); + } + return; + } + + if (token.type === 'comment-inline') { + // Inline comments consume through end-of-line; treat as line end. + endStatement(); + return; + } + + if (token.type === 'comment-block') { + // Block comments are allowed between DELIMITER and the value; skip. + return; + } + + if (delimiterStart === undefined) { + delimiterStart = token.start; + lastMeaningfulValue = token.value; + } else { + lastMeaningfulValue = (lastMeaningfulValue ?? '') + token.value; + } + lastMeaningfulEnd = token.end; + }, + }; +} + function createUnknownStatementParser(options: ParseOptions) { const statement = createInitialStatement(); @@ -887,7 +1001,7 @@ function stateMachineStatementParser( (!statementsWithEnds.includes(statement.type) || (openBlocks === 0 && (statement.type === 'UNKNOWN' || statement.canEnd))) ) { - statement.endStatement = ';'; + statement.endStatement = token.value; return; } diff --git a/src/tokenizer.ts b/src/tokenizer.ts index e558749..55b4c19 100644 --- a/src/tokenizer.ts +++ b/src/tokenizer.ts @@ -72,11 +72,12 @@ const KEYWORDS = [ 'TRIGGERS', 'VARIABLES', 'WARNINGS', + 'DELIMITER', ]; -const INDIVIDUALS: Record = { - ';': 'semicolon', -}; +// The semicolon token is now emitted by the delimiter-match path in +// scanToken, so it can handle arbitrary terminators like '$$' or '//'. +const INDIVIDUALS: Record = {}; const ENDTOKENS: Record = { '"': '"', @@ -89,6 +90,7 @@ export function scanToken( state: State, dialect: Dialect = 'generic', paramTypes: ParamTypes = { positional: true }, + delimiter = ';', ): Token { const ch = read(state); @@ -112,7 +114,9 @@ export function scanToken( return scanParameter(state, dialect, paramTypes); } - if (isDollarQuotedString(state)) { + // MySQL/MariaDB does not support dollar-quoted strings, and treating `$$` + // as one would conflict with its use as a custom DELIMITER terminator. + if (dialect !== 'mysql' && isDollarQuotedString(state)) { return scanDollarQuotedString(state); } @@ -120,6 +124,15 @@ export function scanToken( return scanQuotedIdentifier(state, ENDTOKENS[ch]); } + // Match the current statement terminator. Handles ';', '$', '$$', '//', etc. + // Runs before scanIndividualCharacter so it's the single source of + // terminator tokens. Word-like delimiters would be consumed by scanWord + // above, so only symbol delimiters are fully supported. + const delimiterToken = scanDelimiter(state, delimiter); + if (delimiterToken) { + return delimiterToken; + } + if (isLetter(ch)) { return scanWord(state); } @@ -132,6 +145,24 @@ export function scanToken( return skipChar(state); } +function scanDelimiter(state: State, delimiter: string): Token | null { + if (!delimiter) { + return null; + } + if (state.input.slice(state.start, state.start + delimiter.length) !== delimiter) { + return null; + } + for (let i = 0; i < delimiter.length - 1; i++) { + read(state); + } + return { + type: 'semicolon', + value: delimiter, + start: state.start, + end: state.start + delimiter.length - 1, + }; +} + function read(state: State, skip = 0): Char { if (state.position + skip === state.input.length - 1) { return null; diff --git a/test/identifier/multiple-statement.spec.ts b/test/identifier/multiple-statement.spec.ts index 76fa52f..5160583 100644 --- a/test/identifier/multiple-statement.spec.ts +++ b/test/identifier/multiple-statement.spec.ts @@ -3,6 +3,76 @@ import { expect } from 'chai'; import { identify } from '../../src'; describe('identifier', () => { + describe('MySQL DELIMITER directive', () => { + it('should identify the canonical example from issue #66', () => { + const actual = identify('\nSELECT 1;\n\nDELIMITER $\n\nSELECT 2$\n\nSELECT 3$\n', { + dialect: 'mysql', + }); + expect(actual).to.eql([ + { + start: 1, + end: 9, + text: 'SELECT 1;', + type: 'SELECT', + executionType: 'LISTING', + parameters: [], + tables: [], + columns: [], + }, + { + start: 12, + end: 22, + text: 'DELIMITER $', + type: 'DELIMITER', + executionType: 'MODIFICATION', + parameters: [], + tables: [], + columns: [], + }, + { + start: 25, + end: 33, + text: 'SELECT 2$', + type: 'SELECT', + executionType: 'LISTING', + parameters: [], + tables: [], + columns: [], + }, + { + start: 36, + end: 44, + text: 'SELECT 3$', + type: 'SELECT', + executionType: 'LISTING', + parameters: [], + tables: [], + columns: [], + }, + ]); + }); + + it('should split a CREATE PROCEDURE body with $$ delimiter', () => { + const input = + 'DELIMITER $$\nCREATE PROCEDURE foo()\nBEGIN\n SELECT 1;\n SELECT 2;\nEND$$\nDELIMITER ;'; + const actual = identify(input, { dialect: 'mysql' }); + + expect(actual).to.have.lengthOf(3); + expect(actual[0]).to.include({ type: 'DELIMITER', text: 'DELIMITER $$' }); + expect(actual[1]).to.include({ + type: 'CREATE_PROCEDURE', + text: 'CREATE PROCEDURE foo()\nBEGIN\n SELECT 1;\n SELECT 2;\nEND$$', + }); + expect(actual[2]).to.include({ type: 'DELIMITER', text: 'DELIMITER ;' }); + }); + + it('should strip matching surrounding quotes from the delimiter value', () => { + const actual = identify('DELIMITER "//"\nSELECT 1//', { dialect: 'mysql' }); + expect(actual.map((stmt) => stmt.type)).to.eql(['DELIMITER', 'SELECT']); + expect(actual[1].text).to.eql('SELECT 1//'); + }); + }); + describe('given queries with multiple statements', () => { it('should identify a query with different statements in a single line', () => { const actual = identify( diff --git a/test/index.spec.ts b/test/index.spec.ts index 861a2c5..afd0d6e 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -333,7 +333,7 @@ describe('getExecutionType', () => { expect(getExecutionType('SELECT')).to.equal('LISTING'); }); - ['UPDATE', 'DELETE', 'INSERT', 'TRUNCATE'].forEach((type) => { + ['UPDATE', 'DELETE', 'INSERT', 'TRUNCATE', 'DELIMITER'].forEach((type) => { it(`should return MODIFICATION for ${type}`, () => { expect(getExecutionType(type)).to.equal('MODIFICATION'); }); diff --git a/test/parser/multiple-statements.spec.ts b/test/parser/multiple-statements.spec.ts index 57a751b..9df604e 100644 --- a/test/parser/multiple-statements.spec.ts +++ b/test/parser/multiple-statements.spec.ts @@ -76,6 +76,130 @@ describe('parser', () => { expect(actual).to.eql(expected); }); + describe('MySQL DELIMITER directive', () => { + it('should split statements using a single-char delimiter', () => { + const input = 'DELIMITER $\nSELECT 1$\nSELECT 2$'; + const actual = parse(input, true, 'mysql'); + + expect(actual.body).to.have.lengthOf(3); + expect(actual.body[0]).to.include({ + type: 'DELIMITER', + executionType: 'MODIFICATION', + start: 0, + end: 10, + endStatement: '\n', + newDelimiter: '$', + }); + expect(actual.body[1]).to.include({ + type: 'SELECT', + start: 12, + end: 20, + endStatement: '$', + }); + expect(actual.body[2]).to.include({ + type: 'SELECT', + start: 22, + end: 30, + endStatement: '$', + }); + }); + + it('should split statements using a multi-char delimiter', () => { + const input = 'DELIMITER $$\nSELECT 1$$\nSELECT 2$$'; + const actual = parse(input, true, 'mysql'); + + expect(actual.body).to.have.lengthOf(3); + expect(actual.body[0]).to.include({ type: 'DELIMITER', newDelimiter: '$$' }); + expect(actual.body[1]).to.include({ type: 'SELECT', endStatement: '$$' }); + expect(actual.body[2]).to.include({ type: 'SELECT', endStatement: '$$' }); + }); + + it('should not treat literal ; as a terminator while delimiter is $$', () => { + const input = 'DELIMITER $$\nCREATE PROCEDURE foo() BEGIN SELECT 1; SELECT 2; END$$'; + const actual = parse(input, true, 'mysql'); + + expect(actual.body).to.have.lengthOf(2); + expect(actual.body[0]).to.include({ type: 'DELIMITER', newDelimiter: '$$' }); + expect(actual.body[1]).to.include({ + type: 'CREATE_PROCEDURE', + endStatement: '$$', + }); + }); + + it('should reset delimiter back to ; with DELIMITER ;', () => { + const input = 'DELIMITER $$\nSELECT 1$$\nDELIMITER ;\nSELECT 2;'; + const actual = parse(input, true, 'mysql'); + + expect(actual.body).to.have.lengthOf(4); + expect(actual.body[0]).to.include({ type: 'DELIMITER', newDelimiter: '$$' }); + expect(actual.body[1]).to.include({ type: 'SELECT', endStatement: '$$' }); + expect(actual.body[2]).to.include({ type: 'DELIMITER', newDelimiter: ';' }); + expect(actual.body[3]).to.include({ type: 'SELECT', endStatement: ';' }); + }); + + it('should finalize DELIMITER statement at EOF without a trailing newline', () => { + const input = 'SELECT 1;\nDELIMITER $$'; + const actual = parse(input, true, 'mysql'); + + expect(actual.body).to.have.lengthOf(2); + expect(actual.body[1]).to.include({ + type: 'DELIMITER', + newDelimiter: '$$', + start: 10, + end: 21, + }); + }); + + it('should strip matching surrounding quotes from the delimiter value', () => { + const input = 'DELIMITER "//"\nSELECT 1//'; + const actual = parse(input, true, 'mysql'); + + expect(actual.body).to.have.lengthOf(2); + expect(actual.body[0]).to.include({ type: 'DELIMITER', newDelimiter: '//' }); + expect(actual.body[1]).to.include({ type: 'SELECT', endStatement: '//' }); + }); + + it('should accept lowercase delimiter keyword', () => { + const input = 'delimiter $$\nSELECT 1$$'; + const actual = parse(input, true, 'mysql'); + + expect(actual.body).to.have.lengthOf(2); + expect(actual.body[0]).to.include({ type: 'DELIMITER', newDelimiter: '$$' }); + }); + + it('should handle \\r\\n line endings on the DELIMITER line', () => { + const input = 'DELIMITER $$\r\nSELECT 1$$'; + const actual = parse(input, true, 'mysql'); + + expect(actual.body).to.have.lengthOf(2); + expect(actual.body[0]).to.include({ + type: 'DELIMITER', + newDelimiter: '$$', + end: 11, + }); + expect(actual.body[1]).to.include({ type: 'SELECT' }); + }); + + it('should ignore trailing inline comments on the DELIMITER line', () => { + const input = 'DELIMITER $$ -- switch terminator\nSELECT 1$$'; + const actual = parse(input, true, 'mysql'); + + expect(actual.body).to.have.lengthOf(2); + expect(actual.body[0]).to.include({ type: 'DELIMITER', newDelimiter: '$$' }); + }); + + it('should throw in strict mode for non-mysql dialects', () => { + expect(() => parse('DELIMITER $$', true, 'generic')).to.throw( + 'Invalid statement parser "DELIMITER"', + ); + }); + + it('should fall back to UNKNOWN in non-strict mode for non-mysql dialects', () => { + const actual = parse('DELIMITER $$\nSELECT 1$$', false, 'generic'); + expect(actual.body[0].type).to.eql('UNKNOWN'); + }); + }); + it('should identify a query with different statements in multiple lines', () => { const actual = parse(` INSERT INTO Persons (PersonID, Name) VALUES (1, 'Jack'); diff --git a/test/tokenizer/index.spec.ts b/test/tokenizer/index.spec.ts index 74762fa..c5229a7 100644 --- a/test/tokenizer/index.spec.ts +++ b/test/tokenizer/index.spec.ts @@ -281,6 +281,38 @@ describe('scan', () => { expect(actual).to.eql(expected); }); + it('does not treat $$ as a dollar-quoted string for mysql dialect', () => { + const actual = scanToken(initState('$$'), 'mysql'); + expect(actual.type).to.not.eql('string'); + }); + + describe('custom delimiter', () => { + it('defaults to ; as semicolon token', () => { + const actual = scanToken(initState(';')); + expect(actual).to.eql({ type: 'semicolon', value: ';', start: 0, end: 0 }); + }); + + it('emits semicolon token for single-char custom delimiter', () => { + const actual = scanToken(initState('$'), 'mysql', undefined, '$'); + expect(actual).to.eql({ type: 'semicolon', value: '$', start: 0, end: 0 }); + }); + + it('emits semicolon token for multi-char custom delimiter', () => { + const actual = scanToken(initState('$$rest'), 'mysql', undefined, '$$'); + expect(actual).to.eql({ type: 'semicolon', value: '$$', start: 0, end: 1 }); + }); + + it('does not treat ; as semicolon when delimiter is different', () => { + const actual = scanToken(initState(';'), 'mysql', undefined, '$$'); + expect(actual.type).to.not.eql('semicolon'); + }); + + it('handles // as delimiter', () => { + const actual = scanToken(initState('//rest'), 'mysql', undefined, '//'); + expect(actual).to.eql({ type: 'semicolon', value: '//', start: 0, end: 1 }); + }); + }); + describe('tokenizing parameters', () => { describe('tokenizing just parameter starting character', () => { [ From 95b23fa1f7450b621d15091f6f87e6dfeffea38f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 15:20:15 +0000 Subject: [PATCH 2/4] Address PR feedback: expose terminator info, NO_OP executionType, README example - Expose `endStatement` on every IdentifyResult (the terminator string that ended the statement), so consumers can reliably strip or interpret it. - Expose `newDelimiter` on DELIMITER statements (the new terminator for the following statements). - Add a new `NO_OP` executionType and use it for DELIMITER instead of MODIFICATION, since DELIMITER is a client-side directive that does not modify the database and should not be filtered with write operations. - Document DELIMITER handling in the README with a worked example showing how a consumer should interpret the output to execute statements against a MySQL server. - Also: set `endStatement` on the CTE-termination UNKNOWN statement path for consistency with the rest of the parser. --- README.md | 53 ++++++++++++++++++++++ src/defines.ts | 13 ++++++ src/index.ts | 8 ++++ src/parser.ts | 11 +++-- test/identifier/inner-statements.spec.ts | 2 + test/identifier/multiple-statement.spec.ts | 31 ++++++++++++- test/identifier/single-statement.spec.ts | 32 +++++++++++++ test/index.spec.ts | 19 +++++++- test/parser/multiple-statements.spec.ts | 2 +- 9 files changed, 165 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9e637b6..54f6b3c 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ Execution types allow to know what is the query behavior * `MODIFICATION:` is when the query modificate the database somehow (structure or data) * `INFORMATION:` is show some data information such as a profile data * `ANON_BLOCK: ` is for an anonymous block query which may contain multiple statements of unknown type (BigQuery and Oracle dialects only) +* `NO_OP:` the statement has no effect on the database server; currently used for `DELIMITER`, which is a client-side directive that changes how subsequent statements are split +* `TRANSACTION:` transaction-control statements like `BEGIN`, `COMMIT`, `ROLLBACK` * `UNKNOWN`: (only available if strict mode is disabled) ## Installation @@ -155,6 +157,57 @@ 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` and `mssql`. *(default=generic)* +Each returned statement has: + +* `start`, `end`, `text`: position and raw text (including the terminator). +* `type`, `executionType`. +* `parameters`, `tables`, `columns`. +* `endStatement` (optional): the terminator string that ended this statement (e.g. `;`, `$`, `$$`). Absent if the statement ran to EOF without a terminator, or for `DELIMITER` statements (terminated by end-of-line). +* `newDelimiter` (optional, only on `DELIMITER` statements): the new terminator string that should be used for the statements that follow. + +## Working with MySQL `DELIMITER` + +The `mysql` dialect understands the client-side `DELIMITER` directive used by the `mysql` CLI, MySQL Workbench, etc. to author stored programs whose bodies contain inner `;` terminators. Pass `{ dialect: 'mysql' }` to enable it. + +```js +import { identify } from 'sql-query-identifier'; + +const statements = identify( + `DELIMITER $$ +CREATE PROCEDURE foo() +BEGIN + SELECT 1; + SELECT 2; +END$$ +DELIMITER ; +SELECT 3;`, + { dialect: 'mysql' }, +); +``` + +`statements` is: + +```js +[ + { type: 'DELIMITER', executionType: 'NO_OP', text: 'DELIMITER $$', newDelimiter: '$$', /* ... */ }, + { type: 'CREATE_PROCEDURE', executionType: 'MODIFICATION', text: 'CREATE PROCEDURE foo()\nBEGIN\n SELECT 1;\n SELECT 2;\nEND$$', endStatement: '$$', /* ... */ }, + { type: 'DELIMITER', executionType: 'NO_OP', text: 'DELIMITER ;', newDelimiter: ';', /* ... */ }, + { type: 'SELECT', executionType: 'LISTING', text: 'SELECT 3;', endStatement: ';', /* ... */ }, +] +``` + +Because `DELIMITER` is a client-side directive (the server never sees it), its `executionType` is `NO_OP`. To execute the identified statements against a MySQL server, skip any with `type === 'DELIMITER'` and strip the `endStatement` from each remaining statement's `text` before sending it: + +```js +for (const stmt of statements) { + if (stmt.type === 'DELIMITER') continue; // client-side only + const sql = stmt.endStatement + ? stmt.text.slice(0, -stmt.endStatement.length) + : stmt.text; + await connection.query(sql); +} +``` + ## Contributing It is required to use [editorconfig](https://editorconfig.org/) and please write and run specs before pushing any changes: diff --git a/src/defines.ts b/src/defines.ts index d8cdb62..5d8c2f1 100644 --- a/src/defines.ts +++ b/src/defines.ts @@ -84,6 +84,7 @@ export type ExecutionType = | 'INFORMATION' | 'ANON_BLOCK' | 'TRANSACTION' + | 'NO_OP' | 'UNKNOWN'; export interface ParamTypes { @@ -127,6 +128,18 @@ export interface IdentifyResult { parameters: string[]; tables: TableReference[]; columns: ColumnReference[]; + /** + * The terminator string (e.g. `;`, `$`, `$$`) that ended this statement. + * `undefined` when the statement ran to EOF without a terminator, or for + * `DELIMITER` statements (which are terminated by end-of-line, not a + * delimiter). + */ + endStatement?: string; + /** + * Only set for statements of type `DELIMITER`. The new terminator string + * that should be used for statements that follow. + */ + newDelimiter?: string; } export interface Statement { diff --git a/src/index.ts b/src/index.ts index f5c10f3..a495f66 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,14 @@ export function identify(query: string, options: IdentifyOptions = {}): Identify tables: statement.tables || [], columns: statement.columns || [], }; + // DELIMITER's internal `endStatement` is a `\n` sentinel, not a real + // terminator; don't expose it to consumers. + if (statement.type !== 'DELIMITER' && statement.endStatement) { + result.endStatement = statement.endStatement; + } + if (statement.newDelimiter) { + result.newDelimiter = statement.newDelimiter; + } return result; }); } diff --git a/src/parser.ts b/src/parser.ts index c8ea21e..e0d6bc1 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -95,7 +95,7 @@ export const EXECUTION_TYPES: Record = { ROLLBACK: 'TRANSACTION', UNKNOWN: 'UNKNOWN', ANON_BLOCK: 'ANON_BLOCK', - DELIMITER: 'MODIFICATION', + DELIMITER: 'NO_OP', }; const statementsWithEnds = [ @@ -213,6 +213,7 @@ export function parse( end: token.end, type: 'UNKNOWN', executionType: 'UNKNOWN', + endStatement: token.value, parameters: [], tables: [], columns: [], @@ -300,7 +301,11 @@ export function parse( statementParser.flush(); const statement = statementParser.getStatement(); if (!statement.endStatement) { - statement.end = topLevelStatement.end; + if (statement.type !== 'DELIMITER' || !statement.end) { + // DELIMITER parsers set `end` themselves to the last char of the + // delimiter value; don't overwrite with trailing-whitespace EOF. + statement.end = topLevelStatement.end; + } topLevelStatement.body.push(statement as ConcreteStatement); if (statement.type === 'DELIMITER' && statement.newDelimiter) { currentDelimiter = statement.newDelimiter; @@ -818,7 +823,7 @@ function createDelimiterStatementParser(): StatementParser { start: -1, end: 0, type: 'DELIMITER', - executionType: 'MODIFICATION', + executionType: 'NO_OP', parameters: [], tables: [], columns: [], diff --git a/test/identifier/inner-statements.spec.ts b/test/identifier/inner-statements.spec.ts index 8fbcce0..30954ef 100644 --- a/test/identifier/inner-statements.spec.ts +++ b/test/identifier/inner-statements.spec.ts @@ -60,6 +60,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -83,6 +84,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; diff --git a/test/identifier/multiple-statement.spec.ts b/test/identifier/multiple-statement.spec.ts index 5160583..8058388 100644 --- a/test/identifier/multiple-statement.spec.ts +++ b/test/identifier/multiple-statement.spec.ts @@ -18,16 +18,18 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, { start: 12, end: 22, text: 'DELIMITER $', type: 'DELIMITER', - executionType: 'MODIFICATION', + executionType: 'NO_OP', parameters: [], tables: [], columns: [], + newDelimiter: '$', }, { start: 25, @@ -38,6 +40,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: '$', }, { start: 36, @@ -48,6 +51,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: '$', }, ]); }); @@ -88,6 +92,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, { end: 76, @@ -120,6 +125,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, { start: 74, @@ -130,6 +136,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -155,6 +162,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, { start: 35, @@ -165,6 +173,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -189,6 +198,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, { start: 20, @@ -199,6 +209,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, { start: 50, @@ -209,6 +220,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -254,6 +266,7 @@ describe('identifier', () => { text: 'DECLARE\n PK_NAME VARCHAR(200);\n\n BEGIN\n EXECUTE IMMEDIATE (\'CREATE SEQUENCE "untitled_table8_seq"\');\n\n SELECT\n cols.column_name INTO PK_NAME\n FROM\n all_constraints cons,\n all_cons_columns cols\n WHERE\n cons.constraint_type = \'P\'\n AND cons.constraint_name = cols.constraint_name\n AND cons.owner = cols.owner\n AND cols.table_name = \'untitled_table8\';\n\n execute immediate (\n \'create or replace trigger "untitled_table8_autoinc_trg" BEFORE INSERT on "untitled_table8" for each row declare checking number := 1; begin if (:new."\' || PK_NAME || \'" is null) then while checking >= 1 loop select "untitled_table8_seq".nextval into :new."\' || PK_NAME || \'" from dual; select count("\' || PK_NAME || \'") into checking from "untitled_table8" where "\' || PK_NAME || \'" = :new."\' || PK_NAME || \'"; end loop; end if; end;\'\n );\n\n END;', type: 'ANON_BLOCK', columns: [], + endStatement: ';', }, ]; expect(actual).to.eql(expected); @@ -303,6 +316,7 @@ describe('identifier', () => { text: 'create table\n "untitled_table8" (\n "id" integer not null primary key,\n "created_at" varchar(255) not null\n );', type: 'CREATE_TABLE', columns: [], + endStatement: ';', }, { end: 1212, @@ -313,6 +327,7 @@ describe('identifier', () => { text: 'DECLARE\n PK_NAME VARCHAR(200);\n\n BEGIN\n EXECUTE IMMEDIATE (\'CREATE SEQUENCE "untitled_table8_seq"\');\n\n SELECT\n cols.column_name INTO PK_NAME\n FROM\n all_constraints cons,\n all_cons_columns cols\n WHERE\n cons.constraint_type = \'P\'\n AND cons.constraint_name = cols.constraint_name\n AND cons.owner = cols.owner\n AND cols.table_name = \'untitled_table8\';\n\n execute immediate (\n \'create or replace trigger "untitled_table8_autoinc_trg" BEFORE INSERT on "untitled_table8" for each row declare checking number := 1; begin if (:new."\' || PK_NAME || \'" is null) then while checking >= 1 loop select "untitled_table8_seq".nextval into :new."\' || PK_NAME || \'" from dual; select count("\' || PK_NAME || \'") into checking from "untitled_table8" where "\' || PK_NAME || \'" = :new."\' || PK_NAME || \'"; end loop; end if; end;\'\n );\n\n END;', type: 'ANON_BLOCK', columns: [], + endStatement: ';', }, ]; expect(actual).to.eql(expected); @@ -344,6 +359,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, { start: 79, @@ -354,6 +370,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, { start: 250, @@ -364,6 +381,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -388,6 +406,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, { start: 54, @@ -398,6 +417,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -423,6 +443,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, { start: 6, @@ -433,6 +454,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -457,6 +479,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, { start: 24, @@ -490,6 +513,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, { start: 19, @@ -500,6 +524,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, { start: 29, @@ -510,6 +535,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; expect(actual).to.eql(expected); @@ -531,6 +557,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, { start: 19 + offset, @@ -541,6 +568,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, { start: 29 + offset, @@ -551,6 +579,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; expect(actual).to.eql(expected); diff --git a/test/identifier/single-statement.spec.ts b/test/identifier/single-statement.spec.ts index 7952083..83158cd 100644 --- a/test/identifier/single-statement.spec.ts +++ b/test/identifier/single-statement.spec.ts @@ -91,6 +91,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -117,6 +118,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -136,6 +138,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -157,6 +160,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -188,6 +192,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -226,6 +231,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -259,6 +265,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -282,6 +289,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -307,6 +315,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; expect(actual).to.eql(expected); @@ -334,6 +343,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; expect(actual).to.eql(expected); @@ -370,6 +380,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; expect(actual).to.eql(expected); @@ -619,6 +630,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -821,6 +833,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; expect(actual).to.eql(expected); @@ -924,6 +937,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -950,6 +964,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -968,6 +983,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -986,6 +1002,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -1004,6 +1021,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; expect(actual).to.eql(expected); @@ -1022,6 +1040,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; expect(actual).to.eql(expected); @@ -1040,6 +1059,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; expect(actual).to.eql(expected); @@ -1057,6 +1077,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -1075,6 +1096,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -1093,6 +1115,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -1113,6 +1136,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; expect(actual).to.eql(expected); @@ -1130,6 +1154,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -1336,6 +1361,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -1360,6 +1386,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -1383,6 +1410,7 @@ describe('identifier', () => { parameters: [], tables: [], // FIXME: should return 'table'? columns: [], + endStatement: ';', }, ]; @@ -1453,6 +1481,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -1580,6 +1609,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -1606,6 +1636,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; @@ -1625,6 +1656,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]; diff --git a/test/index.spec.ts b/test/index.spec.ts index afd0d6e..d0efb8b 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -333,12 +333,16 @@ describe('getExecutionType', () => { expect(getExecutionType('SELECT')).to.equal('LISTING'); }); - ['UPDATE', 'DELETE', 'INSERT', 'TRUNCATE', 'DELIMITER'].forEach((type) => { + ['UPDATE', 'DELETE', 'INSERT', 'TRUNCATE'].forEach((type) => { it(`should return MODIFICATION for ${type}`, () => { expect(getExecutionType(type)).to.equal('MODIFICATION'); }); }); + it('should return NO_OP for DELIMITER', () => { + expect(getExecutionType('DELIMITER')).to.equal('NO_OP'); + }); + ['BEGIN_TRANSACTION', 'COMMIT', 'ROLLBACK'].forEach((type) => { it(`should return TRANSACTION for ${type}`, () => { expect(getExecutionType(type)).to.equal('TRANSACTION'); @@ -417,6 +421,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]); }); @@ -477,6 +482,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]); }); @@ -498,6 +504,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]); @@ -511,6 +518,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]); }); @@ -526,6 +534,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]); @@ -539,6 +548,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]); @@ -552,6 +562,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]); @@ -565,6 +576,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]); @@ -580,6 +592,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]); }); @@ -595,6 +608,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]); @@ -608,6 +622,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]); @@ -621,6 +636,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]); @@ -636,6 +652,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + endStatement: ';', }, ]); }); diff --git a/test/parser/multiple-statements.spec.ts b/test/parser/multiple-statements.spec.ts index 9df604e..530dc0a 100644 --- a/test/parser/multiple-statements.spec.ts +++ b/test/parser/multiple-statements.spec.ts @@ -84,7 +84,7 @@ describe('parser', () => { expect(actual.body).to.have.lengthOf(3); expect(actual.body[0]).to.include({ type: 'DELIMITER', - executionType: 'MODIFICATION', + executionType: 'NO_OP', start: 0, end: 10, endStatement: '\n', From 67783ff49f913d9aa8356346ceedd24f85a3fd9e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 14:24:30 +0000 Subject: [PATCH 3/4] Validate DELIMITER argument; rename semicolon token type Addresses PR feedback from @not-night-but: - Rename Token type `'semicolon'` to `'delimiter'`. Calling a custom terminator like `$$` or `//` a "semicolon" was confusing; the new name matches what the token actually represents. - Validate DELIMITER arguments. Referencing mysql-shell's `Sql_splitter::set_delimiter` (mysqlshdk/libs/utils/utils_mysql_parsing.cc), which only rejects empty and backslash, we are a little stricter because several other characters silently break our tokenizer: * empty argument * backslash (`\`) * string/identifier quotes (`'`, `"`, `` ` ``) * inline comment markers (`--`, `#`) * block-comment characters (`/`, `*`) In strict mode the parser throws; in non-strict mode the DELIMITER statement is returned without `newDelimiter` and the previous delimiter is kept in effect, matching mysql-shell's behaviour. - Handle DELIMITER lines via raw input scanning instead of token consumption. A malformed argument such as `DELIMITER '` used to tokenise as an unterminated string that ate the rest of the input, hiding all subsequent statements. Raw scanning also drops the previous quote-stripping convenience, matching mysql-shell which treats the argument as a whitespace-delimited raw word. - Add comprehensive tests for every rejection case plus a regression test confirming that a malformed DELIMITER does not swallow the rest of the script in non-strict mode. - Document the validation rules in the README. --- README.md | 12 ++ src/defines.ts | 2 +- src/parser.ts | 209 ++++++++++++--------- src/table-parser.ts | 2 +- src/tokenizer.ts | 6 +- test/identifier/multiple-statement.spec.ts | 8 +- test/parser/multiple-statements.spec.ts | 79 +++++++- test/parser/single-statements.spec.ts | 20 +- test/tokenizer/index.spec.ts | 20 +- 9 files changed, 228 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index 54f6b3c..5fed84b 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,18 @@ for (const stmt of statements) { } ``` +### DELIMITER validation + +The parser rejects delimiter values that would break subsequent tokenization: + +* Empty argument (`DELIMITER` with no value) +* Backslash (`\`) — matches mysql-shell's explicit rejection +* String/identifier quote characters (`'`, `"`, `` ` ``) +* Inline comment markers (`--`, `#`) +* Block-comment characters (`/`, `*`) + +In strict mode (the default), an invalid `DELIMITER` throws. In non-strict mode, the `DELIMITER` statement is still returned but without a `newDelimiter` field, and the previous delimiter is kept — matching mysql-shell's behaviour of leaving the old delimiter in effect when an argument is rejected. + ## Contributing It is required to use [editorconfig](https://editorconfig.org/) and please write and run specs before pushing any changes: diff --git a/src/defines.ts b/src/defines.ts index 5d8c2f1..e476f1a 100644 --- a/src/defines.ts +++ b/src/defines.ts @@ -177,7 +177,7 @@ export interface Token { | 'comment-inline' | 'comment-block' | 'string' - | 'semicolon' + | 'delimiter' | 'keyword' | 'parameter' | 'table' diff --git a/src/parser.ts b/src/parser.ts index e0d6bc1..6cd64c4 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -181,7 +181,7 @@ export function parse( params: [], }; - const ignoreOutsideBlankTokens = ['whitespace', 'comment-inline', 'comment-block', 'semicolon']; + const ignoreOutsideBlankTokens = ['whitespace', 'comment-inline', 'comment-block', 'delimiter']; while (prevState.position < topLevelState.end) { const tokenState = initState({ prevState }); @@ -205,7 +205,7 @@ export function parse( // If we're scanning in a CTE, handle someone putting a semicolon anywhere (after 'with', // after semicolon, etc.) along it to "early terminate". - } else if (cteState.isCte && token.type === 'semicolon') { + } else if (cteState.isCte && token.type === 'delimiter') { topLevelStatement.tokens.push(token); prevState = tokenState; topLevelStatement.body.push({ @@ -253,6 +253,33 @@ export function parse( ) { topLevelStatement.tokens.push(token); prevState = tokenState; + } else if ( + !cteState.isCte && + dialect === 'mysql' && + token.type === 'keyword' && + token.value.toUpperCase() === 'DELIMITER' + ) { + // Handle DELIMITER entirely by raw-scanning the input from the + // keyword onwards. If we let this go through the token-based + // statement parser, a malformed argument like `DELIMITER '` would + // be tokenised as a string spanning the rest of the file, which + // would hide every subsequent statement. Raw scanning is also what + // mysql-shell does: arguments are whitespace-delimited; the rest + // of the line is consumed as part of the directive. + topLevelStatement.tokens.push(token); + const lineResult = parseDelimiterLine(input, token, currentDelimiter, isStrict); + topLevelStatement.body.push(lineResult.statement as ConcreteStatement); + if (lineResult.statement.newDelimiter) { + currentDelimiter = lineResult.statement.newDelimiter; + } + // Advance prevState to the character before the next scan position + // (first char after the consumed DELIMITER line). + prevState = { + input, + position: lineResult.consumedTo, + start: lineResult.consumedTo, + end: input.length - 1, + }; } else { statementParser = createStatementParserByToken(token, nextToken, { isStrict, @@ -383,11 +410,8 @@ function createStatementParserByToken( return createBlockStatementParser(options); } break; - case 'DELIMITER': - if (options.dialect === 'mysql') { - return createDelimiterStatementParser(); - } - break; + // DELIMITER is intercepted inline in parse() for the mysql dialect, + // so we never reach a `case 'DELIMITER'` here for that dialect. default: break; } @@ -818,10 +842,71 @@ function createRollbackStatementParser(options: ParseOptions) { return stateMachineStatementParser(statement, steps, options); } -function createDelimiterStatementParser(): StatementParser { +/** + * Validate a candidate DELIMITER value. Matches mysql-shell's explicit + * rejections (empty, backslash) and additionally rejects characters that + * would break our tokenizer if used as a delimiter: + * + * - `'` `"` `` ` `` — collide with string / quoted-identifier scanning + * - `--` `#` — collide with inline comment scanning + * - `/` `*` — collide with block-comment scanning (`/*`, `*\/`) + * + * mysql-shell itself accepts these characters but they wreck subsequent + * parsing, so we're stricter on purpose. Returns `null` on success or an + * error message string on rejection. + */ +function validateDelimiterValue(raw: string): string | null { + if (raw.length === 0) { + return "DELIMITER must be followed by a 'delimiter' character or string"; + } + if (raw.includes('\\')) { + return 'DELIMITER cannot contain a backslash character'; + } + if (/['"`]/.test(raw)) { + return 'DELIMITER cannot contain quote characters (\', ", `)'; + } + if (/#/.test(raw) || raw.includes('--')) { + return 'DELIMITER cannot contain SQL comment markers (--, #)'; + } + if (raw.includes('/') || raw.includes('*')) { + return 'DELIMITER cannot contain block-comment characters (/, *)'; + } + return null; +} + +/** + * Raw-scan the DELIMITER line starting from the keyword token. Consumes + * characters up to the first newline (or EOF). Returns the built statement + * along with the input position the main parse loop should resume from. + * + * Bypassing the tokenizer here is deliberate: a bad argument like + * `DELIMITER '` would otherwise tokenise as an unterminated string spanning + * the rest of the file, hiding every subsequent statement. mysql-shell's + * parser likewise lexes the delimiter argument as a single + * whitespace-delimited word at the character level. + */ +function parseDelimiterLine( + input: string, + keywordToken: Token, + currentDelimiter: string, + isStrict: boolean, +): { statement: Statement; consumedTo: number } { + // Skip spaces/tabs (but not newlines) after the keyword. + let i = keywordToken.end + 1; + while (i < input.length && (input[i] === ' ' || input[i] === '\t')) i++; + const argStart = i; + // Capture up to the first whitespace or EOF. + while (i < input.length && !/\s/.test(input[i])) i++; + const argEnd = i; // exclusive + const raw = input.slice(argStart, argEnd); + + // `currentDelimiter` isn't used in the raw scan but is worth asserting on + // so future callers don't pass it in erroneously; silence unused-var lint. + void currentDelimiter; + const statement: Statement = { - start: -1, - end: 0, + start: keywordToken.start, + end: argEnd > argStart ? argEnd - 1 : keywordToken.end, type: 'DELIMITER', executionType: 'NO_OP', parameters: [], @@ -829,90 +914,28 @@ function createDelimiterStatementParser(): StatementParser { columns: [], }; - let delimiterStart: number | undefined; - let lastMeaningfulEnd: number | undefined; - let lastMeaningfulValue: string | undefined; - let finalized = false; - - const captureDelimiter = () => { - if (finalized || delimiterStart === undefined || lastMeaningfulEnd === undefined) { - return; + const error = validateDelimiterValue(raw); + if (error) { + if (isStrict) { + throw new Error(error); } - let raw = lastMeaningfulValue ?? ''; - // Strip matching surrounding quotes (e.g. DELIMITER "//", DELIMITER '//'). - if (raw.length >= 2 && (raw[0] === '"' || raw[0] === "'") && raw[raw.length - 1] === raw[0]) { - raw = raw.slice(1, -1); - } - if (raw.length > 0) { - statement.newDelimiter = raw; - } - statement.end = lastMeaningfulEnd; - finalized = true; - }; - - return { - getStatement() { - return statement; - }, - - flush() { - // Reached EOF without seeing a newline — capture whatever we have. - // We intentionally do not set `endStatement` here so that the parse() - // trailing-statement branch still pushes this statement to the body. - captureDelimiter(); - }, - - addToken(token: Token) { - if (statement.endStatement) { - return; - } - - if (statement.start < 0) { - // first token is the DELIMITER keyword - if (token.type === 'keyword' && token.value.toUpperCase() === 'DELIMITER') { - statement.start = token.start; - } - return; - } - - const endStatement = () => { - captureDelimiter(); - // Truthy sentinel that tells parse() to flush this statement. - statement.endStatement = '\n'; - if (lastMeaningfulEnd === undefined) { - // DELIMITER keyword with no value; end the statement just before - // the terminating whitespace/comment token. - statement.end = token.start - 1; - } - }; - - if (token.type === 'whitespace') { - if (/[\r\n]/.test(token.value)) { - endStatement(); - } - return; - } - - if (token.type === 'comment-inline') { - // Inline comments consume through end-of-line; treat as line end. - endStatement(); - return; - } - - if (token.type === 'comment-block') { - // Block comments are allowed between DELIMITER and the value; skip. - return; - } + // Non-strict: emit the statement without `newDelimiter`. The main loop + // will then NOT update currentDelimiter, matching mysql-shell's + // behaviour of keeping the previous delimiter on a rejected argument. + } else { + statement.newDelimiter = raw; + } - if (delimiterStart === undefined) { - delimiterStart = token.start; - lastMeaningfulValue = token.value; - } else { - lastMeaningfulValue = (lastMeaningfulValue ?? '') + token.value; - } - lastMeaningfulEnd = token.end; - }, - }; + // Consume through end-of-line so we resume from the next line. + let consumedTo = argEnd; + while (consumedTo < input.length && input[consumedTo] !== '\n') consumedTo++; + // position should point at the last consumed char so that the main loop's + // `prevState.position + 1` start picks up the next character. + if (consumedTo < input.length) { + // include the newline itself + statement.endStatement = '\n'; + } + return { statement, consumedTo }; } function createUnknownStatementParser(options: ParseOptions) { @@ -1002,7 +1025,7 @@ function stateMachineStatementParser( if ( statement.type && - token.type === 'semicolon' && + token.type === 'delimiter' && (!statementsWithEnds.includes(statement.type) || (openBlocks === 0 && (statement.type === 'UNKNOWN' || statement.canEnd))) ) { diff --git a/src/table-parser.ts b/src/table-parser.ts index 0a5861b..bcac254 100644 --- a/src/table-parser.ts +++ b/src/table-parser.ts @@ -86,7 +86,7 @@ export class TableParser { const nextUpper = nextToken.value.toUpperCase(); if ( this.NON_ALIAS_KEYWORDS.has(nextUpper) || - nextToken.type === 'semicolon' || + nextToken.type === 'delimiter' || nextToken.value === ',' || nextToken.value === '(' || nextToken.value === ')' diff --git a/src/tokenizer.ts b/src/tokenizer.ts index 55b4c19..e95a182 100644 --- a/src/tokenizer.ts +++ b/src/tokenizer.ts @@ -75,8 +75,8 @@ const KEYWORDS = [ 'DELIMITER', ]; -// The semicolon token is now emitted by the delimiter-match path in -// scanToken, so it can handle arbitrary terminators like '$$' or '//'. +// Delimiter-typed tokens (including `;`) are emitted by the delimiter-match +// path in scanToken, so it can handle arbitrary terminators like '$$' or '//'. const INDIVIDUALS: Record = {}; const ENDTOKENS: Record = { @@ -156,7 +156,7 @@ function scanDelimiter(state: State, delimiter: string): Token | null { read(state); } return { - type: 'semicolon', + type: 'delimiter', value: delimiter, start: state.start, end: state.start + delimiter.length - 1, diff --git a/test/identifier/multiple-statement.spec.ts b/test/identifier/multiple-statement.spec.ts index 8058388..65d2606 100644 --- a/test/identifier/multiple-statement.spec.ts +++ b/test/identifier/multiple-statement.spec.ts @@ -70,10 +70,10 @@ describe('identifier', () => { expect(actual[2]).to.include({ type: 'DELIMITER', text: 'DELIMITER ;' }); }); - it('should strip matching surrounding quotes from the delimiter value', () => { - const actual = identify('DELIMITER "//"\nSELECT 1//', { dialect: 'mysql' }); - expect(actual.map((stmt) => stmt.type)).to.eql(['DELIMITER', 'SELECT']); - expect(actual[1].text).to.eql('SELECT 1//'); + it('should reject a delimiter containing quote characters', () => { + expect(() => identify('DELIMITER "//"\nSELECT 1//', { dialect: 'mysql' })).to.throw( + 'DELIMITER cannot contain quote characters', + ); }); }); diff --git a/test/parser/multiple-statements.spec.ts b/test/parser/multiple-statements.spec.ts index 530dc0a..4a68fe1 100644 --- a/test/parser/multiple-statements.spec.ts +++ b/test/parser/multiple-statements.spec.ts @@ -52,7 +52,7 @@ describe('parser', () => { }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 55, end: 55, @@ -150,13 +150,21 @@ describe('parser', () => { }); }); - it('should strip matching surrounding quotes from the delimiter value', () => { - const input = 'DELIMITER "//"\nSELECT 1//'; - const actual = parse(input, true, 'mysql'); + it('should reject a delimiter containing quote characters in strict mode', () => { + expect(() => parse('DELIMITER "//"\nSELECT 1//', true, 'mysql')).to.throw( + 'DELIMITER cannot contain quote characters', + ); + }); - expect(actual.body).to.have.lengthOf(2); - expect(actual.body[0]).to.include({ type: 'DELIMITER', newDelimiter: '//' }); - expect(actual.body[1]).to.include({ type: 'SELECT', endStatement: '//' }); + it('should keep the previous delimiter when a DELIMITER is rejected in non-strict mode', () => { + // "//" is rejected because of the quote characters; currentDelimiter + // stays as `;` so the following `SELECT 1;` still terminates correctly. + const actual = parse('DELIMITER "//"\nSELECT 1;\nSELECT 2;', false, 'mysql'); + expect(actual.body).to.have.lengthOf(3); + expect(actual.body[0]).to.include({ type: 'DELIMITER' }); + expect(actual.body[0]).to.not.have.property('newDelimiter'); + expect(actual.body[1]).to.include({ type: 'SELECT', endStatement: ';' }); + expect(actual.body[2]).to.include({ type: 'SELECT', endStatement: ';' }); }); it('should accept lowercase delimiter keyword', () => { @@ -198,6 +206,61 @@ describe('parser', () => { const actual = parse('DELIMITER $$\nSELECT 1$$', false, 'generic'); expect(actual.body[0].type).to.eql('UNKNOWN'); }); + + describe('validation (strict mode rejections)', () => { + // These mirror the characters that would wreck subsequent tokenization + // if accepted as a delimiter. mysql-shell only explicitly rejects + // empty and backslash; we're stricter because the other values + // silently break our tokenizer. + const invalidDelimiterCases: Array<[string, string, string]> = [ + ['empty argument (nothing after DELIMITER keyword)', 'DELIMITER\n', 'must be followed'], + ['only whitespace after DELIMITER', 'DELIMITER \n', 'must be followed'], + ['backslash', 'DELIMITER \\end\n', 'backslash'], + ['single quote', "DELIMITER '\nSELECT 1", 'quote characters'], + ['double quote', 'DELIMITER "\nSELECT 1', 'quote characters'], + ['quoted //', 'DELIMITER "//"\nSELECT 1', 'quote characters'], + ['backtick', 'DELIMITER `x\nSELECT 1', 'quote characters'], + ['inline comment --', 'DELIMITER --\n', 'comment markers'], + ['hash comment #', 'DELIMITER #\n', 'comment markers'], + ['block comment start /*', 'DELIMITER /*\n', 'block-comment characters'], + ['block comment end */', 'DELIMITER */\n', 'block-comment characters'], + ['bare slash', 'DELIMITER /\n', 'block-comment characters'], + ['bare asterisk', 'DELIMITER *\n', 'block-comment characters'], + ]; + + invalidDelimiterCases.forEach(([name, sql, expected]) => { + it(`rejects ${name} in strict mode`, () => { + expect(() => parse(sql, true, 'mysql')).to.throw(expected); + }); + }); + + it('rejects empty DELIMITER at EOF (no trailing newline)', () => { + expect(() => parse('DELIMITER', true, 'mysql')).to.throw('must be followed'); + }); + }); + + describe('non-strict rejection behaviour', () => { + it('keeps the previous delimiter and emits a DELIMITER statement without newDelimiter', () => { + const actual = parse("DELIMITER '\nSELECT 1;", false, 'mysql'); + expect(actual.body[0]).to.include({ type: 'DELIMITER' }); + expect(actual.body[0]).to.not.have.property('newDelimiter'); + // currentDelimiter stayed as `;`, so the following statement + // terminates normally on `;`. + const selectStmt = actual.body.find((stmt) => stmt.type === 'SELECT'); + expect(selectStmt).to.not.be.undefined; + expect(selectStmt).to.include({ endStatement: ';' }); + }); + + it('does not swallow the rest of the script when the argument starts with a quote', () => { + // Regression: without validation, `DELIMITER '` made scanString eat + // the rest of the input as one big string token, hiding all other + // statements. + const actual = parse("DELIMITER '\nSELECT 1;\nSELECT 2;", false, 'mysql'); + const types = actual.body.map((stmt) => stmt.type); + expect(types).to.include('SELECT'); + expect(actual.body.filter((stmt) => stmt.type === 'SELECT')).to.have.lengthOf(2); + }); + }); }); it('should identify a query with different statements in multiple lines', () => { @@ -254,7 +317,7 @@ describe('parser', () => { end: 63, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 64, end: 64, diff --git a/test/parser/single-statements.spec.ts b/test/parser/single-statements.spec.ts index 37a4208..847b3a8 100644 --- a/test/parser/single-statements.spec.ts +++ b/test/parser/single-statements.spec.ts @@ -186,7 +186,7 @@ describe('parser', () => { end: 53, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 54, end: 54, @@ -252,7 +252,7 @@ describe('parser', () => { end: 6 + type.length + 1 + 5 + 42, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 6 + type.length + 1 + 5 + 42 + 1, end: 6 + type.length + 1 + 5 + 42 + 1, @@ -318,7 +318,7 @@ describe('parser', () => { end: 61, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 62, end: 62, @@ -382,7 +382,7 @@ describe('parser', () => { end: 22, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 23, end: 23, @@ -440,7 +440,7 @@ describe('parser', () => { end: 17, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 18, end: 18, @@ -504,7 +504,7 @@ describe('parser', () => { end: 20, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 21, end: 21, @@ -549,7 +549,7 @@ describe('parser', () => { end: 54, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 55, end: 55, @@ -595,7 +595,7 @@ describe('parser', () => { end: 50, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 51, end: 51, @@ -641,7 +641,7 @@ describe('parser', () => { end: 37, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 38, end: 38, @@ -699,7 +699,7 @@ describe('parser', () => { end: 21, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 22, end: 22, diff --git a/test/tokenizer/index.spec.ts b/test/tokenizer/index.spec.ts index c5229a7..b1c61b5 100644 --- a/test/tokenizer/index.spec.ts +++ b/test/tokenizer/index.spec.ts @@ -240,7 +240,7 @@ describe('scan', () => { it('scans ; individual identifier', () => { const actual = scanToken(initState(';')); const expected = { - type: 'semicolon', + type: 'delimiter', value: ';', start: 0, end: 0, @@ -287,29 +287,29 @@ describe('scan', () => { }); describe('custom delimiter', () => { - it('defaults to ; as semicolon token', () => { + it('defaults to ; as a delimiter token', () => { const actual = scanToken(initState(';')); - expect(actual).to.eql({ type: 'semicolon', value: ';', start: 0, end: 0 }); + expect(actual).to.eql({ type: 'delimiter', value: ';', start: 0, end: 0 }); }); - it('emits semicolon token for single-char custom delimiter', () => { + it('emits a delimiter token for a single-char custom delimiter', () => { const actual = scanToken(initState('$'), 'mysql', undefined, '$'); - expect(actual).to.eql({ type: 'semicolon', value: '$', start: 0, end: 0 }); + expect(actual).to.eql({ type: 'delimiter', value: '$', start: 0, end: 0 }); }); - it('emits semicolon token for multi-char custom delimiter', () => { + it('emits a delimiter token for a multi-char custom delimiter', () => { const actual = scanToken(initState('$$rest'), 'mysql', undefined, '$$'); - expect(actual).to.eql({ type: 'semicolon', value: '$$', start: 0, end: 1 }); + expect(actual).to.eql({ type: 'delimiter', value: '$$', start: 0, end: 1 }); }); - it('does not treat ; as semicolon when delimiter is different', () => { + it('does not treat ; as a delimiter when the custom delimiter is different', () => { const actual = scanToken(initState(';'), 'mysql', undefined, '$$'); - expect(actual.type).to.not.eql('semicolon'); + expect(actual.type).to.not.eql('delimiter'); }); it('handles // as delimiter', () => { const actual = scanToken(initState('//rest'), 'mysql', undefined, '//'); - expect(actual).to.eql({ type: 'semicolon', value: '//', start: 0, end: 1 }); + expect(actual).to.eql({ type: 'delimiter', value: '//', start: 0, end: 1 }); }); }); From f50a6c3e0a8be8855a074afccad4250fd08dd174 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 14:52:37 +0000 Subject: [PATCH 4/4] =?UTF-8?q?Address=20review:=20remove=20dead=20INDIVID?= =?UTF-8?q?UALS=20code;=20rename=20endStatement=20=E2=86=92=20delimiter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses @MasterOdin's feedback: - Remove the now-empty `INDIVIDUALS` map in src/tokenizer.ts along with `scanIndividualCharacter` and `resolveIndividualTokenType`. Since the delimiter-match path is the only producer of `'delimiter'` tokens, the individual-character code path was dead. Easy to re-add if a new single-char token type comes up. - Rename the public/internal field `endStatement` to `delimiter` on both `IdentifyResult` and the internal `Statement` interface. The shorter name matches the token type and the domain vocabulary. All tests and README examples updated accordingly. --- README.md | 12 ++-- src/defines.ts | 4 +- src/index.ts | 6 +- src/parser.ts | 12 ++-- src/tokenizer.ts | 34 +----------- test/identifier/inner-statements.spec.ts | 4 +- test/identifier/multiple-statement.spec.ts | 56 +++++++++---------- test/identifier/single-statement.spec.ts | 64 +++++++++++----------- test/index.spec.ts | 26 ++++----- test/parser/multiple-statements.spec.ts | 26 ++++----- test/parser/single-statements.spec.ts | 20 +++---- 11 files changed, 118 insertions(+), 146 deletions(-) diff --git a/README.md b/README.md index 5fed84b..2f8b84f 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ Each returned statement has: * `start`, `end`, `text`: position and raw text (including the terminator). * `type`, `executionType`. * `parameters`, `tables`, `columns`. -* `endStatement` (optional): the terminator string that ended this statement (e.g. `;`, `$`, `$$`). Absent if the statement ran to EOF without a terminator, or for `DELIMITER` statements (terminated by end-of-line). +* `delimiter` (optional): the terminator string that ended this statement (e.g. `;`, `$`, `$$`). Absent if the statement ran to EOF without a terminator, or for `DELIMITER` statements (terminated by end-of-line). * `newDelimiter` (optional, only on `DELIMITER` statements): the new terminator string that should be used for the statements that follow. ## Working with MySQL `DELIMITER` @@ -190,19 +190,19 @@ SELECT 3;`, ```js [ { type: 'DELIMITER', executionType: 'NO_OP', text: 'DELIMITER $$', newDelimiter: '$$', /* ... */ }, - { type: 'CREATE_PROCEDURE', executionType: 'MODIFICATION', text: 'CREATE PROCEDURE foo()\nBEGIN\n SELECT 1;\n SELECT 2;\nEND$$', endStatement: '$$', /* ... */ }, + { type: 'CREATE_PROCEDURE', executionType: 'MODIFICATION', text: 'CREATE PROCEDURE foo()\nBEGIN\n SELECT 1;\n SELECT 2;\nEND$$', delimiter: '$$', /* ... */ }, { type: 'DELIMITER', executionType: 'NO_OP', text: 'DELIMITER ;', newDelimiter: ';', /* ... */ }, - { type: 'SELECT', executionType: 'LISTING', text: 'SELECT 3;', endStatement: ';', /* ... */ }, + { type: 'SELECT', executionType: 'LISTING', text: 'SELECT 3;', delimiter: ';', /* ... */ }, ] ``` -Because `DELIMITER` is a client-side directive (the server never sees it), its `executionType` is `NO_OP`. To execute the identified statements against a MySQL server, skip any with `type === 'DELIMITER'` and strip the `endStatement` from each remaining statement's `text` before sending it: +Because `DELIMITER` is a client-side directive (the server never sees it), its `executionType` is `NO_OP`. To execute the identified statements against a MySQL server, skip any with `type === 'DELIMITER'` and strip the `delimiter` from each remaining statement's `text` before sending it: ```js for (const stmt of statements) { if (stmt.type === 'DELIMITER') continue; // client-side only - const sql = stmt.endStatement - ? stmt.text.slice(0, -stmt.endStatement.length) + const sql = stmt.delimiter + ? stmt.text.slice(0, -stmt.delimiter.length) : stmt.text; await connection.query(sql); } diff --git a/src/defines.ts b/src/defines.ts index e476f1a..ae45440 100644 --- a/src/defines.ts +++ b/src/defines.ts @@ -134,7 +134,7 @@ export interface IdentifyResult { * `DELIMITER` statements (which are terminated by end-of-line, not a * delimiter). */ - endStatement?: string; + delimiter?: string; /** * Only set for statements of type `DELIMITER`. The new terminator string * that should be used for statements that follow. @@ -147,7 +147,7 @@ export interface Statement { end: number; type?: StatementType; executionType?: ExecutionType; - endStatement?: string; + delimiter?: string; canEnd?: boolean; definer?: number; algorithm?: number; diff --git a/src/index.ts b/src/index.ts index a495f66..d6b8d1a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,10 +46,10 @@ export function identify(query: string, options: IdentifyOptions = {}): Identify tables: statement.tables || [], columns: statement.columns || [], }; - // DELIMITER's internal `endStatement` is a `\n` sentinel, not a real + // DELIMITER's internal `delimiter` is a `\n` sentinel, not a real // terminator; don't expose it to consumers. - if (statement.type !== 'DELIMITER' && statement.endStatement) { - result.endStatement = statement.endStatement; + if (statement.type !== 'DELIMITER' && statement.delimiter) { + result.delimiter = statement.delimiter; } if (statement.newDelimiter) { result.newDelimiter = statement.newDelimiter; diff --git a/src/parser.ts b/src/parser.ts index 6cd64c4..9086593 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -213,7 +213,7 @@ export function parse( end: token.end, type: 'UNKNOWN', executionType: 'UNKNOWN', - endStatement: token.value, + delimiter: token.value, parameters: [], tables: [], columns: [], @@ -307,7 +307,7 @@ export function parse( prevState = tokenState; const statement = statementParser.getStatement(); - if (statement.endStatement) { + if (statement.delimiter) { statementParser.flush(); if (statement.type !== 'DELIMITER') { // DELIMITER sets its own `end` to the last delimiter-value char @@ -327,7 +327,7 @@ export function parse( if (statementParser) { statementParser.flush(); const statement = statementParser.getStatement(); - if (!statement.endStatement) { + if (!statement.delimiter) { if (statement.type !== 'DELIMITER' || !statement.end) { // DELIMITER parsers set `end` themselves to the last char of the // delimiter value; don't overwrite with trailing-whitespace EOF. @@ -933,7 +933,7 @@ function parseDelimiterLine( // `prevState.position + 1` start picks up the next character. if (consumedTo < input.length) { // include the newline itself - statement.endStatement = '\n'; + statement.delimiter = '\n'; } return { statement, consumedTo }; } @@ -1019,7 +1019,7 @@ function stateMachineStatementParser( addToken(token: Token, nextToken: Token) { /* eslint no-param-reassign: 0 */ - if (statement.endStatement) { + if (statement.delimiter) { throw new Error('This statement has already got to the end.'); } @@ -1029,7 +1029,7 @@ function stateMachineStatementParser( (!statementsWithEnds.includes(statement.type) || (openBlocks === 0 && (statement.type === 'UNKNOWN' || statement.canEnd))) ) { - statement.endStatement = token.value; + statement.delimiter = token.value; return; } diff --git a/src/tokenizer.ts b/src/tokenizer.ts index e95a182..16e3b4e 100644 --- a/src/tokenizer.ts +++ b/src/tokenizer.ts @@ -75,10 +75,6 @@ const KEYWORDS = [ 'DELIMITER', ]; -// Delimiter-typed tokens (including `;`) are emitted by the delimiter-match -// path in scanToken, so it can handle arbitrary terminators like '$$' or '//'. -const INDIVIDUALS: Record = {}; - const ENDTOKENS: Record = { '"': '"', "'": "'", @@ -125,9 +121,9 @@ export function scanToken( } // Match the current statement terminator. Handles ';', '$', '$$', '//', etc. - // Runs before scanIndividualCharacter so it's the single source of - // terminator tokens. Word-like delimiters would be consumed by scanWord - // above, so only symbol delimiters are fully supported. + // The delimiter match is the single source of terminator tokens. + // Word-like delimiters are consumed by scanWord below, so only symbol + // delimiters are fully supported. const delimiterToken = scanDelimiter(state, delimiter); if (delimiterToken) { return delimiterToken; @@ -137,11 +133,6 @@ export function scanToken( return scanWord(state); } - const individual = scanIndividualCharacter(state); - if (individual) { - return individual; - } - return skipChar(state); } @@ -199,10 +190,6 @@ function isKeyword(word: string): boolean { return KEYWORDS.includes(word.toUpperCase()); } -function resolveIndividualTokenType(ch: string): Token['type'] | undefined { - return INDIVIDUALS[ch]; -} - function scanWhitespace(state: State): Token { let nextChar: string | null; @@ -459,21 +446,6 @@ function scanWord(state: State): Token { }; } -function scanIndividualCharacter(state: State): Token | null { - const value = state.input.slice(state.start, state.position + 1); - const type = resolveIndividualTokenType(value); - if (!type) { - return null; - } - - return { - type, - value, - start: state.start, - end: state.start + value.length - 1, - }; -} - function skipChar(state: State): Token { return { type: 'unknown', diff --git a/test/identifier/inner-statements.spec.ts b/test/identifier/inner-statements.spec.ts index 30954ef..9d1edc5 100644 --- a/test/identifier/inner-statements.spec.ts +++ b/test/identifier/inner-statements.spec.ts @@ -60,7 +60,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -84,7 +84,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; diff --git a/test/identifier/multiple-statement.spec.ts b/test/identifier/multiple-statement.spec.ts index 65d2606..ab2d11b 100644 --- a/test/identifier/multiple-statement.spec.ts +++ b/test/identifier/multiple-statement.spec.ts @@ -18,7 +18,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, { start: 12, @@ -40,7 +40,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: '$', + delimiter: '$', }, { start: 36, @@ -51,7 +51,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: '$', + delimiter: '$', }, ]); }); @@ -92,7 +92,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, { end: 76, @@ -125,7 +125,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, { start: 74, @@ -136,7 +136,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -162,7 +162,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, { start: 35, @@ -173,7 +173,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -198,7 +198,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, { start: 20, @@ -209,7 +209,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, { start: 50, @@ -220,7 +220,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -266,7 +266,7 @@ describe('identifier', () => { text: 'DECLARE\n PK_NAME VARCHAR(200);\n\n BEGIN\n EXECUTE IMMEDIATE (\'CREATE SEQUENCE "untitled_table8_seq"\');\n\n SELECT\n cols.column_name INTO PK_NAME\n FROM\n all_constraints cons,\n all_cons_columns cols\n WHERE\n cons.constraint_type = \'P\'\n AND cons.constraint_name = cols.constraint_name\n AND cons.owner = cols.owner\n AND cols.table_name = \'untitled_table8\';\n\n execute immediate (\n \'create or replace trigger "untitled_table8_autoinc_trg" BEFORE INSERT on "untitled_table8" for each row declare checking number := 1; begin if (:new."\' || PK_NAME || \'" is null) then while checking >= 1 loop select "untitled_table8_seq".nextval into :new."\' || PK_NAME || \'" from dual; select count("\' || PK_NAME || \'") into checking from "untitled_table8" where "\' || PK_NAME || \'" = :new."\' || PK_NAME || \'"; end loop; end if; end;\'\n );\n\n END;', type: 'ANON_BLOCK', columns: [], - endStatement: ';', + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -316,7 +316,7 @@ describe('identifier', () => { text: 'create table\n "untitled_table8" (\n "id" integer not null primary key,\n "created_at" varchar(255) not null\n );', type: 'CREATE_TABLE', columns: [], - endStatement: ';', + delimiter: ';', }, { end: 1212, @@ -327,7 +327,7 @@ describe('identifier', () => { text: 'DECLARE\n PK_NAME VARCHAR(200);\n\n BEGIN\n EXECUTE IMMEDIATE (\'CREATE SEQUENCE "untitled_table8_seq"\');\n\n SELECT\n cols.column_name INTO PK_NAME\n FROM\n all_constraints cons,\n all_cons_columns cols\n WHERE\n cons.constraint_type = \'P\'\n AND cons.constraint_name = cols.constraint_name\n AND cons.owner = cols.owner\n AND cols.table_name = \'untitled_table8\';\n\n execute immediate (\n \'create or replace trigger "untitled_table8_autoinc_trg" BEFORE INSERT on "untitled_table8" for each row declare checking number := 1; begin if (:new."\' || PK_NAME || \'" is null) then while checking >= 1 loop select "untitled_table8_seq".nextval into :new."\' || PK_NAME || \'" from dual; select count("\' || PK_NAME || \'") into checking from "untitled_table8" where "\' || PK_NAME || \'" = :new."\' || PK_NAME || \'"; end loop; end if; end;\'\n );\n\n END;', type: 'ANON_BLOCK', columns: [], - endStatement: ';', + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -359,7 +359,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, { start: 79, @@ -370,7 +370,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, { start: 250, @@ -381,7 +381,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -406,7 +406,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, { start: 54, @@ -417,7 +417,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -443,7 +443,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, { start: 6, @@ -454,7 +454,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -479,7 +479,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, { start: 24, @@ -513,7 +513,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, { start: 19, @@ -524,7 +524,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, { start: 29, @@ -535,7 +535,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -557,7 +557,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, { start: 19 + offset, @@ -568,7 +568,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, { start: 29 + offset, @@ -579,7 +579,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; expect(actual).to.eql(expected); diff --git a/test/identifier/single-statement.spec.ts b/test/identifier/single-statement.spec.ts index 83158cd..14089d0 100644 --- a/test/identifier/single-statement.spec.ts +++ b/test/identifier/single-statement.spec.ts @@ -91,7 +91,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -118,7 +118,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -138,7 +138,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -160,7 +160,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -192,7 +192,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -231,7 +231,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -265,7 +265,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -289,7 +289,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -315,7 +315,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -343,7 +343,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -380,7 +380,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -630,7 +630,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -833,7 +833,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -937,7 +937,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -964,7 +964,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -983,7 +983,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -1002,7 +1002,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -1021,7 +1021,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -1040,7 +1040,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -1059,7 +1059,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -1077,7 +1077,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -1096,7 +1096,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -1115,7 +1115,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -1136,7 +1136,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -1154,7 +1154,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -1361,7 +1361,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -1386,7 +1386,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -1410,7 +1410,7 @@ describe('identifier', () => { parameters: [], tables: [], // FIXME: should return 'table'? columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -1481,7 +1481,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -1609,7 +1609,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -1636,7 +1636,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; @@ -1656,7 +1656,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]; diff --git a/test/index.spec.ts b/test/index.spec.ts index d0efb8b..05c82a6 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -421,7 +421,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]); }); @@ -482,7 +482,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]); }); @@ -504,7 +504,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]); @@ -518,7 +518,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]); }); @@ -534,7 +534,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]); @@ -548,7 +548,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]); @@ -562,7 +562,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]); @@ -576,7 +576,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]); @@ -592,7 +592,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]); }); @@ -608,7 +608,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]); @@ -622,7 +622,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]); @@ -636,7 +636,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]); @@ -652,7 +652,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], - endStatement: ';', + delimiter: ';', }, ]); }); diff --git a/test/parser/multiple-statements.spec.ts b/test/parser/multiple-statements.spec.ts index 4a68fe1..860df5b 100644 --- a/test/parser/multiple-statements.spec.ts +++ b/test/parser/multiple-statements.spec.ts @@ -22,7 +22,7 @@ describe('parser', () => { end: 55, type: 'INSERT', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -87,20 +87,20 @@ describe('parser', () => { executionType: 'NO_OP', start: 0, end: 10, - endStatement: '\n', + delimiter: '\n', newDelimiter: '$', }); expect(actual.body[1]).to.include({ type: 'SELECT', start: 12, end: 20, - endStatement: '$', + delimiter: '$', }); expect(actual.body[2]).to.include({ type: 'SELECT', start: 22, end: 30, - endStatement: '$', + delimiter: '$', }); }); @@ -110,8 +110,8 @@ describe('parser', () => { expect(actual.body).to.have.lengthOf(3); expect(actual.body[0]).to.include({ type: 'DELIMITER', newDelimiter: '$$' }); - expect(actual.body[1]).to.include({ type: 'SELECT', endStatement: '$$' }); - expect(actual.body[2]).to.include({ type: 'SELECT', endStatement: '$$' }); + expect(actual.body[1]).to.include({ type: 'SELECT', delimiter: '$$' }); + expect(actual.body[2]).to.include({ type: 'SELECT', delimiter: '$$' }); }); it('should not treat literal ; as a terminator while delimiter is $$', () => { @@ -122,7 +122,7 @@ describe('parser', () => { expect(actual.body[0]).to.include({ type: 'DELIMITER', newDelimiter: '$$' }); expect(actual.body[1]).to.include({ type: 'CREATE_PROCEDURE', - endStatement: '$$', + delimiter: '$$', }); }); @@ -132,9 +132,9 @@ describe('parser', () => { expect(actual.body).to.have.lengthOf(4); expect(actual.body[0]).to.include({ type: 'DELIMITER', newDelimiter: '$$' }); - expect(actual.body[1]).to.include({ type: 'SELECT', endStatement: '$$' }); + expect(actual.body[1]).to.include({ type: 'SELECT', delimiter: '$$' }); expect(actual.body[2]).to.include({ type: 'DELIMITER', newDelimiter: ';' }); - expect(actual.body[3]).to.include({ type: 'SELECT', endStatement: ';' }); + expect(actual.body[3]).to.include({ type: 'SELECT', delimiter: ';' }); }); it('should finalize DELIMITER statement at EOF without a trailing newline', () => { @@ -163,8 +163,8 @@ describe('parser', () => { expect(actual.body).to.have.lengthOf(3); expect(actual.body[0]).to.include({ type: 'DELIMITER' }); expect(actual.body[0]).to.not.have.property('newDelimiter'); - expect(actual.body[1]).to.include({ type: 'SELECT', endStatement: ';' }); - expect(actual.body[2]).to.include({ type: 'SELECT', endStatement: ';' }); + expect(actual.body[1]).to.include({ type: 'SELECT', delimiter: ';' }); + expect(actual.body[2]).to.include({ type: 'SELECT', delimiter: ';' }); }); it('should accept lowercase delimiter keyword', () => { @@ -248,7 +248,7 @@ describe('parser', () => { // terminates normally on `;`. const selectStmt = actual.body.find((stmt) => stmt.type === 'SELECT'); expect(selectStmt).to.not.be.undefined; - expect(selectStmt).to.include({ endStatement: ';' }); + expect(selectStmt).to.include({ delimiter: ';' }); }); it('does not swallow the rest of the script when the argument starts with a quote', () => { @@ -282,7 +282,7 @@ describe('parser', () => { end: 64, type: 'INSERT', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], diff --git a/test/parser/single-statements.spec.ts b/test/parser/single-statements.spec.ts index 847b3a8..fe83855 100644 --- a/test/parser/single-statements.spec.ts +++ b/test/parser/single-statements.spec.ts @@ -154,7 +154,7 @@ describe('parser', () => { end: 54, type: 'CREATE_TABLE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -220,7 +220,7 @@ describe('parser', () => { end: 54 + type.length + 1, type: 'CREATE_TABLE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -286,7 +286,7 @@ describe('parser', () => { end: 62, type: 'CREATE_TABLE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -344,7 +344,7 @@ describe('parser', () => { end: 23, type: 'CREATE_DATABASE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -408,7 +408,7 @@ describe('parser', () => { end: 18, type: 'DROP_TABLE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -466,7 +466,7 @@ describe('parser', () => { end: 21, type: 'DROP_DATABASE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -529,7 +529,7 @@ describe('parser', () => { end: 55, type: 'INSERT', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -575,7 +575,7 @@ describe('parser', () => { end: 51, type: 'UPDATE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -621,7 +621,7 @@ describe('parser', () => { end: 38, type: 'DELETE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -667,7 +667,7 @@ describe('parser', () => { end: 22, type: 'TRUNCATE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [],