Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions src/defines.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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;
Expand Down
61 changes: 57 additions & 4 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,17 @@ export const EXECUTION_TYPES: Record<StatementType, ExecutionType> = {
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<Dialect, string[]> = {
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 {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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 (
Expand All @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/tokenizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ const KEYWORDS = [
'WITH',
'AS',
'MATERIALIZED',
'BEGIN',
'DECLARE',
'CASE',
];

const INDIVIDUALS: Record<string, Token['type']> = {
Expand Down
98 changes: 98 additions & 0 deletions test/identifier/multiple-statement.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Copy link
Member

@MasterOdin MasterOdin May 30, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why have this test repeated here and in parser/oracle.spec.ts? Given that identify is a relative shallow operation over the result of the parser, I'd rather just beef up the asserts there, and get rid of this test.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just as an idiot check to be honest. Costs very little, gives me some assurances

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For example, I changed the execution type that this returns, the tests reflect that. Nothing wrong with over testing.

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(
Expand Down
2 changes: 1 addition & 1 deletion test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
});

Expand Down
Loading