diff --git a/src/defines.ts b/src/defines.ts index f3ec63f..81a7c96 100644 --- a/src/defines.ts +++ b/src/defines.ts @@ -6,6 +6,7 @@ export const DIALECTS = [ 'psql', 'bigquery', 'dynamodb', + 'snowflake', 'generic', ] as const; export type Dialect = (typeof DIALECTS)[number]; @@ -54,6 +55,16 @@ export type StatementType = | 'SHOW_KEYS' | 'SHOW_INDEX' | 'SHOW_TABLE' + | 'SHOW_WAREHOUSES' + | 'SHOW_USERS' + | 'SHOW_ROLES' + | 'SHOW_SCHEMAS' + | 'SHOW_STAGES' + | 'SHOW_INTEGRATIONS' + | 'SHOW_STREAMS' + | 'SHOW_TASKS' + | 'SHOW_PIPES' + | 'SHOW_SEQUENCES' | 'SHOW_TABLES' | 'SHOW_COLUMNS' | 'DROP_DATABASE' @@ -76,6 +87,18 @@ export type StatementType = | 'COMMIT' | 'ROLLBACK' | 'ANON_BLOCK' + | 'MERGE' + | 'CALL' + | 'GRANT' + | 'REVOKE' + | 'EXPLAIN' + | 'DESCRIBE' + | 'USE' + | 'COPY' + | 'PUT' + | 'GET' + | 'LIST' + | 'REMOVE' | 'UNKNOWN'; export type ExecutionType = diff --git a/src/parser.ts b/src/parser.ts index aec8a28..96a6635 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -93,6 +93,28 @@ export const EXECUTION_TYPES: Record = { BEGIN_TRANSACTION: 'TRANSACTION', COMMIT: 'TRANSACTION', ROLLBACK: 'TRANSACTION', + MERGE: 'MODIFICATION', + CALL: 'MODIFICATION', + GRANT: 'MODIFICATION', + REVOKE: 'MODIFICATION', + EXPLAIN: 'INFORMATION', + DESCRIBE: 'INFORMATION', + USE: 'INFORMATION', + COPY: 'MODIFICATION', + PUT: 'MODIFICATION', + GET: 'LISTING', + LIST: 'LISTING', + REMOVE: 'MODIFICATION', + SHOW_WAREHOUSES: 'LISTING', + SHOW_USERS: 'LISTING', + SHOW_ROLES: 'LISTING', + SHOW_SCHEMAS: 'LISTING', + SHOW_STAGES: 'LISTING', + SHOW_INTEGRATIONS: 'LISTING', + SHOW_STREAMS: 'LISTING', + SHOW_TASKS: 'LISTING', + SHOW_PIPES: 'LISTING', + SHOW_SEQUENCES: 'LISTING', UNKNOWN: 'UNKNOWN', ANON_BLOCK: 'ANON_BLOCK', }; @@ -114,6 +136,7 @@ const blockOpeners: Record = { oracle: ['DECLARE', 'BEGIN', 'CASE'], bigquery: ['BEGIN', 'CASE', 'IF', 'LOOP', 'REPEAT', 'WHILE', 'FOR'], dynamodb: [], + snowflake: ['DECLARE', 'BEGIN', 'CASE', 'LOOP', 'IF', 'WHILE', 'FOR', 'REPEAT'], }; interface ParseOptions { @@ -357,7 +380,7 @@ function createStatementParserByToken( case 'CREATE': return createCreateStatementParser(options); case 'SHOW': - if (['mysql', 'generic'].includes(options.dialect)) { + if (['mysql', 'generic', 'snowflake'].includes(options.dialect)) { return createShowStatementParser(options); } break; @@ -374,7 +397,13 @@ function createStatementParserByToken( case 'TRUNCATE': return createTruncateStatementParser(options); case 'BEGIN': - if (['bigquery', 'oracle'].includes(options.dialect) && nextToken.value !== 'TRANSACTION') { + if ( + (['bigquery', 'oracle'].includes(options.dialect) && + nextToken.value.toUpperCase() !== 'TRANSACTION') || + (options.dialect === 'snowflake' && + nextToken.value.toUpperCase() !== 'WORK' && + nextToken.value.toUpperCase() !== 'TRANSACTION') + ) { return createBlockStatementParser(options); } return createBeginTransactionStatementParser(options); @@ -388,10 +417,44 @@ function createStatementParserByToken( case 'ROLLBACK': return createRollbackStatementParser(options); case 'DECLARE': - if (options.dialect === 'oracle') { + if (['oracle', 'snowflake'].includes(options.dialect)) { return createBlockStatementParser(options); } break; + case 'MERGE': + return createMergeStatementParser(options); + case 'CALL': + return createCallStatementParser(options); + case 'GRANT': + return createGrantStatementParser(options); + case 'REVOKE': + return createRevokeStatementParser(options); + case 'EXPLAIN': + return createExplainStatementParser(options); + case 'DESCRIBE': + case 'DESC': + return createDescribeStatementParser(options); + case 'USE': + if (['mssql', 'mysql', 'snowflake'].includes(options.dialect)) { + return createUseStatementParser(options); + } + break; + case 'COPY': + return createCopyStatementParser(options); + case 'PUT': + if (options.dialect === 'snowflake') return createPutStatementParser(options); + break; + case 'GET': + if (options.dialect === 'snowflake') return createGetStatementParser(options); + break; + case 'LIST': + case 'LS': + if (options.dialect === 'snowflake') return createListStatementParser(options); + break; + case 'REMOVE': + case 'RM': + if (options.dialect === 'snowflake') return createRemoveStatementParser(options); + break; default: break; } @@ -437,7 +500,9 @@ function createBlockStatementParser(options: ParseOptions) { preCanGoToNext: () => false, validation: { acceptTokens: [ - ...(options.dialect === 'oracle' ? [{ type: 'keyword', value: 'DECLARE' }] : []), + ...(['oracle', 'snowflake'].includes(options.dialect) + ? [{ type: 'keyword', value: 'DECLARE' }] + : []), { type: 'keyword', value: 'BEGIN' }, ], }, @@ -685,6 +750,80 @@ function createTruncateStatementParser(options: ParseOptions) { return stateMachineStatementParser(statement, steps, options); } +function createSingleKeywordStatementParser( + options: ParseOptions, + type: StatementType, + keywords: string[], +) { + const statement = createInitialStatement(); + + const steps: Step[] = [ + { + preCanGoToNext: () => false, + validation: { + acceptTokens: keywords.map((value) => ({ type: 'keyword', value })), + }, + add: (token) => { + statement.type = type; + if (statement.start < 0) { + statement.start = token.start; + } + }, + postCanGoToNext: () => true, + }, + ]; + + return stateMachineStatementParser(statement, steps, options); +} + +function createMergeStatementParser(options: ParseOptions) { + return createSingleKeywordStatementParser(options, 'MERGE', ['MERGE']); +} + +function createCallStatementParser(options: ParseOptions) { + return createSingleKeywordStatementParser(options, 'CALL', ['CALL']); +} + +function createGrantStatementParser(options: ParseOptions) { + return createSingleKeywordStatementParser(options, 'GRANT', ['GRANT']); +} + +function createRevokeStatementParser(options: ParseOptions) { + return createSingleKeywordStatementParser(options, 'REVOKE', ['REVOKE']); +} + +function createExplainStatementParser(options: ParseOptions) { + return createSingleKeywordStatementParser(options, 'EXPLAIN', ['EXPLAIN']); +} + +function createDescribeStatementParser(options: ParseOptions) { + return createSingleKeywordStatementParser(options, 'DESCRIBE', ['DESCRIBE', 'DESC']); +} + +function createUseStatementParser(options: ParseOptions) { + return createSingleKeywordStatementParser(options, 'USE', ['USE']); +} + +function createCopyStatementParser(options: ParseOptions) { + return createSingleKeywordStatementParser(options, 'COPY', ['COPY']); +} + +function createPutStatementParser(options: ParseOptions) { + return createSingleKeywordStatementParser(options, 'PUT', ['PUT']); +} + +function createGetStatementParser(options: ParseOptions) { + return createSingleKeywordStatementParser(options, 'GET', ['GET']); +} + +function createListStatementParser(options: ParseOptions) { + return createSingleKeywordStatementParser(options, 'LIST', ['LIST', 'LS']); +} + +function createRemoveStatementParser(options: ParseOptions) { + return createSingleKeywordStatementParser(options, 'REMOVE', ['REMOVE', 'RM']); +} + function createShowStatementParser(options: ParseOptions) { const statement = createInitialStatement(); @@ -741,6 +880,16 @@ function createShowStatementParser(options: ParseOptions) { { type: 'keyword', value: 'TRIGGERS' }, { type: 'keyword', value: 'VARIABLES' }, { type: 'keyword', value: 'WARNINGS' }, + { type: 'keyword', value: 'WAREHOUSES' }, + { type: 'keyword', value: 'USERS' }, + { type: 'keyword', value: 'ROLES' }, + { type: 'keyword', value: 'SCHEMAS' }, + { type: 'keyword', value: 'STAGES' }, + { type: 'keyword', value: 'INTEGRATIONS' }, + { type: 'keyword', value: 'STREAMS' }, + { type: 'keyword', value: 'TASKS' }, + { type: 'keyword', value: 'PIPES' }, + { type: 'keyword', value: 'SEQUENCES' }, ], }, add: (token) => { @@ -938,12 +1087,13 @@ function stateMachineStatementParser( (token.value.toUpperCase() !== 'BEGIN' || (token.value.toUpperCase() === 'BEGIN' && nextToken.value.toUpperCase() !== 'TRANSACTION' && + (dialect !== 'snowflake' || nextToken.value.toUpperCase() !== 'WORK') && (dialect !== 'sqlite' || (dialect === 'sqlite' && !['DEFERRED', 'IMMEDIATE', 'EXCLUSIVE'].includes(nextToken.value.toUpperCase()))))) ) { if ( - dialect === 'oracle' && + ['oracle', 'snowflake'].includes(dialect) && lastBlockOpener?.value === 'DECLARE' && token.value.toUpperCase() === 'BEGIN' ) { @@ -1001,13 +1151,22 @@ function stateMachineStatementParser( } if ( - ['psql', 'mssql', 'bigquery'].includes(dialect) && + ['psql', 'mssql', 'bigquery', 'snowflake'].includes(dialect) && token.value.toUpperCase() === 'MATERIALIZED' ) { setPrevToken(token); return; } + let afterOrTokens: string[]; + if (dialect === 'mssql') { + afterOrTokens = ['ALTER']; + } else if (dialect === 'snowflake') { + afterOrTokens = ['ALTER', 'REPLACE']; + } else { + afterOrTokens = ['REPLACE']; + } + // technically these dialects don't allow "OR REPLACE" or "OR ALTER" between all statement // types, but we'll allow it for now. // For "ALTER", we need to make sure we only catch it here if it directly follows "OR", so @@ -1016,7 +1175,7 @@ function stateMachineStatementParser( dialect !== 'sqlite' && (token.value.toUpperCase() === 'OR' || (prevNonWhitespaceToken?.value.toUpperCase() === 'OR' && - token.value.toUpperCase() === (dialect === 'mssql' ? 'ALTER' : 'REPLACE'))) + afterOrTokens.includes(token.value.toUpperCase()))) ) { setPrevToken(token); return; @@ -1026,7 +1185,9 @@ function stateMachineStatementParser( if ( (dialect === 'psql' && ['TEMP', 'TEMPORARY'].includes(token.value.toUpperCase())) || (dialect === 'sqlite' && - ['TEMP', 'TEMPORARY', 'VIRTUAL'].includes(token.value.toUpperCase())) + ['TEMP', 'TEMPORARY', 'VIRTUAL'].includes(token.value.toUpperCase())) || + (dialect === 'snowflake' && + ['TEMP', 'TEMPORARY', 'TRANSIENT', 'VOLATILE'].includes(token.value.toUpperCase())) ) { setPrevToken(token); return; @@ -1190,6 +1351,11 @@ export function defaultParamTypesFor(dialect: Dialect): ParamTypes { return { positional: true, }; + case 'snowflake': + return { + positional: true, + named: [':'], + }; default: return { positional: true, diff --git a/src/tokenizer.ts b/src/tokenizer.ts index 773465c..a620dfe 100644 --- a/src/tokenizer.ts +++ b/src/tokenizer.ts @@ -73,6 +73,34 @@ const KEYWORDS = [ 'TRIGGERS', 'VARIABLES', 'WARNINGS', + 'MERGE', + 'CALL', + 'GRANT', + 'REVOKE', + 'EXPLAIN', + 'DESCRIBE', + 'DESC', + 'USE', + 'COPY', + 'USERS', + 'ROLES', + 'SCHEMAS', + 'SEQUENCES', +]; + +const SNOWFLAKE_KEYWORDS = [ + 'PUT', + 'GET', + 'LIST', + 'LS', + 'REMOVE', + 'RM', + 'WAREHOUSES', + 'STAGES', + 'INTEGRATIONS', + 'STREAMS', + 'TASKS', + 'PIPES', ]; const INDIVIDUALS: Record = { @@ -122,7 +150,7 @@ export function scanToken( } if (isLetter(ch)) { - return scanWord(state); + return scanWord(state, dialect); } const individual = scanIndividualCharacter(state); @@ -165,8 +193,18 @@ function peek(state: State): Char { return state.input[state.position + 1]; } -function isKeyword(word: string): boolean { - return KEYWORDS.includes(word.toUpperCase()); +function getDialectSpecificKeywords(dialect: Dialect): string[] { + switch (dialect) { + case 'snowflake': + return SNOWFLAKE_KEYWORDS; + default: + return []; + } +} + +function isKeyword(word: string, dialect: Dialect): boolean { + const dialectSpecific = getDialectSpecificKeywords(dialect); + return KEYWORDS.includes(word.toUpperCase()) || dialectSpecific.includes(word.toUpperCase()); } function resolveIndividualTokenType(ch: string): Token['type'] | undefined { @@ -418,7 +456,7 @@ function scanQuotedIdentifier(state: State, endToken: Char, dialect: Dialect): T }; } -function scanWord(state: State): Token { +function scanWord(state: State, dialect: Dialect): Token { let nextChar: Char; do { @@ -430,7 +468,7 @@ function scanWord(state: State): Token { } const value = state.input.slice(state.start, state.position + 1); - if (!isKeyword(value)) { + if (!isKeyword(value, dialect)) { return skipWord(state, value); } diff --git a/src/utils.ts b/src/utils.ts index 65f1f4d..1495159 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,6 +3,8 @@ import { Dialect, Token } from './defines'; export function getStartQuotes(dialect: Dialect): string[] { if (dialect === 'mssql') { return ['"', '[']; + } else if (dialect === 'snowflake') { + return ['"']; } else { return ['"', '`']; } diff --git a/test/index.spec.ts b/test/index.spec.ts index 5e9662a..cb2b230 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -5,7 +5,7 @@ import { ParamTypes } from '../src/defines'; describe('identify', () => { it('should throw error for invalid dialect', () => { expect(() => identify('SELECT * FROM foo', { dialect: 'invalid' as Dialect })).to.throw( - 'Unknown dialect. Allowed values: mssql, sqlite, mysql, oracle, psql, bigquery, dynamodb, generic', + 'Unknown dialect. Allowed values: mssql, sqlite, mysql, oracle, psql, bigquery, dynamodb, snowflake, generic', ); }); diff --git a/test/parser/single-statements.spec.ts b/test/parser/single-statements.spec.ts index 37a4208..33a65de 100644 --- a/test/parser/single-statements.spec.ts +++ b/test/parser/single-statements.spec.ts @@ -710,6 +710,56 @@ describe('parser', () => { expect(actual).to.eql(expected); }); + it('should parse "MERGE" statement', () => { + const actual = parse( + 'MERGE INTO target t USING source s ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.v = s.v;', + ); + expect(actual.body.length).to.eql(1); + expect(actual.body[0].type).to.eql('MERGE'); + expect(actual.body[0].executionType).to.eql('MODIFICATION'); + }); + + it('should parse "CALL" statement', () => { + const actual = parse('CALL my_proc(1);'); + expect(actual.body.length).to.eql(1); + expect(actual.body[0].type).to.eql('CALL'); + expect(actual.body[0].executionType).to.eql('MODIFICATION'); + }); + + it('should parse "GRANT" statement', () => { + const actual = parse('GRANT SELECT ON t TO bob;'); + expect(actual.body.length).to.eql(1); + expect(actual.body[0].type).to.eql('GRANT'); + expect(actual.body[0].executionType).to.eql('MODIFICATION'); + }); + + it('should parse "REVOKE" statement', () => { + const actual = parse('REVOKE SELECT ON t FROM bob;'); + expect(actual.body.length).to.eql(1); + expect(actual.body[0].type).to.eql('REVOKE'); + expect(actual.body[0].executionType).to.eql('MODIFICATION'); + }); + + it('should parse "EXPLAIN" statement as EXPLAIN, not SELECT', () => { + const actual = parse('EXPLAIN SELECT * FROM t;'); + expect(actual.body.length).to.eql(1); + expect(actual.body[0].type).to.eql('EXPLAIN'); + expect(actual.body[0].executionType).to.eql('INFORMATION'); + }); + + it('should parse psql "COPY" statement', () => { + const actual = parse("COPY t FROM '/tmp/x.csv';", true, 'psql'); + expect(actual.body.length).to.eql(1); + expect(actual.body[0].type).to.eql('COPY'); + expect(actual.body[0].executionType).to.eql('MODIFICATION'); + }); + + it('should still parse SELECT with ORDER BY DESC as SELECT (not DESCRIBE)', () => { + const actual = parse('SELECT * FROM t ORDER BY a DESC;'); + expect(actual.body.length).to.eql(1); + expect(actual.body[0].type).to.eql('SELECT'); + }); + describe('with parameters', () => { it('should extract the parameters', () => { const actual = parse('select x from a where x = ?'); diff --git a/test/parser/snowflake.spec.ts b/test/parser/snowflake.spec.ts new file mode 100644 index 0000000..e1d3e85 --- /dev/null +++ b/test/parser/snowflake.spec.ts @@ -0,0 +1,601 @@ +import { parse, defaultParamTypesFor } from '../../src/parser'; +import { expect } from 'chai'; + +describe('Parser for snowflake', () => { + // Anonymous blocks + describe('anonymous blocks', () => { + it('should parse bare BEGIN as ANON_BLOCK', () => { + const result = parse('BEGIN SELECT 1; END;', false, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('ANON_BLOCK'); + }); + + it('should parse bare BEGIN...END followed by another statement', () => { + const result = parse('BEGIN SELECT 1; END; SELECT 2;', false, 'snowflake'); + expect(result.body.length).to.eql(2); + expect(result.body[0].type).to.eql('ANON_BLOCK'); + expect(result.body[1].type).to.eql('SELECT'); + }); + + it('should parse DECLARE...BEGIN...END as a single ANON_BLOCK', () => { + const sql = `DECLARE + x INTEGER; + BEGIN + x := 1; + SELECT x; + END;`; + const result = parse(sql, false, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('ANON_BLOCK'); + }); + + it('should parse DECLARE...BEGIN...END followed by another statement', () => { + const sql = `DECLARE + x INTEGER; + BEGIN + SELECT x; + END; + + SELECT * FROM foo;`; + const result = parse(sql, false, 'snowflake'); + expect(result.body.length).to.eql(2); + expect(result.body[0].type).to.eql('ANON_BLOCK'); + expect(result.body[1].type).to.eql('SELECT'); + }); + + it('should handle nested BEGIN...END blocks', () => { + const sql = `BEGIN + BEGIN + SELECT 1; + END; + SELECT 2; + END;`; + const result = parse(sql, false, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('ANON_BLOCK'); + }); + + it('should handle a block with multiple internal statements', () => { + const sql = `BEGIN + INSERT INTO t1 VALUES (1); + INSERT INTO t1 VALUES (2); + INSERT INTO t1 VALUES (3); + END;`; + const result = parse(sql, false, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('ANON_BLOCK'); + }); + + it('should handle CASE inside a block', () => { + const sql = `BEGIN + SELECT CASE WHEN a = 1 THEN 'yes' ELSE 'no' END CASE FROM t; + END;`; + const result = parse(sql, false, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('ANON_BLOCK'); + }); + + it('should handle IF inside a block', () => { + const sql = `BEGIN + IF (x > 0) THEN + SELECT 1; + ELSE + SELECT 2; + END IF; + END;`; + const result = parse(sql, false, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('ANON_BLOCK'); + }); + + it('should handle WHILE inside a block', () => { + const sql = `BEGIN + WHILE (x < 10) DO + SELECT x; + END WHILE; + END;`; + const result = parse(sql, false, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('ANON_BLOCK'); + }); + + it('should handle FOR inside a block', () => { + const sql = `BEGIN + FOR i IN 1 TO 10 DO + SELECT i; + END FOR; + END;`; + const result = parse(sql, false, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('ANON_BLOCK'); + }); + + it('should handle LOOP inside a block', () => { + const sql = `BEGIN + LOOP + SELECT 1; + END LOOP; + END;`; + const result = parse(sql, false, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('ANON_BLOCK'); + }); + + it('should handle REPEAT inside a block', () => { + const sql = `BEGIN + REPEAT + SELECT 1; + UNTIL (x > 10) END REPEAT; + END;`; + const result = parse(sql, false, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('ANON_BLOCK'); + }); + + it('should parse DECLARE...BEGIN...END as ANON_BLOCK in strict mode', () => { + const sql = `DECLARE + x INTEGER; + BEGIN + x := 1; + SELECT x; + END;`; + const result = parse(sql, true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('ANON_BLOCK'); + }); + }); + + // Transactions + describe('transactions', () => { + it('should parse BEGIN TRANSACTION as BEGIN_TRANSACTION', () => { + const result = parse('BEGIN TRANSACTION; SELECT 1; COMMIT;', false, 'snowflake'); + expect(result.body.length).to.eql(3); + expect(result.body[0].type).to.eql('BEGIN_TRANSACTION'); + expect(result.body[1].type).to.eql('SELECT'); + expect(result.body[2].type).to.eql('COMMIT'); + }); + + it('should parse BEGIN WORK as BEGIN_TRANSACTION', () => { + const result = parse('BEGIN WORK; SELECT 1; COMMIT;', false, 'snowflake'); + expect(result.body.length).to.eql(3); + expect(result.body[0].type).to.eql('BEGIN_TRANSACTION'); + expect(result.body[1].type).to.eql('SELECT'); + expect(result.body[2].type).to.eql('COMMIT'); + }); + + it('should parse START TRANSACTION as BEGIN_TRANSACTION', () => { + const result = parse('START TRANSACTION; SELECT 1; COMMIT;', false, 'snowflake'); + expect(result.body.length).to.eql(3); + expect(result.body[0].type).to.eql('BEGIN_TRANSACTION'); + expect(result.body[1].type).to.eql('SELECT'); + expect(result.body[2].type).to.eql('COMMIT'); + }); + + it('should parse BEGIN TRANSACTION with NAME', () => { + const result = parse( + 'BEGIN TRANSACTION NAME T1; INSERT INTO t VALUES (1); COMMIT;', + false, + 'snowflake', + ); + expect(result.body.length).to.eql(3); + expect(result.body[0].type).to.eql('BEGIN_TRANSACTION'); + expect(result.body[1].type).to.eql('INSERT'); + expect(result.body[2].type).to.eql('COMMIT'); + }); + + it('should parse COMMIT and ROLLBACK', () => { + const result = parse('COMMIT;', false, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('COMMIT'); + + const result2 = parse('ROLLBACK;', false, 'snowflake'); + expect(result2.body.length).to.eql(1); + expect(result2.body[0].type).to.eql('ROLLBACK'); + }); + + it('should parse lowercase "BEGIN transaction" as BEGIN_TRANSACTION', () => { + const result = parse('BEGIN transaction; SELECT 1; COMMIT;', false, 'snowflake'); + expect(result.body.length).to.eql(3); + expect(result.body[0].type).to.eql('BEGIN_TRANSACTION'); + expect(result.body[1].type).to.eql('SELECT'); + expect(result.body[2].type).to.eql('COMMIT'); + }); + + it('should parse lowercase "BEGIN work" as BEGIN_TRANSACTION', () => { + const result = parse('BEGIN work; SELECT 1; COMMIT;', false, 'snowflake'); + expect(result.body.length).to.eql(3); + expect(result.body[0].type).to.eql('BEGIN_TRANSACTION'); + expect(result.body[1].type).to.eql('SELECT'); + expect(result.body[2].type).to.eql('COMMIT'); + }); + + it('should not treat BEGIN WORK inside a block as a block opener', () => { + const sql = `BEGIN + BEGIN WORK; + INSERT INTO t VALUES (1); + COMMIT; + END;`; + const result = parse(sql, false, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('ANON_BLOCK'); + }); + }); + + // Standard statement identification + describe('standard statements', () => { + it('should identify SELECT', () => { + const result = parse('SELECT * FROM foo;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('SELECT'); + }); + + it('should identify INSERT', () => { + const result = parse('INSERT INTO foo VALUES (1);', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('INSERT'); + }); + + it('should identify UPDATE', () => { + const result = parse('UPDATE foo SET bar = 1;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('UPDATE'); + }); + + it('should identify DELETE', () => { + const result = parse('DELETE FROM foo;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('DELETE'); + }); + + it('should identify TRUNCATE', () => { + const result = parse('TRUNCATE TABLE foo;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('TRUNCATE'); + }); + }); + + // CREATE statements with modifiers + describe('CREATE statements', () => { + it('should identify CREATE TABLE', () => { + const result = parse('CREATE TABLE foo (id INTEGER);', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('CREATE_TABLE'); + }); + + it('should identify CREATE OR REPLACE TABLE', () => { + const result = parse('CREATE OR REPLACE TABLE foo (id INTEGER);', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('CREATE_TABLE'); + }); + + it('should identify CREATE OR ALTER VIEW', () => { + const result = parse('CREATE OR ALTER VIEW v AS SELECT 1;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('CREATE_VIEW'); + }); + + it('should identify CREATE TEMPORARY TABLE', () => { + const result = parse('CREATE TEMPORARY TABLE foo (id INTEGER);', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('CREATE_TABLE'); + }); + + it('should identify CREATE TEMP TABLE', () => { + const result = parse('CREATE TEMP TABLE foo (id INTEGER);', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('CREATE_TABLE'); + }); + + it('should identify CREATE TRANSIENT TABLE', () => { + const result = parse('CREATE TRANSIENT TABLE foo (id INTEGER);', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('CREATE_TABLE'); + }); + + it('should identify CREATE VOLATILE TABLE', () => { + const result = parse('CREATE VOLATILE TABLE foo (id INTEGER);', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('CREATE_TABLE'); + }); + + it('should identify CREATE MATERIALIZED VIEW', () => { + const result = parse('CREATE MATERIALIZED VIEW v AS SELECT 1;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('CREATE_VIEW'); + }); + + it('should identify CREATE VIEW', () => { + const result = parse('CREATE VIEW v AS SELECT 1;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('CREATE_VIEW'); + }); + + it('should identify CREATE SCHEMA', () => { + const result = parse('CREATE SCHEMA myschema;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('CREATE_SCHEMA'); + }); + + it('should identify CREATE DATABASE', () => { + const result = parse('CREATE DATABASE mydb;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('CREATE_DATABASE'); + }); + + it('should identify CREATE FUNCTION', () => { + const result = parse( + 'CREATE FUNCTION myfunc() RETURNS INTEGER AS $$ SELECT 1 $$;', + true, + 'snowflake', + ); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('CREATE_FUNCTION'); + }); + + it('should identify CREATE PROCEDURE', () => { + const result = parse( + 'CREATE PROCEDURE myproc() RETURNS INTEGER AS $$ SELECT 1 $$;', + true, + 'snowflake', + ); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('CREATE_PROCEDURE'); + }); + + it('should identify CREATE OR REPLACE FUNCTION', () => { + const result = parse( + 'CREATE OR REPLACE FUNCTION myfunc() RETURNS INTEGER AS $$ SELECT 1 $$;', + true, + 'snowflake', + ); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('CREATE_FUNCTION'); + }); + }); + + // DROP statements + describe('DROP statements', () => { + it('should identify DROP TABLE', () => { + const result = parse('DROP TABLE foo;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('DROP_TABLE'); + }); + + it('should identify DROP VIEW', () => { + const result = parse('DROP VIEW v;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('DROP_VIEW'); + }); + + it('should identify DROP DATABASE', () => { + const result = parse('DROP DATABASE mydb;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('DROP_DATABASE'); + }); + + it('should identify DROP SCHEMA', () => { + const result = parse('DROP SCHEMA myschema;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('DROP_SCHEMA'); + }); + }); + + // ALTER statements + describe('ALTER statements', () => { + it('should identify ALTER TABLE', () => { + const result = parse('ALTER TABLE foo ADD COLUMN bar INTEGER;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('ALTER_TABLE'); + }); + + it('should identify ALTER VIEW', () => { + const result = parse("ALTER VIEW v SET COMMENT = 'test';", true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('ALTER_VIEW'); + }); + }); + + // Multiple statements / splitting + describe('statement splitting', () => { + it('should split multiple simple statements', () => { + const result = parse('SELECT 1; SELECT 2; SELECT 3;', false, 'snowflake'); + expect(result.body.length).to.eql(3); + expect(result.body[0].type).to.eql('SELECT'); + expect(result.body[1].type).to.eql('SELECT'); + expect(result.body[2].type).to.eql('SELECT'); + }); + + it('should split mixed statement types', () => { + const result = parse( + 'CREATE TABLE foo (id INTEGER); INSERT INTO foo VALUES (1); SELECT * FROM foo;', + true, + 'snowflake', + ); + expect(result.body.length).to.eql(3); + expect(result.body[0].type).to.eql('CREATE_TABLE'); + expect(result.body[1].type).to.eql('INSERT'); + expect(result.body[2].type).to.eql('SELECT'); + }); + + it('should not split on semicolons inside BEGIN...END blocks', () => { + const sql = `BEGIN + INSERT INTO t1 VALUES (1); + INSERT INTO t2 VALUES (2); + END; + SELECT * FROM t1;`; + const result = parse(sql, false, 'snowflake'); + expect(result.body.length).to.eql(2); + expect(result.body[0].type).to.eql('ANON_BLOCK'); + expect(result.body[1].type).to.eql('SELECT'); + }); + + it('should not split on semicolons inside DECLARE...BEGIN...END blocks', () => { + const sql = `DECLARE + x INTEGER; + BEGIN + INSERT INTO t1 VALUES (1); + INSERT INTO t2 VALUES (2); + END; + SELECT * FROM t1;`; + const result = parse(sql, false, 'snowflake'); + expect(result.body.length).to.eql(2); + expect(result.body[0].type).to.eql('ANON_BLOCK'); + expect(result.body[1].type).to.eql('SELECT'); + }); + + it('should handle a block after a CREATE TABLE', () => { + const sql = `CREATE TABLE foo (id INTEGER); + BEGIN + INSERT INTO foo VALUES (1); + INSERT INTO foo VALUES (2); + END;`; + const result = parse(sql, false, 'snowflake'); + expect(result.body.length).to.eql(2); + expect(result.body[0].type).to.eql('CREATE_TABLE'); + expect(result.body[1].type).to.eql('ANON_BLOCK'); + }); + + it('should not split on MERGE inside a BEGIN...END block', () => { + const sql = `BEGIN + MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.v = s.v; + SELECT 1; + END;`; + const result = parse(sql, false, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('ANON_BLOCK'); + }); + }); + + describe('DESCRIBE / DESC', () => { + it('should identify DESCRIBE TABLE', () => { + const result = parse('DESCRIBE TABLE foo;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('DESCRIBE'); + expect(result.body[0].executionType).to.eql('INFORMATION'); + }); + + it('should identify DESC TABLE as DESCRIBE', () => { + const result = parse('DESC TABLE foo;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('DESCRIBE'); + expect(result.body[0].executionType).to.eql('INFORMATION'); + }); + }); + + describe('USE', () => { + ['WAREHOUSE', 'DATABASE', 'ROLE'].forEach((target) => { + it(`should identify USE ${target}`, () => { + const result = parse(`USE ${target} foo;`, true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('USE'); + expect(result.body[0].executionType).to.eql('INFORMATION'); + }); + }); + }); + + describe('stage commands', () => { + it('should identify PUT', () => { + const result = parse('PUT file:///tmp/x.csv @stage;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('PUT'); + expect(result.body[0].executionType).to.eql('MODIFICATION'); + }); + + it('should identify GET', () => { + const result = parse('GET @stage file:///tmp/x.csv;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('GET'); + expect(result.body[0].executionType).to.eql('LISTING'); + }); + + it('should identify LIST', () => { + const result = parse('LIST @stage;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('LIST'); + expect(result.body[0].executionType).to.eql('LISTING'); + }); + + it('should identify LS as LIST', () => { + const result = parse('LS @stage;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('LIST'); + }); + + it('should identify REMOVE', () => { + const result = parse('REMOVE @stage/f.csv;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('REMOVE'); + expect(result.body[0].executionType).to.eql('MODIFICATION'); + }); + + it('should identify RM as REMOVE', () => { + const result = parse('RM @stage/f.csv;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('REMOVE'); + }); + }); + + describe('COPY', () => { + it('should identify COPY INTO table FROM stage (load)', () => { + const result = parse('COPY INTO foo FROM @stage;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('COPY'); + expect(result.body[0].executionType).to.eql('MODIFICATION'); + }); + + it('should identify COPY INTO stage FROM table (unload)', () => { + const result = parse('COPY INTO @stage FROM foo;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('COPY'); + expect(result.body[0].executionType).to.eql('MODIFICATION'); + }); + }); + + describe('EXPLAIN', () => { + it('should identify EXPLAIN SELECT', () => { + const result = parse('EXPLAIN SELECT * FROM foo;', true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql('EXPLAIN'); + expect(result.body[0].executionType).to.eql('INFORMATION'); + }); + }); + + describe('SHOW', () => { + ['WAREHOUSES', 'USERS', 'ROLES', 'SCHEMAS', 'STAGES'].forEach((target) => { + it(`should identify SHOW ${target}`, () => { + const result = parse(`SHOW ${target};`, true, 'snowflake'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eql(`SHOW_${target}`); + expect(result.body[0].executionType).to.eql('LISTING'); + }); + }); + }); + + // Parameters + describe('parameters', () => { + it('should identify positional parameters', () => { + const result = parse( + 'SELECT * FROM foo WHERE id = ?;', + true, + 'snowflake', + false, + false, + defaultParamTypesFor('snowflake'), + ); + expect(result.body[0].parameters).to.eql(['?']); + }); + + it('should identify named parameters with colon', () => { + const result = parse( + 'SELECT * FROM foo WHERE id = :id AND name = :name;', + true, + 'snowflake', + false, + false, + defaultParamTypesFor('snowflake'), + ); + expect(result.body[0].parameters).to.include(':id'); + expect(result.body[0].parameters).to.include(':name'); + }); + }); +});