diff --git a/README.md b/README.md index d8e7bd5..032a538 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) @@ -103,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 @@ -170,6 +174,69 @@ identify(`SELECT * FROM "Orders" WHERE OrderID = ?`, { dialect: 'dynamodb' }); // [{ type: 'SELECT', executionType: 'LISTING', parameters: ['?'], ... }] ``` +Each returned statement has: + +* `start`, `end`, `text`: position and raw text (including the terminator). +* `type`, `executionType`. +* `parameters`, `tables`, `columns`. +* `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` + +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$$', delimiter: '$$', /* ... */ }, + { type: 'DELIMITER', executionType: 'NO_OP', text: 'DELIMITER ;', newDelimiter: ';', /* ... */ }, + { 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 `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.delimiter + ? stmt.text.slice(0, -stmt.delimiter.length) + : stmt.text; + await connection.query(sql); +} +``` + +### 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 c2d4ba0..0345154 100644 --- a/src/defines.ts +++ b/src/defines.ts @@ -88,6 +88,7 @@ export type StatementType = | 'COMMIT' | 'ROLLBACK' | 'ANON_BLOCK' + | 'DELIMITER' | 'MERGE' | 'CALL' | 'GRANT' @@ -108,6 +109,7 @@ export type ExecutionType = | 'INFORMATION' | 'ANON_BLOCK' | 'TRANSACTION' + | 'NO_OP' | 'UNKNOWN'; export interface ParamTypes { @@ -151,6 +153,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). + */ + delimiter?: string; + /** + * Only set for statements of type `DELIMITER`. The new terminator string + * that should be used for statements that follow. + */ + newDelimiter?: string; limit?: boolean; offset?: boolean; } @@ -160,7 +174,7 @@ export interface Statement { end: number; type?: StatementType; executionType?: ExecutionType; - endStatement?: string; + delimiter?: string; canEnd?: boolean; definer?: number; algorithm?: number; @@ -171,6 +185,7 @@ export interface Statement { limit?: boolean; offset?: boolean; isCte?: boolean; + newDelimiter?: string; } export interface ConcreteStatement extends Statement { @@ -191,7 +206,7 @@ export interface Token { | 'comment-inline' | 'comment-block' | 'string' - | 'semicolon' + | 'delimiter' | 'keyword' | 'parameter' | 'table' diff --git a/src/index.ts b/src/index.ts index e4288d8..656de20 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 `delimiter` is a `\n` sentinel, not a real + // terminator; don't expose it to consumers. + if (statement.type !== 'DELIMITER' && statement.delimiter) { + result.delimiter = statement.delimiter; + } + if (statement.newDelimiter) { + result.newDelimiter = statement.newDelimiter; + } if (statement.limit !== undefined) result.limit = statement.limit; if (statement.offset !== undefined) result.offset = statement.offset; return result; diff --git a/src/parser.ts b/src/parser.ts index 5bfd6b0..24fad27 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -118,6 +118,7 @@ export const EXECUTION_TYPES: Record = { SHOW_SEQUENCES: 'LISTING', UNKNOWN: 'UNKNOWN', ANON_BLOCK: 'ANON_BLOCK', + DELIMITER: 'NO_OP', }; // Statement types whose bodies may legitimately contain token-level semicolons @@ -173,11 +174,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; } @@ -204,6 +205,7 @@ export function parse( let prevState: State = topLevelState; let statementParser: StatementParser | null = null; + let currentDelimiter = ';'; const cteState: { isCte: boolean; asSeen: boolean; @@ -220,12 +222,12 @@ 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 }); - 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 @@ -244,7 +246,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({ @@ -252,6 +254,7 @@ export function parse( end: token.end, type: 'UNKNOWN', executionType: 'UNKNOWN', + delimiter: token.value, parameters: [], tables: [], columns: [], @@ -291,6 +294,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, @@ -318,10 +348,17 @@ export function parse( prevState = tokenState; const statement = statementParser.getStatement(); - if (statement.endStatement) { + if (statement.delimiter) { 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; } } @@ -331,9 +368,16 @@ export function parse( if (statementParser) { statementParser.flush(); const statement = statementParser.getStatement(); - if (!statement.endStatement) { - statement.end = topLevelStatement.end; + 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. + statement.end = topLevelStatement.end; + } topLevelStatement.body.push(statement as ConcreteStatement); + if (statement.type === 'DELIMITER' && statement.newDelimiter) { + currentDelimiter = statement.newDelimiter; + } } } @@ -438,6 +482,8 @@ function createStatementParserByToken( return createBlockStatementParser(options); } break; + // DELIMITER is intercepted inline in parse() for the mysql dialect, + // so we never reach a `case 'DELIMITER'` here for that dialect. case 'MERGE': return createMergeStatementParser(options); case 'CALL': @@ -990,6 +1036,102 @@ function createRollbackStatementParser(options: ParseOptions) { return stateMachineStatementParser(statement, steps, options); } +/** + * 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: keywordToken.start, + end: argEnd > argStart ? argEnd - 1 : keywordToken.end, + type: 'DELIMITER', + executionType: 'NO_OP', + parameters: [], + tables: [], + columns: [], + }; + + const error = validateDelimiterValue(raw); + if (error) { + if (isStrict) { + throw new Error(error); + } + // 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; + } + + // 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.delimiter = '\n'; + } + return { statement, consumedTo }; +} + function createUnknownStatementParser(options: ParseOptions) { const statement = createInitialStatement(); @@ -1075,17 +1217,17 @@ 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.'); } if ( statement.type && - token.type === 'semicolon' && + token.type === 'delimiter' && (!statementsWithEnds.includes(statement.type) || (openBlocks === 0 && (statement.type === 'UNKNOWN' || statement.canEnd))) ) { - statement.endStatement = ';'; + statement.delimiter = token.value; return; } diff --git a/src/table-parser.ts b/src/table-parser.ts index 05ebc24..d98d1f3 100644 --- a/src/table-parser.ts +++ b/src/table-parser.ts @@ -89,7 +89,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 d0265e6..dbc57c2 100644 --- a/src/tokenizer.ts +++ b/src/tokenizer.ts @@ -73,6 +73,7 @@ const KEYWORDS = [ 'TRIGGERS', 'VARIABLES', 'WARNINGS', + 'DELIMITER', 'MERGE', 'CALL', 'GRANT', @@ -111,10 +112,6 @@ const SNOWFLAKE_KEYWORDS = [ 'INTO', ]; -const INDIVIDUALS: Record = { - ';': 'semicolon', -}; - const ENDTOKENS: Record = { '"': '"', "'": "'", @@ -126,6 +123,7 @@ export function scanToken( state: State, dialect: Dialect = 'generic', paramTypes: ParamTypes = { positional: true }, + delimiter = ';', ): Token { const ch = read(state); @@ -149,7 +147,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); } @@ -157,18 +157,40 @@ export function scanToken( return scanQuotedIdentifier(state, ENDTOKENS[ch], dialect); } - if (isLetter(ch)) { - return scanWord(state, dialect); + // Match the current statement terminator. Handles ';', '$', '$$', '//', etc. + // 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; } - const individual = scanIndividualCharacter(state); - if (individual) { - return individual; + if (isLetter(ch)) { + return scanWord(state, dialect); } 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: 'delimiter', + 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; @@ -215,10 +237,6 @@ function isKeyword(word: string, dialect: Dialect): boolean { return KEYWORDS.includes(word.toUpperCase()) || dialectSpecific.includes(word.toUpperCase()); } -function resolveIndividualTokenType(ch: string): Token['type'] | undefined { - return INDIVIDUALS[ch]; -} - function scanWhitespace(state: State): Token { let nextChar: string | null; @@ -488,21 +506,6 @@ function scanWord(state: State, dialect: Dialect): 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 8fbcce0..9d1edc5 100644 --- a/test/identifier/inner-statements.spec.ts +++ b/test/identifier/inner-statements.spec.ts @@ -60,6 +60,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -83,6 +84,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; diff --git a/test/identifier/multiple-statement.spec.ts b/test/identifier/multiple-statement.spec.ts index 4d055bf..eb0b99b 100644 --- a/test/identifier/multiple-statement.spec.ts +++ b/test/identifier/multiple-statement.spec.ts @@ -3,6 +3,86 @@ 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: [], + delimiter: ';', + limit: false, + offset: false, + }, + { + start: 12, + end: 22, + text: 'DELIMITER $', + type: 'DELIMITER', + executionType: 'NO_OP', + parameters: [], + tables: [], + columns: [], + newDelimiter: '$', + }, + { + start: 25, + end: 33, + text: 'SELECT 2$', + type: 'SELECT', + executionType: 'LISTING', + parameters: [], + tables: [], + columns: [], + delimiter: '$', + limit: false, + offset: false, + }, + { + start: 36, + end: 44, + text: 'SELECT 3$', + type: 'SELECT', + executionType: 'LISTING', + parameters: [], + tables: [], + columns: [], + delimiter: '$', + limit: false, + offset: false, + }, + ]); + }); + + 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 reject a delimiter containing quote characters', () => { + expect(() => identify('DELIMITER "//"\nSELECT 1//', { dialect: 'mysql' })).to.throw( + 'DELIMITER cannot contain quote characters', + ); + }); + }); + describe('given queries with multiple statements', () => { it('should identify a query with different statements in a single line', () => { const actual = identify( @@ -18,6 +98,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { end: 76, @@ -52,6 +133,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { start: 74, @@ -62,6 +144,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', limit: false, offset: false, }, @@ -89,6 +172,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', limit: false, offset: false, }, @@ -101,6 +185,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', limit: false, offset: false, }, @@ -127,6 +212,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', limit: false, offset: false, }, @@ -139,6 +225,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', limit: false, offset: false, }, @@ -151,6 +238,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', limit: false, offset: false, }, @@ -198,6 +286,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: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -247,6 +336,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: [], + delimiter: ';', }, { end: 1212, @@ -257,6 +347,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: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -324,6 +415,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { start: 79, @@ -334,6 +426,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', limit: false, offset: false, }, @@ -346,6 +439,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', limit: false, offset: false, }, @@ -372,6 +466,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { start: 54, @@ -382,6 +477,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', limit: false, offset: false, }, @@ -409,6 +505,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { start: 6, @@ -419,6 +516,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', limit: false, offset: false, }, @@ -445,6 +543,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { start: 24, @@ -480,6 +579,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { start: 19, @@ -490,6 +590,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', limit: false, offset: false, }, @@ -502,6 +603,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -523,6 +625,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { start: 19 + offset, @@ -533,6 +636,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', limit: false, offset: false, }, @@ -545,6 +649,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); diff --git a/test/identifier/single-statement.spec.ts b/test/identifier/single-statement.spec.ts index c5b7f7e..620f430 100644 --- a/test/identifier/single-statement.spec.ts +++ b/test/identifier/single-statement.spec.ts @@ -99,6 +99,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -125,6 +126,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -144,6 +146,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -165,6 +168,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -196,6 +200,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -234,6 +239,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -267,6 +273,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -290,6 +297,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -315,6 +323,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -342,6 +351,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -378,6 +388,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -627,6 +638,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -676,6 +688,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -728,6 +741,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -754,6 +768,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -829,6 +844,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -932,6 +948,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -958,6 +975,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -976,6 +994,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -994,6 +1013,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -1012,6 +1032,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -1030,6 +1051,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -1048,6 +1070,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -1065,6 +1088,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -1083,6 +1107,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -1101,6 +1126,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -1121,6 +1147,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -1138,6 +1165,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -1356,6 +1384,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -1380,6 +1409,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', limit: false, offset: false, }, @@ -1405,6 +1435,7 @@ describe('identifier', () => { parameters: [], tables: [], // FIXME: should return 'table'? columns: [], + delimiter: ';', limit: false, offset: false, }, @@ -1479,6 +1510,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', limit: false, offset: false, }, @@ -1618,6 +1650,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -1644,6 +1677,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -1663,6 +1697,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; diff --git a/test/index.spec.ts b/test/index.spec.ts index 46969a7..b785038 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -351,6 +351,10 @@ describe('getExecutionType', () => { }); }); + 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'); @@ -429,6 +433,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); }); @@ -489,6 +494,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); }); @@ -510,6 +516,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); @@ -523,6 +530,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); }); @@ -538,6 +546,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); @@ -551,6 +560,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); @@ -564,6 +574,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); @@ -577,6 +588,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); @@ -592,6 +604,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); }); @@ -607,6 +620,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); @@ -620,6 +634,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); @@ -633,6 +648,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); @@ -648,6 +664,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); }); diff --git a/test/parser/multiple-statements.spec.ts b/test/parser/multiple-statements.spec.ts index a9b8ffd..307b88a 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: [], @@ -66,7 +66,7 @@ describe('parser', () => { }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 55, end: 55, @@ -102,6 +102,193 @@ 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: 'NO_OP', + start: 0, + end: 10, + delimiter: '\n', + newDelimiter: '$', + }); + expect(actual.body[1]).to.include({ + type: 'SELECT', + start: 12, + end: 20, + delimiter: '$', + }); + expect(actual.body[2]).to.include({ + type: 'SELECT', + start: 22, + end: 30, + delimiter: '$', + }); + }); + + 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', delimiter: '$$' }); + expect(actual.body[2]).to.include({ type: 'SELECT', delimiter: '$$' }); + }); + + 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', + delimiter: '$$', + }); + }); + + 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', delimiter: '$$' }); + expect(actual.body[2]).to.include({ type: 'DELIMITER', newDelimiter: ';' }); + expect(actual.body[3]).to.include({ type: 'SELECT', delimiter: ';' }); + }); + + 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 reject a delimiter containing quote characters in strict mode', () => { + expect(() => parse('DELIMITER "//"\nSELECT 1//', true, 'mysql')).to.throw( + 'DELIMITER cannot contain quote characters', + ); + }); + + 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', delimiter: ';' }); + expect(actual.body[2]).to.include({ type: 'SELECT', delimiter: ';' }); + }); + + 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'); + }); + + 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({ delimiter: ';' }); + }); + + 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', () => { const actual = parse(` INSERT INTO Persons (PersonID, Name) VALUES (1, 'Jack'); @@ -121,7 +308,7 @@ describe('parser', () => { end: 64, type: 'INSERT', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -170,7 +357,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 1a7dd6c..e577f84 100644 --- a/test/parser/single-statements.spec.ts +++ b/test/parser/single-statements.spec.ts @@ -186,7 +186,7 @@ describe('parser', () => { end: 54, type: 'CREATE_TABLE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -218,7 +218,7 @@ describe('parser', () => { end: 53, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 54, end: 54, @@ -252,7 +252,7 @@ describe('parser', () => { end: 54 + type.length + 1, type: 'CREATE_TABLE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -284,7 +284,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: 62, type: 'CREATE_TABLE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -350,7 +350,7 @@ describe('parser', () => { end: 61, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 62, end: 62, @@ -376,7 +376,7 @@ describe('parser', () => { end: 23, type: 'CREATE_DATABASE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -414,7 +414,7 @@ describe('parser', () => { end: 22, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 23, end: 23, @@ -440,7 +440,7 @@ describe('parser', () => { end: 18, type: 'DROP_TABLE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -472,7 +472,7 @@ describe('parser', () => { end: 17, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 18, end: 18, @@ -498,7 +498,7 @@ describe('parser', () => { end: 21, type: 'DROP_DATABASE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -536,7 +536,7 @@ describe('parser', () => { end: 20, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 21, end: 21, @@ -561,7 +561,7 @@ describe('parser', () => { end: 55, type: 'INSERT', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -593,7 +593,7 @@ describe('parser', () => { end: 54, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 55, end: 55, @@ -619,7 +619,7 @@ describe('parser', () => { end: 51, type: 'UPDATE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -639,7 +639,7 @@ describe('parser', () => { end: 50, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 51, end: 51, @@ -665,7 +665,7 @@ describe('parser', () => { end: 38, type: 'DELETE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -697,7 +697,7 @@ describe('parser', () => { end: 37, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 38, end: 38, @@ -723,7 +723,7 @@ describe('parser', () => { end: 22, type: 'TRUNCATE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -755,7 +755,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 74762fa..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, @@ -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 a delimiter token', () => { + const actual = scanToken(initState(';')); + expect(actual).to.eql({ type: 'delimiter', value: ';', start: 0, end: 0 }); + }); + + it('emits a delimiter token for a single-char custom delimiter', () => { + const actual = scanToken(initState('$'), 'mysql', undefined, '$'); + expect(actual).to.eql({ type: 'delimiter', value: '$', start: 0, end: 0 }); + }); + + it('emits a delimiter token for a multi-char custom delimiter', () => { + const actual = scanToken(initState('$$rest'), 'mysql', undefined, '$$'); + expect(actual).to.eql({ type: 'delimiter', value: '$$', start: 0, end: 1 }); + }); + + 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('delimiter'); + }); + + it('handles // as delimiter', () => { + const actual = scanToken(initState('//rest'), 'mysql', undefined, '//'); + expect(actual).to.eql({ type: 'delimiter', value: '//', start: 0, end: 1 }); + }); + }); + describe('tokenizing parameters', () => { describe('tokenizing just parameter starting character', () => { [