Skip to content

Commit 5f5554c

Browse files
authored
Custom paramTypes config (#582)
2 parents 949569b + 0a3d401 commit 5f5554c

File tree

6 files changed

+112
-2
lines changed

6 files changed

+112
-2
lines changed

docs/paramTypes.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ An object with the following following optional fields:
7070
For example in MySQL using `paramTypes: {quoted: [':']}` would allow you to use `` :`name` `` syntax,
7171
while in Transact-SQL `:"name"` and `:[name]` would work instead.
7272
See [identifier syntax wiki page][] for information about differences in support quoted identifiers.
73+
- **`custom`**: `Array<{ regex: string, key?: (text: string) => string }>`.
74+
An option to implement custom syntax for parameter placeholders. See below for details.
7375

7476
Note that using this config will override the by default supported placeholders types.
7577
For example PL/SQL supports numbered (`:1`) and named (`:name`) placeholders by default.
@@ -89,5 +91,53 @@ The result will be:
8991

9092
This config option can be used together with [params][] to substitute the placeholders with actual values.
9193

94+
## Custom parameter syntax
95+
96+
Say, you'd like to support the `{name}` parameter placeholders in this SQL:
97+
98+
```sql
99+
SELECT id, fname, age FROM person WHERE lname = {lname} AND age > {age};
100+
```
101+
102+
You can define a regex pattern to match the custom parameters:
103+
104+
```js
105+
paramTypes: {
106+
custom: [{ regex: '\\{[a-zA-Z0-9_]+\\}' }];
107+
}
108+
```
109+
110+
Note the double backslashes. You can get around the double-escaping problem by using `String.raw`:
111+
112+
```js
113+
paramTypes: {
114+
custom: [{ regex: String.raw`\{[a-zA-Z0-9_]+\}` }];
115+
}
116+
```
117+
118+
You can also use the [params][] option to substitute values of these parameters.
119+
However by default the parameter names contain the whole string that is matched by the regex:
120+
121+
```js
122+
params: { '{lname}': 'Doe', '{age}': '25' },
123+
```
124+
125+
To get around this, you can also specify the `key` function to extract the name of the parameter:
126+
127+
```js
128+
paramTypes: {
129+
custom: [{
130+
regex: String.raw`\{[a-zA-Z0-9_]+\}`
131+
key: (text) => text.slice(1, -1), // discard first and last char
132+
}]
133+
}
134+
```
135+
136+
Now you can refer to the parameters by their actual name:
137+
138+
```js
139+
params: { 'lname': 'Doe', 'age': '25' },
140+
```
141+
92142
[params]: ./params.md
93143
[identifier syntax wiki page]: https://github.com/sql-formatter-org/sql-formatter/wiki/identifiers

src/lexer/Tokenizer.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Token, TokenType } from './token.js';
22
import * as regex from './regexFactory.js';
33
import { ParamTypes, TokenizerOptions } from './TokenizerOptions.js';
44
import TokenizerEngine, { TokenRule } from './TokenizerEngine.js';
5-
import { escapeRegExp } from './regexUtil.js';
5+
import { escapeRegExp, patternToRegex } from './regexUtil.js';
66
import { equalizeWhitespace, Optional } from '../utils.js';
77
import { NestedComment } from './NestedComment.js';
88

@@ -196,6 +196,7 @@ export default class Tokenizer {
196196
typeof paramTypesOverrides?.positional === 'boolean'
197197
? paramTypesOverrides.positional
198198
: cfg.paramTypes?.positional,
199+
custom: paramTypesOverrides?.custom || cfg.paramTypes?.custom || [],
199200
};
200201

201202
return this.validRules([
@@ -226,6 +227,13 @@ export default class Tokenizer {
226227
type: TokenType.POSITIONAL_PARAMETER,
227228
regex: paramTypes.positional ? /[?]/y : undefined,
228229
},
230+
...paramTypes.custom.map(
231+
(customParam): TokenRule => ({
232+
type: TokenType.CUSTOM_PARAMETER,
233+
regex: patternToRegex(customParam.regex),
234+
key: customParam.key ?? (v => v),
235+
})
236+
),
229237
]);
230238
}
231239

src/lexer/TokenizerOptions.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ export interface ParamTypes {
4040
// Prefixes for quoted parameter placeholders to support, e.g. :"name"
4141
// The type of quotes will depend on `identifierTypes` option.
4242
quoted?: (':' | '@' | '$')[];
43+
// Custom parameter type definitions
44+
custom?: CustomParameter[];
45+
}
46+
47+
export interface CustomParameter {
48+
// Regex pattern for matching the parameter
49+
regex: string;
50+
// Takes the matched parameter string and returns the name of the parameter
51+
// For example we might match "{foo}" and the name would be "foo".
52+
key?: (text: string) => string;
4353
}
4454

4555
export interface TokenizerOptions {

src/lexer/token.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export enum TokenType {
3636
QUOTED_PARAMETER = 'QUOTED_PARAMETER',
3737
NUMBERED_PARAMETER = 'NUMBERED_PARAMETER',
3838
POSITIONAL_PARAMETER = 'POSITIONAL_PARAMETER',
39+
CUSTOM_PARAMETER = 'CUSTOM_PARAMETER',
3940
DELIMITER = 'DELIMITER',
4041
EOF = 'EOF',
4142
}

src/parser/grammar.ne

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,8 @@ parameter ->
314314
( %NAMED_PARAMETER
315315
| %QUOTED_PARAMETER
316316
| %NUMBERED_PARAMETER
317-
| %POSITIONAL_PARAMETER ) {% ([[token]]) => ({ type: NodeType.parameter, key: token.key, text: token.text }) %}
317+
| %POSITIONAL_PARAMETER
318+
| %CUSTOM_PARAMETER ) {% ([[token]]) => ({ type: NodeType.parameter, key: token.key, text: token.text }) %}
318319

319320
literal ->
320321
( %NUMBER

test/options/paramTypes.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,44 @@ export default function supportsParamTypes(format: FormatFn) {
5656
//
5757
// - it likely works when the other paramTypes tests work
5858
// - it's the config that's least likely to be actually used in practice.
59+
60+
describe('when paramTypes.custom=[...]', () => {
61+
it('replaces %blah% numbered placeholders with param values', () => {
62+
const result = format('SELECT %1%, %2%, %3%;', {
63+
paramTypes: { custom: [{ regex: '%[0-9]+%' }] },
64+
params: { '%1%': 'first', '%2%': 'second', '%3%': 'third' },
65+
});
66+
expect(result).toBe(dedent`
67+
SELECT
68+
first,
69+
second,
70+
third;
71+
`);
72+
});
73+
74+
it('supports custom function for extracting parameter name', () => {
75+
const result = format('SELECT %1%, %2%, %3%;', {
76+
paramTypes: { custom: [{ regex: '%[0-9]+%', key: v => v.slice(1, -1) }] },
77+
params: { '1': 'first', '2': 'second', '3': 'third' },
78+
});
79+
expect(result).toBe(dedent`
80+
SELECT
81+
first,
82+
second,
83+
third;
84+
`);
85+
});
86+
87+
it('supports multiple custom param types', () => {
88+
const result = format('SELECT %1%, {2};', {
89+
paramTypes: { custom: [{ regex: '%[0-9]+%' }, { regex: String.raw`\{[0-9]\}` }] },
90+
params: { '%1%': 'first', '{2}': 'second' },
91+
});
92+
expect(result).toBe(dedent`
93+
SELECT
94+
first,
95+
second;
96+
`);
97+
});
98+
});
5999
}

0 commit comments

Comments
 (0)