diff --git a/README.md b/README.md index 831f55f..d8e26ac 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ This way you have sure is a valid query before trying to identify the types. * ALTER_TRIGGER * ALTER_FUNCTION * ALTER_INDEX +* ANON_BLOCK (Oracle Database only) * UNKNOWN (only available if strict mode is disabled) ## Execution types @@ -64,6 +65,7 @@ Execution types allow to know what is the query behavior * `LISTING:` is when the query list the data * `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 (Oracle Database only) * `UNKNOWN`: (only available if strict mode is disabled) ## Installation @@ -112,7 +114,7 @@ console.log(statements); 1. `input (string)`: the whole SQL script text to be processed 1. `options (object)`: allow to set different configurations 1. `strict (bool)`: allow disable strict mode which will ignore unknown types *(default=true)* - 2. `dialect (string)`: Specify your database dialect, values: `generic`, `mysql`, `psql`, `sqlite` and `mssql`. *(default=generic)* + 2. `dialect (string)`: Specify your database dialect, values: `generic`, `mysql`, `oracle`, `psql`, `sqlite` and `mssql`. *(default=generic)* ## Contributing diff --git a/src/defines.ts b/src/defines.ts index 0d4b8d1..558f12f 100644 --- a/src/defines.ts +++ b/src/defines.ts @@ -1,4 +1,4 @@ -export const DIALECTS = ['mssql', 'sqlite', 'mysql', 'psql', 'generic'] as const; +export const DIALECTS = ['mssql', 'sqlite', 'mysql', 'oracle', 'psql', 'generic'] as const; export type Dialect = typeof DIALECTS[number]; export type StatementType = | 'INSERT' @@ -27,9 +27,10 @@ export type StatementType = | 'ALTER_TRIGGER' | 'ALTER_FUNCTION' | 'ALTER_INDEX' + | 'ANON_BLOCK' | 'UNKNOWN'; -export type ExecutionType = 'LISTING' | 'MODIFICATION' | 'UNKNOWN'; +export type ExecutionType = 'LISTING' | 'MODIFICATION' | 'ANON_BLOCK' | 'UNKNOWN'; export interface IdentifyOptions { strict?: boolean; diff --git a/src/parser.ts b/src/parser.ts index 64e6312..d8a6d3a 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -51,15 +51,17 @@ export const EXECUTION_TYPES: Record = { ALTER_FUNCTION: 'MODIFICATION', ALTER_INDEX: 'MODIFICATION', UNKNOWN: 'UNKNOWN', + ANON_BLOCK: 'ANON_BLOCK', }; -const statementsWithEnds = ['CREATE_TRIGGER', 'CREATE_FUNCTION']; +const statementsWithEnds = ['CREATE_TRIGGER', 'CREATE_FUNCTION', 'ANON_BLOCK']; const blockOpeners: Record = { generic: ['BEGIN', 'CASE'], psql: ['BEGIN', 'CASE', 'LOOP', 'IF'], mysql: ['BEGIN', 'CASE', 'LOOP', 'IF'], mssql: ['BEGIN', 'CASE'], sqlite: ['BEGIN', 'CASE'], + oracle: ['DECLARE', 'BEGIN', 'CASE'], }; interface ParseOptions { @@ -250,6 +252,12 @@ function createStatementParserByToken(token: Token, options: ParseOptions): Stat return createDeleteStatementParser(options); case 'TRUNCATE': return createTruncateStatementParser(options); + case 'DECLARE': + case 'BEGIN': + if (options.dialect === 'oracle') { + return createBlockStatementParser(options); + } + // eslint-disable-next-line no-fallthrough default: break; } @@ -285,6 +293,32 @@ function createSelectStatementParser(options: ParseOptions) { return stateMachineStatementParser(statement, steps, options); } +function createBlockStatementParser(options: ParseOptions) { + const statement = createInitialStatement(); + statement.type = 'ANON_BLOCK'; + + const steps: Step[] = [ + // Select + { + preCanGoToNext: () => false, + validation: { + acceptTokens: [ + { type: 'keyword', value: 'DECLARE' }, + { type: 'keyword', value: 'BEGIN' }, + ], + }, + add: (token) => { + if (statement.start < 0) { + statement.start = token.start; + } + }, + postCanGoToNext: () => true, + }, + ]; + + return stateMachineStatementParser(statement, steps, options); +} + function createInsertStatementParser(options: ParseOptions) { const statement = createInitialStatement(); @@ -540,6 +574,9 @@ function stateMachineStatementParser( let prevToken: Token; let prevPrevToken: Token; + let lastBlockOpener: Token; + let anonBlockStarted = false; + let openBlocks = 0; /* eslint arrow-body-style: 0, no-extra-parens: 0 */ @@ -600,11 +637,27 @@ function stateMachineStatementParser( if ( token.type === 'keyword' && blockOpeners[dialect].includes(token.value) && - prevPrevToken.value.toUpperCase() !== 'END' + prevPrevToken?.value.toUpperCase() !== 'END' ) { + if ( + dialect === 'oracle' && + lastBlockOpener?.value === 'DECLARE' && + token.value.toUpperCase() === 'BEGIN' + ) { + // don't open a new block! + setPrevToken(token); + lastBlockOpener = token; + return; + } openBlocks++; + lastBlockOpener = token; setPrevToken(token); - return; + if (statement.type === 'ANON_BLOCK' && !anonBlockStarted) { + anonBlockStarted = true; + // don't return + } else { + return; + } } if ( @@ -614,7 +667,7 @@ function stateMachineStatementParser( statement.parameters.push(token.value); } - if (statement.type) { + if (statement.type && statement.start >= 0) { // statement has already been identified // just wait until end of the statement return; diff --git a/src/tokenizer.ts b/src/tokenizer.ts index 3927265..79dc64e 100644 --- a/src/tokenizer.ts +++ b/src/tokenizer.ts @@ -25,6 +25,9 @@ const KEYWORDS = [ 'WITH', 'AS', 'MATERIALIZED', + 'BEGIN', + 'DECLARE', + 'CASE', ]; const INDIVIDUALS: Record = { diff --git a/test/identifier/multiple-statement.spec.ts b/test/identifier/multiple-statement.spec.ts index 90cfa56..0dbca16 100644 --- a/test/identifier/multiple-statement.spec.ts +++ b/test/identifier/multiple-statement.spec.ts @@ -127,6 +127,104 @@ describe('identifier', () => { expect(actual).to.eql(expected); }); + describe('identifying statements with anonymous blocks', () => { + it('should work in strict mode', () => { + const actual = identify( + ` + DECLARE + PK_NAME VARCHAR(200); + + BEGIN + EXECUTE IMMEDIATE ('CREATE SEQUENCE "untitled_table8_seq"'); + + SELECT + cols.column_name INTO PK_NAME + FROM + all_constraints cons, + all_cons_columns cols + WHERE + cons.constraint_type = 'P' + AND cons.constraint_name = cols.constraint_name + AND cons.owner = cols.owner + AND cols.table_name = 'untitled_table8'; + + execute immediate ( + '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;' + ); + + END; + `, + { dialect: 'oracle', strict: true }, + ); + const expected = [ + { + end: 1043, + executionType: 'ANON_BLOCK', + parameters: [], + start: 11, + 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', + }, + ]; + expect(actual).to.eql(expected); + }); + + it('should identify a create table then a block', () => { + const actual = identify( + ` + create table + "untitled_table8" ( + "id" integer not null primary key, + "created_at" varchar(255) not null + ); + + DECLARE + PK_NAME VARCHAR(200); + + BEGIN + EXECUTE IMMEDIATE ('CREATE SEQUENCE "untitled_table8_seq"'); + + SELECT + cols.column_name INTO PK_NAME + FROM + all_constraints cons, + all_cons_columns cols + WHERE + cons.constraint_type = 'P' + AND cons.constraint_name = cols.constraint_name + AND cons.owner = cols.owner + AND cols.table_name = 'untitled_table8'; + + execute immediate ( + '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;' + ); + + END; + `, + { dialect: 'oracle', strict: false }, + ); + const expected = [ + { + end: 167, + executionType: 'MODIFICATION', + parameters: [], + start: 11, + text: 'create table\n "untitled_table8" (\n "id" integer not null primary key,\n "created_at" varchar(255) not null\n );', + type: 'CREATE_TABLE', + }, + { + end: 1212, + executionType: 'ANON_BLOCK', + parameters: [], + start: 180, + 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', + }, + ]; + expect(actual).to.eql(expected); + }); + }); + describe('identifying multiple statements with CTEs', () => { it('should able to detect queries with a CTE in middle query', () => { const actual = identify( diff --git a/test/index.spec.ts b/test/index.spec.ts index 5d27a55..5ef8026 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -4,7 +4,7 @@ import { expect } from 'chai'; 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, psql, generic', + 'Unknown dialect. Allowed values: mssql, sqlite, mysql, oracle, psql, generic', ); }); diff --git a/test/parser/oracle.spec.ts b/test/parser/oracle.spec.ts new file mode 100644 index 0000000..4b0402d --- /dev/null +++ b/test/parser/oracle.spec.ts @@ -0,0 +1,200 @@ +import { parse } from '../../src/parser'; +import { expect } from 'chai'; + +describe('Parser for oracle', () => { + describe('Given a CASE Statement', () => { + it('should parse a simple case statement', () => { + const sql = `SELECT CASE WHEN a = 'a' THEN 'foo' ELSE 'bar' END CASE from table;`; + const result = parse(sql, false, 'oracle'); + expect(result.body.length).to.eql(1); + }); + }); + + describe('given an anonymous block with an OUT pram', () => { + it('should treat a simple block as a single query', () => { + const sql = `BEGIN + SELECT + cols.column_name INTO :variable + FROM + example_table; + END`; + const result = parse(sql, false, 'oracle'); + expect(result.body.length).to.eql(1); + expect(result.body[0].type).to.eq('ANON_BLOCK'); + expect(result.body[0].start).to.eq(0); + expect(result.body[0].end).to.eq(119); + }); + + it('should easily identify two blocks', () => { + const sql = `BEGIN + SELECT + cols.column_name INTO :variable + FROM + example_table; + END; + + BEGIN + SELECT + cols.column_name INTO :variable + FROM + example_table; + END + `; + const result = parse(sql, false, 'oracle'); + + expect(result.body.length).to.eql(2); + expect(result.body[0].type).to.eq('ANON_BLOCK'); + expect(result.body[0].start).to.eq(0); + expect(result.body[0].end).to.eq(120); + expect(result.body[1].type).to.eq('ANON_BLOCK'); + expect(result.body[1].start).to.eq(131); + expect(result.body[1].end).to.eq(259); + }); + + it('should identify a block query and a normal query together', () => { + const sql = `BEGIN + SELECT + cols.column_name INTO :variable + FROM + example_table; + END; + + select * from another_thing + `; + const result = parse(sql, false, 'oracle'); + expect(result.body.length).to.eql(2); + expect(result.body[0].type).to.eq('ANON_BLOCK'); + expect(result.body[0].start).to.eq(0); + expect(result.body[0].end).to.eq(98); + expect(result.body[1].type).to.eq('SELECT'); + expect(result.body[1].start).to.eq(107); + }); + }); + describe('given an anonymous block with a variable', () => { + it('should treat a block with DECLARE and another query as two separate queries', () => { + const sql = `DECLARE + PK_NAME VARCHAR(200); + BEGIN + SELECT + cols.column_name INTO PK_NAME + FROM + example_table; + END; + + select * from foo; + `; + const result = parse(sql, false, 'oracle'); + expect(result.body.length).to.eql(2); + expect(result.body[0].type).to.eq('ANON_BLOCK'); + expect(result.body[0].start).to.eq(0); + expect(result.body[0].end).to.eq(166); + expect(result.body[1].type).to.eq('SELECT'); + expect(result.body[1].start).to.eq(177); + }); + + it('Should treat a block with two queries as a single query', () => { + const sql = ` + DECLARE + PK_NAME VARCHAR(200); + FOO integer; + + BEGIN + SELECT + cols.column_name INTO PK_NAME + FROM + example_table; + SELECT 1 INTO FOO from other_example; + END; + `; + const result = parse(sql, false, 'oracle'); + expect(result.body.length).to.eql(1); + }); + + it('Should treat a complex block as a single query', () => { + const sql = ` + DECLARE + PK_NAME VARCHAR(200); + + BEGIN + EXECUTE IMMEDIATE ('CREATE SEQUENCE "untitled_table3_seq"'); + + SELECT + cols.column_name INTO PK_NAME + FROM + all_constraints cons, + all_cons_columns cols + WHERE + cons.constraint_type = 'P' + AND cons.constraint_name = cols.constraint_name + AND cons.owner = cols.owner + AND cols.table_name = 'untitled_table3'; + + execute immediate ( + 'create or replace trigger "untitled_table3_autoinc_trg" BEFORE INSERT on "untitled_table3" for each row declare checking number := 1; begin if (:new."' || PK_NAME || '" is null) then while checking >= 1 loop select "untitled_table3_seq".nextval into :new."' || PK_NAME || '" from dual; select count("' || PK_NAME || '") into checking from "untitled_table3" where "' || PK_NAME || '" = :new."' || PK_NAME || '"; end loop; end if; end;' + ); + END; + `; + const result = parse(sql, false, 'oracle'); + expect(result.body.length).to.eql(1); + }); + + it('should identify a compound statement with a nested compound statement as a single statement', () => { + const sql = `DECLARE + n_emp_id EMPLOYEES.EMPLOYEE_ID%TYPE := &emp_id1; + BEGIN + DECLARE + n_emp_id employees.employee_id%TYPE := &emp_id2; + v_name employees.first_name%TYPE; + BEGIN + SELECT first_name, CASE foo WHEN 'a' THEN 1 ELSE 2 END CASE as other + INTO v_name + FROM employees + WHERE employee_id = n_emp_id; + + DBMS_OUTPUT.PUT_LINE('First name of employee ' || n_emp_id || + ' is ' || v_name); + EXCEPTION + WHEN no_data_found THEN + DBMS_OUTPUT.PUT_LINE('Employee ' || n_emp_id || ' not found'); + END; + END;`; + // yes this is still just one statement. + const result = parse(sql, false, 'oracle'); + expect(result.body.length).to.eql(1); + }); + it('should identify a block query after a create table query', () => { + const sql = `create table + "untitled_table8" ( + "id" integer not null primary key, + "created_at" varchar(255) not null + ); + + DECLARE + PK_NAME VARCHAR(200); + + BEGIN + EXECUTE IMMEDIATE ('CREATE SEQUENCE "untitled_table8_seq"'); + + SELECT + cols.column_name INTO PK_NAME + FROM + all_constraints cons, + all_cons_columns cols + WHERE + cons.constraint_type = 'P' + AND cons.constraint_name = cols.constraint_name + AND cons.owner = cols.owner + AND cols.table_name = 'untitled_table8'; + + execute immediate ( + '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;' + ); + + END;`; + const result = parse(sql, false, 'oracle'); + expect(result.body.length).to.eq(2); + expect(result.body[0].type).to.eq('CREATE_TABLE'); + expect(result.body[1].type).to.eq('ANON_BLOCK'); + }); + }); +});