@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.
Files changed (51) hide show
  1. package/README.md +25 -10
  2. package/dist/cli.js +155 -4
  3. package/dist/emit-dart.js +3 -2
  4. package/dist/emit-kotlin.js +3 -2
  5. package/dist/emit-queries-dart.js +87 -15
  6. package/dist/emit-queries-kotlin.js +89 -15
  7. package/dist/emit-queries-swift.js +92 -14
  8. package/dist/emit-queries.js +131 -22
  9. package/dist/emit-swift.js +3 -2
  10. package/dist/emit.d.ts +2 -1
  11. package/dist/emit.js +13 -27
  12. package/dist/fmt.d.ts +3 -0
  13. package/dist/fmt.js +356 -0
  14. package/dist/generate.d.ts +16 -6
  15. package/dist/generate.js +34 -9
  16. package/dist/index.d.ts +6 -0
  17. package/dist/index.js +6 -0
  18. package/dist/lower.d.ts +54 -0
  19. package/dist/lower.js +283 -0
  20. package/dist/lsp.d.ts +20 -0
  21. package/dist/lsp.js +376 -0
  22. package/dist/manifest.d.ts +11 -0
  23. package/dist/manifest.js +20 -0
  24. package/dist/naming.d.ts +22 -0
  25. package/dist/naming.js +240 -0
  26. package/dist/query-ir.d.ts +14 -0
  27. package/dist/query-ir.js +69 -0
  28. package/dist/query.d.ts +102 -6
  29. package/dist/query.js +126 -34
  30. package/dist/syql.d.ts +83 -0
  31. package/dist/syql.js +945 -0
  32. package/package.json +2 -2
  33. package/src/cli.ts +155 -4
  34. package/src/emit-dart.ts +3 -2
  35. package/src/emit-kotlin.ts +3 -2
  36. package/src/emit-queries-dart.ts +109 -16
  37. package/src/emit-queries-kotlin.ts +111 -18
  38. package/src/emit-queries-swift.ts +106 -17
  39. package/src/emit-queries.ts +179 -28
  40. package/src/emit-swift.ts +3 -2
  41. package/src/emit.ts +18 -28
  42. package/src/fmt.ts +351 -0
  43. package/src/generate.ts +54 -11
  44. package/src/index.ts +6 -0
  45. package/src/lower.ts +331 -0
  46. package/src/lsp.ts +445 -0
  47. package/src/manifest.ts +38 -0
  48. package/src/naming.ts +271 -0
  49. package/src/query-ir.ts +82 -0
  50. package/src/query.ts +241 -34
  51. 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 v1-style migrations unusable
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 `.sql` file, and typegen transpiles it into a typed
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; Kysely stays the TS **dynamic** tier, and raw
324
- `query(sql, params)` stays the escape hatch.
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 `useSyncQuery`'s `{tables}` option so a named query
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 `useNamedQuery(query, params?)`,
455
- which takes the TS `NamedQuery` descriptor and reuses `useSyncQuery`'s
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 } = useNamedQuery(listTodosQuery, { listId });
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 { checkOutputs, generate, writeOutputs } from './generate.js';
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 { MANIFEST_FILENAME } from './manifest.js';
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 = { manifestDir: '.', check: false, watch: false };
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
- const pascal = pascalCase(name);
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
  /** §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
- const pascal = pascalCase(name);
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
- const pascal = pascalCase(name);
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.name)}]`;
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.name)};`);
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.name)}`)
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.name);
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.name);
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
- lines.push(`const String _${query.name}Sql = ${quote(query.positionalSql)};`);
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 = query.params
101
- .map((p) => `required ${DART_TYPE[p.type]} ${camelCase(p.name)}`)
102
- .join(', ');
103
- const argsClause = query.params.length > 0 ? `, {${args}}` : '';
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) => paramValue(p.type, camelCase(p.name)))
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(_${query.name}Sql, params: params).map(${Row}.fromRow).whereType<${Row}>().toList();`);
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(_${query.name}Sql).map(${Row}.fromRow).whereType<${Row}>().toList();`);
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'));