@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/typegen",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Syncular schema-to-TypeScript type generator and the `syncular` CLI",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"exports": {
|
|
32
32
|
".": {
|
|
33
33
|
"bun": "./src/index.ts",
|
|
34
|
-
"browser": "./
|
|
34
|
+
"browser": "./dist/index.js",
|
|
35
35
|
"import": {
|
|
36
36
|
"types": "./dist/index.d.ts",
|
|
37
37
|
"default": "./dist/index.js"
|
package/src/cli.ts
CHANGED
|
@@ -15,11 +15,13 @@
|
|
|
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';
|
|
21
|
+
import { checkOutputs, generate, loadQueries, writeOutputs } from './generate';
|
|
21
22
|
import { InitError, initProject } from './init';
|
|
22
|
-
import {
|
|
23
|
+
import { runLspStdio } from './lsp';
|
|
24
|
+
import { MANIFEST_FILENAME, parseManifest } from './manifest';
|
|
23
25
|
|
|
24
26
|
const DOCS_HINT =
|
|
25
27
|
'See the schema guide: https://github.com/bkniffler/syncular (guide-schema), ' +
|
|
@@ -29,12 +31,22 @@ const USAGE = `usage: syncular <command> [options]
|
|
|
29
31
|
|
|
30
32
|
commands:
|
|
31
33
|
generate build the IR + typed module from syncular.json + migrations/
|
|
34
|
+
fmt format .syql query files canonically (one style, no options)
|
|
35
|
+
lsp run the .syql language server over stdio (editor tooling)
|
|
32
36
|
init scaffold a starter syncular.json + migrations/0001_initial
|
|
33
37
|
|
|
34
38
|
generate options:
|
|
35
39
|
--manifest-dir <dir> directory holding syncular.json (default: .)
|
|
36
40
|
--check fail unless generated files are byte-exactly fresh
|
|
37
41
|
--watch regenerate on file change (Ctrl-C to stop)
|
|
42
|
+
--print <name> print one named query's lowered, checked SQL
|
|
43
|
+
(params, tables, knob variants) and exit
|
|
44
|
+
|
|
45
|
+
fmt options:
|
|
46
|
+
[files…] .syql files to format (default: the manifest's
|
|
47
|
+
queries directory, recursively)
|
|
48
|
+
--manifest-dir <dir> directory holding syncular.json (default: .)
|
|
49
|
+
--check fail unless every file is already canonical
|
|
38
50
|
|
|
39
51
|
init options:
|
|
40
52
|
--manifest-dir <dir> directory to scaffold into (default: .)`;
|
|
@@ -48,10 +60,16 @@ interface GenerateArgs {
|
|
|
48
60
|
manifestDir: string;
|
|
49
61
|
check: boolean;
|
|
50
62
|
watch: boolean;
|
|
63
|
+
print: string | undefined;
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
function parseGenerateArgs(argv: readonly string[]): GenerateArgs {
|
|
54
|
-
const args: GenerateArgs = {
|
|
67
|
+
const args: GenerateArgs = {
|
|
68
|
+
manifestDir: '.',
|
|
69
|
+
check: false,
|
|
70
|
+
watch: false,
|
|
71
|
+
print: undefined,
|
|
72
|
+
};
|
|
55
73
|
for (let i = 0; i < argv.length; i++) {
|
|
56
74
|
const arg = argv[i];
|
|
57
75
|
if (arg === '--check') {
|
|
@@ -63,6 +81,11 @@ function parseGenerateArgs(argv: readonly string[]): GenerateArgs {
|
|
|
63
81
|
if (value === undefined) fail('--manifest-dir requires a value');
|
|
64
82
|
args.manifestDir = value;
|
|
65
83
|
i += 1;
|
|
84
|
+
} else if (arg === '--print') {
|
|
85
|
+
const value = argv[i + 1];
|
|
86
|
+
if (value === undefined) fail('--print requires a query name');
|
|
87
|
+
args.print = value;
|
|
88
|
+
i += 1;
|
|
66
89
|
} else {
|
|
67
90
|
fail(`unknown argument ${JSON.stringify(arg)}\n${USAGE}`);
|
|
68
91
|
}
|
|
@@ -70,9 +93,59 @@ function parseGenerateArgs(argv: readonly string[]): GenerateArgs {
|
|
|
70
93
|
if (args.check && args.watch) {
|
|
71
94
|
fail('--check and --watch cannot be combined');
|
|
72
95
|
}
|
|
96
|
+
if (args.print !== undefined && (args.check || args.watch)) {
|
|
97
|
+
fail('--print cannot be combined with --check/--watch');
|
|
98
|
+
}
|
|
73
99
|
return args;
|
|
74
100
|
}
|
|
75
101
|
|
|
102
|
+
/** `generate --print <name>`: "what does this actually run" — the lowered,
|
|
103
|
+
* checked SQL for one named query, plus its params/tables/knob variants. */
|
|
104
|
+
function runGeneratePrint(args: GenerateArgs, name: string): void {
|
|
105
|
+
let result: ReturnType<typeof generate>;
|
|
106
|
+
try {
|
|
107
|
+
result = generate(args.manifestDir);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
fail(friendlyGenerateError(error));
|
|
110
|
+
}
|
|
111
|
+
const query = result.queries.find((q) => q.name === name);
|
|
112
|
+
if (query === undefined) {
|
|
113
|
+
const known = result.queries.map((q) => q.name).join(', ');
|
|
114
|
+
fail(
|
|
115
|
+
`no named query ${JSON.stringify(name)} — this manifest defines: ${known.length > 0 ? known : '(none)'}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
console.log(`-- ${query.name} (${query.file})`);
|
|
119
|
+
console.log(`-- tables: ${query.tables.join(', ')}`);
|
|
120
|
+
if (query.params.length > 0) {
|
|
121
|
+
console.log('-- params:');
|
|
122
|
+
for (const param of query.params) {
|
|
123
|
+
const marks = [
|
|
124
|
+
param.optional === true ? 'optional' : 'required',
|
|
125
|
+
...(param.flag === true ? ['flag'] : []),
|
|
126
|
+
...(param.group !== undefined ? [`group ${param.group}`] : []),
|
|
127
|
+
].join(', ');
|
|
128
|
+
console.log(`-- :${param.name} ${param.type} (${marks})`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
console.log(query.sql);
|
|
132
|
+
if (query.orderBy !== undefined) {
|
|
133
|
+
console.log('-- orderBy variants (each checked against the schema):');
|
|
134
|
+
const base = query.positionalSqlBase ?? '';
|
|
135
|
+
for (const col of query.orderBy.allowed) {
|
|
136
|
+
const isDefault = col.name === query.orderBy.defaultColumn;
|
|
137
|
+
console.log(
|
|
138
|
+
`-- ${col.langName}: ${base} order by ${col.name} asc|desc${query.positionalLimitTail ?? ''}${isDefault ? ' (default)' : ''}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (query.limit !== undefined) {
|
|
143
|
+
console.log(
|
|
144
|
+
`-- limit: bound value, default ${query.limit.default ?? query.limit.max}${query.limit.max !== undefined ? `, clamped to ${query.limit.max}` : ''}`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
76
149
|
function parseManifestDir(argv: readonly string[]): string {
|
|
77
150
|
let manifestDir = '.';
|
|
78
151
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -150,10 +223,80 @@ function runGenerateWatch(args: GenerateArgs): void {
|
|
|
150
223
|
});
|
|
151
224
|
}
|
|
152
225
|
|
|
226
|
+
/** `syncular fmt`: canonicalize `.syql` files in place (or `--check`). */
|
|
227
|
+
function runFmt(argv: readonly string[]): void {
|
|
228
|
+
let manifestDir = '.';
|
|
229
|
+
let check = false;
|
|
230
|
+
const files: string[] = [];
|
|
231
|
+
for (let i = 0; i < argv.length; i++) {
|
|
232
|
+
const arg = argv[i] as string;
|
|
233
|
+
if (arg === '--check') check = true;
|
|
234
|
+
else if (arg === '--manifest-dir') {
|
|
235
|
+
const value = argv[i + 1];
|
|
236
|
+
if (value === undefined) fail('--manifest-dir requires a value');
|
|
237
|
+
manifestDir = value;
|
|
238
|
+
i += 1;
|
|
239
|
+
} else if (arg.startsWith('--')) {
|
|
240
|
+
fail(`unknown argument ${JSON.stringify(arg)}\n${USAGE}`);
|
|
241
|
+
} else {
|
|
242
|
+
files.push(arg);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
let targets: string[];
|
|
246
|
+
if (files.length > 0) {
|
|
247
|
+
targets = files.map((f) => resolve(f));
|
|
248
|
+
} else {
|
|
249
|
+
const manifestPath = resolve(manifestDir, MANIFEST_FILENAME);
|
|
250
|
+
if (!existsSync(manifestPath)) {
|
|
251
|
+
fail(
|
|
252
|
+
`no files given and no ${MANIFEST_FILENAME} in ${manifestDir} — pass .syql files or --manifest-dir`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const manifest = parseManifest(
|
|
256
|
+
JSON.parse(readFileSync(manifestPath, 'utf8')),
|
|
257
|
+
);
|
|
258
|
+
const queriesDir = resolve(manifestDir, manifest.queries);
|
|
259
|
+
targets = loadQueries(queriesDir)
|
|
260
|
+
.filter((q) => q.file.endsWith('.syql'))
|
|
261
|
+
.map((q) => resolve(queriesDir, q.file));
|
|
262
|
+
}
|
|
263
|
+
if (targets.length === 0) {
|
|
264
|
+
console.log('no .syql files to format');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
let drifted = 0;
|
|
268
|
+
for (const path of targets) {
|
|
269
|
+
if (!path.endsWith('.syql')) {
|
|
270
|
+
fail(`fmt formats .syql files only, got ${path}`);
|
|
271
|
+
}
|
|
272
|
+
const source = readFileSync(path, 'utf8');
|
|
273
|
+
let formatted: string;
|
|
274
|
+
try {
|
|
275
|
+
formatted = formatSyql(path, source);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
278
|
+
}
|
|
279
|
+
if (formatted === source) continue;
|
|
280
|
+
drifted += 1;
|
|
281
|
+
if (check) {
|
|
282
|
+
console.error(`${path}: not canonical — run \`syncular fmt\``);
|
|
283
|
+
} else {
|
|
284
|
+
writeFileSync(path, formatted, 'utf8');
|
|
285
|
+
console.log(`formatted ${path}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (check && drifted > 0) process.exit(1);
|
|
289
|
+
if (drifted === 0) console.log('all .syql files canonical');
|
|
290
|
+
}
|
|
291
|
+
|
|
153
292
|
export function runCli(argv: readonly string[]): void {
|
|
154
293
|
const command = argv[0];
|
|
155
294
|
if (command === 'generate') {
|
|
156
295
|
const args = parseGenerateArgs(argv.slice(1));
|
|
296
|
+
if (args.print !== undefined) {
|
|
297
|
+
runGeneratePrint(args, args.print);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
157
300
|
if (args.watch) {
|
|
158
301
|
runGenerateWatch(args);
|
|
159
302
|
return;
|
|
@@ -161,6 +304,14 @@ export function runCli(argv: readonly string[]): void {
|
|
|
161
304
|
runGenerateOnce(args);
|
|
162
305
|
return;
|
|
163
306
|
}
|
|
307
|
+
if (command === 'fmt') {
|
|
308
|
+
runFmt(argv.slice(1));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (command === 'lsp') {
|
|
312
|
+
void runLspStdio();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
164
315
|
if (command === 'init') {
|
|
165
316
|
const dir = parseManifestDir(argv.slice(1));
|
|
166
317
|
try {
|
package/src/emit-dart.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* verifies freshness byte-exactly.
|
|
15
15
|
*/
|
|
16
16
|
import type { IrColumnType, IrDocument, IrSubscription, IrTable } from './ir';
|
|
17
|
+
import { snakeToCamel } from './naming';
|
|
17
18
|
|
|
18
19
|
/** §2.4 column type → honest Dart type. `json`/`blob_ref` are the raw
|
|
19
20
|
* canonical JSON string; `bytes`/`crdt` are opaque `List<int>`. */
|
|
@@ -45,9 +46,9 @@ function pascalCase(name: string): string {
|
|
|
45
46
|
.join('');
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/** Language-facing field name — the pinned §12 naming map. */
|
|
48
50
|
function camelCase(name: string): string {
|
|
49
|
-
|
|
50
|
-
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
51
|
+
return snakeToCamel(name);
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
function quote(value: string): string {
|
package/src/emit-kotlin.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* the IR hash so `--check` verifies freshness byte-exactly.
|
|
14
14
|
*/
|
|
15
15
|
import type { IrColumnType, IrDocument, IrSubscription, IrTable } from './ir';
|
|
16
|
+
import { snakeToCamel } from './naming';
|
|
16
17
|
|
|
17
18
|
/** §2.4 column type → honest Kotlin type. `json`/`blob_ref` are the raw
|
|
18
19
|
* canonical JSON string; `bytes`/`crdt` are opaque `ByteArray`. */
|
|
@@ -44,9 +45,9 @@ function pascalCase(name: string): string {
|
|
|
44
45
|
.join('');
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
/** Language-facing field name — the pinned §12 naming map. */
|
|
47
49
|
function camelCase(name: string): string {
|
|
48
|
-
|
|
49
|
-
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
50
|
+
return snakeToCamel(name);
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
function quote(value: string): string {
|
package/src/emit-queries-dart.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* Header carries the IR hash for byte-exact `--check`.
|
|
9
9
|
*/
|
|
10
10
|
import type { IrColumnType } from './ir';
|
|
11
|
-
import
|
|
11
|
+
import { snakeToCamel } from './naming';
|
|
12
|
+
import type { AnalyzedQuery, QueryColumn, QueryParam } from './query';
|
|
12
13
|
|
|
13
14
|
const DART_TYPE: Readonly<Record<IrColumnType, string>> = {
|
|
14
15
|
string: 'String',
|
|
@@ -29,9 +30,9 @@ function pascalCase(name: string): string {
|
|
|
29
30
|
.join('');
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/** Language-facing field name — the pinned §12 naming map. */
|
|
32
34
|
function camelCase(name: string): string {
|
|
33
|
-
|
|
34
|
-
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
35
|
+
return snakeToCamel(name);
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
function typeName(name: string): string {
|
|
@@ -43,7 +44,7 @@ function quote(value: string): string {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
function rowAccessor(column: QueryColumn): string {
|
|
46
|
-
const key = `row[${quote(column.
|
|
47
|
+
const key = `row[${quote(column.langName)}]`;
|
|
47
48
|
switch (column.type) {
|
|
48
49
|
case 'string':
|
|
49
50
|
case 'json':
|
|
@@ -72,6 +73,46 @@ function paramValue(type: IrColumnType, name: string): string {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
/** Bind for a §4 OPTIONAL param: Dart `null` rides as JSON null (the §7
|
|
77
|
+
* neutralization guards make it a no-op). */
|
|
78
|
+
function optionalParamValue(type: IrColumnType, name: string): string {
|
|
79
|
+
switch (type) {
|
|
80
|
+
case 'bytes':
|
|
81
|
+
case 'crdt':
|
|
82
|
+
return `${name} == null ? null : _queryBindBytes(${name})`;
|
|
83
|
+
default:
|
|
84
|
+
return name;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isOptionalParam(query: AnalyzedQuery, p: QueryParam): boolean {
|
|
89
|
+
return (
|
|
90
|
+
p.optional === true ||
|
|
91
|
+
p.flag === true ||
|
|
92
|
+
(query.limit !== undefined && p.name === 'limit')
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Per-query orderBy allowlist enum (column = the checked SQL column). */
|
|
97
|
+
function emitOrderByEnum(query: AnalyzedQuery): string[] {
|
|
98
|
+
if (query.orderBy === undefined) return [];
|
|
99
|
+
const lines: string[] = [];
|
|
100
|
+
lines.push(
|
|
101
|
+
`/// §6 orderBy allowlist for ${query.name} — checked at generate time.`,
|
|
102
|
+
);
|
|
103
|
+
lines.push(`enum ${typeName(query.name)}OrderBy {`);
|
|
104
|
+
lines.push(
|
|
105
|
+
query.orderBy.allowed
|
|
106
|
+
.map((col) => ` ${camelCase(col.langName)}(${quote(col.name)})`)
|
|
107
|
+
.join(',\n') + ';',
|
|
108
|
+
);
|
|
109
|
+
lines.push('');
|
|
110
|
+
lines.push(` const ${typeName(query.name)}OrderBy(this.column);`);
|
|
111
|
+
lines.push(' final String column;');
|
|
112
|
+
lines.push('}');
|
|
113
|
+
return lines;
|
|
114
|
+
}
|
|
115
|
+
|
|
75
116
|
function emitClass(query: AnalyzedQuery): string[] {
|
|
76
117
|
const Row = `${typeName(query.name)}Row`;
|
|
77
118
|
const lines: string[] = [];
|
|
@@ -80,26 +121,26 @@ function emitClass(query: AnalyzedQuery): string[] {
|
|
|
80
121
|
for (const column of query.columns) {
|
|
81
122
|
const opt = column.nullable ? '?' : '';
|
|
82
123
|
lines.push(
|
|
83
|
-
` final ${DART_TYPE[column.type]}${opt} ${camelCase(column.
|
|
124
|
+
` final ${DART_TYPE[column.type]}${opt} ${camelCase(column.langName)};`,
|
|
84
125
|
);
|
|
85
126
|
}
|
|
86
127
|
lines.push('');
|
|
87
128
|
const ctorParams = query.columns
|
|
88
|
-
.map((c) => `${c.nullable ? '' : 'required '}this.${camelCase(c.
|
|
129
|
+
.map((c) => `${c.nullable ? '' : 'required '}this.${camelCase(c.langName)}`)
|
|
89
130
|
.join(', ');
|
|
90
131
|
lines.push(` const ${Row}({${ctorParams}});`);
|
|
91
132
|
lines.push('');
|
|
92
133
|
lines.push(` static ${Row}? fromRow(Map<String, Object?> row) {`);
|
|
93
134
|
for (const column of query.columns) {
|
|
94
135
|
if (!column.nullable) {
|
|
95
|
-
const name = camelCase(column.
|
|
136
|
+
const name = camelCase(column.langName);
|
|
96
137
|
lines.push(` final ${name} = ${rowAccessor(column)};`);
|
|
97
138
|
lines.push(` if (${name} == null) return null;`);
|
|
98
139
|
}
|
|
99
140
|
}
|
|
100
141
|
const args = query.columns
|
|
101
142
|
.map((c) => {
|
|
102
|
-
const name = camelCase(c.
|
|
143
|
+
const name = camelCase(c.langName);
|
|
103
144
|
return c.nullable ? `${name}: ${rowAccessor(c)}` : `${name}: ${name}`;
|
|
104
145
|
})
|
|
105
146
|
.join(', ');
|
|
@@ -120,27 +161,68 @@ function emitRunner(query: AnalyzedQuery): string[] {
|
|
|
120
161
|
`const List<String> syncular${Pascal}QueryTables = [${query.tables.map(quote).join(', ')}];`,
|
|
121
162
|
);
|
|
122
163
|
lines.push('');
|
|
123
|
-
|
|
164
|
+
if (query.orderBy !== undefined) {
|
|
165
|
+
lines.push(
|
|
166
|
+
`const String _${query.name}SqlBase = ${quote(query.positionalSqlBase ?? '')};`,
|
|
167
|
+
);
|
|
168
|
+
} else {
|
|
169
|
+
lines.push(
|
|
170
|
+
`const String _${query.name}Sql = ${quote(query.positionalSql)};`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
124
173
|
lines.push('');
|
|
125
174
|
lines.push(`/// Run the ${query.name} named query (SELECT-only).`);
|
|
126
|
-
const args =
|
|
127
|
-
|
|
128
|
-
.
|
|
129
|
-
|
|
175
|
+
const args: string[] = [];
|
|
176
|
+
for (const p of query.params) {
|
|
177
|
+
const name = camelCase(p.langName);
|
|
178
|
+
if (isOptionalParam(query, p)) {
|
|
179
|
+
args.push(`${DART_TYPE[p.type]}? ${name}`);
|
|
180
|
+
} else {
|
|
181
|
+
args.push(`required ${DART_TYPE[p.type]} ${name}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (query.orderBy !== undefined) {
|
|
185
|
+
const defaultCase = camelCase(
|
|
186
|
+
query.orderBy.allowed.find((c) => c.name === query.orderBy?.defaultColumn)
|
|
187
|
+
?.langName ?? query.orderBy.defaultColumn,
|
|
188
|
+
);
|
|
189
|
+
args.push(
|
|
190
|
+
`${typeName(query.name)}OrderBy orderBy = ${typeName(query.name)}OrderBy.${defaultCase}`,
|
|
191
|
+
);
|
|
192
|
+
args.push(
|
|
193
|
+
`SyncularQueryDir dir = SyncularQueryDir.${query.orderBy.defaultDir}`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const argsClause = args.length > 0 ? `, {${args.join(', ')}}` : '';
|
|
130
197
|
lines.push(
|
|
131
198
|
`List<${Row}> syncular${Pascal}Query(SyncularClient client${argsClause}) {`,
|
|
132
199
|
);
|
|
200
|
+
if (query.orderBy !== undefined) {
|
|
201
|
+
const limitTail =
|
|
202
|
+
query.positionalLimitTail !== undefined
|
|
203
|
+
? ` ${quote(query.positionalLimitTail.trim())}`
|
|
204
|
+
: '';
|
|
205
|
+
lines.push(
|
|
206
|
+
` final sql = '$_${query.name}SqlBase order by \${orderBy.column} \${dir.name}'${limitTail === '' ? '' : `\n ' ' ${limitTail.trim()}`};`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const sqlRef = query.orderBy !== undefined ? 'sql' : `_${query.name}Sql`;
|
|
133
210
|
if (query.params.length > 0) {
|
|
134
211
|
const binds = query.params
|
|
135
|
-
.map((p) =>
|
|
212
|
+
.map((p) => {
|
|
213
|
+
const name = camelCase(p.langName);
|
|
214
|
+
return isOptionalParam(query, p)
|
|
215
|
+
? optionalParamValue(p.type, name)
|
|
216
|
+
: paramValue(p.type, name);
|
|
217
|
+
})
|
|
136
218
|
.join(', ');
|
|
137
219
|
lines.push(` final params = <Object?>[${binds}];`);
|
|
138
220
|
lines.push(
|
|
139
|
-
` return client.query(
|
|
221
|
+
` return client.query(${sqlRef}, params: params).map(${Row}.fromRow).whereType<${Row}>().toList();`,
|
|
140
222
|
);
|
|
141
223
|
} else {
|
|
142
224
|
lines.push(
|
|
143
|
-
` return client.query(
|
|
225
|
+
` return client.query(${sqlRef}).map(${Row}.fromRow).whereType<${Row}>().toList();`,
|
|
144
226
|
);
|
|
145
227
|
}
|
|
146
228
|
lines.push('}');
|
|
@@ -192,8 +274,19 @@ export function emitQueriesDartModule(
|
|
|
192
274
|
].join('\n'),
|
|
193
275
|
);
|
|
194
276
|
|
|
277
|
+
if (queries.some((q) => q.orderBy !== undefined)) {
|
|
278
|
+
parts.push(
|
|
279
|
+
[
|
|
280
|
+
'/// §6 orderBy direction (shared by every orderBy-knob query).',
|
|
281
|
+
'enum SyncularQueryDir { asc, desc }',
|
|
282
|
+
].join('\n'),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
195
286
|
for (const query of queries) {
|
|
196
287
|
parts.push(emitClass(query).join('\n'));
|
|
288
|
+
const orderByEnum = emitOrderByEnum(query);
|
|
289
|
+
if (orderByEnum.length > 0) parts.push(orderByEnum.join('\n'));
|
|
197
290
|
}
|
|
198
291
|
for (const query of queries) {
|
|
199
292
|
parts.push(emitRunner(query).join('\n'));
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* separate output). Header carries the IR hash for byte-exact `--check`.
|
|
12
12
|
*/
|
|
13
13
|
import type { IrColumnType } from './ir';
|
|
14
|
-
import
|
|
14
|
+
import { snakeToCamel } from './naming';
|
|
15
|
+
import type { AnalyzedQuery, QueryColumn, QueryParam } from './query';
|
|
15
16
|
|
|
16
17
|
const KOTLIN_TYPE: Readonly<Record<IrColumnType, string>> = {
|
|
17
18
|
string: 'String',
|
|
@@ -32,9 +33,9 @@ function pascalCase(name: string): string {
|
|
|
32
33
|
.join('');
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/** Language-facing field name — the pinned §12 naming map. */
|
|
35
37
|
function camelCase(name: string): string {
|
|
36
|
-
|
|
37
|
-
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
38
|
+
return snakeToCamel(name);
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
function typeName(name: string): string {
|
|
@@ -46,7 +47,7 @@ function quote(value: string): string {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
function rowAccessor(column: QueryColumn): string {
|
|
49
|
-
const key = `row[${quote(column.
|
|
50
|
+
const key = `row[${quote(column.langName)}]`;
|
|
50
51
|
switch (column.type) {
|
|
51
52
|
case 'string':
|
|
52
53
|
case 'json':
|
|
@@ -81,6 +82,45 @@ function paramValue(type: IrColumnType, name: string): string {
|
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
/** Bind for a §4 OPTIONAL param: `null` rides as JSON null (the §7
|
|
86
|
+
* neutralization guards make it a no-op). */
|
|
87
|
+
function optionalParamValue(type: IrColumnType, name: string): string {
|
|
88
|
+
switch (type) {
|
|
89
|
+
case 'integer':
|
|
90
|
+
return `${name}?.let { JsonValue.of(it.toDouble()) } ?: JsonValue.Null`;
|
|
91
|
+
case 'bytes':
|
|
92
|
+
case 'crdt':
|
|
93
|
+
return `${name}?.let { queryBindBytes(it) } ?: JsonValue.Null`;
|
|
94
|
+
default:
|
|
95
|
+
return `${name}?.let { JsonValue.of(it) } ?: JsonValue.Null`;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isOptionalParam(query: AnalyzedQuery, p: QueryParam): boolean {
|
|
100
|
+
return (
|
|
101
|
+
p.optional === true ||
|
|
102
|
+
p.flag === true ||
|
|
103
|
+
(query.limit !== undefined && p.name === 'limit')
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Per-query orderBy allowlist enum (column = the checked SQL column). */
|
|
108
|
+
function emitOrderByEnum(query: AnalyzedQuery): string[] {
|
|
109
|
+
if (query.orderBy === undefined) return [];
|
|
110
|
+
const lines: string[] = [];
|
|
111
|
+
lines.push(
|
|
112
|
+
`/** §6 orderBy allowlist for ${query.name} — checked at generate time. */`,
|
|
113
|
+
);
|
|
114
|
+
lines.push(`enum class ${typeName(query.name)}OrderBy(val column: String) {`);
|
|
115
|
+
lines.push(
|
|
116
|
+
query.orderBy.allowed
|
|
117
|
+
.map((col) => ` ${camelCase(col.langName)}(${quote(col.name)})`)
|
|
118
|
+
.join(',\n') + ';',
|
|
119
|
+
);
|
|
120
|
+
lines.push('}');
|
|
121
|
+
return lines;
|
|
122
|
+
}
|
|
123
|
+
|
|
84
124
|
function emitDataClass(query: AnalyzedQuery): string[] {
|
|
85
125
|
const Row = `${typeName(query.name)}Row`;
|
|
86
126
|
const lines: string[] = [];
|
|
@@ -89,14 +129,14 @@ function emitDataClass(query: AnalyzedQuery): string[] {
|
|
|
89
129
|
for (const column of query.columns) {
|
|
90
130
|
const opt = column.nullable ? '?' : '';
|
|
91
131
|
lines.push(
|
|
92
|
-
` val ${camelCase(column.
|
|
132
|
+
` val ${camelCase(column.langName)}: ${KOTLIN_TYPE[column.type]}${opt},`,
|
|
93
133
|
);
|
|
94
134
|
}
|
|
95
135
|
lines.push(') {');
|
|
96
136
|
lines.push(' companion object {');
|
|
97
137
|
lines.push(` fun fromRow(row: JsonValue): ${Row}? {`);
|
|
98
138
|
for (const column of query.columns) {
|
|
99
|
-
const name = camelCase(column.
|
|
139
|
+
const name = camelCase(column.langName);
|
|
100
140
|
const accessor = rowAccessor(column);
|
|
101
141
|
if (column.nullable) {
|
|
102
142
|
lines.push(` val ${name} = ${accessor}`);
|
|
@@ -104,7 +144,7 @@ function emitDataClass(query: AnalyzedQuery): string[] {
|
|
|
104
144
|
lines.push(` val ${name} = ${accessor} ?: return null`);
|
|
105
145
|
}
|
|
106
146
|
}
|
|
107
|
-
const args = query.columns.map((c) => camelCase(c.
|
|
147
|
+
const args = query.columns.map((c) => camelCase(c.langName)).join(', ');
|
|
108
148
|
lines.push(` return ${Row}(${args})`);
|
|
109
149
|
lines.push(' }');
|
|
110
150
|
lines.push(' }');
|
|
@@ -118,30 +158,69 @@ function emitRunner(query: AnalyzedQuery): string[] {
|
|
|
118
158
|
lines.push(
|
|
119
159
|
` val ${query.name}Tables = listOf(${query.tables.map(quote).join(', ')})`,
|
|
120
160
|
);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
161
|
+
if (query.orderBy !== undefined) {
|
|
162
|
+
lines.push(
|
|
163
|
+
` private const val ${query.name}SqlBase = ${quote(query.positionalSqlBase ?? '')}`,
|
|
164
|
+
);
|
|
165
|
+
} else {
|
|
166
|
+
lines.push(
|
|
167
|
+
` private const val ${query.name}Sql = ${quote(query.positionalSql)}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
124
170
|
lines.push('');
|
|
125
171
|
lines.push(` /** Run the ${query.name} named query (SELECT-only). */`);
|
|
126
|
-
const args =
|
|
127
|
-
|
|
128
|
-
.
|
|
172
|
+
const args: string[] = [];
|
|
173
|
+
for (const p of query.params) {
|
|
174
|
+
const name = camelCase(p.langName);
|
|
175
|
+
if (isOptionalParam(query, p)) {
|
|
176
|
+
args.push(`${name}: ${KOTLIN_TYPE[p.type]}? = null`);
|
|
177
|
+
} else {
|
|
178
|
+
args.push(`${name}: ${KOTLIN_TYPE[p.type]}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (query.orderBy !== undefined) {
|
|
182
|
+
const defaultCase = camelCase(
|
|
183
|
+
query.orderBy.allowed.find((c) => c.name === query.orderBy?.defaultColumn)
|
|
184
|
+
?.langName ?? query.orderBy.defaultColumn,
|
|
185
|
+
);
|
|
186
|
+
args.push(
|
|
187
|
+
`orderBy: ${typeName(query.name)}OrderBy = ${typeName(query.name)}OrderBy.${defaultCase}`,
|
|
188
|
+
);
|
|
189
|
+
args.push(
|
|
190
|
+
`dir: SyncularQueryDir = SyncularQueryDir.${query.orderBy.defaultDir.toUpperCase()}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
129
193
|
const signature =
|
|
130
|
-
|
|
131
|
-
? `client: SyncularClient, ${args}`
|
|
194
|
+
args.length > 0
|
|
195
|
+
? `client: SyncularClient, ${args.join(', ')}`
|
|
132
196
|
: 'client: SyncularClient';
|
|
133
197
|
lines.push(` fun ${query.name}(${signature}): List<${Row}> {`);
|
|
198
|
+
if (query.orderBy !== undefined) {
|
|
199
|
+
const limitTail =
|
|
200
|
+
query.positionalLimitTail !== undefined
|
|
201
|
+
? ` + ${quote(query.positionalLimitTail)}`
|
|
202
|
+
: '';
|
|
203
|
+
lines.push(
|
|
204
|
+
` val sql = ${query.name}SqlBase + " order by " + orderBy.column + " " + dir.sql${limitTail}`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const sqlRef = query.orderBy !== undefined ? 'sql' : `${query.name}Sql`;
|
|
134
208
|
if (query.params.length > 0) {
|
|
135
209
|
const binds = query.params
|
|
136
|
-
.map((p) =>
|
|
210
|
+
.map((p) => {
|
|
211
|
+
const name = camelCase(p.langName);
|
|
212
|
+
return isOptionalParam(query, p)
|
|
213
|
+
? optionalParamValue(p.type, name)
|
|
214
|
+
: paramValue(p.type, name);
|
|
215
|
+
})
|
|
137
216
|
.join(', ');
|
|
138
217
|
lines.push(` val params = listOf(${binds})`);
|
|
139
218
|
lines.push(
|
|
140
|
-
` return client.query(${
|
|
219
|
+
` return client.query(${sqlRef}, params).mapNotNull { ${Row}.fromRow(it) }`,
|
|
141
220
|
);
|
|
142
221
|
} else {
|
|
143
222
|
lines.push(
|
|
144
|
-
` return client.query(${
|
|
223
|
+
` return client.query(${sqlRef}).mapNotNull { ${Row}.fromRow(it) }`,
|
|
145
224
|
);
|
|
146
225
|
}
|
|
147
226
|
lines.push(' }');
|
|
@@ -194,8 +273,22 @@ export function emitQueriesKotlinModule(
|
|
|
194
273
|
].join('\n'),
|
|
195
274
|
);
|
|
196
275
|
|
|
276
|
+
if (queries.some((q) => q.orderBy !== undefined)) {
|
|
277
|
+
parts.push(
|
|
278
|
+
[
|
|
279
|
+
'/** §6 orderBy direction (shared by every orderBy-knob query). */',
|
|
280
|
+
'enum class SyncularQueryDir(val sql: String) {',
|
|
281
|
+
' ASC("asc"),',
|
|
282
|
+
' DESC("desc");',
|
|
283
|
+
'}',
|
|
284
|
+
].join('\n'),
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
197
288
|
for (const query of queries) {
|
|
198
289
|
parts.push(emitDataClass(query).join('\n'));
|
|
290
|
+
const orderByEnum = emitOrderByEnum(query);
|
|
291
|
+
if (orderByEnum.length > 0) parts.push(orderByEnum.join('\n'));
|
|
199
292
|
}
|
|
200
293
|
|
|
201
294
|
const objLines: string[] = [];
|