@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/dist/emit-queries.js
CHANGED
|
@@ -20,25 +20,264 @@ function propertyKey(name) {
|
|
|
20
20
|
}
|
|
21
21
|
/** A named-query param value is the SqlValue subset its type maps to. */
|
|
22
22
|
const PARAM_TS_TYPE = TS_TYPE;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (query.limit !== undefined && param.name === 'limit') {
|
|
30
|
-
// The default + clamp live IN the SQL (`min(coalesce(?, d), m)`).
|
|
31
|
-
return `${access}.limit ?? null`;
|
|
32
|
-
}
|
|
33
|
-
const key = propertyKey(param.langName);
|
|
34
|
-
return isOptional(param) ? `${access}.${key} ?? null` : `params.${key}`;
|
|
23
|
+
const SYQL_PARAM_TS_TYPE = {
|
|
24
|
+
...TS_TYPE,
|
|
25
|
+
integer: 'bigint',
|
|
26
|
+
};
|
|
27
|
+
function bindExpr(param) {
|
|
28
|
+
return `params.${propertyKey(param.langName)}`;
|
|
35
29
|
}
|
|
36
30
|
function reactiveParam(query, name) {
|
|
31
|
+
if (query.syql !== undefined) {
|
|
32
|
+
const input = query.syql.inputs.find((candidate) => candidate.kind === 'value' && candidate.name === name);
|
|
33
|
+
if (input?.kind !== 'value')
|
|
34
|
+
throw new Error(`unknown reactive input ${name}`);
|
|
35
|
+
return `String(params.${propertyKey(input.langName)})`;
|
|
36
|
+
}
|
|
37
37
|
const param = query.params.find((candidate) => candidate.name === name);
|
|
38
38
|
if (param === undefined)
|
|
39
39
|
throw new Error(`unknown reactive param ${name}`);
|
|
40
40
|
return `String(params.${propertyKey(param.langName)})`;
|
|
41
41
|
}
|
|
42
|
+
function syqlInput(query, name) {
|
|
43
|
+
const input = query.syql?.inputs.find((candidate) => candidate.name === name);
|
|
44
|
+
if (input === undefined)
|
|
45
|
+
throw new Error(`unknown SYQL input ${name}`);
|
|
46
|
+
return input;
|
|
47
|
+
}
|
|
48
|
+
function syqlTsValueType(type, nullable) {
|
|
49
|
+
return `${SYQL_PARAM_TS_TYPE[type]}${nullable ? ' | null' : ''}`;
|
|
50
|
+
}
|
|
51
|
+
function syqlTsTypeCheck(expression, type, nullable) {
|
|
52
|
+
let check;
|
|
53
|
+
switch (type) {
|
|
54
|
+
case 'integer':
|
|
55
|
+
check = `typeof ${expression} === 'bigint'`;
|
|
56
|
+
break;
|
|
57
|
+
case 'float':
|
|
58
|
+
check = `typeof ${expression} === 'number' && Number.isFinite(${expression})`;
|
|
59
|
+
break;
|
|
60
|
+
case 'boolean':
|
|
61
|
+
check = `typeof ${expression} === 'boolean'`;
|
|
62
|
+
break;
|
|
63
|
+
case 'bytes':
|
|
64
|
+
case 'crdt':
|
|
65
|
+
check = `${expression} instanceof Uint8Array`;
|
|
66
|
+
break;
|
|
67
|
+
default:
|
|
68
|
+
check = `typeof ${expression} === 'string'`;
|
|
69
|
+
}
|
|
70
|
+
return nullable ? `${expression} === null || (${check})` : check;
|
|
71
|
+
}
|
|
72
|
+
function syqlControlActive(query, control, params) {
|
|
73
|
+
const input = syqlInput(query, control);
|
|
74
|
+
const access = `${params}.${propertyKey(input.langName)}`;
|
|
75
|
+
if (input.kind === 'switch')
|
|
76
|
+
return `${access} === true`;
|
|
77
|
+
if (input.kind === 'value' || input.kind === 'group') {
|
|
78
|
+
return `${access} !== undefined`;
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`${control} is not an activation control`);
|
|
81
|
+
}
|
|
82
|
+
function syqlBindExpr(query, bind, params) {
|
|
83
|
+
if (bind.kind === 'condition-active') {
|
|
84
|
+
return bind.controls
|
|
85
|
+
.map((control) => syqlControlActive(query, control, params))
|
|
86
|
+
.join(' && ');
|
|
87
|
+
}
|
|
88
|
+
const input = syqlInput(query, bind.input);
|
|
89
|
+
const access = `${params}.${propertyKey(input.langName)}`;
|
|
90
|
+
if (bind.kind === 'page') {
|
|
91
|
+
if (input.kind !== 'page')
|
|
92
|
+
throw new Error('page bind/input mismatch');
|
|
93
|
+
return `${access} ?? ${input.defaultSize}`;
|
|
94
|
+
}
|
|
95
|
+
if (bind.kind === 'group-member') {
|
|
96
|
+
if (input.kind !== 'group')
|
|
97
|
+
throw new Error('group bind/input mismatch');
|
|
98
|
+
const member = input.members.find((candidate) => candidate.name === bind.member);
|
|
99
|
+
if (member === undefined)
|
|
100
|
+
throw new Error(`unknown group member ${bind.member}`);
|
|
101
|
+
return `${access}?.${propertyKey(member.langName)} ?? null`;
|
|
102
|
+
}
|
|
103
|
+
if (input.kind !== 'value')
|
|
104
|
+
throw new Error('value bind/input mismatch');
|
|
105
|
+
if (input.required)
|
|
106
|
+
return access;
|
|
107
|
+
return input.nullable ? `${access}?.value ?? null` : `${access} ?? null`;
|
|
108
|
+
}
|
|
109
|
+
function emitSyqlValidation(query, Params) {
|
|
110
|
+
const inputs = query.syql?.inputs ?? [];
|
|
111
|
+
const lines = [];
|
|
112
|
+
lines.push(`function ${query.name}Validate(raw?: ${Params}): ${Params} {`, ` const params = raw ?? ({} as ${Params});`, ` const allowed = new Set([${inputs.map((input) => quote(input.langName)).join(', ')}]);`, ' for (const key of Object.keys(params)) {', ` if (!allowed.has(key)) throw new SyqlInputError('SYQL_RUNTIME_UNKNOWN_INPUT', ${quote(query.name)} + ': unknown input ' + key);`, ' }');
|
|
113
|
+
for (const input of inputs) {
|
|
114
|
+
const key = propertyKey(input.langName);
|
|
115
|
+
const access = `params.${key}`;
|
|
116
|
+
if (input.kind === 'value') {
|
|
117
|
+
if (input.required) {
|
|
118
|
+
lines.push(` 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}`)});`, ` if (!(${syqlTsTypeCheck(access, input.type, input.nullable)})) throw new SyqlInputError('SYQL_RUNTIME_INVALID_INPUT', ${quote(`${query.name}: invalid input ${input.name}`)});`);
|
|
119
|
+
}
|
|
120
|
+
else if (input.nullable) {
|
|
121
|
+
lines.push(` 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}`)});`);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
lines.push(` if (${access} !== undefined && !(${syqlTsTypeCheck(access, input.type, false)})) throw new SyqlInputError('SYQL_RUNTIME_INVALID_INPUT', ${quote(`${query.name}: invalid optional input ${input.name}`)});`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else if (input.kind === 'group') {
|
|
128
|
+
lines.push(` if (${access} !== undefined) {`, ` if (typeof ${access} !== 'object' || ${access} === null) throw new SyqlInputError('SYQL_RUNTIME_INVALID_GROUP', ${quote(`${query.name}: invalid group ${input.name}`)});`, ` const allowedMembers = new Set([${input.members.map((member) => quote(member.langName)).join(', ')}]);`, ` 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}`)});`);
|
|
129
|
+
for (const member of input.members) {
|
|
130
|
+
const memberAccess = `${access}.${propertyKey(member.langName)}`;
|
|
131
|
+
lines.push(` 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}`)});`);
|
|
132
|
+
}
|
|
133
|
+
lines.push(' }');
|
|
134
|
+
}
|
|
135
|
+
else if (input.kind === 'switch') {
|
|
136
|
+
lines.push(` if (${access} !== undefined && typeof ${access} !== 'boolean') throw new SyqlInputError('SYQL_RUNTIME_INVALID_INPUT', ${quote(`${query.name}: invalid switch ${input.name}`)});`);
|
|
137
|
+
}
|
|
138
|
+
else if (input.kind === 'sort') {
|
|
139
|
+
lines.push(` 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`)});`);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
lines.push(` 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}`)});`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
lines.push(' return params;', '}');
|
|
146
|
+
return lines;
|
|
147
|
+
}
|
|
148
|
+
function emitSyqlQuery(query, hash) {
|
|
149
|
+
const metadata = query.syql;
|
|
150
|
+
if (metadata === undefined)
|
|
151
|
+
throw new Error('missing SYQL metadata');
|
|
152
|
+
const Row = `${pascalCase(query.name)}Row`;
|
|
153
|
+
const Params = `${pascalCase(query.name)}Params`;
|
|
154
|
+
const inputs = metadata.inputs;
|
|
155
|
+
const hasParams = inputs.length > 0;
|
|
156
|
+
const requiresParams = inputs.some((input) => input.kind === 'value' && input.required);
|
|
157
|
+
const lines = [];
|
|
158
|
+
lines.push(`/** One row of the ${quote(query.name)} query (its projection). */`);
|
|
159
|
+
lines.push(`export interface ${Row} {`);
|
|
160
|
+
for (const column of query.columns) {
|
|
161
|
+
lines.push(` ${propertyKey(column.langName)}: ${TS_TYPE[column.type]}${column.nullable ? ' | null' : ''};`);
|
|
162
|
+
}
|
|
163
|
+
lines.push('}', '');
|
|
164
|
+
for (const input of inputs) {
|
|
165
|
+
if (input.kind === 'group') {
|
|
166
|
+
lines.push(`export interface ${pascalCase(query.name)}${pascalCase(input.langName)} {`);
|
|
167
|
+
for (const member of input.members) {
|
|
168
|
+
lines.push(` ${propertyKey(member.langName)}: ${syqlTsValueType(member.type, member.nullable)};`);
|
|
169
|
+
}
|
|
170
|
+
lines.push('}', '');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (hasParams) {
|
|
174
|
+
lines.push(`/** Public revision-1 SYQL inputs for ${quote(query.name)}. */`);
|
|
175
|
+
lines.push(`export interface ${Params} {`);
|
|
176
|
+
for (const input of inputs) {
|
|
177
|
+
const key = propertyKey(input.langName);
|
|
178
|
+
if (input.kind === 'value') {
|
|
179
|
+
const optional = !input.required;
|
|
180
|
+
const type = syqlTsValueType(input.type, input.nullable);
|
|
181
|
+
lines.push(` ${key}${optional ? '?' : ''}: ${optional && input.nullable ? `SyqlPresent<${type}>` : type};`);
|
|
182
|
+
}
|
|
183
|
+
else if (input.kind === 'group') {
|
|
184
|
+
lines.push(` ${key}?: ${pascalCase(query.name)}${pascalCase(input.langName)};`);
|
|
185
|
+
}
|
|
186
|
+
else if (input.kind === 'switch') {
|
|
187
|
+
lines.push(` ${key}?: boolean;`);
|
|
188
|
+
}
|
|
189
|
+
else if (input.kind === 'sort') {
|
|
190
|
+
lines.push(` ${key}?: ${input.profiles.map((profile) => quote(profile.langName)).join(' | ')};`);
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
lines.push(` ${key}?: number;`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
lines.push('}', '');
|
|
197
|
+
lines.push(...emitSyqlValidation(query, Params), '');
|
|
198
|
+
}
|
|
199
|
+
lines.push(`/** Tables ${quote(query.name)} reads (compatibility/export surface). */`, `export const ${query.name}Tables = [${query.tables.map(quote).join(', ')}] as const;`, '');
|
|
200
|
+
const paramDecl = !hasParams
|
|
201
|
+
? ''
|
|
202
|
+
: requiresParams
|
|
203
|
+
? `raw: ${Params}`
|
|
204
|
+
: `raw?: ${Params}`;
|
|
205
|
+
const validated = hasParams ? `${query.name}Validate(raw)` : undefined;
|
|
206
|
+
const statementType = hasParams
|
|
207
|
+
? `{ sql: string; bind: (params: ${Params}) => QueryValue[] }`
|
|
208
|
+
: '{ sql: string; bind: () => QueryValue[] }';
|
|
209
|
+
lines.push(`const ${query.name}Statements: ${statementType}[] = [`);
|
|
210
|
+
for (const statement of metadata.plan.statements) {
|
|
211
|
+
const binds = statement.binds
|
|
212
|
+
.map((bind) => syqlBindExpr(query, bind, 'params'))
|
|
213
|
+
.join(', ');
|
|
214
|
+
lines.push(` { sql: ${quote(statement.positionalSql)}, bind: (${hasParams ? 'params' : ''}) => [${binds}] },`);
|
|
215
|
+
}
|
|
216
|
+
lines.push('];');
|
|
217
|
+
const sort = inputs.find((input) => input.kind === 'sort');
|
|
218
|
+
if (sort?.kind === 'sort') {
|
|
219
|
+
lines.push(`const ${query.name}SortIndexes = { ${sort.profiles.map((profile, index) => `${propertyKey(profile.langName)}: ${index}`).join(', ')} } as const;`);
|
|
220
|
+
}
|
|
221
|
+
lines.push(`function ${query.name}Select(${paramDecl}): { sql: string; bind: QueryValue[] } {`);
|
|
222
|
+
if (validated !== undefined)
|
|
223
|
+
lines.push(` const params = ${validated};`);
|
|
224
|
+
let maskExpression = '0';
|
|
225
|
+
if (metadata.plan.backend === 'variants') {
|
|
226
|
+
lines.push(' let mask = 0;');
|
|
227
|
+
metadata.plan.activationControls.forEach((control, index) => {
|
|
228
|
+
lines.push(` if (${syqlControlActive(query, control, 'params')}) mask |= ${2 ** index};`);
|
|
229
|
+
});
|
|
230
|
+
maskExpression = 'mask';
|
|
231
|
+
}
|
|
232
|
+
let sortExpression = '0';
|
|
233
|
+
const profileCount = sort?.kind === 'sort' ? sort.profiles.length : 1;
|
|
234
|
+
if (sort?.kind === 'sort') {
|
|
235
|
+
sortExpression = `${query.name}SortIndexes[params.${propertyKey(sort.langName)} ?? ${quote(sort.profiles.find((profile) => profile.name === sort.defaultProfile)?.langName ?? sort.defaultProfile)}]`;
|
|
236
|
+
}
|
|
237
|
+
const indexExpression = metadata.plan.backend === 'variants'
|
|
238
|
+
? `${maskExpression} * ${profileCount} + ${sortExpression}`
|
|
239
|
+
: sortExpression;
|
|
240
|
+
lines.push(` const statement = ${query.name}Statements[${indexExpression}];`, ` if (statement === undefined) throw new Error('invalid generated SYQL statement index');`, ` return { sql: statement.sql, bind: statement.bind(${hasParams ? 'params' : ''}) };`, '}', '');
|
|
241
|
+
const runnerParam = !hasParams
|
|
242
|
+
? ''
|
|
243
|
+
: requiresParams
|
|
244
|
+
? `, params: ${Params}`
|
|
245
|
+
: `, params?: ${Params}`;
|
|
246
|
+
lines.push(`/** Run the ${quote(query.name)} named query (SELECT-only). */`, `export async function ${query.name}(client: QueryClient${runnerParam}): Promise<${Row}[]> {`, ` const selected = ${query.name}Select(${hasParams ? 'params' : ''});`, ' const rows = await client.query(selected.sql, selected.bind);', ` return rows as unknown as ${Row}[];`, '}', '');
|
|
247
|
+
const paramsType = hasParams ? Params : 'undefined';
|
|
248
|
+
const defaultStatement = metadata.plan.statements.find((statement) => (statement.activationMask === undefined ||
|
|
249
|
+
statement.activationMask === 0) &&
|
|
250
|
+
(sort?.kind !== 'sort' || statement.sortProfile === sort.defaultProfile));
|
|
251
|
+
lines.push(`/** Revisioned reactive descriptor for \`useQuery(${query.name}Query${hasParams ? ', params' : ''})\`. */`, `export const ${query.name}Query: NamedQuery<${Row}, ${paramsType}> = {`, ` id: ${quote(`${hash}/${query.name}`)},`, ` hasParams: ${hasParams},`, ` sql: ${quote(defaultStatement?.positionalSql ?? query.positionalSql)},`);
|
|
252
|
+
if (hasParams) {
|
|
253
|
+
lines.push(` sqlFor: (params: ${Params}) => ${query.name}Select(params).sql,`);
|
|
254
|
+
}
|
|
255
|
+
lines.push(` tables: ${query.name}Tables,`);
|
|
256
|
+
const reactiveUsesParams = query.reactive.dependencies.some((dependency) => dependency.scopes.some((scope) => scope.params.length > 0));
|
|
257
|
+
lines.push(` dependencies: (${reactiveUsesParams ? 'params' : ''}) => [`);
|
|
258
|
+
for (const dependency of query.reactive.dependencies) {
|
|
259
|
+
const keys = dependency.scopes.flatMap((scope) => scope.params.map((param) => scopeKeyExpression(query, scope.pattern, scope.variable, param)));
|
|
260
|
+
lines.push(` { table: ${quote(dependency.table)}${keys.length > 0 ? `, scopeKeys: [${keys.join(', ')}]` : ''} },`);
|
|
261
|
+
}
|
|
262
|
+
lines.push(' ],');
|
|
263
|
+
const coverageUsesParams = query.reactive.coverage.some((coverage) => coverage.units.length > 0 ||
|
|
264
|
+
coverage.fixedScopes.some((scope) => scope.params.length > 0));
|
|
265
|
+
lines.push(` coverage: (${coverageUsesParams ? 'params' : ''}) => [`);
|
|
266
|
+
for (const coverage of query.reactive.coverage) {
|
|
267
|
+
const fixed = coverage.fixedScopes
|
|
268
|
+
.map((scope) => `${propertyKey(scope.variable)}: [${scope.params.map((param) => reactiveParam(query, param)).join(', ')}]`)
|
|
269
|
+
.join(', ');
|
|
270
|
+
lines.push(` { base: { table: ${quote(coverage.table)}, variable: ${quote(coverage.variable)}${fixed.length > 0 ? `, fixedScopes: { ${fixed} }` : ''} }, units: [${coverage.units.map((param) => reactiveParam(query, param)).join(', ')}] },`);
|
|
271
|
+
}
|
|
272
|
+
lines.push(' ],', hasParams
|
|
273
|
+
? ` bind: (params: ${Params}) => ${query.name}Select(params).bind,`
|
|
274
|
+
: ` bind: () => ${query.name}Select().bind,`);
|
|
275
|
+
if (query.reactive.rowKey !== undefined) {
|
|
276
|
+
lines.push(` rowKey: (row) => [${query.reactive.rowKey.map((key) => `row.${propertyKey(key)}`).join(', ')}],`);
|
|
277
|
+
}
|
|
278
|
+
lines.push('};');
|
|
279
|
+
return lines.join('\n');
|
|
280
|
+
}
|
|
42
281
|
function scopeKeyExpression(query, pattern, variable, param) {
|
|
43
282
|
const marker = `{${variable}}`;
|
|
44
283
|
const index = pattern.indexOf(marker);
|
|
@@ -49,11 +288,11 @@ function scopeKeyExpression(query, pattern, variable, param) {
|
|
|
49
288
|
return `${quote(prefix)} + ${reactiveParam(query, param)} + ${quote(suffix)}`;
|
|
50
289
|
}
|
|
51
290
|
function emitQuery(query, hash) {
|
|
291
|
+
if (query.syql !== undefined)
|
|
292
|
+
return emitSyqlQuery(query, hash);
|
|
52
293
|
const Row = `${pascalCase(query.name)}Row`;
|
|
53
294
|
const Params = `${pascalCase(query.name)}Params`;
|
|
54
295
|
const lines = [];
|
|
55
|
-
// Row interface — keyed by the language-facing names, which ARE the
|
|
56
|
-
// runtime result keys (§5 projection lowering aliases them in SQL).
|
|
57
296
|
lines.push(`/** One row of the ${quote(query.name)} query (its projection). */`);
|
|
58
297
|
lines.push(`export interface ${Row} {`);
|
|
59
298
|
for (const column of query.columns) {
|
|
@@ -61,138 +300,42 @@ function emitQuery(query, hash) {
|
|
|
61
300
|
}
|
|
62
301
|
lines.push('}');
|
|
63
302
|
lines.push('');
|
|
64
|
-
const hasParams = query.params.length > 0
|
|
65
|
-
const requiresParams = query.params.some((p) => !isOptional(p) && !(query.limit !== undefined && p.name === 'limit'));
|
|
66
|
-
// Params interface (params and/or knob keys).
|
|
303
|
+
const hasParams = query.params.length > 0;
|
|
67
304
|
if (hasParams) {
|
|
68
305
|
lines.push(`/** Named parameters for ${quote(query.name)}. */`);
|
|
69
306
|
lines.push(`export interface ${Params} {`);
|
|
70
307
|
for (const param of query.params) {
|
|
71
|
-
|
|
72
|
-
continue;
|
|
73
|
-
const opt = isOptional(param);
|
|
74
|
-
lines.push(` ${propertyKey(param.langName)}${opt ? '?' : ''}: ${PARAM_TS_TYPE[param.type]}${opt ? ' | null' : ''};`);
|
|
75
|
-
}
|
|
76
|
-
if (query.orderBy !== undefined) {
|
|
77
|
-
const keys = query.orderBy.allowed
|
|
78
|
-
.map((c) => quote(c.langName))
|
|
79
|
-
.join(' | ');
|
|
80
|
-
lines.push(` /** §6 orderBy knob — a generate-time-checked allowlist. */`);
|
|
81
|
-
lines.push(` orderBy?: ${keys};`);
|
|
82
|
-
lines.push(` dir?: 'asc' | 'desc';`);
|
|
83
|
-
}
|
|
84
|
-
if (query.limit !== undefined) {
|
|
85
|
-
const clamp = query.limit.max !== undefined ? ` (clamped to ${query.limit.max})` : '';
|
|
86
|
-
lines.push(` /** §6 limit knob — binds as a value${clamp}; default ${query.limit.default ?? query.limit.max}. */`);
|
|
87
|
-
lines.push(' limit?: number;');
|
|
308
|
+
lines.push(` ${propertyKey(param.langName)}: ${PARAM_TS_TYPE[param.type]};`);
|
|
88
309
|
}
|
|
89
310
|
lines.push('}');
|
|
90
311
|
lines.push('');
|
|
91
312
|
}
|
|
92
|
-
// Tables dependency set (for useRawSql {tables} / useQuery).
|
|
93
313
|
lines.push(`/** Tables ${quote(query.name)} reads (compatibility/export surface). */`);
|
|
94
314
|
lines.push(`export const ${query.name}Tables = [${query.tables.map(quote).join(', ')}] as const;`);
|
|
95
315
|
lines.push('');
|
|
96
|
-
// SQL constants: the static default statement, plus (orderBy knob) the
|
|
97
|
-
// baked base + column map + compose function.
|
|
98
316
|
const sqlConst = `${query.name}Sql`;
|
|
99
317
|
lines.push(`const ${sqlConst} = ${quote(query.positionalSql)};`);
|
|
100
|
-
const composeFn = `${query.name}ComposeSql`;
|
|
101
|
-
if (query.orderBy !== undefined) {
|
|
102
|
-
const base = `${query.name}SqlBase`;
|
|
103
|
-
const colsConst = `${query.name}OrderColumns`;
|
|
104
|
-
const defaultLang = query.orderBy.allowed.find((c) => c.name === query.orderBy?.defaultColumn)
|
|
105
|
-
?.langName ?? query.orderBy.defaultColumn;
|
|
106
|
-
lines.push(`const ${base} = ${quote(query.positionalSqlBase ?? '')};`);
|
|
107
|
-
lines.push(`const ${colsConst} = { ${query.orderBy.allowed
|
|
108
|
-
.map((c) => `${propertyKey(c.langName)}: ${quote(c.name)}`)
|
|
109
|
-
.join(', ')} } as const;`);
|
|
110
|
-
lines.push(`function ${composeFn}(params?: ${Params}): string {`);
|
|
111
|
-
lines.push(` const column = ${colsConst}[params?.orderBy ?? ${quote(defaultLang)}] ?? ${quote(query.orderBy.defaultColumn)};`);
|
|
112
|
-
lines.push(` const dir = (params?.dir ?? ${quote(query.orderBy.defaultDir)}) === 'desc' ? 'desc' : 'asc';`);
|
|
113
|
-
lines.push(` return \`\${${base}} order by \${column} \${dir}${query.positionalLimitTail ?? ''}\`;`);
|
|
114
|
-
lines.push('}');
|
|
115
|
-
}
|
|
116
|
-
// Positional bind list.
|
|
117
|
-
const access = requiresParams ? 'params' : 'params?';
|
|
118
318
|
const positional = hasParams
|
|
119
|
-
? `[${query.params.map(
|
|
319
|
+
? `[${query.params.map(bindExpr).join(', ')}]`
|
|
120
320
|
: '[]';
|
|
121
|
-
// §7 variant backend: one statement per provided-combination, selected by
|
|
122
|
-
// a bitmask over the optional groups (bit i = group i provided/true). The
|
|
123
|
-
// neutralization statement above stays the canonical/default form.
|
|
124
|
-
const selectFn = `${query.name}SelectVariant`;
|
|
125
|
-
const paramArgDecl = requiresParams
|
|
126
|
-
? `params: ${Params}`
|
|
127
|
-
: `params?: ${Params}`;
|
|
128
|
-
if (query.variants !== undefined && query.variantGroups !== undefined) {
|
|
129
|
-
const variantsConst = `${query.name}Variants`;
|
|
130
|
-
lines.push(`const ${variantsConst}: { sql: string; bind: (${paramArgDecl}) => QueryValue[] }[] = [`);
|
|
131
|
-
for (const variant of query.variants) {
|
|
132
|
-
const binds = variant.params
|
|
133
|
-
.map((name) => {
|
|
134
|
-
const param = query.params.find((p) => p.name === name);
|
|
135
|
-
if (param === undefined)
|
|
136
|
-
throw new Error(`unknown param ${name}`);
|
|
137
|
-
return bindExpr(query, param, access);
|
|
138
|
-
})
|
|
139
|
-
.join(', ');
|
|
140
|
-
lines.push(` // [${variant.when.join(' + ') || 'no optional filters'}]`);
|
|
141
|
-
lines.push(` { sql: ${quote(variant.positionalSql)}, bind: (${requiresParams ? 'params' : 'params?'}) => [${binds}] },`);
|
|
142
|
-
}
|
|
143
|
-
lines.push('];');
|
|
144
|
-
lines.push(`function ${selectFn}(${paramArgDecl}): { sql: string; bind: QueryValue[] } {`);
|
|
145
|
-
lines.push(' let mask = 0;');
|
|
146
|
-
query.variantGroups.forEach((group, index) => {
|
|
147
|
-
const langOf = (name) => query.params.find((p) => p.name === name)?.langName ?? name;
|
|
148
|
-
const condition = group.flag
|
|
149
|
-
? `params?.${propertyKey(langOf(group.params[0]))} === true`
|
|
150
|
-
: group.params
|
|
151
|
-
.map((name) => `(params?.${propertyKey(langOf(name))} ?? null) !== null`)
|
|
152
|
-
.join(' && ');
|
|
153
|
-
lines.push(` if (${condition}) mask |= ${1 << index};`);
|
|
154
|
-
});
|
|
155
|
-
lines.push(` const variant = ${variantsConst}[mask] as (typeof ${variantsConst})[number];`);
|
|
156
|
-
lines.push(' return { sql: variant.sql, bind: variant.bind(params) };');
|
|
157
|
-
lines.push('}');
|
|
158
|
-
}
|
|
159
321
|
lines.push('');
|
|
160
|
-
// The runner.
|
|
161
|
-
const paramArg = !hasParams ? '' : paramArgDecl;
|
|
162
322
|
lines.push(`/** Run the ${quote(query.name)} named query (SELECT-only). */`);
|
|
163
|
-
lines.push(`export async function ${query.name}(client: QueryClient${hasParams ? `, ${
|
|
164
|
-
if (
|
|
165
|
-
lines.push(` const
|
|
166
|
-
lines.push(' const rows = await client.query(variant.sql, variant.bind);');
|
|
323
|
+
lines.push(`export async function ${query.name}(client: QueryClient${hasParams ? `, params: ${Params}` : ''}): Promise<${Row}[]> {`);
|
|
324
|
+
if (hasParams) {
|
|
325
|
+
lines.push(` const rows = await client.query(${sqlConst}, ${positional});`);
|
|
167
326
|
}
|
|
168
327
|
else {
|
|
169
|
-
const
|
|
170
|
-
if (hasParams) {
|
|
171
|
-
lines.push(` const rows = await client.query(${sqlExpr}, ${positional});`);
|
|
172
|
-
}
|
|
173
|
-
else {
|
|
174
|
-
lines.push(` const rows = await client.query(${sqlExpr});`);
|
|
175
|
-
}
|
|
328
|
+
lines.push(` const rows = await client.query(${sqlConst});`);
|
|
176
329
|
}
|
|
177
330
|
lines.push(` return rows as unknown as ${Row}[];`);
|
|
178
331
|
lines.push('}');
|
|
179
332
|
lines.push('');
|
|
180
|
-
// A descriptor for react's `useQuery` — the SQL (static, composed from
|
|
181
|
-
// the baked allowlist, or variant-selected), the exact table dependency
|
|
182
|
-
// set, and a `bind(params)` → positional array (always paired with the
|
|
183
|
-
// sqlFor selection). Typed by the query's own Row/Params.
|
|
184
333
|
lines.push(`/** Revisioned reactive descriptor for \`useQuery(${query.name}Query${hasParams ? ', params' : ''})\`. */`);
|
|
185
334
|
const paramsTypeArg = hasParams ? Params : 'undefined';
|
|
186
335
|
lines.push(`export const ${query.name}Query: NamedQuery<${Row}, ${paramsTypeArg}> = {`);
|
|
187
336
|
lines.push(` id: ${quote(`${hash}/${query.name}`)},`);
|
|
188
337
|
lines.push(` hasParams: ${hasParams},`);
|
|
189
338
|
lines.push(` sql: ${sqlConst},`);
|
|
190
|
-
if (query.variants !== undefined) {
|
|
191
|
-
lines.push(` sqlFor: (params: ${Params}) => ${selectFn}(params).sql,`);
|
|
192
|
-
}
|
|
193
|
-
else if (query.orderBy !== undefined) {
|
|
194
|
-
lines.push(` sqlFor: (params: ${Params}) => ${composeFn}(params),`);
|
|
195
|
-
}
|
|
196
339
|
lines.push(` tables: ${query.name}Tables,`);
|
|
197
340
|
const reactiveUsesParams = query.reactive.dependencies.some((dependency) => dependency.scopes.some((scope) => scope.params.length > 0));
|
|
198
341
|
lines.push(` dependencies: (${reactiveUsesParams ? 'params' : ''}) => [`);
|
|
@@ -212,10 +355,7 @@ function emitQuery(query, hash) {
|
|
|
212
355
|
lines.push(` { base: { table: ${quote(coverage.table)}, variable: ${quote(coverage.variable)}${fixedPart} }, units: [${coverage.units.map((param) => reactiveParam(query, param)).join(', ')}] },`);
|
|
213
356
|
}
|
|
214
357
|
lines.push(' ],');
|
|
215
|
-
if (
|
|
216
|
-
lines.push(` bind: (params: ${Params}) => ${selectFn}(params).bind,`);
|
|
217
|
-
}
|
|
218
|
-
else if (hasParams) {
|
|
358
|
+
if (hasParams) {
|
|
219
359
|
lines.push(` bind: (params: ${Params}) => ${positional},`);
|
|
220
360
|
}
|
|
221
361
|
else {
|
|
@@ -234,6 +374,33 @@ export function emitQueriesModule(queries, hash, irVersion) {
|
|
|
234
374
|
`// irVersion: ${irVersion}`,
|
|
235
375
|
`// irHash: ${hash}`,
|
|
236
376
|
].join('\n'));
|
|
377
|
+
if (queries.some((query) => query.syql !== undefined)) {
|
|
378
|
+
parts.push([
|
|
379
|
+
'export type SyqlRuntimeErrorCode =',
|
|
380
|
+
" | 'SYQL_RUNTIME_MISSING_REQUIRED_INPUT'",
|
|
381
|
+
" | 'SYQL_RUNTIME_UNKNOWN_INPUT'",
|
|
382
|
+
" | 'SYQL_RUNTIME_INVALID_INPUT'",
|
|
383
|
+
" | 'SYQL_RUNTIME_INVALID_GROUP'",
|
|
384
|
+
" | 'SYQL_RUNTIME_INVALID_SORT'",
|
|
385
|
+
" | 'SYQL_RUNTIME_INVALID_PAGE';",
|
|
386
|
+
'',
|
|
387
|
+
'export class SyqlInputError extends Error {',
|
|
388
|
+
" readonly name = 'SyqlInputError';",
|
|
389
|
+
' constructor(readonly code: SyqlRuntimeErrorCode, message: string) {',
|
|
390
|
+
' super(message);',
|
|
391
|
+
' }',
|
|
392
|
+
'}',
|
|
393
|
+
'',
|
|
394
|
+
'export interface SyqlPresent<T> {',
|
|
395
|
+
' readonly present: true;',
|
|
396
|
+
' readonly value: T;',
|
|
397
|
+
'}',
|
|
398
|
+
'',
|
|
399
|
+
'export function syqlPresent<T>(value: T): SyqlPresent<T> {',
|
|
400
|
+
' return { present: true, value };',
|
|
401
|
+
'}',
|
|
402
|
+
].join('\n'));
|
|
403
|
+
}
|
|
237
404
|
parts.push([
|
|
238
405
|
"/** A bindable SQL param/row value (the wrapper's SqlValue subset). */",
|
|
239
406
|
'export type QueryValue =',
|
|
@@ -258,8 +425,7 @@ export function emitQueriesModule(queries, hash, irVersion) {
|
|
|
258
425
|
' * `bind(params)` → positional args. Consumed by',
|
|
259
426
|
" * `@syncular/react`'s `useQuery`. `Row` is the projection row",
|
|
260
427
|
' * type; `Params` is `undefined` for a param-less query. `sqlFor`',
|
|
261
|
-
' *
|
|
262
|
-
' * generate-time-checked column allowlist. */',
|
|
428
|
+
' * selects a checked revision-1 SYQL physical statement when needed. */',
|
|
263
429
|
'export interface NamedQuery<Row, Params = undefined> {',
|
|
264
430
|
' readonly id: string;',
|
|
265
431
|
' readonly hasParams: boolean;',
|
package/dist/fmt.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
/** Format one `.syql` source
|
|
2
|
-
*
|
|
1
|
+
/** Format one `.syql` source. Invalid or non-equivalent output is rejected so
|
|
2
|
+
* the CLI never writes a partially understood file. */
|
|
3
3
|
export declare function formatSyql(file: string, source: string): string;
|