@syncular/typegen 0.5.1 → 0.6.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 +75 -33
- package/dist/cli.js +43 -17
- package/dist/emit-queries-dart.js +167 -55
- package/dist/emit-queries-kotlin.js +166 -55
- package/dist/emit-queries-swift.js +182 -57
- package/dist/emit-queries.js +289 -123
- package/dist/fmt.d.ts +2 -2
- package/dist/fmt.js +323 -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 +359 -185
- 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 +115 -63
- package/dist/query.js +60 -11
- 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 +411 -0
- package/dist/syql-semantics.d.ts +76 -0
- package/dist/syql-semantics.js +551 -0
- package/dist/syql-validator.d.ts +35 -0
- package/dist/syql-validator.js +966 -0
- package/package.json +3 -3
- package/src/cli.ts +51 -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 +413 -165
- package/src/fmt.ts +377 -303
- package/src/generate.ts +43 -9
- package/src/index.ts +3 -1
- package/src/lsp.ts +425 -215
- package/src/manifest.ts +2 -4
- package/src/query-ir.ts +52 -42
- package/src/query.ts +199 -79
- package/src/syql-lexer.ts +12 -1
- package/src/syql-lowering.ts +638 -0
- package/src/syql-semantics.ts +859 -0
- package/src/syql-validator.ts +1492 -0
- package/dist/syql.d.ts +0 -102
- package/dist/syql.js +0 -1141
- package/src/syql.ts +0 -1470
package/src/emit-queries.ts
CHANGED
|
@@ -4,18 +4,13 @@
|
|
|
4
4
|
*
|
|
5
5
|
* - `QRow` — the projection row interface (its OWN type per query — the
|
|
6
6
|
* drift-kill: the row shape is exactly what the SELECT returns),
|
|
7
|
-
* - `QParams` — the typed
|
|
8
|
-
* knobs); §4 optional params are optional keys (`status?: string | null`),
|
|
9
|
-
* §6 knobs add `orderBy?/dir?/limit?`,
|
|
7
|
+
* - `QParams` — the typed public input object when the query has inputs,
|
|
10
8
|
* - `qTables` — the readonly table-dependency set (feeds useRawSql `{tables}`
|
|
11
9
|
* for EXACT invalidation),
|
|
12
10
|
* - `q(client, params?): Promise<QRow[]>` — runs the query over the wrapper's
|
|
13
11
|
* positional `query(sql, params[])` surface, reordering the named params
|
|
14
|
-
* object into the positional array the wire expects
|
|
15
|
-
*
|
|
16
|
-
* - for an orderBy knob: a baked column map + a compose function — user
|
|
17
|
-
* input only ever SELECTS from the generate-time-checked allowlist (I2);
|
|
18
|
-
* it never becomes SQL text.
|
|
12
|
+
* object into the positional array the wire expects. Revision-1 SYQL
|
|
13
|
+
* selection is driven exclusively by its target-neutral physical plan.
|
|
19
14
|
*
|
|
20
15
|
* A tiny structural `QueryClient` interface (just `query(sql, params?)`) keeps
|
|
21
16
|
* the module import-free — it structurally accepts `SyncClientLike` /
|
|
@@ -23,7 +18,12 @@
|
|
|
23
18
|
* `--check` gates freshness byte-exactly, like every other emitter.
|
|
24
19
|
*/
|
|
25
20
|
import type { IrColumnType } from './ir';
|
|
26
|
-
import type {
|
|
21
|
+
import type {
|
|
22
|
+
AnalyzedQuery,
|
|
23
|
+
QueryParam,
|
|
24
|
+
QuerySyqlPlanBind,
|
|
25
|
+
QuerySyqlPublicInput,
|
|
26
|
+
} from './query';
|
|
27
27
|
|
|
28
28
|
const TS_TYPE: Readonly<Record<IrColumnType, string>> = {
|
|
29
29
|
string: 'string',
|
|
@@ -51,30 +51,377 @@ function propertyKey(name: string): string {
|
|
|
51
51
|
|
|
52
52
|
/** A named-query param value is the SqlValue subset its type maps to. */
|
|
53
53
|
const PARAM_TS_TYPE: Readonly<Record<IrColumnType, string>> = TS_TYPE;
|
|
54
|
+
const SYQL_PARAM_TS_TYPE: Readonly<Record<IrColumnType, string>> = {
|
|
55
|
+
...TS_TYPE,
|
|
56
|
+
integer: 'bigint',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function bindExpr(param: QueryParam): string {
|
|
60
|
+
return `params.${propertyKey(param.langName)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function reactiveParam(query: AnalyzedQuery, name: string): string {
|
|
64
|
+
if (query.syql !== undefined) {
|
|
65
|
+
const input = query.syql.inputs.find(
|
|
66
|
+
(candidate) => candidate.kind === 'value' && candidate.name === name,
|
|
67
|
+
);
|
|
68
|
+
if (input?.kind !== 'value')
|
|
69
|
+
throw new Error(`unknown reactive input ${name}`);
|
|
70
|
+
return `String(params.${propertyKey(input.langName)})`;
|
|
71
|
+
}
|
|
72
|
+
const param = query.params.find((candidate) => candidate.name === name);
|
|
73
|
+
if (param === undefined) throw new Error(`unknown reactive param ${name}`);
|
|
74
|
+
return `String(params.${propertyKey(param.langName)})`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function syqlInput(query: AnalyzedQuery, name: string): QuerySyqlPublicInput {
|
|
78
|
+
const input = query.syql?.inputs.find((candidate) => candidate.name === name);
|
|
79
|
+
if (input === undefined) throw new Error(`unknown SYQL input ${name}`);
|
|
80
|
+
return input;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function syqlTsValueType(type: IrColumnType, nullable: boolean): string {
|
|
84
|
+
return `${SYQL_PARAM_TS_TYPE[type]}${nullable ? ' | null' : ''}`;
|
|
85
|
+
}
|
|
54
86
|
|
|
55
|
-
function
|
|
56
|
-
|
|
87
|
+
function syqlTsTypeCheck(
|
|
88
|
+
expression: string,
|
|
89
|
+
type: IrColumnType,
|
|
90
|
+
nullable: boolean,
|
|
91
|
+
): string {
|
|
92
|
+
let check: string;
|
|
93
|
+
switch (type) {
|
|
94
|
+
case 'integer':
|
|
95
|
+
check = `typeof ${expression} === 'bigint'`;
|
|
96
|
+
break;
|
|
97
|
+
case 'float':
|
|
98
|
+
check = `typeof ${expression} === 'number' && Number.isFinite(${expression})`;
|
|
99
|
+
break;
|
|
100
|
+
case 'boolean':
|
|
101
|
+
check = `typeof ${expression} === 'boolean'`;
|
|
102
|
+
break;
|
|
103
|
+
case 'bytes':
|
|
104
|
+
case 'crdt':
|
|
105
|
+
check = `${expression} instanceof Uint8Array`;
|
|
106
|
+
break;
|
|
107
|
+
default:
|
|
108
|
+
check = `typeof ${expression} === 'string'`;
|
|
109
|
+
}
|
|
110
|
+
return nullable ? `${expression} === null || (${check})` : check;
|
|
57
111
|
}
|
|
58
112
|
|
|
59
|
-
|
|
60
|
-
* `params?` depending on whether the params object itself may be absent. */
|
|
61
|
-
function bindExpr(
|
|
113
|
+
function syqlControlActive(
|
|
62
114
|
query: AnalyzedQuery,
|
|
63
|
-
|
|
64
|
-
|
|
115
|
+
control: string,
|
|
116
|
+
params: string,
|
|
65
117
|
): string {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
118
|
+
const input = syqlInput(query, control);
|
|
119
|
+
const access = `${params}.${propertyKey(input.langName)}`;
|
|
120
|
+
if (input.kind === 'switch') return `${access} === true`;
|
|
121
|
+
if (input.kind === 'value' || input.kind === 'group') {
|
|
122
|
+
return `${access} !== undefined`;
|
|
69
123
|
}
|
|
70
|
-
|
|
71
|
-
return isOptional(param) ? `${access}.${key} ?? null` : `params.${key}`;
|
|
124
|
+
throw new Error(`${control} is not an activation control`);
|
|
72
125
|
}
|
|
73
126
|
|
|
74
|
-
function
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
127
|
+
function syqlBindExpr(
|
|
128
|
+
query: AnalyzedQuery,
|
|
129
|
+
bind: QuerySyqlPlanBind,
|
|
130
|
+
params: string,
|
|
131
|
+
): string {
|
|
132
|
+
if (bind.kind === 'condition-active') {
|
|
133
|
+
return bind.controls
|
|
134
|
+
.map((control) => syqlControlActive(query, control, params))
|
|
135
|
+
.join(' && ');
|
|
136
|
+
}
|
|
137
|
+
const input = syqlInput(query, bind.input);
|
|
138
|
+
const access = `${params}.${propertyKey(input.langName)}`;
|
|
139
|
+
if (bind.kind === 'page') {
|
|
140
|
+
if (input.kind !== 'page') throw new Error('page bind/input mismatch');
|
|
141
|
+
return `${access} ?? ${input.defaultSize}`;
|
|
142
|
+
}
|
|
143
|
+
if (bind.kind === 'group-member') {
|
|
144
|
+
if (input.kind !== 'group') throw new Error('group bind/input mismatch');
|
|
145
|
+
const member = input.members.find(
|
|
146
|
+
(candidate) => candidate.name === bind.member,
|
|
147
|
+
);
|
|
148
|
+
if (member === undefined)
|
|
149
|
+
throw new Error(`unknown group member ${bind.member}`);
|
|
150
|
+
return `${access}?.${propertyKey(member.langName)} ?? null`;
|
|
151
|
+
}
|
|
152
|
+
if (input.kind !== 'value') throw new Error('value bind/input mismatch');
|
|
153
|
+
if (input.required) return access;
|
|
154
|
+
return input.nullable ? `${access}?.value ?? null` : `${access} ?? null`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function emitSyqlValidation(query: AnalyzedQuery, Params: string): string[] {
|
|
158
|
+
const inputs = query.syql?.inputs ?? [];
|
|
159
|
+
const lines: string[] = [];
|
|
160
|
+
lines.push(
|
|
161
|
+
`function ${query.name}Validate(raw?: ${Params}): ${Params} {`,
|
|
162
|
+
` const params = raw ?? ({} as ${Params});`,
|
|
163
|
+
` const allowed = new Set([${inputs.map((input) => quote(input.langName)).join(', ')}]);`,
|
|
164
|
+
' for (const key of Object.keys(params)) {',
|
|
165
|
+
` if (!allowed.has(key)) throw new SyqlInputError('SYQL_RUNTIME_UNKNOWN_INPUT', ${quote(query.name)} + ': unknown input ' + key);`,
|
|
166
|
+
' }',
|
|
167
|
+
);
|
|
168
|
+
for (const input of inputs) {
|
|
169
|
+
const key = propertyKey(input.langName);
|
|
170
|
+
const access = `params.${key}`;
|
|
171
|
+
if (input.kind === 'value') {
|
|
172
|
+
if (input.required) {
|
|
173
|
+
lines.push(
|
|
174
|
+
` if (!Object.prototype.hasOwnProperty.call(params, ${quote(input.langName)})) throw new SyqlInputError('SYQL_RUNTIME_MISSING_REQUIRED_INPUT', ${quote(`${query.name}: missing required input ${input.name}`)});`,
|
|
175
|
+
` if (!(${syqlTsTypeCheck(access, input.type, input.nullable)})) throw new SyqlInputError('SYQL_RUNTIME_INVALID_INPUT', ${quote(`${query.name}: invalid input ${input.name}`)});`,
|
|
176
|
+
);
|
|
177
|
+
} else if (input.nullable) {
|
|
178
|
+
lines.push(
|
|
179
|
+
` if (${access} !== undefined && (typeof ${access} !== 'object' || ${access} === null || ${access}.present !== true || !(${syqlTsTypeCheck(`${access}.value`, input.type, true)}))) throw new SyqlInputError('SYQL_RUNTIME_INVALID_INPUT', ${quote(`${query.name}: invalid optional input ${input.name}`)});`,
|
|
180
|
+
);
|
|
181
|
+
} else {
|
|
182
|
+
lines.push(
|
|
183
|
+
` if (${access} !== undefined && !(${syqlTsTypeCheck(access, input.type, false)})) throw new SyqlInputError('SYQL_RUNTIME_INVALID_INPUT', ${quote(`${query.name}: invalid optional input ${input.name}`)});`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
} else if (input.kind === 'group') {
|
|
187
|
+
lines.push(
|
|
188
|
+
` if (${access} !== undefined) {`,
|
|
189
|
+
` if (typeof ${access} !== 'object' || ${access} === null) throw new SyqlInputError('SYQL_RUNTIME_INVALID_GROUP', ${quote(`${query.name}: invalid group ${input.name}`)});`,
|
|
190
|
+
` const allowedMembers = new Set([${input.members.map((member) => quote(member.langName)).join(', ')}]);`,
|
|
191
|
+
` if (Object.keys(${access}).some((key) => !allowedMembers.has(key))) throw new SyqlInputError('SYQL_RUNTIME_INVALID_GROUP', ${quote(`${query.name}: unknown member in group ${input.name}`)});`,
|
|
192
|
+
);
|
|
193
|
+
for (const member of input.members) {
|
|
194
|
+
const memberAccess = `${access}.${propertyKey(member.langName)}`;
|
|
195
|
+
lines.push(
|
|
196
|
+
` if (!Object.prototype.hasOwnProperty.call(${access}, ${quote(member.langName)}) || !(${syqlTsTypeCheck(memberAccess, member.type, member.nullable)})) throw new SyqlInputError('SYQL_RUNTIME_INVALID_GROUP', ${quote(`${query.name}: invalid or partial group ${input.name}`)});`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
lines.push(' }');
|
|
200
|
+
} else if (input.kind === 'switch') {
|
|
201
|
+
lines.push(
|
|
202
|
+
` if (${access} !== undefined && typeof ${access} !== 'boolean') throw new SyqlInputError('SYQL_RUNTIME_INVALID_INPUT', ${quote(`${query.name}: invalid switch ${input.name}`)});`,
|
|
203
|
+
);
|
|
204
|
+
} else if (input.kind === 'sort') {
|
|
205
|
+
lines.push(
|
|
206
|
+
` if (${access} !== undefined && ![${input.profiles.map((profile) => quote(profile.langName)).join(', ')}].includes(${access})) throw new SyqlInputError('SYQL_RUNTIME_INVALID_SORT', ${quote(`${query.name}: invalid sort profile`)});`,
|
|
207
|
+
);
|
|
208
|
+
} else {
|
|
209
|
+
lines.push(
|
|
210
|
+
` if (${access} !== undefined && (!Number.isSafeInteger(${access}) || ${access} < 1 || ${access} > ${input.maxSize})) throw new SyqlInputError('SYQL_RUNTIME_INVALID_PAGE', ${quote(`${query.name}: page size must be an integer from 1 through ${input.maxSize}`)});`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
lines.push(' return params;', '}');
|
|
215
|
+
return lines;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function emitSyqlQuery(query: AnalyzedQuery, hash: string): string {
|
|
219
|
+
const metadata = query.syql;
|
|
220
|
+
if (metadata === undefined) throw new Error('missing SYQL metadata');
|
|
221
|
+
const Row = `${pascalCase(query.name)}Row`;
|
|
222
|
+
const Params = `${pascalCase(query.name)}Params`;
|
|
223
|
+
const inputs = metadata.inputs;
|
|
224
|
+
const hasParams = inputs.length > 0;
|
|
225
|
+
const requiresParams = inputs.some(
|
|
226
|
+
(input) => input.kind === 'value' && input.required,
|
|
227
|
+
);
|
|
228
|
+
const lines: string[] = [];
|
|
229
|
+
lines.push(
|
|
230
|
+
`/** One row of the ${quote(query.name)} query (its projection). */`,
|
|
231
|
+
);
|
|
232
|
+
lines.push(`export interface ${Row} {`);
|
|
233
|
+
for (const column of query.columns) {
|
|
234
|
+
lines.push(
|
|
235
|
+
` ${propertyKey(column.langName)}: ${TS_TYPE[column.type]}${column.nullable ? ' | null' : ''};`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
lines.push('}', '');
|
|
239
|
+
|
|
240
|
+
for (const input of inputs) {
|
|
241
|
+
if (input.kind === 'group') {
|
|
242
|
+
lines.push(
|
|
243
|
+
`export interface ${pascalCase(query.name)}${pascalCase(input.langName)} {`,
|
|
244
|
+
);
|
|
245
|
+
for (const member of input.members) {
|
|
246
|
+
lines.push(
|
|
247
|
+
` ${propertyKey(member.langName)}: ${syqlTsValueType(member.type, member.nullable)};`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
lines.push('}', '');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (hasParams) {
|
|
254
|
+
lines.push(
|
|
255
|
+
`/** Public revision-1 SYQL inputs for ${quote(query.name)}. */`,
|
|
256
|
+
);
|
|
257
|
+
lines.push(`export interface ${Params} {`);
|
|
258
|
+
for (const input of inputs) {
|
|
259
|
+
const key = propertyKey(input.langName);
|
|
260
|
+
if (input.kind === 'value') {
|
|
261
|
+
const optional = !input.required;
|
|
262
|
+
const type = syqlTsValueType(input.type, input.nullable);
|
|
263
|
+
lines.push(
|
|
264
|
+
` ${key}${optional ? '?' : ''}: ${optional && input.nullable ? `SyqlPresent<${type}>` : type};`,
|
|
265
|
+
);
|
|
266
|
+
} else if (input.kind === 'group') {
|
|
267
|
+
lines.push(
|
|
268
|
+
` ${key}?: ${pascalCase(query.name)}${pascalCase(input.langName)};`,
|
|
269
|
+
);
|
|
270
|
+
} else if (input.kind === 'switch') {
|
|
271
|
+
lines.push(` ${key}?: boolean;`);
|
|
272
|
+
} else if (input.kind === 'sort') {
|
|
273
|
+
lines.push(
|
|
274
|
+
` ${key}?: ${input.profiles.map((profile) => quote(profile.langName)).join(' | ')};`,
|
|
275
|
+
);
|
|
276
|
+
} else {
|
|
277
|
+
lines.push(` ${key}?: number;`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
lines.push('}', '');
|
|
281
|
+
lines.push(...emitSyqlValidation(query, Params), '');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
lines.push(
|
|
285
|
+
`/** Tables ${quote(query.name)} reads (compatibility/export surface). */`,
|
|
286
|
+
`export const ${query.name}Tables = [${query.tables.map(quote).join(', ')}] as const;`,
|
|
287
|
+
'',
|
|
288
|
+
);
|
|
289
|
+
const paramDecl = !hasParams
|
|
290
|
+
? ''
|
|
291
|
+
: requiresParams
|
|
292
|
+
? `raw: ${Params}`
|
|
293
|
+
: `raw?: ${Params}`;
|
|
294
|
+
const validated = hasParams ? `${query.name}Validate(raw)` : undefined;
|
|
295
|
+
const statementType = hasParams
|
|
296
|
+
? `{ sql: string; bind: (params: ${Params}) => QueryValue[] }`
|
|
297
|
+
: '{ sql: string; bind: () => QueryValue[] }';
|
|
298
|
+
lines.push(`const ${query.name}Statements: ${statementType}[] = [`);
|
|
299
|
+
for (const statement of metadata.plan.statements) {
|
|
300
|
+
const binds = statement.binds
|
|
301
|
+
.map((bind) => syqlBindExpr(query, bind, 'params'))
|
|
302
|
+
.join(', ');
|
|
303
|
+
lines.push(
|
|
304
|
+
` { sql: ${quote(statement.positionalSql)}, bind: (${hasParams ? 'params' : ''}) => [${binds}] },`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
lines.push('];');
|
|
308
|
+
const sort = inputs.find((input) => input.kind === 'sort');
|
|
309
|
+
if (sort?.kind === 'sort') {
|
|
310
|
+
lines.push(
|
|
311
|
+
`const ${query.name}SortIndexes = { ${sort.profiles.map((profile, index) => `${propertyKey(profile.langName)}: ${index}`).join(', ')} } as const;`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
lines.push(
|
|
315
|
+
`function ${query.name}Select(${paramDecl}): { sql: string; bind: QueryValue[] } {`,
|
|
316
|
+
);
|
|
317
|
+
if (validated !== undefined) lines.push(` const params = ${validated};`);
|
|
318
|
+
let maskExpression = '0';
|
|
319
|
+
if (metadata.plan.backend === 'variants') {
|
|
320
|
+
lines.push(' let mask = 0;');
|
|
321
|
+
metadata.plan.activationControls.forEach((control, index) => {
|
|
322
|
+
lines.push(
|
|
323
|
+
` if (${syqlControlActive(query, control, 'params')}) mask |= ${2 ** index};`,
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
maskExpression = 'mask';
|
|
327
|
+
}
|
|
328
|
+
let sortExpression = '0';
|
|
329
|
+
const profileCount = sort?.kind === 'sort' ? sort.profiles.length : 1;
|
|
330
|
+
if (sort?.kind === 'sort') {
|
|
331
|
+
sortExpression = `${query.name}SortIndexes[params.${propertyKey(sort.langName)} ?? ${quote(sort.profiles.find((profile) => profile.name === sort.defaultProfile)?.langName ?? sort.defaultProfile)}]`;
|
|
332
|
+
}
|
|
333
|
+
const indexExpression =
|
|
334
|
+
metadata.plan.backend === 'variants'
|
|
335
|
+
? `${maskExpression} * ${profileCount} + ${sortExpression}`
|
|
336
|
+
: sortExpression;
|
|
337
|
+
lines.push(
|
|
338
|
+
` const statement = ${query.name}Statements[${indexExpression}];`,
|
|
339
|
+
` if (statement === undefined) throw new Error('invalid generated SYQL statement index');`,
|
|
340
|
+
` return { sql: statement.sql, bind: statement.bind(${hasParams ? 'params' : ''}) };`,
|
|
341
|
+
'}',
|
|
342
|
+
'',
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
const runnerParam = !hasParams
|
|
346
|
+
? ''
|
|
347
|
+
: requiresParams
|
|
348
|
+
? `, params: ${Params}`
|
|
349
|
+
: `, params?: ${Params}`;
|
|
350
|
+
lines.push(
|
|
351
|
+
`/** Run the ${quote(query.name)} named query (SELECT-only). */`,
|
|
352
|
+
`export async function ${query.name}(client: QueryClient${runnerParam}): Promise<${Row}[]> {`,
|
|
353
|
+
` const selected = ${query.name}Select(${hasParams ? 'params' : ''});`,
|
|
354
|
+
' const rows = await client.query(selected.sql, selected.bind);',
|
|
355
|
+
` return rows as unknown as ${Row}[];`,
|
|
356
|
+
'}',
|
|
357
|
+
'',
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
const paramsType = hasParams ? Params : 'undefined';
|
|
361
|
+
const defaultStatement = metadata.plan.statements.find(
|
|
362
|
+
(statement) =>
|
|
363
|
+
(statement.activationMask === undefined ||
|
|
364
|
+
statement.activationMask === 0) &&
|
|
365
|
+
(sort?.kind !== 'sort' || statement.sortProfile === sort.defaultProfile),
|
|
366
|
+
);
|
|
367
|
+
lines.push(
|
|
368
|
+
`/** Revisioned reactive descriptor for \`useQuery(${query.name}Query${hasParams ? ', params' : ''})\`. */`,
|
|
369
|
+
`export const ${query.name}Query: NamedQuery<${Row}, ${paramsType}> = {`,
|
|
370
|
+
` id: ${quote(`${hash}/${query.name}`)},`,
|
|
371
|
+
` hasParams: ${hasParams},`,
|
|
372
|
+
` sql: ${quote(defaultStatement?.positionalSql ?? query.positionalSql)},`,
|
|
373
|
+
);
|
|
374
|
+
if (hasParams) {
|
|
375
|
+
lines.push(
|
|
376
|
+
` sqlFor: (params: ${Params}) => ${query.name}Select(params).sql,`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
lines.push(` tables: ${query.name}Tables,`);
|
|
380
|
+
const reactiveUsesParams = query.reactive.dependencies.some((dependency) =>
|
|
381
|
+
dependency.scopes.some((scope) => scope.params.length > 0),
|
|
382
|
+
);
|
|
383
|
+
lines.push(` dependencies: (${reactiveUsesParams ? 'params' : ''}) => [`);
|
|
384
|
+
for (const dependency of query.reactive.dependencies) {
|
|
385
|
+
const keys = dependency.scopes.flatMap((scope) =>
|
|
386
|
+
scope.params.map((param) =>
|
|
387
|
+
scopeKeyExpression(query, scope.pattern, scope.variable, param),
|
|
388
|
+
),
|
|
389
|
+
);
|
|
390
|
+
lines.push(
|
|
391
|
+
` { table: ${quote(dependency.table)}${keys.length > 0 ? `, scopeKeys: [${keys.join(', ')}]` : ''} },`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
lines.push(' ],');
|
|
395
|
+
const coverageUsesParams = query.reactive.coverage.some(
|
|
396
|
+
(coverage) =>
|
|
397
|
+
coverage.units.length > 0 ||
|
|
398
|
+
coverage.fixedScopes.some((scope) => scope.params.length > 0),
|
|
399
|
+
);
|
|
400
|
+
lines.push(` coverage: (${coverageUsesParams ? 'params' : ''}) => [`);
|
|
401
|
+
for (const coverage of query.reactive.coverage) {
|
|
402
|
+
const fixed = coverage.fixedScopes
|
|
403
|
+
.map(
|
|
404
|
+
(scope) =>
|
|
405
|
+
`${propertyKey(scope.variable)}: [${scope.params.map((param) => reactiveParam(query, param)).join(', ')}]`,
|
|
406
|
+
)
|
|
407
|
+
.join(', ');
|
|
408
|
+
lines.push(
|
|
409
|
+
` { base: { table: ${quote(coverage.table)}, variable: ${quote(coverage.variable)}${fixed.length > 0 ? `, fixedScopes: { ${fixed} }` : ''} }, units: [${coverage.units.map((param) => reactiveParam(query, param)).join(', ')}] },`,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
lines.push(
|
|
413
|
+
' ],',
|
|
414
|
+
hasParams
|
|
415
|
+
? ` bind: (params: ${Params}) => ${query.name}Select(params).bind,`
|
|
416
|
+
: ` bind: () => ${query.name}Select().bind,`,
|
|
417
|
+
);
|
|
418
|
+
if (query.reactive.rowKey !== undefined) {
|
|
419
|
+
lines.push(
|
|
420
|
+
` rowKey: (row) => [${query.reactive.rowKey.map((key) => `row.${propertyKey(key)}`).join(', ')}],`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
lines.push('};');
|
|
424
|
+
return lines.join('\n');
|
|
78
425
|
}
|
|
79
426
|
|
|
80
427
|
function scopeKeyExpression(
|
|
@@ -92,12 +439,11 @@ function scopeKeyExpression(
|
|
|
92
439
|
}
|
|
93
440
|
|
|
94
441
|
function emitQuery(query: AnalyzedQuery, hash: string): string {
|
|
442
|
+
if (query.syql !== undefined) return emitSyqlQuery(query, hash);
|
|
95
443
|
const Row = `${pascalCase(query.name)}Row`;
|
|
96
444
|
const Params = `${pascalCase(query.name)}Params`;
|
|
97
445
|
const lines: string[] = [];
|
|
98
446
|
|
|
99
|
-
// Row interface — keyed by the language-facing names, which ARE the
|
|
100
|
-
// runtime result keys (§5 projection lowering aliases them in SQL).
|
|
101
447
|
lines.push(
|
|
102
448
|
`/** One row of the ${quote(query.name)} query (its projection). */`,
|
|
103
449
|
);
|
|
@@ -110,45 +456,19 @@ function emitQuery(query: AnalyzedQuery, hash: string): string {
|
|
|
110
456
|
lines.push('}');
|
|
111
457
|
lines.push('');
|
|
112
458
|
|
|
113
|
-
const hasParams = query.params.length > 0
|
|
114
|
-
const requiresParams = query.params.some(
|
|
115
|
-
(p) => !isOptional(p) && !(query.limit !== undefined && p.name === 'limit'),
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
// Params interface (params and/or knob keys).
|
|
459
|
+
const hasParams = query.params.length > 0;
|
|
119
460
|
if (hasParams) {
|
|
120
461
|
lines.push(`/** Named parameters for ${quote(query.name)}. */`);
|
|
121
462
|
lines.push(`export interface ${Params} {`);
|
|
122
463
|
for (const param of query.params) {
|
|
123
|
-
if (query.limit !== undefined && param.name === 'limit') continue;
|
|
124
|
-
const opt = isOptional(param);
|
|
125
|
-
lines.push(
|
|
126
|
-
` ${propertyKey(param.langName)}${opt ? '?' : ''}: ${PARAM_TS_TYPE[param.type]}${opt ? ' | null' : ''};`,
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
if (query.orderBy !== undefined) {
|
|
130
|
-
const keys = query.orderBy.allowed
|
|
131
|
-
.map((c) => quote(c.langName))
|
|
132
|
-
.join(' | ');
|
|
133
|
-
lines.push(
|
|
134
|
-
` /** §6 orderBy knob — a generate-time-checked allowlist. */`,
|
|
135
|
-
);
|
|
136
|
-
lines.push(` orderBy?: ${keys};`);
|
|
137
|
-
lines.push(` dir?: 'asc' | 'desc';`);
|
|
138
|
-
}
|
|
139
|
-
if (query.limit !== undefined) {
|
|
140
|
-
const clamp =
|
|
141
|
-
query.limit.max !== undefined ? ` (clamped to ${query.limit.max})` : '';
|
|
142
464
|
lines.push(
|
|
143
|
-
`
|
|
465
|
+
` ${propertyKey(param.langName)}: ${PARAM_TS_TYPE[param.type]};`,
|
|
144
466
|
);
|
|
145
|
-
lines.push(' limit?: number;');
|
|
146
467
|
}
|
|
147
468
|
lines.push('}');
|
|
148
469
|
lines.push('');
|
|
149
470
|
}
|
|
150
471
|
|
|
151
|
-
// Tables dependency set (for useRawSql {tables} / useQuery).
|
|
152
472
|
lines.push(
|
|
153
473
|
`/** Tables ${quote(query.name)} reads (compatibility/export surface). */`,
|
|
154
474
|
);
|
|
@@ -157,121 +477,28 @@ function emitQuery(query: AnalyzedQuery, hash: string): string {
|
|
|
157
477
|
);
|
|
158
478
|
lines.push('');
|
|
159
479
|
|
|
160
|
-
// SQL constants: the static default statement, plus (orderBy knob) the
|
|
161
|
-
// baked base + column map + compose function.
|
|
162
480
|
const sqlConst = `${query.name}Sql`;
|
|
163
481
|
lines.push(`const ${sqlConst} = ${quote(query.positionalSql)};`);
|
|
164
|
-
const composeFn = `${query.name}ComposeSql`;
|
|
165
|
-
if (query.orderBy !== undefined) {
|
|
166
|
-
const base = `${query.name}SqlBase`;
|
|
167
|
-
const colsConst = `${query.name}OrderColumns`;
|
|
168
|
-
const defaultLang =
|
|
169
|
-
query.orderBy.allowed.find((c) => c.name === query.orderBy?.defaultColumn)
|
|
170
|
-
?.langName ?? query.orderBy.defaultColumn;
|
|
171
|
-
lines.push(`const ${base} = ${quote(query.positionalSqlBase ?? '')};`);
|
|
172
|
-
lines.push(
|
|
173
|
-
`const ${colsConst} = { ${query.orderBy.allowed
|
|
174
|
-
.map((c) => `${propertyKey(c.langName)}: ${quote(c.name)}`)
|
|
175
|
-
.join(', ')} } as const;`,
|
|
176
|
-
);
|
|
177
|
-
lines.push(`function ${composeFn}(params?: ${Params}): string {`);
|
|
178
|
-
lines.push(
|
|
179
|
-
` const column = ${colsConst}[params?.orderBy ?? ${quote(defaultLang)}] ?? ${quote(query.orderBy.defaultColumn)};`,
|
|
180
|
-
);
|
|
181
|
-
lines.push(
|
|
182
|
-
` const dir = (params?.dir ?? ${quote(query.orderBy.defaultDir)}) === 'desc' ? 'desc' : 'asc';`,
|
|
183
|
-
);
|
|
184
|
-
lines.push(
|
|
185
|
-
` return \`\${${base}} order by \${column} \${dir}${query.positionalLimitTail ?? ''}\`;`,
|
|
186
|
-
);
|
|
187
|
-
lines.push('}');
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Positional bind list.
|
|
191
|
-
const access = requiresParams ? 'params' : 'params?';
|
|
192
482
|
const positional = hasParams
|
|
193
|
-
? `[${query.params.map(
|
|
483
|
+
? `[${query.params.map(bindExpr).join(', ')}]`
|
|
194
484
|
: '[]';
|
|
195
|
-
|
|
196
|
-
// §7 variant backend: one statement per provided-combination, selected by
|
|
197
|
-
// a bitmask over the optional groups (bit i = group i provided/true). The
|
|
198
|
-
// neutralization statement above stays the canonical/default form.
|
|
199
|
-
const selectFn = `${query.name}SelectVariant`;
|
|
200
|
-
const paramArgDecl = requiresParams
|
|
201
|
-
? `params: ${Params}`
|
|
202
|
-
: `params?: ${Params}`;
|
|
203
|
-
if (query.variants !== undefined && query.variantGroups !== undefined) {
|
|
204
|
-
const variantsConst = `${query.name}Variants`;
|
|
205
|
-
lines.push(
|
|
206
|
-
`const ${variantsConst}: { sql: string; bind: (${paramArgDecl}) => QueryValue[] }[] = [`,
|
|
207
|
-
);
|
|
208
|
-
for (const variant of query.variants) {
|
|
209
|
-
const binds = variant.params
|
|
210
|
-
.map((name) => {
|
|
211
|
-
const param = query.params.find((p) => p.name === name);
|
|
212
|
-
if (param === undefined) throw new Error(`unknown param ${name}`);
|
|
213
|
-
return bindExpr(query, param, access);
|
|
214
|
-
})
|
|
215
|
-
.join(', ');
|
|
216
|
-
lines.push(` // [${variant.when.join(' + ') || 'no optional filters'}]`);
|
|
217
|
-
lines.push(
|
|
218
|
-
` { sql: ${quote(variant.positionalSql)}, bind: (${requiresParams ? 'params' : 'params?'}) => [${binds}] },`,
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
lines.push('];');
|
|
222
|
-
lines.push(
|
|
223
|
-
`function ${selectFn}(${paramArgDecl}): { sql: string; bind: QueryValue[] } {`,
|
|
224
|
-
);
|
|
225
|
-
lines.push(' let mask = 0;');
|
|
226
|
-
query.variantGroups.forEach((group, index) => {
|
|
227
|
-
const langOf = (name: string): string =>
|
|
228
|
-
query.params.find((p) => p.name === name)?.langName ?? name;
|
|
229
|
-
const condition = group.flag
|
|
230
|
-
? `params?.${propertyKey(langOf(group.params[0] as string))} === true`
|
|
231
|
-
: group.params
|
|
232
|
-
.map(
|
|
233
|
-
(name) =>
|
|
234
|
-
`(params?.${propertyKey(langOf(name))} ?? null) !== null`,
|
|
235
|
-
)
|
|
236
|
-
.join(' && ');
|
|
237
|
-
lines.push(` if (${condition}) mask |= ${1 << index};`);
|
|
238
|
-
});
|
|
239
|
-
lines.push(
|
|
240
|
-
` const variant = ${variantsConst}[mask] as (typeof ${variantsConst})[number];`,
|
|
241
|
-
);
|
|
242
|
-
lines.push(' return { sql: variant.sql, bind: variant.bind(params) };');
|
|
243
|
-
lines.push('}');
|
|
244
|
-
}
|
|
245
485
|
lines.push('');
|
|
246
486
|
|
|
247
|
-
// The runner.
|
|
248
|
-
const paramArg = !hasParams ? '' : paramArgDecl;
|
|
249
487
|
lines.push(`/** Run the ${quote(query.name)} named query (SELECT-only). */`);
|
|
250
488
|
lines.push(
|
|
251
|
-
`export async function ${query.name}(client: QueryClient${hasParams ? `, ${
|
|
489
|
+
`export async function ${query.name}(client: QueryClient${hasParams ? `, params: ${Params}` : ''}): Promise<${Row}[]> {`,
|
|
252
490
|
);
|
|
253
|
-
if (
|
|
254
|
-
lines.push(
|
|
255
|
-
|
|
491
|
+
if (hasParams) {
|
|
492
|
+
lines.push(
|
|
493
|
+
` const rows = await client.query(${sqlConst}, ${positional});`,
|
|
494
|
+
);
|
|
256
495
|
} else {
|
|
257
|
-
const
|
|
258
|
-
query.orderBy !== undefined ? `${composeFn}(params)` : sqlConst;
|
|
259
|
-
if (hasParams) {
|
|
260
|
-
lines.push(
|
|
261
|
-
` const rows = await client.query(${sqlExpr}, ${positional});`,
|
|
262
|
-
);
|
|
263
|
-
} else {
|
|
264
|
-
lines.push(` const rows = await client.query(${sqlExpr});`);
|
|
265
|
-
}
|
|
496
|
+
lines.push(` const rows = await client.query(${sqlConst});`);
|
|
266
497
|
}
|
|
267
498
|
lines.push(` return rows as unknown as ${Row}[];`);
|
|
268
499
|
lines.push('}');
|
|
269
500
|
lines.push('');
|
|
270
501
|
|
|
271
|
-
// A descriptor for react's `useQuery` — the SQL (static, composed from
|
|
272
|
-
// the baked allowlist, or variant-selected), the exact table dependency
|
|
273
|
-
// set, and a `bind(params)` → positional array (always paired with the
|
|
274
|
-
// sqlFor selection). Typed by the query's own Row/Params.
|
|
275
502
|
lines.push(
|
|
276
503
|
`/** Revisioned reactive descriptor for \`useQuery(${query.name}Query${hasParams ? ', params' : ''})\`. */`,
|
|
277
504
|
);
|
|
@@ -282,11 +509,6 @@ function emitQuery(query: AnalyzedQuery, hash: string): string {
|
|
|
282
509
|
lines.push(` id: ${quote(`${hash}/${query.name}`)},`);
|
|
283
510
|
lines.push(` hasParams: ${hasParams},`);
|
|
284
511
|
lines.push(` sql: ${sqlConst},`);
|
|
285
|
-
if (query.variants !== undefined) {
|
|
286
|
-
lines.push(` sqlFor: (params: ${Params}) => ${selectFn}(params).sql,`);
|
|
287
|
-
} else if (query.orderBy !== undefined) {
|
|
288
|
-
lines.push(` sqlFor: (params: ${Params}) => ${composeFn}(params),`);
|
|
289
|
-
}
|
|
290
512
|
lines.push(` tables: ${query.name}Tables,`);
|
|
291
513
|
const reactiveUsesParams = query.reactive.dependencies.some((dependency) =>
|
|
292
514
|
dependency.scopes.some((scope) => scope.params.length > 0),
|
|
@@ -322,9 +544,7 @@ function emitQuery(query: AnalyzedQuery, hash: string): string {
|
|
|
322
544
|
);
|
|
323
545
|
}
|
|
324
546
|
lines.push(' ],');
|
|
325
|
-
if (
|
|
326
|
-
lines.push(` bind: (params: ${Params}) => ${selectFn}(params).bind,`);
|
|
327
|
-
} else if (hasParams) {
|
|
547
|
+
if (hasParams) {
|
|
328
548
|
lines.push(` bind: (params: ${Params}) => ${positional},`);
|
|
329
549
|
} else {
|
|
330
550
|
lines.push(' bind: () => [],');
|
|
@@ -351,6 +571,35 @@ export function emitQueriesModule(
|
|
|
351
571
|
`// irHash: ${hash}`,
|
|
352
572
|
].join('\n'),
|
|
353
573
|
);
|
|
574
|
+
if (queries.some((query) => query.syql !== undefined)) {
|
|
575
|
+
parts.push(
|
|
576
|
+
[
|
|
577
|
+
'export type SyqlRuntimeErrorCode =',
|
|
578
|
+
" | 'SYQL_RUNTIME_MISSING_REQUIRED_INPUT'",
|
|
579
|
+
" | 'SYQL_RUNTIME_UNKNOWN_INPUT'",
|
|
580
|
+
" | 'SYQL_RUNTIME_INVALID_INPUT'",
|
|
581
|
+
" | 'SYQL_RUNTIME_INVALID_GROUP'",
|
|
582
|
+
" | 'SYQL_RUNTIME_INVALID_SORT'",
|
|
583
|
+
" | 'SYQL_RUNTIME_INVALID_PAGE';",
|
|
584
|
+
'',
|
|
585
|
+
'export class SyqlInputError extends Error {',
|
|
586
|
+
" readonly name = 'SyqlInputError';",
|
|
587
|
+
' constructor(readonly code: SyqlRuntimeErrorCode, message: string) {',
|
|
588
|
+
' super(message);',
|
|
589
|
+
' }',
|
|
590
|
+
'}',
|
|
591
|
+
'',
|
|
592
|
+
'export interface SyqlPresent<T> {',
|
|
593
|
+
' readonly present: true;',
|
|
594
|
+
' readonly value: T;',
|
|
595
|
+
'}',
|
|
596
|
+
'',
|
|
597
|
+
'export function syqlPresent<T>(value: T): SyqlPresent<T> {',
|
|
598
|
+
' return { present: true, value };',
|
|
599
|
+
'}',
|
|
600
|
+
].join('\n'),
|
|
601
|
+
);
|
|
602
|
+
}
|
|
354
603
|
parts.push(
|
|
355
604
|
[
|
|
356
605
|
"/** A bindable SQL param/row value (the wrapper's SqlValue subset). */",
|
|
@@ -376,8 +625,7 @@ export function emitQueriesModule(
|
|
|
376
625
|
' * `bind(params)` → positional args. Consumed by',
|
|
377
626
|
" * `@syncular/react`'s `useQuery`. `Row` is the projection row",
|
|
378
627
|
' * type; `Params` is `undefined` for a param-less query. `sqlFor`',
|
|
379
|
-
' *
|
|
380
|
-
' * generate-time-checked column allowlist. */',
|
|
628
|
+
' * selects a checked revision-1 SYQL physical statement when needed. */',
|
|
381
629
|
'export interface NamedQuery<Row, Params = undefined> {',
|
|
382
630
|
' readonly id: string;',
|
|
383
631
|
' readonly hasParams: boolean;',
|