@syncular/typegen 0.2.1 → 0.3.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 +25 -10
- package/dist/cli.js +155 -4
- package/dist/emit-dart.js +3 -2
- package/dist/emit-kotlin.js +3 -2
- package/dist/emit-queries-dart.js +87 -15
- package/dist/emit-queries-kotlin.js +89 -15
- package/dist/emit-queries-swift.js +92 -14
- package/dist/emit-queries.js +131 -22
- package/dist/emit-swift.js +3 -2
- package/dist/emit.d.ts +2 -1
- package/dist/emit.js +13 -27
- package/dist/fmt.d.ts +3 -0
- package/dist/fmt.js +356 -0
- package/dist/generate.d.ts +16 -6
- package/dist/generate.js +34 -9
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/lower.d.ts +54 -0
- package/dist/lower.js +283 -0
- package/dist/lsp.d.ts +20 -0
- package/dist/lsp.js +376 -0
- package/dist/manifest.d.ts +11 -0
- package/dist/manifest.js +20 -0
- package/dist/naming.d.ts +22 -0
- package/dist/naming.js +240 -0
- package/dist/query-ir.d.ts +14 -0
- package/dist/query-ir.js +69 -0
- package/dist/query.d.ts +102 -6
- package/dist/query.js +126 -34
- package/dist/syql.d.ts +83 -0
- package/dist/syql.js +945 -0
- package/package.json +2 -2
- package/src/cli.ts +155 -4
- package/src/emit-dart.ts +3 -2
- package/src/emit-kotlin.ts +3 -2
- package/src/emit-queries-dart.ts +109 -16
- package/src/emit-queries-kotlin.ts +111 -18
- package/src/emit-queries-swift.ts +106 -17
- package/src/emit-queries.ts +179 -28
- package/src/emit-swift.ts +3 -2
- package/src/emit.ts +18 -28
- package/src/fmt.ts +351 -0
- package/src/generate.ts +54 -11
- package/src/index.ts +6 -0
- package/src/lower.ts +331 -0
- package/src/lsp.ts +445 -0
- package/src/manifest.ts +38 -0
- package/src/naming.ts +271 -0
- package/src/query-ir.ts +82 -0
- package/src/query.ts +241 -34
- package/src/syql.ts +1171 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ SQL migrations + one manifest → neutral schema IR (JSON) → generated TS
|
|
|
4
4
|
module (REVISE B5). This file is the authoritative contract for the three
|
|
5
5
|
tool-level formats: the manifest, the IR, and the SQL subset. Wire-protocol
|
|
6
6
|
semantics (column types, scope patterns, schema-version gating) live in
|
|
7
|
-
[`../../SPEC.md`](../../SPEC.md) §2.4, §3.1, §1.5 — this document never
|
|
7
|
+
[`../../docs/SPEC.md`](../../docs/SPEC.md) §2.4, §3.1, §1.5 — this document never
|
|
8
8
|
overrides it.
|
|
9
9
|
|
|
10
10
|
```sh
|
|
@@ -67,7 +67,9 @@ schema guide and suggests `syncular init`.
|
|
|
67
67
|
|---|---|---|
|
|
68
68
|
| `manifestVersion` | yes | Must be `1`. Format growth happens by bumping this, not by tolerating unknown keys. |
|
|
69
69
|
| `migrations` | no (default `./migrations`) | Directory of `NNNN_name/up.sql` migrations, relative to the manifest. |
|
|
70
|
-
| `queries` | no (default `./queries`) | Directory of `.sql` named-query files (see §6). Only read when some output requests a queries file. |
|
|
70
|
+
| `queries` | no (default `./queries`) | Directory of `.sql` / `.syql` named-query files (see §6). Only read when some output requests a queries file. |
|
|
71
|
+
| `naming` | no (default `"camel"`) | §5-of-DESIGN-queries casing: `"camel"` maps snake_case SQL names to camelCase in generated row types/params (projections lower with `AS` aliases so runtime keys match); `"preserve"` keeps SQL-truth names. Collisions/keyword hazards are generate-time errors. |
|
|
72
|
+
| `queryBackend` | no (default `"neutralize"`) | `.syql` conditional lowering: `"neutralize"` (one guarded statement), `"variants"` (enumerate per provided-combination whenever eligible), `"auto"` (enumerate at ≤ 2 optional groups). The per-query `variants` knob always forces. |
|
|
71
73
|
| `output.ir` | no (default `./syncular.ir.json`) | IR output path, relative to the manifest. |
|
|
72
74
|
| `output.module` | no (default `./syncular.generated.ts`) | Generated TS module path, relative to the manifest. |
|
|
73
75
|
| `output.queries` | no | Opt-in TS named-queries output path (see §6). A sibling `.ts` file. |
|
|
@@ -228,7 +230,7 @@ duplicate or unknown-column index; trailing clauses (`STRICT`).
|
|
|
228
230
|
|
|
229
231
|
**`DEFAULT` literals are accepted and ignored**: typegen extracts the
|
|
230
232
|
schema *shape*; executing migrations (where defaults matter) is the
|
|
231
|
-
host's job. Rejecting them would make real
|
|
233
|
+
host's job. Rejecting them would make real-world migrations unusable
|
|
232
234
|
as input; recording them is not needed by any emitter today.
|
|
233
235
|
|
|
234
236
|
## 4. Generated-module contract
|
|
@@ -318,10 +320,23 @@ null/nil (fail-soft at the decode boundary, not a crash).
|
|
|
318
320
|
## 6. Named queries (the `.sql` → typed-function tier)
|
|
319
321
|
|
|
320
322
|
The **named-query** tier is syncular's cross-platform answer to sqlc /
|
|
321
|
-
SQLDelight: you write a
|
|
323
|
+
SQLDelight: you write a query file, and typegen transpiles it into a typed
|
|
322
324
|
function on every platform — killing query↔type drift *by construction*. It is
|
|
323
|
-
the type-safe **read** tier;
|
|
324
|
-
|
|
325
|
+
the type-safe **read** tier; raw `query(sql, params)` — guarded read-only —
|
|
326
|
+
stays the escape hatch for queries built at runtime.
|
|
327
|
+
|
|
328
|
+
Two frontends feed one pipeline (DESIGN-queries.md is the design of record):
|
|
329
|
+
**`.sql`** — plain SQL + `:params`, the compatibility floor, documented in
|
|
330
|
+
this section — and **`.syql`** — the DSL container (declared optional params
|
|
331
|
+
with auto-guarded conjuncts, `from+to?` groups, `?: flag` guards with
|
|
332
|
+
`if (…) { … }`, file-scoped `@fragments`, `orderBy`/`limit` knobs, and the
|
|
333
|
+
opt-in `variants` enumeration backend), which lowers at generate time to the
|
|
334
|
+
same checked plain SQL. Tooling: `syncular generate --print <name>` (the
|
|
335
|
+
lowered SQL), `syncular fmt` (canonical `.syql` style), `syncular lsp`
|
|
336
|
+
(diagnostics/hover/definition over stdio). Casing follows the manifest
|
|
337
|
+
`naming` mode (§1): generated rows/params are camelCase and projections are
|
|
338
|
+
`AS`-aliased so runtime keys match; `mutate` accepts camel and snake keys
|
|
339
|
+
both.
|
|
325
340
|
|
|
326
341
|
**File & naming convention.** Named queries live in a `queries/` directory
|
|
327
342
|
next to `migrations/` (override with the top-level `"queries"` manifest key).
|
|
@@ -428,7 +443,7 @@ stripped and whitespace collapsed to a clean one-line string.
|
|
|
428
443
|
**The tables dependency set** (for React's exact invalidation). Each query
|
|
429
444
|
emits a `tables` set — the FROM/JOIN tables it reads, resolved against the IR
|
|
430
445
|
and *validated by the same SQLite `prepare()`* (an unknown table would have
|
|
431
|
-
thrown). This feeds `
|
|
446
|
+
thrown). This feeds `useRawSql`'s `{tables}` option so a named query
|
|
432
447
|
re-runs exactly when a depended-on table invalidates. **Boundary**:
|
|
433
448
|
`bun:sqlite` exposes no authorizer and no statement table-list, and EXPLAIN
|
|
434
449
|
opcodes are fragile — so the set is derived by scanning every `FROM`/`JOIN`
|
|
@@ -451,12 +466,12 @@ the same `fromRow` mapping rules (0/1 booleans, `{"$bytes":"<hex>"}` bytes) as
|
|
|
451
466
|
the schema structs. `--check` gates every queries file byte-exactly, like the
|
|
452
467
|
schema files; each carries the DO-NOT-EDIT header + IR hash.
|
|
453
468
|
|
|
454
|
-
**React helper.** `@syncular/react` exports `
|
|
455
|
-
which takes the TS `NamedQuery` descriptor and reuses `
|
|
469
|
+
**React helper.** `@syncular/react` exports `useQuery(query, params?)`,
|
|
470
|
+
which takes the TS `NamedQuery` descriptor and reuses `useRawSql`'s
|
|
456
471
|
invalidation machinery with the descriptor's exact `tables` set:
|
|
457
472
|
|
|
458
473
|
```ts
|
|
459
474
|
import { listTodosQuery } from './syncular.queries';
|
|
460
|
-
const { rows } =
|
|
475
|
+
const { rows } = useQuery(listTodosQuery, { listId });
|
|
461
476
|
// ^ ListTodosRow[] — typed by the query's own projection
|
|
462
477
|
```
|
package/dist/cli.js
CHANGED
|
@@ -15,23 +15,35 @@
|
|
|
15
15
|
* The generate CONTRACT (inputs, outputs, byte-exact `--check`) is unchanged;
|
|
16
16
|
* this file only adds `init`, `--watch`, and friendlier errors.
|
|
17
17
|
*/
|
|
18
|
-
import { watch } from 'node:fs';
|
|
18
|
+
import { existsSync, readFileSync, watch, writeFileSync } from 'node:fs';
|
|
19
19
|
import { resolve } from 'node:path';
|
|
20
|
-
import {
|
|
20
|
+
import { formatSyql } from './fmt.js';
|
|
21
|
+
import { checkOutputs, generate, loadQueries, writeOutputs } from './generate.js';
|
|
21
22
|
import { InitError, initProject } from './init.js';
|
|
22
|
-
import {
|
|
23
|
+
import { runLspStdio } from './lsp.js';
|
|
24
|
+
import { MANIFEST_FILENAME, parseManifest } from './manifest.js';
|
|
23
25
|
const DOCS_HINT = 'See the schema guide: https://github.com/bkniffler/syncular (guide-schema), ' +
|
|
24
26
|
'or run `syncular init` to scaffold a starter manifest + migration.';
|
|
25
27
|
const USAGE = `usage: syncular <command> [options]
|
|
26
28
|
|
|
27
29
|
commands:
|
|
28
30
|
generate build the IR + typed module from syncular.json + migrations/
|
|
31
|
+
fmt format .syql query files canonically (one style, no options)
|
|
32
|
+
lsp run the .syql language server over stdio (editor tooling)
|
|
29
33
|
init scaffold a starter syncular.json + migrations/0001_initial
|
|
30
34
|
|
|
31
35
|
generate options:
|
|
32
36
|
--manifest-dir <dir> directory holding syncular.json (default: .)
|
|
33
37
|
--check fail unless generated files are byte-exactly fresh
|
|
34
38
|
--watch regenerate on file change (Ctrl-C to stop)
|
|
39
|
+
--print <name> print one named query's lowered, checked SQL
|
|
40
|
+
(params, tables, knob variants) and exit
|
|
41
|
+
|
|
42
|
+
fmt options:
|
|
43
|
+
[files…] .syql files to format (default: the manifest's
|
|
44
|
+
queries directory, recursively)
|
|
45
|
+
--manifest-dir <dir> directory holding syncular.json (default: .)
|
|
46
|
+
--check fail unless every file is already canonical
|
|
35
47
|
|
|
36
48
|
init options:
|
|
37
49
|
--manifest-dir <dir> directory to scaffold into (default: .)`;
|
|
@@ -40,7 +52,12 @@ function fail(message) {
|
|
|
40
52
|
process.exit(1);
|
|
41
53
|
}
|
|
42
54
|
function parseGenerateArgs(argv) {
|
|
43
|
-
const args = {
|
|
55
|
+
const args = {
|
|
56
|
+
manifestDir: '.',
|
|
57
|
+
check: false,
|
|
58
|
+
watch: false,
|
|
59
|
+
print: undefined,
|
|
60
|
+
};
|
|
44
61
|
for (let i = 0; i < argv.length; i++) {
|
|
45
62
|
const arg = argv[i];
|
|
46
63
|
if (arg === '--check') {
|
|
@@ -56,6 +73,13 @@ function parseGenerateArgs(argv) {
|
|
|
56
73
|
args.manifestDir = value;
|
|
57
74
|
i += 1;
|
|
58
75
|
}
|
|
76
|
+
else if (arg === '--print') {
|
|
77
|
+
const value = argv[i + 1];
|
|
78
|
+
if (value === undefined)
|
|
79
|
+
fail('--print requires a query name');
|
|
80
|
+
args.print = value;
|
|
81
|
+
i += 1;
|
|
82
|
+
}
|
|
59
83
|
else {
|
|
60
84
|
fail(`unknown argument ${JSON.stringify(arg)}\n${USAGE}`);
|
|
61
85
|
}
|
|
@@ -63,8 +87,52 @@ function parseGenerateArgs(argv) {
|
|
|
63
87
|
if (args.check && args.watch) {
|
|
64
88
|
fail('--check and --watch cannot be combined');
|
|
65
89
|
}
|
|
90
|
+
if (args.print !== undefined && (args.check || args.watch)) {
|
|
91
|
+
fail('--print cannot be combined with --check/--watch');
|
|
92
|
+
}
|
|
66
93
|
return args;
|
|
67
94
|
}
|
|
95
|
+
/** `generate --print <name>`: "what does this actually run" — the lowered,
|
|
96
|
+
* checked SQL for one named query, plus its params/tables/knob variants. */
|
|
97
|
+
function runGeneratePrint(args, name) {
|
|
98
|
+
let result;
|
|
99
|
+
try {
|
|
100
|
+
result = generate(args.manifestDir);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
fail(friendlyGenerateError(error));
|
|
104
|
+
}
|
|
105
|
+
const query = result.queries.find((q) => q.name === name);
|
|
106
|
+
if (query === undefined) {
|
|
107
|
+
const known = result.queries.map((q) => q.name).join(', ');
|
|
108
|
+
fail(`no named query ${JSON.stringify(name)} — this manifest defines: ${known.length > 0 ? known : '(none)'}`);
|
|
109
|
+
}
|
|
110
|
+
console.log(`-- ${query.name} (${query.file})`);
|
|
111
|
+
console.log(`-- tables: ${query.tables.join(', ')}`);
|
|
112
|
+
if (query.params.length > 0) {
|
|
113
|
+
console.log('-- params:');
|
|
114
|
+
for (const param of query.params) {
|
|
115
|
+
const marks = [
|
|
116
|
+
param.optional === true ? 'optional' : 'required',
|
|
117
|
+
...(param.flag === true ? ['flag'] : []),
|
|
118
|
+
...(param.group !== undefined ? [`group ${param.group}`] : []),
|
|
119
|
+
].join(', ');
|
|
120
|
+
console.log(`-- :${param.name} ${param.type} (${marks})`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
console.log(query.sql);
|
|
124
|
+
if (query.orderBy !== undefined) {
|
|
125
|
+
console.log('-- orderBy variants (each checked against the schema):');
|
|
126
|
+
const base = query.positionalSqlBase ?? '';
|
|
127
|
+
for (const col of query.orderBy.allowed) {
|
|
128
|
+
const isDefault = col.name === query.orderBy.defaultColumn;
|
|
129
|
+
console.log(`-- ${col.langName}: ${base} order by ${col.name} asc|desc${query.positionalLimitTail ?? ''}${isDefault ? ' (default)' : ''}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (query.limit !== undefined) {
|
|
133
|
+
console.log(`-- limit: bound value, default ${query.limit.default ?? query.limit.max}${query.limit.max !== undefined ? `, clamped to ${query.limit.max}` : ''}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
68
136
|
function parseManifestDir(argv) {
|
|
69
137
|
let manifestDir = '.';
|
|
70
138
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -142,10 +210,85 @@ function runGenerateWatch(args) {
|
|
|
142
210
|
timer = setTimeout(safeRun, 50);
|
|
143
211
|
});
|
|
144
212
|
}
|
|
213
|
+
/** `syncular fmt`: canonicalize `.syql` files in place (or `--check`). */
|
|
214
|
+
function runFmt(argv) {
|
|
215
|
+
let manifestDir = '.';
|
|
216
|
+
let check = false;
|
|
217
|
+
const files = [];
|
|
218
|
+
for (let i = 0; i < argv.length; i++) {
|
|
219
|
+
const arg = argv[i];
|
|
220
|
+
if (arg === '--check')
|
|
221
|
+
check = true;
|
|
222
|
+
else if (arg === '--manifest-dir') {
|
|
223
|
+
const value = argv[i + 1];
|
|
224
|
+
if (value === undefined)
|
|
225
|
+
fail('--manifest-dir requires a value');
|
|
226
|
+
manifestDir = value;
|
|
227
|
+
i += 1;
|
|
228
|
+
}
|
|
229
|
+
else if (arg.startsWith('--')) {
|
|
230
|
+
fail(`unknown argument ${JSON.stringify(arg)}\n${USAGE}`);
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
files.push(arg);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
let targets;
|
|
237
|
+
if (files.length > 0) {
|
|
238
|
+
targets = files.map((f) => resolve(f));
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
const manifestPath = resolve(manifestDir, MANIFEST_FILENAME);
|
|
242
|
+
if (!existsSync(manifestPath)) {
|
|
243
|
+
fail(`no files given and no ${MANIFEST_FILENAME} in ${manifestDir} — pass .syql files or --manifest-dir`);
|
|
244
|
+
}
|
|
245
|
+
const manifest = parseManifest(JSON.parse(readFileSync(manifestPath, 'utf8')));
|
|
246
|
+
const queriesDir = resolve(manifestDir, manifest.queries);
|
|
247
|
+
targets = loadQueries(queriesDir)
|
|
248
|
+
.filter((q) => q.file.endsWith('.syql'))
|
|
249
|
+
.map((q) => resolve(queriesDir, q.file));
|
|
250
|
+
}
|
|
251
|
+
if (targets.length === 0) {
|
|
252
|
+
console.log('no .syql files to format');
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
let drifted = 0;
|
|
256
|
+
for (const path of targets) {
|
|
257
|
+
if (!path.endsWith('.syql')) {
|
|
258
|
+
fail(`fmt formats .syql files only, got ${path}`);
|
|
259
|
+
}
|
|
260
|
+
const source = readFileSync(path, 'utf8');
|
|
261
|
+
let formatted;
|
|
262
|
+
try {
|
|
263
|
+
formatted = formatSyql(path, source);
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
267
|
+
}
|
|
268
|
+
if (formatted === source)
|
|
269
|
+
continue;
|
|
270
|
+
drifted += 1;
|
|
271
|
+
if (check) {
|
|
272
|
+
console.error(`${path}: not canonical — run \`syncular fmt\``);
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
writeFileSync(path, formatted, 'utf8');
|
|
276
|
+
console.log(`formatted ${path}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (check && drifted > 0)
|
|
280
|
+
process.exit(1);
|
|
281
|
+
if (drifted === 0)
|
|
282
|
+
console.log('all .syql files canonical');
|
|
283
|
+
}
|
|
145
284
|
export function runCli(argv) {
|
|
146
285
|
const command = argv[0];
|
|
147
286
|
if (command === 'generate') {
|
|
148
287
|
const args = parseGenerateArgs(argv.slice(1));
|
|
288
|
+
if (args.print !== undefined) {
|
|
289
|
+
runGeneratePrint(args, args.print);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
149
292
|
if (args.watch) {
|
|
150
293
|
runGenerateWatch(args);
|
|
151
294
|
return;
|
|
@@ -153,6 +296,14 @@ export function runCli(argv) {
|
|
|
153
296
|
runGenerateOnce(args);
|
|
154
297
|
return;
|
|
155
298
|
}
|
|
299
|
+
if (command === 'fmt') {
|
|
300
|
+
runFmt(argv.slice(1));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (command === 'lsp') {
|
|
304
|
+
void runLspStdio();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
156
307
|
if (command === 'init') {
|
|
157
308
|
const dir = parseManifestDir(argv.slice(1));
|
|
158
309
|
try {
|
package/dist/emit-dart.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { snakeToCamel } from './naming.js';
|
|
1
2
|
/** §2.4 column type → honest Dart type. `json`/`blob_ref` are the raw
|
|
2
3
|
* canonical JSON string; `bytes`/`crdt` are opaque `List<int>`. */
|
|
3
4
|
const DART_TYPE = {
|
|
@@ -24,9 +25,9 @@ function pascalCase(name) {
|
|
|
24
25
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
25
26
|
.join('');
|
|
26
27
|
}
|
|
28
|
+
/** Language-facing field name — the pinned §12 naming map. */
|
|
27
29
|
function camelCase(name) {
|
|
28
|
-
|
|
29
|
-
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
30
|
+
return snakeToCamel(name);
|
|
30
31
|
}
|
|
31
32
|
function quote(value) {
|
|
32
33
|
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\$/g, '\\$')}'`;
|
package/dist/emit-kotlin.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { snakeToCamel } from './naming.js';
|
|
1
2
|
/** §2.4 column type → honest Kotlin type. `json`/`blob_ref` are the raw
|
|
2
3
|
* canonical JSON string; `bytes`/`crdt` are opaque `ByteArray`. */
|
|
3
4
|
const KOTLIN_TYPE = {
|
|
@@ -24,9 +25,9 @@ function pascalCase(name) {
|
|
|
24
25
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
25
26
|
.join('');
|
|
26
27
|
}
|
|
28
|
+
/** Language-facing field name — the pinned §12 naming map. */
|
|
27
29
|
function camelCase(name) {
|
|
28
|
-
|
|
29
|
-
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
30
|
+
return snakeToCamel(name);
|
|
30
31
|
}
|
|
31
32
|
function quote(value) {
|
|
32
33
|
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$')}"`;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { snakeToCamel } from './naming.js';
|
|
1
2
|
const DART_TYPE = {
|
|
2
3
|
string: 'String',
|
|
3
4
|
integer: 'int',
|
|
@@ -15,9 +16,9 @@ function pascalCase(name) {
|
|
|
15
16
|
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
16
17
|
.join('');
|
|
17
18
|
}
|
|
19
|
+
/** Language-facing field name — the pinned §12 naming map. */
|
|
18
20
|
function camelCase(name) {
|
|
19
|
-
|
|
20
|
-
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
21
|
+
return snakeToCamel(name);
|
|
21
22
|
}
|
|
22
23
|
function typeName(name) {
|
|
23
24
|
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
@@ -26,7 +27,7 @@ function quote(value) {
|
|
|
26
27
|
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\$/g, '\\$')}'`;
|
|
27
28
|
}
|
|
28
29
|
function rowAccessor(column) {
|
|
29
|
-
const key = `row[${quote(column.
|
|
30
|
+
const key = `row[${quote(column.langName)}]`;
|
|
30
31
|
switch (column.type) {
|
|
31
32
|
case 'string':
|
|
32
33
|
case 'json':
|
|
@@ -53,6 +54,38 @@ function paramValue(type, name) {
|
|
|
53
54
|
return name;
|
|
54
55
|
}
|
|
55
56
|
}
|
|
57
|
+
/** Bind for a §4 OPTIONAL param: Dart `null` rides as JSON null (the §7
|
|
58
|
+
* neutralization guards make it a no-op). */
|
|
59
|
+
function optionalParamValue(type, name) {
|
|
60
|
+
switch (type) {
|
|
61
|
+
case 'bytes':
|
|
62
|
+
case 'crdt':
|
|
63
|
+
return `${name} == null ? null : _queryBindBytes(${name})`;
|
|
64
|
+
default:
|
|
65
|
+
return name;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function isOptionalParam(query, p) {
|
|
69
|
+
return (p.optional === true ||
|
|
70
|
+
p.flag === true ||
|
|
71
|
+
(query.limit !== undefined && p.name === 'limit'));
|
|
72
|
+
}
|
|
73
|
+
/** Per-query orderBy allowlist enum (column = the checked SQL column). */
|
|
74
|
+
function emitOrderByEnum(query) {
|
|
75
|
+
if (query.orderBy === undefined)
|
|
76
|
+
return [];
|
|
77
|
+
const lines = [];
|
|
78
|
+
lines.push(`/// §6 orderBy allowlist for ${query.name} — checked at generate time.`);
|
|
79
|
+
lines.push(`enum ${typeName(query.name)}OrderBy {`);
|
|
80
|
+
lines.push(query.orderBy.allowed
|
|
81
|
+
.map((col) => ` ${camelCase(col.langName)}(${quote(col.name)})`)
|
|
82
|
+
.join(',\n') + ';');
|
|
83
|
+
lines.push('');
|
|
84
|
+
lines.push(` const ${typeName(query.name)}OrderBy(this.column);`);
|
|
85
|
+
lines.push(' final String column;');
|
|
86
|
+
lines.push('}');
|
|
87
|
+
return lines;
|
|
88
|
+
}
|
|
56
89
|
function emitClass(query) {
|
|
57
90
|
const Row = `${typeName(query.name)}Row`;
|
|
58
91
|
const lines = [];
|
|
@@ -60,25 +93,25 @@ function emitClass(query) {
|
|
|
60
93
|
lines.push(`class ${Row} {`);
|
|
61
94
|
for (const column of query.columns) {
|
|
62
95
|
const opt = column.nullable ? '?' : '';
|
|
63
|
-
lines.push(` final ${DART_TYPE[column.type]}${opt} ${camelCase(column.
|
|
96
|
+
lines.push(` final ${DART_TYPE[column.type]}${opt} ${camelCase(column.langName)};`);
|
|
64
97
|
}
|
|
65
98
|
lines.push('');
|
|
66
99
|
const ctorParams = query.columns
|
|
67
|
-
.map((c) => `${c.nullable ? '' : 'required '}this.${camelCase(c.
|
|
100
|
+
.map((c) => `${c.nullable ? '' : 'required '}this.${camelCase(c.langName)}`)
|
|
68
101
|
.join(', ');
|
|
69
102
|
lines.push(` const ${Row}({${ctorParams}});`);
|
|
70
103
|
lines.push('');
|
|
71
104
|
lines.push(` static ${Row}? fromRow(Map<String, Object?> row) {`);
|
|
72
105
|
for (const column of query.columns) {
|
|
73
106
|
if (!column.nullable) {
|
|
74
|
-
const name = camelCase(column.
|
|
107
|
+
const name = camelCase(column.langName);
|
|
75
108
|
lines.push(` final ${name} = ${rowAccessor(column)};`);
|
|
76
109
|
lines.push(` if (${name} == null) return null;`);
|
|
77
110
|
}
|
|
78
111
|
}
|
|
79
112
|
const args = query.columns
|
|
80
113
|
.map((c) => {
|
|
81
|
-
const name = camelCase(c.
|
|
114
|
+
const name = camelCase(c.langName);
|
|
82
115
|
return c.nullable ? `${name}: ${rowAccessor(c)}` : `${name}: ${name}`;
|
|
83
116
|
})
|
|
84
117
|
.join(', ');
|
|
@@ -94,23 +127,53 @@ function emitRunner(query) {
|
|
|
94
127
|
lines.push(`/// Tables the ${query.name} query reads (exact invalidation set).`);
|
|
95
128
|
lines.push(`const List<String> syncular${Pascal}QueryTables = [${query.tables.map(quote).join(', ')}];`);
|
|
96
129
|
lines.push('');
|
|
97
|
-
|
|
130
|
+
if (query.orderBy !== undefined) {
|
|
131
|
+
lines.push(`const String _${query.name}SqlBase = ${quote(query.positionalSqlBase ?? '')};`);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
lines.push(`const String _${query.name}Sql = ${quote(query.positionalSql)};`);
|
|
135
|
+
}
|
|
98
136
|
lines.push('');
|
|
99
137
|
lines.push(`/// Run the ${query.name} named query (SELECT-only).`);
|
|
100
|
-
const args =
|
|
101
|
-
|
|
102
|
-
.
|
|
103
|
-
|
|
138
|
+
const args = [];
|
|
139
|
+
for (const p of query.params) {
|
|
140
|
+
const name = camelCase(p.langName);
|
|
141
|
+
if (isOptionalParam(query, p)) {
|
|
142
|
+
args.push(`${DART_TYPE[p.type]}? ${name}`);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
args.push(`required ${DART_TYPE[p.type]} ${name}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (query.orderBy !== undefined) {
|
|
149
|
+
const defaultCase = camelCase(query.orderBy.allowed.find((c) => c.name === query.orderBy?.defaultColumn)
|
|
150
|
+
?.langName ?? query.orderBy.defaultColumn);
|
|
151
|
+
args.push(`${typeName(query.name)}OrderBy orderBy = ${typeName(query.name)}OrderBy.${defaultCase}`);
|
|
152
|
+
args.push(`SyncularQueryDir dir = SyncularQueryDir.${query.orderBy.defaultDir}`);
|
|
153
|
+
}
|
|
154
|
+
const argsClause = args.length > 0 ? `, {${args.join(', ')}}` : '';
|
|
104
155
|
lines.push(`List<${Row}> syncular${Pascal}Query(SyncularClient client${argsClause}) {`);
|
|
156
|
+
if (query.orderBy !== undefined) {
|
|
157
|
+
const limitTail = query.positionalLimitTail !== undefined
|
|
158
|
+
? ` ${quote(query.positionalLimitTail.trim())}`
|
|
159
|
+
: '';
|
|
160
|
+
lines.push(` final sql = '$_${query.name}SqlBase order by \${orderBy.column} \${dir.name}'${limitTail === '' ? '' : `\n ' ' ${limitTail.trim()}`};`);
|
|
161
|
+
}
|
|
162
|
+
const sqlRef = query.orderBy !== undefined ? 'sql' : `_${query.name}Sql`;
|
|
105
163
|
if (query.params.length > 0) {
|
|
106
164
|
const binds = query.params
|
|
107
|
-
.map((p) =>
|
|
165
|
+
.map((p) => {
|
|
166
|
+
const name = camelCase(p.langName);
|
|
167
|
+
return isOptionalParam(query, p)
|
|
168
|
+
? optionalParamValue(p.type, name)
|
|
169
|
+
: paramValue(p.type, name);
|
|
170
|
+
})
|
|
108
171
|
.join(', ');
|
|
109
172
|
lines.push(` final params = <Object?>[${binds}];`);
|
|
110
|
-
lines.push(` return client.query(
|
|
173
|
+
lines.push(` return client.query(${sqlRef}, params: params).map(${Row}.fromRow).whereType<${Row}>().toList();`);
|
|
111
174
|
}
|
|
112
175
|
else {
|
|
113
|
-
lines.push(` return client.query(
|
|
176
|
+
lines.push(` return client.query(${sqlRef}).map(${Row}.fromRow).whereType<${Row}>().toList();`);
|
|
114
177
|
}
|
|
115
178
|
lines.push('}');
|
|
116
179
|
return lines;
|
|
@@ -150,8 +213,17 @@ export function emitQueriesDartModule(queries, hash, irVersion) {
|
|
|
150
213
|
" return {r'$bytes': hex};",
|
|
151
214
|
'}',
|
|
152
215
|
].join('\n'));
|
|
216
|
+
if (queries.some((q) => q.orderBy !== undefined)) {
|
|
217
|
+
parts.push([
|
|
218
|
+
'/// §6 orderBy direction (shared by every orderBy-knob query).',
|
|
219
|
+
'enum SyncularQueryDir { asc, desc }',
|
|
220
|
+
].join('\n'));
|
|
221
|
+
}
|
|
153
222
|
for (const query of queries) {
|
|
154
223
|
parts.push(emitClass(query).join('\n'));
|
|
224
|
+
const orderByEnum = emitOrderByEnum(query);
|
|
225
|
+
if (orderByEnum.length > 0)
|
|
226
|
+
parts.push(orderByEnum.join('\n'));
|
|
155
227
|
}
|
|
156
228
|
for (const query of queries) {
|
|
157
229
|
parts.push(emitRunner(query).join('\n'));
|