diff --git a/README.md b/README.md index d1bc4dd..2f8b84f 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 @@ -153,6 +157,69 @@ 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`. +* `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 5fc2f15..ae45440 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 = @@ -83,6 +84,7 @@ export type ExecutionType = | 'INFORMATION' | 'ANON_BLOCK' | 'TRANSACTION' + | 'NO_OP' | 'UNKNOWN'; export interface ParamTypes { @@ -126,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). + */ + delimiter?: 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 { @@ -133,7 +147,7 @@ export interface Statement { end: number; type?: StatementType; executionType?: ExecutionType; - endStatement?: string; + delimiter?: string; canEnd?: boolean; definer?: number; algorithm?: number; @@ -142,6 +156,7 @@ export interface Statement { tables: TableReference[]; columns: ColumnReference[]; isCte?: boolean; + newDelimiter?: string; } export interface ConcreteStatement extends Statement { @@ -162,7 +177,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 f5c10f3..d6b8d1a 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; + } return result; }); } diff --git a/src/parser.ts b/src/parser.ts index c180c78..9086593 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: 'NO_OP', }; 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; @@ -179,12 +181,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 @@ -203,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({ @@ -211,6 +213,7 @@ export function parse( end: token.end, type: 'UNKNOWN', executionType: 'UNKNOWN', + delimiter: token.value, parameters: [], tables: [], columns: [], @@ -250,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, @@ -277,10 +307,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; } } @@ -290,9 +327,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; + } } } @@ -366,6 +410,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. default: break; } @@ -796,6 +842,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(); @@ -877,17 +1019,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 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 e558749..16e3b4e 100644 --- a/src/tokenizer.ts +++ b/src/tokenizer.ts @@ -72,12 +72,9 @@ const KEYWORDS = [ 'TRIGGERS', 'VARIABLES', 'WARNINGS', + 'DELIMITER', ]; -const INDIVIDUALS: Record = { - ';': 'semicolon', -}; - const ENDTOKENS: Record = { '"': '"', "'": "'", @@ -89,6 +86,7 @@ export function scanToken( state: State, dialect: Dialect = 'generic', paramTypes: ParamTypes = { positional: true }, + delimiter = ';', ): Token { const ch = read(state); @@ -112,7 +110,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,18 +120,40 @@ export function scanToken( return scanQuotedIdentifier(state, ENDTOKENS[ch]); } - if (isLetter(ch)) { - return scanWord(state); + // 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); } 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; @@ -168,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; @@ -428,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 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 76fa52f..ab2d11b 100644 --- a/test/identifier/multiple-statement.spec.ts +++ b/test/identifier/multiple-statement.spec.ts @@ -3,6 +3,80 @@ 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: ';', + }, + { + 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: '$', + }, + { + start: 36, + end: 44, + text: 'SELECT 3$', + type: 'SELECT', + executionType: 'LISTING', + parameters: [], + tables: [], + columns: [], + delimiter: '$', + }, + ]); + }); + + 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 +92,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { end: 76, @@ -50,6 +125,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { start: 74, @@ -60,6 +136,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -85,6 +162,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { start: 35, @@ -95,6 +173,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -119,6 +198,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { start: 20, @@ -129,6 +209,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { start: 50, @@ -139,6 +220,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -184,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: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -233,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: [], + delimiter: ';', }, { end: 1212, @@ -243,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: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -274,6 +359,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { start: 79, @@ -284,6 +370,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { start: 250, @@ -294,6 +381,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -318,6 +406,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { start: 54, @@ -328,6 +417,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -353,6 +443,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { start: 6, @@ -363,6 +454,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -387,6 +479,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { start: 24, @@ -420,6 +513,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { start: 19, @@ -430,6 +524,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { start: 29, @@ -440,6 +535,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -461,6 +557,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { start: 19 + offset, @@ -471,6 +568,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, { start: 29 + offset, @@ -481,6 +579,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 7952083..14089d0 100644 --- a/test/identifier/single-statement.spec.ts +++ b/test/identifier/single-statement.spec.ts @@ -91,6 +91,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -117,6 +118,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -136,6 +138,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -157,6 +160,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -188,6 +192,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -226,6 +231,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -259,6 +265,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -282,6 +289,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -307,6 +315,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -334,6 +343,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -370,6 +380,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -619,6 +630,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -821,6 +833,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -924,6 +937,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -950,6 +964,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -968,6 +983,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -986,6 +1002,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -1004,6 +1021,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -1022,6 +1040,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -1040,6 +1059,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -1057,6 +1077,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -1075,6 +1096,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -1093,6 +1115,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -1113,6 +1136,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; expect(actual).to.eql(expected); @@ -1130,6 +1154,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -1336,6 +1361,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -1360,6 +1386,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -1383,6 +1410,7 @@ describe('identifier', () => { parameters: [], tables: [], // FIXME: should return 'table'? columns: [], + delimiter: ';', }, ]; @@ -1453,6 +1481,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -1580,6 +1609,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -1606,6 +1636,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; @@ -1625,6 +1656,7 @@ describe('identifier', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]; diff --git a/test/index.spec.ts b/test/index.spec.ts index 861a2c5..05c82a6 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -339,6 +339,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'); @@ -417,6 +421,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); }); @@ -477,6 +482,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); }); @@ -498,6 +504,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); @@ -511,6 +518,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); }); @@ -526,6 +534,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); @@ -539,6 +548,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); @@ -552,6 +562,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); @@ -565,6 +576,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); @@ -580,6 +592,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); }); @@ -595,6 +608,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); @@ -608,6 +622,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); @@ -621,6 +636,7 @@ describe('Transaction statements', () => { parameters: [], tables: [], columns: [], + delimiter: ';', }, ]); @@ -636,6 +652,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 57a751b..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: [], @@ -52,7 +52,7 @@ describe('parser', () => { }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 55, end: 55, @@ -76,6 +76,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'); @@ -95,7 +282,7 @@ describe('parser', () => { end: 64, type: 'INSERT', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -130,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..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: [], @@ -186,7 +186,7 @@ describe('parser', () => { end: 53, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 54, end: 54, @@ -220,7 +220,7 @@ describe('parser', () => { end: 54 + type.length + 1, type: 'CREATE_TABLE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -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, @@ -286,7 +286,7 @@ describe('parser', () => { end: 62, type: 'CREATE_TABLE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -318,7 +318,7 @@ describe('parser', () => { end: 61, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 62, end: 62, @@ -344,7 +344,7 @@ describe('parser', () => { end: 23, type: 'CREATE_DATABASE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -382,7 +382,7 @@ describe('parser', () => { end: 22, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 23, end: 23, @@ -408,7 +408,7 @@ describe('parser', () => { end: 18, type: 'DROP_TABLE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -440,7 +440,7 @@ describe('parser', () => { end: 17, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 18, end: 18, @@ -466,7 +466,7 @@ describe('parser', () => { end: 21, type: 'DROP_DATABASE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -504,7 +504,7 @@ describe('parser', () => { end: 20, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 21, end: 21, @@ -529,7 +529,7 @@ describe('parser', () => { end: 55, type: 'INSERT', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -549,7 +549,7 @@ describe('parser', () => { end: 54, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 55, end: 55, @@ -575,7 +575,7 @@ describe('parser', () => { end: 51, type: 'UPDATE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -595,7 +595,7 @@ describe('parser', () => { end: 50, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 51, end: 51, @@ -621,7 +621,7 @@ describe('parser', () => { end: 38, type: 'DELETE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -641,7 +641,7 @@ describe('parser', () => { end: 37, }, { - type: 'semicolon', + type: 'delimiter', value: ';', start: 38, end: 38, @@ -667,7 +667,7 @@ describe('parser', () => { end: 22, type: 'TRUNCATE', executionType: 'MODIFICATION', - endStatement: ';', + delimiter: ';', parameters: [], tables: [], columns: [], @@ -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 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', () => { [