@syncular/typegen 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -32
- package/dist/cli.js +40 -17
- package/dist/emit-queries-dart.js +168 -55
- package/dist/emit-queries-kotlin.js +167 -55
- package/dist/emit-queries-swift.js +183 -57
- package/dist/emit-queries.js +286 -123
- package/dist/fmt.d.ts +2 -2
- package/dist/fmt.js +348 -316
- package/dist/generate.d.ts +1 -1
- package/dist/generate.js +28 -9
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/lsp.d.ts +1 -3
- package/dist/lsp.js +349 -187
- package/dist/manifest.d.ts +1 -3
- package/dist/manifest.js +1 -1
- package/dist/query-ir.js +53 -42
- package/dist/query.d.ts +110 -63
- package/dist/query.js +89 -11
- package/dist/syql-ast.d.ts +12 -13
- package/dist/syql-ast.js +26 -20
- package/dist/syql-lexer.d.ts +2 -0
- package/dist/syql-lexer.js +9 -2
- package/dist/syql-lowering.d.ts +22 -0
- package/dist/syql-lowering.js +434 -0
- package/dist/syql-parser.d.ts +19 -18
- package/dist/syql-parser.js +341 -93
- package/dist/syql-semantics.d.ts +73 -0
- package/dist/syql-semantics.js +607 -0
- package/dist/syql-template-parser.d.ts +5 -20
- package/dist/syql-template-parser.js +116 -109
- package/dist/syql-validator.d.ts +35 -0
- package/dist/syql-validator.js +993 -0
- package/package.json +3 -3
- package/src/cli.ts +49 -21
- package/src/emit-queries-dart.ts +195 -69
- package/src/emit-queries-kotlin.ts +197 -69
- package/src/emit-queries-swift.ts +224 -68
- package/src/emit-queries.ts +410 -165
- package/src/fmt.ts +405 -303
- package/src/generate.ts +43 -9
- package/src/index.ts +3 -1
- package/src/lsp.ts +414 -216
- package/src/manifest.ts +2 -4
- package/src/query-ir.ts +52 -42
- package/src/query.ts +229 -79
- package/src/syql-ast.ts +38 -34
- package/src/syql-lexer.ts +12 -1
- package/src/syql-lowering.ts +664 -0
- package/src/syql-parser.ts +472 -134
- package/src/syql-semantics.ts +930 -0
- package/src/syql-template-parser.ts +119 -163
- package/src/syql-validator.ts +1486 -0
- package/dist/syql.d.ts +0 -102
- package/dist/syql.js +0 -1141
- package/src/syql.ts +0 -1470
package/dist/syql.js
DELETED
|
@@ -1,1141 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The `.syql` frontend (DESIGN-queries.md §3–§7): a functional CONTAINER —
|
|
3
|
-
* GraphQL-style signatures + SQLDelight-style inference — around SQL
|
|
4
|
-
* expressions. A file holds any number of `query` and `fragment`
|
|
5
|
-
* declarations:
|
|
6
|
-
*
|
|
7
|
-
* ```text
|
|
8
|
-
* fragment visibleIn(listId) {
|
|
9
|
-
* list_id = :listId and archived_at is null
|
|
10
|
-
* }
|
|
11
|
-
*
|
|
12
|
-
* query listTodos(listId, status?, from+to?, unassigned?: flag)
|
|
13
|
-
* orderBy position | created_at | title default position
|
|
14
|
-
* limit max 200 default 50
|
|
15
|
-
* {
|
|
16
|
-
* select id, title, done, created_at
|
|
17
|
-
* from todos
|
|
18
|
-
* where @visibleIn(:listId)
|
|
19
|
-
* and status = :status
|
|
20
|
-
* and created_at between :from and :to
|
|
21
|
-
* and if (:unassigned) { assignee_id is null }
|
|
22
|
-
* }
|
|
23
|
-
* ```
|
|
24
|
-
*
|
|
25
|
-
* Everything composes at GENERATE time: fragments splice textually (values
|
|
26
|
-
* only — args are `:param` refs), optional params lower to §7 neutralization
|
|
27
|
-
* guards (`:p IS NULL OR (…)`), knobs lower to a checked allowlist
|
|
28
|
-
* (orderBy) and a bound+clamped param (limit), and the result is ONE plain
|
|
29
|
-
* SQL statement that rides the exact same SQLite-check + naming-lowering
|
|
30
|
-
* pipeline as the `.sql` tier. Nothing below the produced `AnalyzedQuery`
|
|
31
|
-
* knows which frontend a query came from (§1).
|
|
32
|
-
*
|
|
33
|
-
* Conditional rules (§4, recommendation B with A as primitive):
|
|
34
|
-
* - AUTO-GUARD: a top-level WHERE conjunct that mentions optional params
|
|
35
|
-
* applies only when ALL of them are provided.
|
|
36
|
-
* - B1: an optional param outside a top-level conjunct (projection, FROM,
|
|
37
|
-
* subquery, or under an OR inside its conjunct) is a loud error telling
|
|
38
|
-
* the author to write an explicit `if` guard or make it required.
|
|
39
|
-
* - B2: `if (:p, …) { predicate }` is the explicit primitive — required for
|
|
40
|
-
* flag params (which never appear in a predicate as written).
|
|
41
|
-
*/
|
|
42
|
-
import { TypegenError } from './errors.js';
|
|
43
|
-
import { snakeToCamel } from './naming.js';
|
|
44
|
-
import { analyzeStatement, toPositionalSql, validateOverrideName, } from './query.js';
|
|
45
|
-
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
46
|
-
// ---------------------------------------------------------------------------
|
|
47
|
-
// File parser
|
|
48
|
-
// ---------------------------------------------------------------------------
|
|
49
|
-
/** Cursor over the file with comment/whitespace skipping. */
|
|
50
|
-
class Cursor {
|
|
51
|
-
src;
|
|
52
|
-
file;
|
|
53
|
-
i = 0;
|
|
54
|
-
constructor(src, file) {
|
|
55
|
-
this.src = src;
|
|
56
|
-
this.file = file;
|
|
57
|
-
}
|
|
58
|
-
fail(message) {
|
|
59
|
-
const line = this.src.slice(0, this.i).split('\n').length;
|
|
60
|
-
throw new TypegenError(`${this.file}:${line}`, message);
|
|
61
|
-
}
|
|
62
|
-
/** Skip whitespace and `--`/`/* */` comments. */
|
|
63
|
-
skip() {
|
|
64
|
-
for (;;) {
|
|
65
|
-
while (this.i < this.src.length && /\s/.test(this.src[this.i]))
|
|
66
|
-
this.i += 1;
|
|
67
|
-
if (this.src.startsWith('--', this.i)) {
|
|
68
|
-
const nl = this.src.indexOf('\n', this.i);
|
|
69
|
-
this.i = nl === -1 ? this.src.length : nl + 1;
|
|
70
|
-
}
|
|
71
|
-
else if (this.src.startsWith('/*', this.i)) {
|
|
72
|
-
const end = this.src.indexOf('*/', this.i + 2);
|
|
73
|
-
this.i = end === -1 ? this.src.length : end + 2;
|
|
74
|
-
}
|
|
75
|
-
else {
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
atEnd() {
|
|
81
|
-
this.skip();
|
|
82
|
-
return this.i >= this.src.length;
|
|
83
|
-
}
|
|
84
|
-
/** Read one identifier-shaped word. */
|
|
85
|
-
word(what) {
|
|
86
|
-
this.skip();
|
|
87
|
-
const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(this.src.slice(this.i));
|
|
88
|
-
if (m === null)
|
|
89
|
-
this.fail(`expected ${what}`);
|
|
90
|
-
this.i += m[0].length;
|
|
91
|
-
return m[0];
|
|
92
|
-
}
|
|
93
|
-
/** Peek the next identifier-shaped word without consuming. */
|
|
94
|
-
peekWord() {
|
|
95
|
-
this.skip();
|
|
96
|
-
const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(this.src.slice(this.i));
|
|
97
|
-
return m === null ? null : m[0];
|
|
98
|
-
}
|
|
99
|
-
/** Consume one exact character. */
|
|
100
|
-
expect(ch) {
|
|
101
|
-
this.skip();
|
|
102
|
-
if (this.src[this.i] !== ch) {
|
|
103
|
-
this.fail(`expected ${JSON.stringify(ch)}`);
|
|
104
|
-
}
|
|
105
|
-
this.i += 1;
|
|
106
|
-
}
|
|
107
|
-
peekChar() {
|
|
108
|
-
this.skip();
|
|
109
|
-
return this.src[this.i];
|
|
110
|
-
}
|
|
111
|
-
/** Read a non-negative integer literal. */
|
|
112
|
-
int(what) {
|
|
113
|
-
this.skip();
|
|
114
|
-
const m = /^\d+/.exec(this.src.slice(this.i));
|
|
115
|
-
if (m === null)
|
|
116
|
-
this.fail(`expected ${what} (an integer)`);
|
|
117
|
-
this.i += m[0].length;
|
|
118
|
-
return Number.parseInt(m[0], 10);
|
|
119
|
-
}
|
|
120
|
-
/** Read a balanced `{ … }` block (string/comment aware); returns the
|
|
121
|
-
* INNER text. */
|
|
122
|
-
braceBlock() {
|
|
123
|
-
this.expect('{');
|
|
124
|
-
const start = this.i;
|
|
125
|
-
let depth = 1;
|
|
126
|
-
while (this.i < this.src.length) {
|
|
127
|
-
const ch = this.src[this.i];
|
|
128
|
-
if (ch === "'") {
|
|
129
|
-
this.i += 1;
|
|
130
|
-
while (this.i < this.src.length) {
|
|
131
|
-
if (this.src[this.i] === "'") {
|
|
132
|
-
if (this.src[this.i + 1] === "'")
|
|
133
|
-
this.i += 2;
|
|
134
|
-
else {
|
|
135
|
-
this.i += 1;
|
|
136
|
-
break;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
else
|
|
140
|
-
this.i += 1;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
else if (this.src.startsWith('--', this.i)) {
|
|
144
|
-
const nl = this.src.indexOf('\n', this.i);
|
|
145
|
-
this.i = nl === -1 ? this.src.length : nl + 1;
|
|
146
|
-
}
|
|
147
|
-
else if (this.src.startsWith('/*', this.i)) {
|
|
148
|
-
const end = this.src.indexOf('*/', this.i + 2);
|
|
149
|
-
this.i = end === -1 ? this.src.length : end + 2;
|
|
150
|
-
}
|
|
151
|
-
else if (ch === '{') {
|
|
152
|
-
depth += 1;
|
|
153
|
-
this.i += 1;
|
|
154
|
-
}
|
|
155
|
-
else if (ch === '}') {
|
|
156
|
-
depth -= 1;
|
|
157
|
-
this.i += 1;
|
|
158
|
-
if (depth === 0)
|
|
159
|
-
return this.src.slice(start, this.i - 1);
|
|
160
|
-
}
|
|
161
|
-
else {
|
|
162
|
-
this.i += 1;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
this.fail('unterminated { … } block');
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
function parseSignature(cur, owner) {
|
|
169
|
-
cur.expect('(');
|
|
170
|
-
const params = [];
|
|
171
|
-
const seen = new Set();
|
|
172
|
-
const push = (p) => {
|
|
173
|
-
if (!IDENT_RE.test(p.name)) {
|
|
174
|
-
cur.fail(`${owner}: invalid param name ${JSON.stringify(p.name)}`);
|
|
175
|
-
}
|
|
176
|
-
if (seen.has(p.name)) {
|
|
177
|
-
cur.fail(`${owner}: duplicate param ${JSON.stringify(p.name)}`);
|
|
178
|
-
}
|
|
179
|
-
seen.add(p.name);
|
|
180
|
-
params.push(p);
|
|
181
|
-
};
|
|
182
|
-
cur.skip();
|
|
183
|
-
if (cur.peekChar() === ')') {
|
|
184
|
-
cur.expect(')');
|
|
185
|
-
return params;
|
|
186
|
-
}
|
|
187
|
-
for (;;) {
|
|
188
|
-
const first = cur.word(`${owner}: param name`);
|
|
189
|
-
cur.skip();
|
|
190
|
-
if (cur.peekChar() === '+') {
|
|
191
|
-
// `from+to?` — an optional GROUP: both provided or both omitted.
|
|
192
|
-
cur.expect('+');
|
|
193
|
-
const second = cur.word(`${owner}: second param of the ${first}+ group`);
|
|
194
|
-
cur.skip();
|
|
195
|
-
if (cur.peekChar() !== '?') {
|
|
196
|
-
cur.fail(`${owner}: a ${first}+${second} group must be optional — write ${first}+${second}?`);
|
|
197
|
-
}
|
|
198
|
-
cur.expect('?');
|
|
199
|
-
const group = first;
|
|
200
|
-
push({ name: first, optional: true, group, flag: false });
|
|
201
|
-
push({ name: second, optional: true, group, flag: false });
|
|
202
|
-
}
|
|
203
|
-
else {
|
|
204
|
-
let optional = false;
|
|
205
|
-
if (cur.peekChar() === '?') {
|
|
206
|
-
cur.expect('?');
|
|
207
|
-
optional = true;
|
|
208
|
-
}
|
|
209
|
-
let flag = false;
|
|
210
|
-
cur.skip();
|
|
211
|
-
if (cur.peekChar() === ':') {
|
|
212
|
-
cur.expect(':');
|
|
213
|
-
const anno = cur.word(`${owner}: param annotation`);
|
|
214
|
-
if (anno !== 'flag') {
|
|
215
|
-
cur.fail(`${owner}: unknown annotation ${JSON.stringify(anno)} — \`: flag\` is the only param annotation (§3); every other type is inferred`);
|
|
216
|
-
}
|
|
217
|
-
if (!optional) {
|
|
218
|
-
cur.fail(`${owner}: a flag param is a guard — write ${first}?: flag`);
|
|
219
|
-
}
|
|
220
|
-
flag = true;
|
|
221
|
-
}
|
|
222
|
-
push({ name: first, optional, flag });
|
|
223
|
-
}
|
|
224
|
-
cur.skip();
|
|
225
|
-
if (cur.peekChar() === ',') {
|
|
226
|
-
cur.expect(',');
|
|
227
|
-
continue;
|
|
228
|
-
}
|
|
229
|
-
cur.expect(')');
|
|
230
|
-
return params;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
function parseKnobs(cur, owner) {
|
|
234
|
-
let orderBy;
|
|
235
|
-
let limit;
|
|
236
|
-
let variants;
|
|
237
|
-
const depends = [];
|
|
238
|
-
const windows = [];
|
|
239
|
-
let keyBy;
|
|
240
|
-
const paramList = (label) => {
|
|
241
|
-
const out = [cur.word(`${owner}: ${label} param`)];
|
|
242
|
-
while (cur.peekChar() === '|') {
|
|
243
|
-
cur.expect('|');
|
|
244
|
-
out.push(cur.word(`${owner}: ${label} param`));
|
|
245
|
-
}
|
|
246
|
-
return out;
|
|
247
|
-
};
|
|
248
|
-
for (;;) {
|
|
249
|
-
const word = cur.peekWord();
|
|
250
|
-
if (word === 'variants') {
|
|
251
|
-
if (variants === true)
|
|
252
|
-
cur.fail(`${owner}: duplicate variants knob`);
|
|
253
|
-
cur.word('variants');
|
|
254
|
-
variants = true;
|
|
255
|
-
}
|
|
256
|
-
else if (word === 'depends') {
|
|
257
|
-
cur.word('depends');
|
|
258
|
-
const table = cur.word(`${owner}: dependency table`);
|
|
259
|
-
const on = cur.word(`${owner}: dependency \`on\``);
|
|
260
|
-
if (on !== 'on')
|
|
261
|
-
cur.fail(`${owner}: depends syntax is \`depends <table> on <scope> = <param>\``);
|
|
262
|
-
const variable = cur.word(`${owner}: dependency scope variable`);
|
|
263
|
-
cur.expect('=');
|
|
264
|
-
depends.push({ table, variable, params: paramList('dependency') });
|
|
265
|
-
}
|
|
266
|
-
else if (word === 'window') {
|
|
267
|
-
cur.word('window');
|
|
268
|
-
const table = cur.word(`${owner}: window table`);
|
|
269
|
-
const by = cur.word(`${owner}: window \`by\``);
|
|
270
|
-
if (by !== 'by')
|
|
271
|
-
cur.fail(`${owner}: window syntax is \`window <table> by <scope> = <param>\``);
|
|
272
|
-
const variable = cur.word(`${owner}: window scope variable`);
|
|
273
|
-
cur.expect('=');
|
|
274
|
-
const units = paramList('window unit');
|
|
275
|
-
const fixedScopes = [];
|
|
276
|
-
if (cur.peekWord() === 'fixed') {
|
|
277
|
-
cur.word('fixed');
|
|
278
|
-
for (;;) {
|
|
279
|
-
const fixedVariable = cur.word(`${owner}: fixed scope variable`);
|
|
280
|
-
cur.expect('=');
|
|
281
|
-
const param = cur.word(`${owner}: fixed scope param`);
|
|
282
|
-
fixedScopes.push({ variable: fixedVariable, param });
|
|
283
|
-
if (cur.peekChar() !== ',')
|
|
284
|
-
break;
|
|
285
|
-
cur.expect(',');
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
windows.push({ table, variable, units, fixedScopes });
|
|
289
|
-
}
|
|
290
|
-
else if (word === 'key') {
|
|
291
|
-
if (keyBy !== undefined)
|
|
292
|
-
cur.fail(`${owner}: duplicate key by declaration`);
|
|
293
|
-
cur.word('key');
|
|
294
|
-
const by = cur.word(`${owner}: key \`by\``);
|
|
295
|
-
if (by !== 'by')
|
|
296
|
-
cur.fail(`${owner}: key syntax is \`key by <result-column>\``);
|
|
297
|
-
keyBy = paramList('key column');
|
|
298
|
-
}
|
|
299
|
-
else if (word === 'orderBy') {
|
|
300
|
-
if (orderBy !== undefined)
|
|
301
|
-
cur.fail(`${owner}: duplicate orderBy knob`);
|
|
302
|
-
cur.word('orderBy');
|
|
303
|
-
const allowed = [];
|
|
304
|
-
allowed.push(cur.word(`${owner}: orderBy column`));
|
|
305
|
-
cur.skip();
|
|
306
|
-
while (cur.peekChar() === '|') {
|
|
307
|
-
cur.expect('|');
|
|
308
|
-
allowed.push(cur.word(`${owner}: orderBy column`));
|
|
309
|
-
cur.skip();
|
|
310
|
-
}
|
|
311
|
-
const kw = cur.word(`${owner}: orderBy default`);
|
|
312
|
-
if (kw !== 'default') {
|
|
313
|
-
cur.fail(`${owner}: orderBy needs \`default <column>\` after the allowlist`);
|
|
314
|
-
}
|
|
315
|
-
const defaultColumn = cur.word(`${owner}: orderBy default column`);
|
|
316
|
-
if (!allowed.includes(defaultColumn)) {
|
|
317
|
-
cur.fail(`${owner}: orderBy default ${JSON.stringify(defaultColumn)} is not in the allowlist (${allowed.join(' | ')})`);
|
|
318
|
-
}
|
|
319
|
-
let defaultDir = 'asc';
|
|
320
|
-
const dir = cur.peekWord();
|
|
321
|
-
if (dir === 'asc' || dir === 'desc') {
|
|
322
|
-
cur.word('direction');
|
|
323
|
-
defaultDir = dir;
|
|
324
|
-
}
|
|
325
|
-
const dup = new Set();
|
|
326
|
-
for (const col of allowed) {
|
|
327
|
-
if (dup.has(col)) {
|
|
328
|
-
cur.fail(`${owner}: orderBy lists ${JSON.stringify(col)} twice`);
|
|
329
|
-
}
|
|
330
|
-
dup.add(col);
|
|
331
|
-
}
|
|
332
|
-
orderBy = { allowed, defaultColumn, defaultDir };
|
|
333
|
-
}
|
|
334
|
-
else if (word === 'limit') {
|
|
335
|
-
if (limit !== undefined)
|
|
336
|
-
cur.fail(`${owner}: duplicate limit knob`);
|
|
337
|
-
cur.word('limit');
|
|
338
|
-
let max;
|
|
339
|
-
let def;
|
|
340
|
-
for (;;) {
|
|
341
|
-
const part = cur.peekWord();
|
|
342
|
-
if (part === 'max' && max === undefined) {
|
|
343
|
-
cur.word('max');
|
|
344
|
-
max = cur.int(`${owner}: limit max`);
|
|
345
|
-
}
|
|
346
|
-
else if (part === 'default' && def === undefined) {
|
|
347
|
-
cur.word('default');
|
|
348
|
-
def = cur.int(`${owner}: limit default`);
|
|
349
|
-
}
|
|
350
|
-
else {
|
|
351
|
-
break;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
if (max === undefined && def === undefined) {
|
|
355
|
-
cur.fail(`${owner}: limit needs \`max <n>\` and/or \`default <n>\``);
|
|
356
|
-
}
|
|
357
|
-
if (max !== undefined && def !== undefined && def > max) {
|
|
358
|
-
cur.fail(`${owner}: limit default ${def} exceeds max ${max}`);
|
|
359
|
-
}
|
|
360
|
-
limit = {
|
|
361
|
-
...(max !== undefined ? { max } : {}),
|
|
362
|
-
...(def !== undefined ? { default: def } : {}),
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
else {
|
|
366
|
-
return {
|
|
367
|
-
...(orderBy !== undefined ? { orderBy } : {}),
|
|
368
|
-
...(limit !== undefined ? { limit } : {}),
|
|
369
|
-
...(variants !== undefined ? { variants } : {}),
|
|
370
|
-
...(depends.length > 0 ? { depends } : {}),
|
|
371
|
-
...(windows.length > 0 ? { windows } : {}),
|
|
372
|
-
...(keyBy !== undefined ? { keyBy } : {}),
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
/** Parse one `.syql` file into its declarations (no lowering yet). */
|
|
378
|
-
export function parseSyqlFile(file, content) {
|
|
379
|
-
const cur = new Cursor(content, file);
|
|
380
|
-
const fragments = [];
|
|
381
|
-
const queries = [];
|
|
382
|
-
const names = new Set();
|
|
383
|
-
while (!cur.atEnd()) {
|
|
384
|
-
cur.skip();
|
|
385
|
-
const offset = cur.i;
|
|
386
|
-
const kw = cur.word('`query` or `fragment`');
|
|
387
|
-
if (kw !== 'query' && kw !== 'fragment') {
|
|
388
|
-
cur.fail(`expected \`query\` or \`fragment\`, found ${JSON.stringify(kw)}`);
|
|
389
|
-
}
|
|
390
|
-
const name = cur.word(`${kw} name`);
|
|
391
|
-
validateOverrideName(name, file);
|
|
392
|
-
if (names.has(name)) {
|
|
393
|
-
cur.fail(`duplicate declaration ${JSON.stringify(name)} in ${file}`);
|
|
394
|
-
}
|
|
395
|
-
names.add(name);
|
|
396
|
-
const owner = `${kw} ${name}`;
|
|
397
|
-
const params = parseSignature(cur, owner);
|
|
398
|
-
if (kw === 'fragment') {
|
|
399
|
-
cur.skip();
|
|
400
|
-
const body = cur.braceBlock().trim();
|
|
401
|
-
if (body.length === 0)
|
|
402
|
-
cur.fail(`${owner}: empty fragment body`);
|
|
403
|
-
fragments.push({ name, params, body, offset });
|
|
404
|
-
}
|
|
405
|
-
else {
|
|
406
|
-
const knobs = parseKnobs(cur, owner);
|
|
407
|
-
cur.skip();
|
|
408
|
-
const body = cur.braceBlock().trim();
|
|
409
|
-
if (body.length === 0)
|
|
410
|
-
cur.fail(`${owner}: empty query body`);
|
|
411
|
-
queries.push({ name, params, ...knobs, body, offset });
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
return { fragments, queries };
|
|
415
|
-
}
|
|
416
|
-
// ---------------------------------------------------------------------------
|
|
417
|
-
// SQL-shape scanning helpers (string/comment aware)
|
|
418
|
-
// ---------------------------------------------------------------------------
|
|
419
|
-
/** Blank out strings and comments (preserving length) so scans are safe. */
|
|
420
|
-
function blank(sql) {
|
|
421
|
-
let out = '';
|
|
422
|
-
let i = 0;
|
|
423
|
-
while (i < sql.length) {
|
|
424
|
-
const ch = sql[i];
|
|
425
|
-
if (sql.startsWith('--', i)) {
|
|
426
|
-
const nl = sql.indexOf('\n', i);
|
|
427
|
-
const end = nl === -1 ? sql.length : nl;
|
|
428
|
-
out += ' '.repeat(end - i);
|
|
429
|
-
i = end;
|
|
430
|
-
}
|
|
431
|
-
else if (sql.startsWith('/*', i)) {
|
|
432
|
-
const close = sql.indexOf('*/', i + 2);
|
|
433
|
-
const end = close === -1 ? sql.length : close + 2;
|
|
434
|
-
out += ' '.repeat(end - i);
|
|
435
|
-
i = end;
|
|
436
|
-
}
|
|
437
|
-
else if (ch === "'") {
|
|
438
|
-
let j = i + 1;
|
|
439
|
-
for (;;) {
|
|
440
|
-
if (j >= sql.length)
|
|
441
|
-
break;
|
|
442
|
-
if (sql[j] === "'") {
|
|
443
|
-
if (sql[j + 1] === "'")
|
|
444
|
-
j += 2;
|
|
445
|
-
else {
|
|
446
|
-
j += 1;
|
|
447
|
-
break;
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
else
|
|
451
|
-
j += 1;
|
|
452
|
-
}
|
|
453
|
-
out += ' '.repeat(j - i);
|
|
454
|
-
i = j;
|
|
455
|
-
}
|
|
456
|
-
else {
|
|
457
|
-
out += ch;
|
|
458
|
-
i += 1;
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
return out;
|
|
462
|
-
}
|
|
463
|
-
/** All `:param` names in `text` (strings/comments already blanked). */
|
|
464
|
-
function scanParams(text) {
|
|
465
|
-
const out = [];
|
|
466
|
-
for (const m of blank(text).matchAll(/:([A-Za-z_][A-Za-z0-9_]*)/g)) {
|
|
467
|
-
const name = m[1];
|
|
468
|
-
if (!out.includes(name))
|
|
469
|
-
out.push(name);
|
|
470
|
-
}
|
|
471
|
-
return out;
|
|
472
|
-
}
|
|
473
|
-
const CLAUSE_ENDERS = new Set(['group', 'having', 'order', 'limit', 'window']);
|
|
474
|
-
/** Locate the MAIN statement's WHERE clause at paren depth 0. */
|
|
475
|
-
function findWhere(body) {
|
|
476
|
-
const b = blank(body);
|
|
477
|
-
let depth = 0;
|
|
478
|
-
let i = 0;
|
|
479
|
-
let kwStart = -1;
|
|
480
|
-
let start = -1;
|
|
481
|
-
while (i < b.length) {
|
|
482
|
-
const ch = b[i];
|
|
483
|
-
if (ch === '(')
|
|
484
|
-
depth += 1;
|
|
485
|
-
else if (ch === ')')
|
|
486
|
-
depth -= 1;
|
|
487
|
-
else if (/[A-Za-z_]/.test(ch)) {
|
|
488
|
-
let j = i + 1;
|
|
489
|
-
while (j < b.length && /[A-Za-z0-9_]/.test(b[j]))
|
|
490
|
-
j += 1;
|
|
491
|
-
const word = b.slice(i, j).toLowerCase();
|
|
492
|
-
if (depth === 0) {
|
|
493
|
-
if (start === -1 && word === 'where') {
|
|
494
|
-
kwStart = i;
|
|
495
|
-
start = j;
|
|
496
|
-
}
|
|
497
|
-
else if (start !== -1 && CLAUSE_ENDERS.has(word)) {
|
|
498
|
-
return { kwStart, start, end: i };
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
i = j;
|
|
502
|
-
continue;
|
|
503
|
-
}
|
|
504
|
-
i += 1;
|
|
505
|
-
}
|
|
506
|
-
return start === -1 ? null : { kwStart, start, end: body.length };
|
|
507
|
-
}
|
|
508
|
-
/**
|
|
509
|
-
* Split a WHERE expression into top-level conjuncts on `AND` — paren-aware,
|
|
510
|
-
* and BETWEEN-aware (the `AND` closing a `BETWEEN` is not a conjunction).
|
|
511
|
-
*/
|
|
512
|
-
function splitConjuncts(expr) {
|
|
513
|
-
const b = blank(expr);
|
|
514
|
-
const parts = [];
|
|
515
|
-
let depth = 0;
|
|
516
|
-
let braceDepth = 0;
|
|
517
|
-
let pendingBetween = 0;
|
|
518
|
-
let start = 0;
|
|
519
|
-
let i = 0;
|
|
520
|
-
while (i < b.length) {
|
|
521
|
-
const ch = b[i];
|
|
522
|
-
if (ch === '(')
|
|
523
|
-
depth += 1;
|
|
524
|
-
else if (ch === ')')
|
|
525
|
-
depth -= 1;
|
|
526
|
-
else if (ch === '{')
|
|
527
|
-
braceDepth += 1;
|
|
528
|
-
else if (ch === '}')
|
|
529
|
-
braceDepth -= 1;
|
|
530
|
-
else if (/[A-Za-z_]/.test(ch)) {
|
|
531
|
-
let j = i + 1;
|
|
532
|
-
while (j < b.length && /[A-Za-z0-9_]/.test(b[j]))
|
|
533
|
-
j += 1;
|
|
534
|
-
const word = b.slice(i, j).toLowerCase();
|
|
535
|
-
if (depth === 0 && braceDepth === 0) {
|
|
536
|
-
if (word === 'between')
|
|
537
|
-
pendingBetween += 1;
|
|
538
|
-
else if (word === 'and') {
|
|
539
|
-
if (pendingBetween > 0)
|
|
540
|
-
pendingBetween -= 1;
|
|
541
|
-
else {
|
|
542
|
-
parts.push(expr.slice(start, i));
|
|
543
|
-
start = j;
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
i = j;
|
|
548
|
-
continue;
|
|
549
|
-
}
|
|
550
|
-
i += 1;
|
|
551
|
-
}
|
|
552
|
-
parts.push(expr.slice(start));
|
|
553
|
-
return parts.map((p) => p.trim()).filter((p) => p.length > 0);
|
|
554
|
-
}
|
|
555
|
-
// ---------------------------------------------------------------------------
|
|
556
|
-
// Lowering
|
|
557
|
-
// ---------------------------------------------------------------------------
|
|
558
|
-
const FRAGMENT_REF_RE = /@([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)/;
|
|
559
|
-
/**
|
|
560
|
-
* Splice `@fragment(:arg, …)` refs (iteratively — fragments may use other
|
|
561
|
-
* fragments; cycles are caught by a depth cap). Fragment params rename to
|
|
562
|
-
* the caller's arg names; a fragment's OPTIONAL params propagate their
|
|
563
|
-
* optionality into `paramInfo` (GraphQL-fragment variable propagation, §3)
|
|
564
|
-
* unless the query declares the arg itself (the declaration wins).
|
|
565
|
-
*/
|
|
566
|
-
function spliceFragments(body, fragments, paramInfo, location) {
|
|
567
|
-
let out = body;
|
|
568
|
-
for (let round = 0;; round += 1) {
|
|
569
|
-
if (round > 10) {
|
|
570
|
-
throw new TypegenError(location, 'fragment expansion exceeded depth 10 — fragments must not reference each other cyclically');
|
|
571
|
-
}
|
|
572
|
-
const blanked = blank(out);
|
|
573
|
-
const m = FRAGMENT_REF_RE.exec(blanked);
|
|
574
|
-
if (m === null)
|
|
575
|
-
return out;
|
|
576
|
-
const [, name, argText] = m;
|
|
577
|
-
const fragment = fragments.get(name);
|
|
578
|
-
if (fragment === undefined) {
|
|
579
|
-
throw new TypegenError(location, `unknown fragment @${name} — fragments are declared in the same .syql file`);
|
|
580
|
-
}
|
|
581
|
-
const args = argText
|
|
582
|
-
.split(',')
|
|
583
|
-
.map((a) => a.trim())
|
|
584
|
-
.filter((a) => a.length > 0);
|
|
585
|
-
if (args.length !== fragment.params.length) {
|
|
586
|
-
throw new TypegenError(location, `@${name} takes ${fragment.params.length} arg(s) (${fragment.params.map((p) => p.name).join(', ')}), got ${args.length}`);
|
|
587
|
-
}
|
|
588
|
-
const rename = new Map();
|
|
589
|
-
fragment.params.forEach((param, index) => {
|
|
590
|
-
const arg = args[index];
|
|
591
|
-
const argMatch = /^:([A-Za-z_][A-Za-z0-9_]*)$/.exec(arg);
|
|
592
|
-
if (argMatch === null) {
|
|
593
|
-
throw new TypegenError(location, `@${name}: arg ${index + 1} must be a \`:param\` reference (values only, I1), got ${JSON.stringify(arg)}`);
|
|
594
|
-
}
|
|
595
|
-
const argName = argMatch[1];
|
|
596
|
-
rename.set(param.name, argName);
|
|
597
|
-
// Propagate the fragment param's optionality unless the query
|
|
598
|
-
// declared this param itself.
|
|
599
|
-
const existing = paramInfo.get(argName);
|
|
600
|
-
if (existing === undefined) {
|
|
601
|
-
paramInfo.set(argName, {
|
|
602
|
-
optional: param.optional,
|
|
603
|
-
flag: param.flag,
|
|
604
|
-
declared: false,
|
|
605
|
-
...(param.group !== undefined
|
|
606
|
-
? { group: `${name}.${param.group}` }
|
|
607
|
-
: {}),
|
|
608
|
-
});
|
|
609
|
-
}
|
|
610
|
-
else if (!existing.declared && param.optional && !existing.optional) {
|
|
611
|
-
existing.optional = true;
|
|
612
|
-
}
|
|
613
|
-
});
|
|
614
|
-
// Substitute the fragment body's params, then splice parenthesized.
|
|
615
|
-
let pred = fragment.body;
|
|
616
|
-
const fragmentParams = new Set(fragment.params.map((p) => p.name));
|
|
617
|
-
for (const used of scanParams(pred)) {
|
|
618
|
-
if (!fragmentParams.has(used)) {
|
|
619
|
-
throw new TypegenError(location, `fragment ${name} uses :${used}, which is not among its params (${[...fragmentParams].join(', ')}) — fragments are closed over their signature`);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
pred = pred.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (whole, p) => {
|
|
623
|
-
const to = rename.get(p);
|
|
624
|
-
return to === undefined ? whole : `:${to}`;
|
|
625
|
-
});
|
|
626
|
-
out = `${out.slice(0, m.index)}(${pred})${out.slice(m.index + m[0].length)}`;
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
const IF_GUARD_RE = /^if\s*\(([^)]*)\)\s*\{([\s\S]*)\}$/;
|
|
630
|
-
/** Guard SQL for one param: value params test provision; flags test truth. */
|
|
631
|
-
function guardTerm(name, info) {
|
|
632
|
-
if (info?.flag === true)
|
|
633
|
-
return `coalesce(:${name}, 0) = 0`;
|
|
634
|
-
return `:${name} is null`;
|
|
635
|
-
}
|
|
636
|
-
/**
|
|
637
|
-
* Lower one query body: splice fragments, auto-guard optional conjuncts,
|
|
638
|
-
* expand `if` guards, enforce B1. Returns plain SQL (knob tails are the
|
|
639
|
-
* caller's job).
|
|
640
|
-
*/
|
|
641
|
-
export function lowerSyqlBody(decl, fragments, location) {
|
|
642
|
-
const paramInfo = new Map();
|
|
643
|
-
for (const p of decl.params) {
|
|
644
|
-
paramInfo.set(p.name, {
|
|
645
|
-
optional: p.optional,
|
|
646
|
-
flag: p.flag,
|
|
647
|
-
declared: true,
|
|
648
|
-
...(p.group !== undefined ? { group: p.group } : {}),
|
|
649
|
-
});
|
|
650
|
-
}
|
|
651
|
-
if (blank(decl.body).includes(';')) {
|
|
652
|
-
throw new TypegenError(location, 'a query body is exactly one SELECT statement — remove the `;`');
|
|
653
|
-
}
|
|
654
|
-
const spliced = spliceFragments(decl.body, fragments, paramInfo, location);
|
|
655
|
-
const isOptional = (name) => paramInfo.get(name)?.optional === true;
|
|
656
|
-
const where = findWhere(spliced);
|
|
657
|
-
const conjuncts = [];
|
|
658
|
-
const structured = [];
|
|
659
|
-
let loweredWhere = '';
|
|
660
|
-
if (where !== null) {
|
|
661
|
-
const expr = spliced.slice(where.start, where.end);
|
|
662
|
-
for (const conjunct of splitConjuncts(expr)) {
|
|
663
|
-
const ifMatch = IF_GUARD_RE.exec(conjunct);
|
|
664
|
-
if (ifMatch !== null) {
|
|
665
|
-
// B2 — the explicit primitive: if (:a, :b) { predicate }.
|
|
666
|
-
const guardParams = ifMatch[1]
|
|
667
|
-
.split(',')
|
|
668
|
-
.map((a) => a.trim())
|
|
669
|
-
.filter((a) => a.length > 0)
|
|
670
|
-
.map((a) => {
|
|
671
|
-
const pm = /^:([A-Za-z_][A-Za-z0-9_]*)$/.exec(a);
|
|
672
|
-
if (pm === null) {
|
|
673
|
-
throw new TypegenError(location, `if (…) guards take \`:param\` refs, got ${JSON.stringify(a)}`);
|
|
674
|
-
}
|
|
675
|
-
return pm[1];
|
|
676
|
-
});
|
|
677
|
-
if (guardParams.length === 0) {
|
|
678
|
-
throw new TypegenError(location, 'if () needs at least one :param');
|
|
679
|
-
}
|
|
680
|
-
const pred = ifMatch[2].trim();
|
|
681
|
-
if (pred.length === 0) {
|
|
682
|
-
throw new TypegenError(location, 'if (…) { } has an empty predicate');
|
|
683
|
-
}
|
|
684
|
-
for (const g of guardParams) {
|
|
685
|
-
const info = paramInfo.get(g);
|
|
686
|
-
if (info === undefined) {
|
|
687
|
-
throw new TypegenError(location, `if (:${g}): param ${g} is not declared in the signature`);
|
|
688
|
-
}
|
|
689
|
-
if (!info.optional) {
|
|
690
|
-
throw new TypegenError(location, `if (:${g}): ${g} is required — an if-guard on a required param never varies; drop the guard or mark the param optional (${g}?)`);
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
const guards = guardParams
|
|
694
|
-
.map((g) => guardTerm(g, paramInfo.get(g)))
|
|
695
|
-
.join(' or ');
|
|
696
|
-
conjuncts.push(`(${guards} or (${pred}))`);
|
|
697
|
-
structured.push({ pred, governing: guardParams });
|
|
698
|
-
continue;
|
|
699
|
-
}
|
|
700
|
-
const used = scanParams(conjunct);
|
|
701
|
-
const optionals = used.filter(isOptional);
|
|
702
|
-
const flags = used.filter((p) => paramInfo.get(p)?.flag === true);
|
|
703
|
-
if (flags.length > 0) {
|
|
704
|
-
throw new TypegenError(location, `flag param :${flags[0]} cannot appear in a predicate (§3 — it never binds as written). Use \`if (:${flags[0]}) { … }\`.`);
|
|
705
|
-
}
|
|
706
|
-
if (optionals.length === 0) {
|
|
707
|
-
conjuncts.push(conjunct);
|
|
708
|
-
structured.push({ pred: conjunct, governing: [] });
|
|
709
|
-
continue;
|
|
710
|
-
}
|
|
711
|
-
// B1 placement validator: inside its conjunct, an optional param must
|
|
712
|
-
// not sit under an OR or inside a subquery — semantics get murky;
|
|
713
|
-
// demand the explicit primitive.
|
|
714
|
-
const blankedConjunct = blank(conjunct).toLowerCase();
|
|
715
|
-
if (/\bor\b/.test(blankedConjunct)) {
|
|
716
|
-
throw new TypegenError(location, `optional param :${optionals[0]} sits under an OR — auto-guarding a disjunction is ambiguous (B1). Write \`if (:${optionals[0]}) { … }\` or make the param required.`);
|
|
717
|
-
}
|
|
718
|
-
if (/\bselect\b/.test(blankedConjunct)) {
|
|
719
|
-
throw new TypegenError(location, `optional param :${optionals[0]} sits inside a subquery — auto-guarding cannot see through it (B1). Write \`if (:${optionals[0]}) { … }\` or make the param required.`);
|
|
720
|
-
}
|
|
721
|
-
// Auto-guard (§4 B): the conjunct applies iff ALL its optional params
|
|
722
|
-
// (including every member of a touched group) are provided.
|
|
723
|
-
const governing = new Set(optionals);
|
|
724
|
-
for (const p of optionals) {
|
|
725
|
-
const group = paramInfo.get(p)?.group;
|
|
726
|
-
if (group !== undefined) {
|
|
727
|
-
for (const [other, info] of paramInfo) {
|
|
728
|
-
if (info.group === group)
|
|
729
|
-
governing.add(other);
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
const guards = [...governing]
|
|
734
|
-
.map((g) => guardTerm(g, paramInfo.get(g)))
|
|
735
|
-
.join(' or ');
|
|
736
|
-
conjuncts.push(`(${guards} or (${conjunct}))`);
|
|
737
|
-
structured.push({ pred: conjunct, governing: [...governing] });
|
|
738
|
-
}
|
|
739
|
-
loweredWhere = conjuncts.join(' and ');
|
|
740
|
-
}
|
|
741
|
-
// B1 (rest of statement): optional params may ONLY live in the WHERE.
|
|
742
|
-
const outsideWhere = where === null
|
|
743
|
-
? spliced
|
|
744
|
-
: spliced.slice(0, where.start) + spliced.slice(where.end);
|
|
745
|
-
for (const used of scanParams(outsideWhere)) {
|
|
746
|
-
if (isOptional(used)) {
|
|
747
|
-
throw new TypegenError(location, `optional param :${used} appears outside the WHERE clause (B1) — optional params only guard top-level WHERE conjuncts; make it required or restructure`);
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
// Declared params must actually be used (flags are used by their guards —
|
|
751
|
-
// which splice into the WHERE — so this covers them too).
|
|
752
|
-
const usedAnywhere = new Set(scanParams(spliced));
|
|
753
|
-
for (const p of decl.params) {
|
|
754
|
-
if (!usedAnywhere.has(p.name)) {
|
|
755
|
-
throw new TypegenError(location, `param ${p.name} is declared but never used in the body`);
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
const sql = where === null
|
|
759
|
-
? spliced
|
|
760
|
-
: `${spliced.slice(0, where.start)} ${loweredWhere}${spliced.slice(where.end).length > 0 ? ` ${spliced.slice(where.end).trimStart()}` : ''}`;
|
|
761
|
-
const prefix = (where === null ? spliced : spliced.slice(0, where.kwStart)).replace(/\s+/g, ' ');
|
|
762
|
-
const suffix = (where === null ? '' : spliced.slice(where.end)).replace(/\s+/g, ' ');
|
|
763
|
-
return {
|
|
764
|
-
sql: sql.replace(/\s+/g, ' ').trim(),
|
|
765
|
-
paramInfo,
|
|
766
|
-
prefix: prefix.trim(),
|
|
767
|
-
suffix: suffix.trim(),
|
|
768
|
-
conjuncts: structured.map((c) => ({
|
|
769
|
-
pred: c.pred.replace(/\s+/g, ' ').trim(),
|
|
770
|
-
governing: c.governing,
|
|
771
|
-
})),
|
|
772
|
-
};
|
|
773
|
-
}
|
|
774
|
-
// ---------------------------------------------------------------------------
|
|
775
|
-
// Analysis (parse → lower → the shared SQLite-check pipeline)
|
|
776
|
-
// ---------------------------------------------------------------------------
|
|
777
|
-
function checkedReactiveMetadata(decl, base, params, ir, location) {
|
|
778
|
-
if (decl.depends === undefined &&
|
|
779
|
-
decl.windows === undefined &&
|
|
780
|
-
decl.keyBy === undefined) {
|
|
781
|
-
return base.reactive;
|
|
782
|
-
}
|
|
783
|
-
const paramByName = new Map(params.map((param) => [param.name, param]));
|
|
784
|
-
const explicitScopes = new Map();
|
|
785
|
-
const explicitTables = new Set();
|
|
786
|
-
const tableAndScope = (tableName, variable) => {
|
|
787
|
-
if (!base.tables.includes(tableName)) {
|
|
788
|
-
throw new TypegenError(location, `reactive declaration names table ${JSON.stringify(tableName)}, but the query does not read it`);
|
|
789
|
-
}
|
|
790
|
-
const table = ir.tables.find((candidate) => candidate.name === tableName);
|
|
791
|
-
if (table === undefined) {
|
|
792
|
-
throw new TypegenError(location, `unknown reactive table ${JSON.stringify(tableName)}`);
|
|
793
|
-
}
|
|
794
|
-
const scope = table.scopes.find((candidate) => candidate.variable === variable);
|
|
795
|
-
if (scope === undefined) {
|
|
796
|
-
throw new TypegenError(location, `table ${tableName} has no scope variable ${JSON.stringify(variable)}`);
|
|
797
|
-
}
|
|
798
|
-
return { table, scope };
|
|
799
|
-
};
|
|
800
|
-
const checkedParam = (tableName, variable, name) => {
|
|
801
|
-
const param = paramByName.get(name);
|
|
802
|
-
if (param === undefined) {
|
|
803
|
-
throw new TypegenError(location, `reactive declaration references undeclared param :${name}`);
|
|
804
|
-
}
|
|
805
|
-
if (param.optional === true) {
|
|
806
|
-
throw new TypegenError(location, `reactive scope ${tableName}.${variable} cannot use optional param :${name}; an absent value is not a window unit`);
|
|
807
|
-
}
|
|
808
|
-
const { table, scope } = tableAndScope(tableName, variable);
|
|
809
|
-
const column = table.columns.find((candidate) => candidate.name === scope.column);
|
|
810
|
-
if (column === undefined || column.type !== param.type) {
|
|
811
|
-
throw new TypegenError(location, `reactive param :${name} has type ${param.type}, incompatible with ${tableName}.${scope.column} (${column?.type ?? 'unknown'})`);
|
|
812
|
-
}
|
|
813
|
-
return param;
|
|
814
|
-
};
|
|
815
|
-
const addScope = (table, variable, names) => {
|
|
816
|
-
const { scope } = tableAndScope(table, variable);
|
|
817
|
-
explicitTables.add(table);
|
|
818
|
-
let byVariable = explicitScopes.get(table);
|
|
819
|
-
if (byVariable === undefined) {
|
|
820
|
-
byVariable = new Map();
|
|
821
|
-
explicitScopes.set(table, byVariable);
|
|
822
|
-
}
|
|
823
|
-
let binding = byVariable.get(variable);
|
|
824
|
-
if (binding === undefined) {
|
|
825
|
-
binding = { pattern: scope.pattern, params: new Set() };
|
|
826
|
-
byVariable.set(variable, binding);
|
|
827
|
-
}
|
|
828
|
-
for (const name of names) {
|
|
829
|
-
checkedParam(table, variable, name);
|
|
830
|
-
binding.params.add(name);
|
|
831
|
-
}
|
|
832
|
-
};
|
|
833
|
-
for (const dependency of decl.depends ?? []) {
|
|
834
|
-
addScope(dependency.table, dependency.variable, dependency.params);
|
|
835
|
-
}
|
|
836
|
-
const coverage = [];
|
|
837
|
-
const seenWindows = new Set();
|
|
838
|
-
for (const window of decl.windows ?? []) {
|
|
839
|
-
const key = `${window.table}\0${window.variable}`;
|
|
840
|
-
if (seenWindows.has(key)) {
|
|
841
|
-
throw new TypegenError(location, `duplicate window declaration for ${window.table}.${window.variable}`);
|
|
842
|
-
}
|
|
843
|
-
seenWindows.add(key);
|
|
844
|
-
const { table } = tableAndScope(window.table, window.variable);
|
|
845
|
-
addScope(window.table, window.variable, window.units);
|
|
846
|
-
const fixedByVariable = new Map();
|
|
847
|
-
for (const fixed of window.fixedScopes) {
|
|
848
|
-
if (fixed.variable === window.variable) {
|
|
849
|
-
throw new TypegenError(location, `window ${window.table}.${window.variable} repeats its unit scope as fixed`);
|
|
850
|
-
}
|
|
851
|
-
if (fixedByVariable.has(fixed.variable)) {
|
|
852
|
-
throw new TypegenError(location, `window ${window.table} repeats fixed scope ${fixed.variable}`);
|
|
853
|
-
}
|
|
854
|
-
checkedParam(window.table, fixed.variable, fixed.param);
|
|
855
|
-
fixedByVariable.set(fixed.variable, fixed.param);
|
|
856
|
-
addScope(window.table, fixed.variable, [fixed.param]);
|
|
857
|
-
}
|
|
858
|
-
const missing = table.scopes
|
|
859
|
-
.map((scope) => scope.variable)
|
|
860
|
-
.filter((variable) => variable !== window.variable && !fixedByVariable.has(variable));
|
|
861
|
-
if (missing.length > 0) {
|
|
862
|
-
throw new TypegenError(location, `window ${window.table}.${window.variable} must fix the remaining scope variables: ${missing.join(', ')}`);
|
|
863
|
-
}
|
|
864
|
-
coverage.push({
|
|
865
|
-
table: window.table,
|
|
866
|
-
variable: window.variable,
|
|
867
|
-
units: window.units,
|
|
868
|
-
fixedScopes: table.scopes
|
|
869
|
-
.filter((scope) => scope.variable !== window.variable)
|
|
870
|
-
.map((scope) => ({
|
|
871
|
-
variable: scope.variable,
|
|
872
|
-
params: [fixedByVariable.get(scope.variable)],
|
|
873
|
-
})),
|
|
874
|
-
});
|
|
875
|
-
}
|
|
876
|
-
const dependencies = base.reactive.dependencies.map((dependency) => {
|
|
877
|
-
if (!explicitTables.has(dependency.table))
|
|
878
|
-
return dependency;
|
|
879
|
-
const scopes = [...(explicitScopes.get(dependency.table)?.entries() ?? [])]
|
|
880
|
-
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
881
|
-
.map(([variable, binding]) => ({
|
|
882
|
-
table: dependency.table,
|
|
883
|
-
variable,
|
|
884
|
-
pattern: binding.pattern,
|
|
885
|
-
params: [...binding.params],
|
|
886
|
-
}));
|
|
887
|
-
return { table: dependency.table, scopes };
|
|
888
|
-
});
|
|
889
|
-
let rowKey = base.reactive.rowKey;
|
|
890
|
-
if (decl.keyBy !== undefined) {
|
|
891
|
-
const fields = decl.keyBy.map((name) => {
|
|
892
|
-
const column = base.columns.find((candidate) => candidate.name === name || candidate.langName === name);
|
|
893
|
-
if (column === undefined) {
|
|
894
|
-
throw new TypegenError(location, `key by column ${JSON.stringify(name)} is not projected by the query`);
|
|
895
|
-
}
|
|
896
|
-
return column.langName;
|
|
897
|
-
});
|
|
898
|
-
if (new Set(fields).size !== fields.length) {
|
|
899
|
-
throw new TypegenError(location, 'key by contains a duplicate result column');
|
|
900
|
-
}
|
|
901
|
-
rowKey = fields;
|
|
902
|
-
}
|
|
903
|
-
return {
|
|
904
|
-
dependencies,
|
|
905
|
-
coverage: decl.windows === undefined
|
|
906
|
-
? base.reactive.coverage
|
|
907
|
-
: [
|
|
908
|
-
...base.reactive.coverage.filter((item) => !seenWindows.has(`${item.table}\0${item.variable}`)),
|
|
909
|
-
...coverage,
|
|
910
|
-
],
|
|
911
|
-
...(rowKey !== undefined ? { rowKey } : {}),
|
|
912
|
-
};
|
|
913
|
-
}
|
|
914
|
-
/** Analyze every query of one `.syql` file into `AnalyzedQuery` units —
|
|
915
|
-
* byte-compatible with the `.sql` frontend's output (§1: nothing below the
|
|
916
|
-
* IR knows the frontend). */
|
|
917
|
-
export function analyzeSyqlFile(relPath, content, ir, db, naming) {
|
|
918
|
-
const parsed = parseSyqlFile(relPath, content);
|
|
919
|
-
const fragments = new Map(parsed.fragments.map((f) => [f.name, f]));
|
|
920
|
-
if (parsed.queries.length === 0 && parsed.fragments.length > 0) {
|
|
921
|
-
throw new TypegenError(relPath, 'this .syql file declares only fragments — fragments are file-scoped, so at least one query must use them here');
|
|
922
|
-
}
|
|
923
|
-
return parsed.queries.map((decl) => {
|
|
924
|
-
const location = `${relPath} (query ${decl.name})`;
|
|
925
|
-
const lowered = lowerSyqlBody(decl, fragments, location);
|
|
926
|
-
const blanked = blank(lowered.sql).toLowerCase();
|
|
927
|
-
// Knob preconditions on the BODY (the knob owns the tail).
|
|
928
|
-
if (decl.orderBy !== undefined && /\border\s+by\b/.test(blanked)) {
|
|
929
|
-
throw new TypegenError(location, 'the body has an ORDER BY and the query declares an orderBy knob — one or the other');
|
|
930
|
-
}
|
|
931
|
-
if (decl.limit !== undefined && /\blimit\b/.test(blanked)) {
|
|
932
|
-
throw new TypegenError(location, 'the body has a LIMIT and the query declares a limit knob — one or the other');
|
|
933
|
-
}
|
|
934
|
-
if ((decl.limit !== undefined || decl.orderBy !== undefined) &&
|
|
935
|
-
lowered.paramInfo.has('limit')) {
|
|
936
|
-
throw new TypegenError(location, 'a query with a limit/orderBy knob cannot also declare a param named `limit`');
|
|
937
|
-
}
|
|
938
|
-
// Check every orderBy allowlist column against the schema FIRST (each
|
|
939
|
-
// variant is a real prepared statement — §6) so a bad column errors as
|
|
940
|
-
// "orderBy column …", not as the default-tail check.
|
|
941
|
-
if (decl.orderBy !== undefined) {
|
|
942
|
-
for (const column of decl.orderBy.allowed) {
|
|
943
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(column)) {
|
|
944
|
-
throw new TypegenError(location, `orderBy column ${JSON.stringify(column)} is not a plain identifier`);
|
|
945
|
-
}
|
|
946
|
-
try {
|
|
947
|
-
db.analyze(`${lowered.sql} order by ${column} asc`);
|
|
948
|
-
}
|
|
949
|
-
catch (error) {
|
|
950
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
951
|
-
throw new TypegenError(location, `orderBy column ${JSON.stringify(column)} rejected by SQLite: ${message}`);
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
// Assemble the checked statement: default knob tails baked in, `-- param`
|
|
956
|
-
// headers typing what inference cannot see (flags, the limit bind). The
|
|
957
|
-
// limit's default + clamp live IN the SQL (`min(coalesce(:limit, d), m)`)
|
|
958
|
-
// so an absent runtime value is a no-op — the same neutralization idea
|
|
959
|
-
// as the §7 guards — and every emitter just binds `limit ?? null`.
|
|
960
|
-
const headers = [];
|
|
961
|
-
for (const [name, info] of lowered.paramInfo) {
|
|
962
|
-
if (info.flag)
|
|
963
|
-
headers.push(`-- param :${name} boolean`);
|
|
964
|
-
}
|
|
965
|
-
let limitTail = '';
|
|
966
|
-
if (decl.limit !== undefined) {
|
|
967
|
-
const fallback = decl.limit.default ?? decl.limit.max;
|
|
968
|
-
const inner = `coalesce(:limit, ${fallback})`;
|
|
969
|
-
limitTail = ` limit ${decl.limit.max !== undefined
|
|
970
|
-
? `min(${inner}, ${decl.limit.max})`
|
|
971
|
-
: inner}`;
|
|
972
|
-
headers.push('-- param :limit integer');
|
|
973
|
-
}
|
|
974
|
-
let sqlWithTails = lowered.sql;
|
|
975
|
-
if (decl.orderBy !== undefined) {
|
|
976
|
-
sqlWithTails += ` order by ${decl.orderBy.defaultColumn} ${decl.orderBy.defaultDir}`;
|
|
977
|
-
}
|
|
978
|
-
sqlWithTails += limitTail;
|
|
979
|
-
const statementText = [...headers, sqlWithTails].join('\n');
|
|
980
|
-
const base = analyzeStatement(decl.name, location, statementText, ir, db, naming);
|
|
981
|
-
// Attach optionality/group/flag + knob metadata to the checked unit.
|
|
982
|
-
const params = base.params.map((param) => {
|
|
983
|
-
if (param.name === 'limit' && decl.limit !== undefined) {
|
|
984
|
-
return { ...param, optional: true };
|
|
985
|
-
}
|
|
986
|
-
const info = lowered.paramInfo.get(param.name);
|
|
987
|
-
if (info === undefined)
|
|
988
|
-
return param;
|
|
989
|
-
return {
|
|
990
|
-
...param,
|
|
991
|
-
...(info.optional ? { optional: true } : {}),
|
|
992
|
-
...(info.group !== undefined ? { group: info.group } : {}),
|
|
993
|
-
...(info.flag ? { flag: true } : {}),
|
|
994
|
-
};
|
|
995
|
-
});
|
|
996
|
-
const mode = naming?.naming ?? 'camel';
|
|
997
|
-
const mapCol = (name) => mode === 'preserve' ? name : snakeToCamel(name);
|
|
998
|
-
const reactive = checkedReactiveMetadata(decl, base, params, ir, location);
|
|
999
|
-
const orderBy = decl.orderBy === undefined
|
|
1000
|
-
? undefined
|
|
1001
|
-
: {
|
|
1002
|
-
allowed: decl.orderBy.allowed.map((name) => ({
|
|
1003
|
-
name,
|
|
1004
|
-
langName: mapCol(name),
|
|
1005
|
-
})),
|
|
1006
|
-
defaultColumn: decl.orderBy.defaultColumn,
|
|
1007
|
-
defaultDir: decl.orderBy.defaultDir,
|
|
1008
|
-
};
|
|
1009
|
-
// The static base (positional) emitters compose dynamic tails onto:
|
|
1010
|
-
// positionalSql minus the default tails we appended above. Sliced from
|
|
1011
|
-
// the positional form itself so `?` vs `?N` numbering is preserved
|
|
1012
|
-
// (the appended tails are by construction the LAST clauses).
|
|
1013
|
-
let positionalSqlBase;
|
|
1014
|
-
let positionalLimitTail;
|
|
1015
|
-
if (decl.orderBy !== undefined) {
|
|
1016
|
-
const orderIdx = base.positionalSql.lastIndexOf(' order by ');
|
|
1017
|
-
if (orderIdx === -1) {
|
|
1018
|
-
throw new TypegenError(location, 'internal: lowered SQL lost its appended order-by tail');
|
|
1019
|
-
}
|
|
1020
|
-
positionalSqlBase = base.positionalSql.slice(0, orderIdx);
|
|
1021
|
-
if (decl.limit !== undefined) {
|
|
1022
|
-
const limitIdx = base.positionalSql.lastIndexOf(' limit ');
|
|
1023
|
-
if (limitIdx <= orderIdx) {
|
|
1024
|
-
throw new TypegenError(location, 'internal: lowered SQL lost its appended limit tail');
|
|
1025
|
-
}
|
|
1026
|
-
positionalLimitTail = base.positionalSql.slice(limitIdx);
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1029
|
-
// Drop the internal `-- param` typing headers from the exposed
|
|
1030
|
-
// (projection-lowered) SQL.
|
|
1031
|
-
const exposedSql = base.sql
|
|
1032
|
-
.replace(/^(?:\s*-- param :[^\n]*\n)+/, '')
|
|
1033
|
-
.trimStart();
|
|
1034
|
-
// §7 variant-enumeration backend: opt-in per query (`variants`), or
|
|
1035
|
-
// selected by the manifest's queryBackend heuristic — `auto` enumerates
|
|
1036
|
-
// when the combination count is trivially small (N ≤ 2 groups → ≤ 4
|
|
1037
|
-
// statements, perfect index use for free), `variants` enumerates
|
|
1038
|
-
// whenever the query is eligible. Assembled by swapping the WHERE
|
|
1039
|
-
// clause of the (projection-lowered) neutralization statement, so the
|
|
1040
|
-
// two backends differ ONLY in guard mechanics — semantically identical
|
|
1041
|
-
// by construction.
|
|
1042
|
-
const optionalGroupCount = (() => {
|
|
1043
|
-
const keys = new Set();
|
|
1044
|
-
for (const [name, info] of lowered.paramInfo) {
|
|
1045
|
-
if (info.optional)
|
|
1046
|
-
keys.add(info.group ?? name);
|
|
1047
|
-
}
|
|
1048
|
-
return keys.size;
|
|
1049
|
-
})();
|
|
1050
|
-
const backend = naming?.backend ?? 'neutralize';
|
|
1051
|
-
const wantVariants = decl.variants === true ||
|
|
1052
|
-
(decl.orderBy === undefined &&
|
|
1053
|
-
optionalGroupCount > 0 &&
|
|
1054
|
-
(backend === 'variants' ||
|
|
1055
|
-
(backend === 'auto' && optionalGroupCount <= 2)));
|
|
1056
|
-
let variants;
|
|
1057
|
-
let variantGroups;
|
|
1058
|
-
if (wantVariants) {
|
|
1059
|
-
if (decl.orderBy !== undefined) {
|
|
1060
|
-
throw new TypegenError(location, 'the variants backend and the orderBy knob both multiply statements — they cannot combine (yet); drop one');
|
|
1061
|
-
}
|
|
1062
|
-
const groupList = [];
|
|
1063
|
-
for (const [name, info] of lowered.paramInfo) {
|
|
1064
|
-
if (!info.optional)
|
|
1065
|
-
continue;
|
|
1066
|
-
const key = info.group ?? name;
|
|
1067
|
-
const existing = groupList.find((g) => g.key === key);
|
|
1068
|
-
if (existing !== undefined)
|
|
1069
|
-
existing.params.push(name);
|
|
1070
|
-
else
|
|
1071
|
-
groupList.push({ key, params: [name], flag: info.flag });
|
|
1072
|
-
}
|
|
1073
|
-
const n = groupList.length;
|
|
1074
|
-
if (n === 0) {
|
|
1075
|
-
throw new TypegenError(location, 'the variants backend needs at least one optional param — a fully static query has exactly one variant already');
|
|
1076
|
-
}
|
|
1077
|
-
if (n > 8) {
|
|
1078
|
-
throw new TypegenError(location, `${n} optional groups would enumerate ${2 ** n} statements — a query with nine independent optional filters is a design smell, not a codegen challenge (§7); split the query or drop \`variants\``);
|
|
1079
|
-
}
|
|
1080
|
-
if (n > 4) {
|
|
1081
|
-
console.warn(`${location}: variants enumerates ${2 ** n} statements (${n} optional groups) — consider the default neutralization backend`);
|
|
1082
|
-
}
|
|
1083
|
-
const w = findWhere(exposedSql);
|
|
1084
|
-
if (w === null)
|
|
1085
|
-
throw new Error('unreachable: optional params ⇒ WHERE');
|
|
1086
|
-
const before = exposedSql.slice(0, w.kwStart).trimEnd();
|
|
1087
|
-
const after = exposedSql.slice(w.end).trim();
|
|
1088
|
-
const groupOf = (param) => lowered.paramInfo.get(param)?.group ?? param;
|
|
1089
|
-
const out = [];
|
|
1090
|
-
for (let mask = 0; mask < 2 ** n; mask++) {
|
|
1091
|
-
const provided = new Set(groupList.filter((_, i) => ((mask >> i) & 1) === 1).map((g) => g.key));
|
|
1092
|
-
const included = lowered.conjuncts.filter((c) => c.governing.every((p) => provided.has(groupOf(p))));
|
|
1093
|
-
const whereSql = included.map((c) => c.pred).join(' and ');
|
|
1094
|
-
const sqlV = [
|
|
1095
|
-
before,
|
|
1096
|
-
...(whereSql.length > 0 ? [`where ${whereSql}`] : []),
|
|
1097
|
-
...(after.length > 0 ? [after] : []),
|
|
1098
|
-
]
|
|
1099
|
-
.join(' ')
|
|
1100
|
-
.replace(/\s+/g, ' ')
|
|
1101
|
-
.trim();
|
|
1102
|
-
try {
|
|
1103
|
-
db.analyze(sqlV);
|
|
1104
|
-
}
|
|
1105
|
-
catch (error) {
|
|
1106
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1107
|
-
throw new TypegenError(location, `variant [${[...provided].join('+') || 'none'}] rejected by SQLite: ${message}`);
|
|
1108
|
-
}
|
|
1109
|
-
out.push({
|
|
1110
|
-
when: groupList
|
|
1111
|
-
.filter((_, i) => ((mask >> i) & 1) === 1)
|
|
1112
|
-
.map((g) => g.key),
|
|
1113
|
-
sql: sqlV,
|
|
1114
|
-
positionalSql: toPositionalSql(sqlV),
|
|
1115
|
-
params: scanParams(sqlV),
|
|
1116
|
-
});
|
|
1117
|
-
}
|
|
1118
|
-
variants = out;
|
|
1119
|
-
variantGroups = groupList.map((g) => ({
|
|
1120
|
-
key: g.key,
|
|
1121
|
-
params: g.params,
|
|
1122
|
-
flag: g.flag,
|
|
1123
|
-
}));
|
|
1124
|
-
}
|
|
1125
|
-
return {
|
|
1126
|
-
...base,
|
|
1127
|
-
sourceSql: decl.body.trim(),
|
|
1128
|
-
sql: exposedSql,
|
|
1129
|
-
params,
|
|
1130
|
-
reactive,
|
|
1131
|
-
...(orderBy !== undefined ? { orderBy } : {}),
|
|
1132
|
-
...(decl.limit !== undefined ? { limit: decl.limit } : {}),
|
|
1133
|
-
...(positionalSqlBase !== undefined ? { positionalSqlBase } : {}),
|
|
1134
|
-
...(positionalLimitTail !== undefined && decl.orderBy !== undefined
|
|
1135
|
-
? { positionalLimitTail }
|
|
1136
|
-
: {}),
|
|
1137
|
-
...(variants !== undefined ? { variants } : {}),
|
|
1138
|
-
...(variantGroups !== undefined ? { variantGroups } : {}),
|
|
1139
|
-
};
|
|
1140
|
-
});
|
|
1141
|
-
}
|