Skip to content

Commit 991ba57

Browse files
committed
custom parameters all seem to be working, still have a few more tests to write
1 parent c17b973 commit 991ba57

File tree

5 files changed

+344
-9
lines changed

5 files changed

+344
-9
lines changed

src/defines.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,21 @@ export type StatementType =
7676

7777
export type ExecutionType = 'LISTING' | 'MODIFICATION' | 'INFORMATION' | 'ANON_BLOCK' | 'UNKNOWN';
7878

79+
export interface ParamTypes {
80+
positional?: boolean,
81+
numbered?: Array<"?" | ":" | "$">,
82+
named?: Array<":" | "@" | "$">,
83+
quoted?: Array<":" | "@" | "$">,
84+
// regex is for identifying that it is a param, key is how the token is translated to an object value for the formatter,
85+
// may not be necessary here, we shal see
86+
custom?: Array<{regex: string, key?: (text: string) => string }>
87+
}
88+
7989
export interface IdentifyOptions {
8090
strict?: boolean;
8191
dialect?: Dialect;
8292
identifyTables?: boolean;
93+
paramTypes?: ParamTypes
8394
}
8495

8596
export interface IdentifyResult {

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function identify(query: string, options: IdentifyOptions = {}): Identify
2121
throw new Error(`Unknown dialect. Allowed values: ${DIALECTS.join(', ')}`);
2222
}
2323

24-
const result = parse(query, isStrict, dialect, options.identifyTables);
24+
const result = parse(query, isStrict, dialect, options.identifyTables, options.paramTypes);
2525

2626
return result.body.map((statement) => {
2727
const result: IdentifyResult = {

src/parser.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
Step,
1010
ParseResult,
1111
ConcreteStatement,
12+
ParamTypes,
1213
} from './defines';
1314

1415
interface StatementParser {
@@ -144,6 +145,7 @@ export function parse(
144145
isStrict = true,
145146
dialect: Dialect = 'generic',
146147
identifyTables = false,
148+
paramTypes?: ParamTypes
147149
): ParseResult {
148150
const topLevelState = initState({ input });
149151
const topLevelStatement: ParseResult = {
@@ -174,7 +176,7 @@ export function parse(
174176

175177
while (prevState.position < topLevelState.end) {
176178
const tokenState = initState({ prevState });
177-
const token = scanToken(tokenState, dialect);
179+
const token = scanToken(tokenState, dialect, paramTypes);
178180
const nextToken = nextNonWhitespaceToken(tokenState, dialect);
179181

180182
if (!statementParser) {

src/tokenizer.ts

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Tokenizer
33
*/
44

5-
import type { Token, State, Dialect } from './defines';
5+
import type { Token, State, Dialect, ParamTypes } from './defines';
66

77
type Char = string | null;
88

@@ -76,7 +76,7 @@ const ENDTOKENS: Record<string, Char> = {
7676
'[': ']',
7777
};
7878

79-
export function scanToken(state: State, dialect: Dialect = 'generic'): Token {
79+
export function scanToken(state: State, dialect: Dialect = 'generic', paramTypes?: ParamTypes): Token {
8080
const ch = read(state);
8181

8282
if (isWhitespace(ch)) {
@@ -95,8 +95,8 @@ export function scanToken(state: State, dialect: Dialect = 'generic'): Token {
9595
return scanString(state, ENDTOKENS[ch]);
9696
}
9797

98-
if (isParameter(ch, state, dialect)) {
99-
return scanParameter(state, dialect);
98+
if (isParameter(ch, state, dialect, paramTypes)) {
99+
return scanParameter(state, dialect, paramTypes);
100100
}
101101

102102
if (isDollarQuotedString(state)) {
@@ -253,7 +253,88 @@ function scanString(state: State, endToken: Char): Token {
253253
};
254254
}
255255

256-
function scanParameter(state: State, dialect: Dialect): Token {
256+
function getCustomParam(state: State, paramTypes: ParamTypes): string | null | undefined {
257+
const matches = paramTypes?.custom?.map(({ regex }) => {
258+
const reg = new RegExp(`(?:${regex})`, 'u');
259+
return reg.exec(state.input);
260+
}).filter((value) => !!value)[0];
261+
262+
return matches ? matches[0] : null;
263+
}
264+
265+
function scanParameter(state: State, dialect: Dialect, paramTypes?: ParamTypes): Token {
266+
// user has defined wanted param types, so we only evaluate them
267+
if (paramTypes) {
268+
const curCh: any = state.input[0];
269+
let nextChar = peek(state);
270+
let matched = false
271+
272+
// this could be a named parameter that just starts with a number (ugh)
273+
if (paramTypes.numbered && paramTypes.numbered.length && paramTypes.numbered.includes(curCh)) {
274+
const maybeNumbers = state.input.slice(1, state.input.length);
275+
if (nextChar !== null && !isNaN(Number(nextChar)) && /^\d+$/.test(maybeNumbers)) {
276+
do {
277+
nextChar = read(state);
278+
} while (nextChar !== null && !isNaN(Number(nextChar)) && !isWhitespace(nextChar));
279+
280+
if (nextChar !== null) unread(state);
281+
matched = true;
282+
}
283+
}
284+
285+
if (!matched && paramTypes.named && paramTypes.named.length && paramTypes.named.includes(curCh)) {
286+
if (!isQuotedIdentifier(nextChar, dialect)) {
287+
while (isAlphaNumeric(peek(state))) read(state);
288+
matched = true;
289+
}
290+
}
291+
292+
if (!matched && paramTypes.quoted && paramTypes.quoted.length && paramTypes.quoted.includes(curCh)) {
293+
if (isQuotedIdentifier(nextChar, dialect)) {
294+
const endChars = new Map<string, string>([
295+
['"', '"'],
296+
['[', ']'],
297+
['`', '`']
298+
]);
299+
const quoteChar = read(state) as string;
300+
const end = endChars.get(quoteChar);
301+
// end when we reach the end quote
302+
while ((isAlphaNumeric(peek(state)) || peek(state) === ' ') && peek(state) != end) read(state);
303+
304+
// read the end quote
305+
read(state);
306+
307+
matched = true;
308+
}
309+
}
310+
311+
if (!matched && paramTypes.custom && paramTypes.custom.length) {
312+
const custom = getCustomParam(state, paramTypes);
313+
314+
if (custom) {
315+
read(state, custom.length);
316+
matched = true;
317+
}
318+
}
319+
320+
if (!matched && curCh !== '?' && nextChar !== null) { // not positional, panic
321+
return {
322+
type: 'parameter',
323+
value: 'unknown',
324+
start: state.start,
325+
end: state.end
326+
}
327+
}
328+
329+
const value = state.input.slice(state.start, state.position + 1);
330+
return {
331+
type: 'parameter',
332+
value,
333+
start: state.start,
334+
end: state.start + value.length - 1,
335+
};
336+
}
337+
257338
if (['mysql', 'generic', 'sqlite'].includes(dialect)) {
258339
return {
259340
type: 'parameter',
@@ -413,7 +494,37 @@ function isString(ch: Char, dialect: Dialect): boolean {
413494
return stringStart.includes(ch);
414495
}
415496

416-
function isParameter(ch: Char, state: State, dialect: Dialect): boolean {
497+
function isCustomParam(state: State, paramTypes: ParamTypes): boolean | undefined {
498+
return paramTypes?.custom?.some(({ regex }) => {
499+
const reg = new RegExp(`(?:${regex})`, 'uy');
500+
return reg.test(state.input);
501+
})
502+
}
503+
504+
function isParameter(ch: Char, state: State, dialect: Dialect, paramTypes?: ParamTypes): boolean {
505+
if (paramTypes && ch !== null) {
506+
const curCh: any = ch;
507+
const nextChar = peek(state);
508+
if (paramTypes.positional && ch === '?' && nextChar === null) return true;
509+
510+
if (paramTypes.numbered && paramTypes.numbered.length && paramTypes.numbered.includes(curCh)) {
511+
if (nextChar !== null && !isNaN(Number(nextChar))) {
512+
return true;
513+
}
514+
}
515+
516+
if ((paramTypes.named && paramTypes.named.length && paramTypes.named.includes(curCh)) ||
517+
(paramTypes.quoted && paramTypes.quoted.length && paramTypes.quoted.includes(curCh))) {
518+
return true;
519+
}
520+
521+
if ((paramTypes.custom && paramTypes.custom.length && isCustomParam(state, paramTypes))) {
522+
return true
523+
}
524+
525+
return false;
526+
}
527+
417528
let pStart = '?'; // ansi standard - sqlite, mysql
418529
if (dialect === 'psql') {
419530
pStart = '$';

0 commit comments

Comments
 (0)