@syncular/typegen 0.5.1 → 0.7.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 +79 -32
- package/dist/cli.js +40 -17
- package/dist/emit-queries-dart.js +168 -55
- package/dist/emit-queries-kotlin.js +167 -55
- package/dist/emit-queries-swift.js +183 -57
- package/dist/emit-queries.js +286 -123
- package/dist/fmt.d.ts +2 -2
- package/dist/fmt.js +348 -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 +349 -187
- 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 +110 -63
- package/dist/query.js +89 -11
- package/dist/syql-ast.d.ts +12 -13
- package/dist/syql-ast.js +26 -20
- 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 +434 -0
- package/dist/syql-parser.d.ts +19 -18
- package/dist/syql-parser.js +341 -93
- package/dist/syql-semantics.d.ts +73 -0
- package/dist/syql-semantics.js +607 -0
- package/dist/syql-template-parser.d.ts +5 -20
- package/dist/syql-template-parser.js +116 -109
- package/dist/syql-validator.d.ts +35 -0
- package/dist/syql-validator.js +993 -0
- package/package.json +3 -3
- package/src/cli.ts +49 -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 +410 -165
- package/src/fmt.ts +405 -303
- package/src/generate.ts +43 -9
- package/src/index.ts +3 -1
- package/src/lsp.ts +414 -216
- package/src/manifest.ts +2 -4
- package/src/query-ir.ts +52 -42
- package/src/query.ts +229 -79
- package/src/syql-ast.ts +38 -34
- package/src/syql-lexer.ts +12 -1
- package/src/syql-lowering.ts +664 -0
- package/src/syql-parser.ts +472 -134
- package/src/syql-semantics.ts +930 -0
- package/src/syql-template-parser.ts +119 -163
- package/src/syql-validator.ts +1486 -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,261 @@ 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
|
+
const access = `params.${propertyKey(input.langName)}`;
|
|
36
|
+
return `String(${input.default === false ? `${access} ?? false` : access})`;
|
|
37
|
+
}
|
|
37
38
|
const param = query.params.find((candidate) => candidate.name === name);
|
|
38
39
|
if (param === undefined)
|
|
39
40
|
throw new Error(`unknown reactive param ${name}`);
|
|
40
41
|
return `String(params.${propertyKey(param.langName)})`;
|
|
41
42
|
}
|
|
43
|
+
function syqlInput(query, name) {
|
|
44
|
+
const input = query.syql?.inputs.find((candidate) => candidate.name === name);
|
|
45
|
+
if (input === undefined)
|
|
46
|
+
throw new Error(`unknown SYQL input ${name}`);
|
|
47
|
+
return input;
|
|
48
|
+
}
|
|
49
|
+
function syqlTsValueType(type, nullable) {
|
|
50
|
+
return `${SYQL_PARAM_TS_TYPE[type]}${nullable ? ' | null' : ''}`;
|
|
51
|
+
}
|
|
52
|
+
function syqlTsTypeCheck(expression, type, nullable) {
|
|
53
|
+
let check;
|
|
54
|
+
switch (type) {
|
|
55
|
+
case 'integer':
|
|
56
|
+
check = `typeof ${expression} === 'bigint'`;
|
|
57
|
+
break;
|
|
58
|
+
case 'float':
|
|
59
|
+
check = `typeof ${expression} === 'number' && Number.isFinite(${expression})`;
|
|
60
|
+
break;
|
|
61
|
+
case 'boolean':
|
|
62
|
+
check = `typeof ${expression} === 'boolean'`;
|
|
63
|
+
break;
|
|
64
|
+
case 'bytes':
|
|
65
|
+
case 'crdt':
|
|
66
|
+
check = `${expression} instanceof Uint8Array`;
|
|
67
|
+
break;
|
|
68
|
+
default:
|
|
69
|
+
check = `typeof ${expression} === 'string'`;
|
|
70
|
+
}
|
|
71
|
+
return nullable ? `${expression} === null || (${check})` : check;
|
|
72
|
+
}
|
|
73
|
+
function syqlControlActive(query, control, params) {
|
|
74
|
+
const input = syqlInput(query, control);
|
|
75
|
+
const access = `${params}.${propertyKey(input.langName)}`;
|
|
76
|
+
if (input.kind === 'value' && input.default === false)
|
|
77
|
+
return `${access} === true`;
|
|
78
|
+
if (input.kind === 'value' || input.kind === 'group') {
|
|
79
|
+
return `${access} !== undefined`;
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`${control} is not an activation control`);
|
|
82
|
+
}
|
|
83
|
+
function syqlBindExpr(query, bind, params) {
|
|
84
|
+
if (bind.kind === 'condition-active') {
|
|
85
|
+
return bind.controls
|
|
86
|
+
.map((control) => syqlControlActive(query, control, params))
|
|
87
|
+
.join(' && ');
|
|
88
|
+
}
|
|
89
|
+
const input = syqlInput(query, bind.input);
|
|
90
|
+
const access = `${params}.${propertyKey(input.langName)}`;
|
|
91
|
+
if (bind.kind === 'limit') {
|
|
92
|
+
if (input.kind !== 'limit')
|
|
93
|
+
throw new Error('limit bind/input mismatch');
|
|
94
|
+
return `${access} ?? ${input.defaultSize}`;
|
|
95
|
+
}
|
|
96
|
+
if (bind.kind === 'group-member') {
|
|
97
|
+
if (input.kind !== 'group')
|
|
98
|
+
throw new Error('group bind/input mismatch');
|
|
99
|
+
const member = input.members.find((candidate) => candidate.name === bind.member);
|
|
100
|
+
if (member === undefined)
|
|
101
|
+
throw new Error(`unknown group member ${bind.member}`);
|
|
102
|
+
return `${access}?.${propertyKey(member.langName)} ?? null`;
|
|
103
|
+
}
|
|
104
|
+
if (input.kind !== 'value')
|
|
105
|
+
throw new Error('value bind/input mismatch');
|
|
106
|
+
if (input.default === false)
|
|
107
|
+
return `${access} ?? false`;
|
|
108
|
+
if (input.required)
|
|
109
|
+
return access;
|
|
110
|
+
return input.nullable ? `${access}?.value ?? null` : `${access} ?? null`;
|
|
111
|
+
}
|
|
112
|
+
function emitSyqlValidation(query, Params) {
|
|
113
|
+
const inputs = query.syql?.inputs ?? [];
|
|
114
|
+
const lines = [];
|
|
115
|
+
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);`, ' }');
|
|
116
|
+
for (const input of inputs) {
|
|
117
|
+
const key = propertyKey(input.langName);
|
|
118
|
+
const access = `params.${key}`;
|
|
119
|
+
if (input.kind === 'value') {
|
|
120
|
+
if (input.required) {
|
|
121
|
+
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}`)});`);
|
|
122
|
+
}
|
|
123
|
+
else if (input.nullable) {
|
|
124
|
+
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}`)});`);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
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}`)});`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else if (input.kind === 'group') {
|
|
131
|
+
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}`)});`);
|
|
132
|
+
for (const member of input.members) {
|
|
133
|
+
const memberAccess = `${access}.${propertyKey(member.langName)}`;
|
|
134
|
+
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}`)});`);
|
|
135
|
+
}
|
|
136
|
+
lines.push(' }');
|
|
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_LIMIT', ${quote(`${query.name}: limit 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 === 'sort') {
|
|
187
|
+
lines.push(` ${key}?: ${input.profiles.map((profile) => quote(profile.langName)).join(' | ')};`);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
lines.push(` ${key}?: number;`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
lines.push('}', '');
|
|
194
|
+
lines.push(...emitSyqlValidation(query, Params), '');
|
|
195
|
+
}
|
|
196
|
+
lines.push(`/** Tables ${quote(query.name)} reads (compatibility/export surface). */`, `export const ${query.name}Tables = [${query.tables.map(quote).join(', ')}] as const;`, '');
|
|
197
|
+
const paramDecl = !hasParams
|
|
198
|
+
? ''
|
|
199
|
+
: requiresParams
|
|
200
|
+
? `raw: ${Params}`
|
|
201
|
+
: `raw?: ${Params}`;
|
|
202
|
+
const validated = hasParams ? `${query.name}Validate(raw)` : undefined;
|
|
203
|
+
const statementType = hasParams
|
|
204
|
+
? `{ sql: string; bind: (params: ${Params}) => QueryValue[] }`
|
|
205
|
+
: '{ sql: string; bind: () => QueryValue[] }';
|
|
206
|
+
lines.push(`const ${query.name}Statements: ${statementType}[] = [`);
|
|
207
|
+
for (const statement of metadata.plan.statements) {
|
|
208
|
+
const binds = statement.binds
|
|
209
|
+
.map((bind) => syqlBindExpr(query, bind, 'params'))
|
|
210
|
+
.join(', ');
|
|
211
|
+
lines.push(` { sql: ${quote(statement.positionalSql)}, bind: (${hasParams ? 'params' : ''}) => [${binds}] },`);
|
|
212
|
+
}
|
|
213
|
+
lines.push('];');
|
|
214
|
+
const sort = inputs.find((input) => input.kind === 'sort');
|
|
215
|
+
if (sort?.kind === 'sort') {
|
|
216
|
+
lines.push(`const ${query.name}SortIndexes = { ${sort.profiles.map((profile, index) => `${propertyKey(profile.langName)}: ${index}`).join(', ')} } as const;`);
|
|
217
|
+
}
|
|
218
|
+
lines.push(`function ${query.name}Select(${paramDecl}): { sql: string; bind: QueryValue[] } {`);
|
|
219
|
+
if (validated !== undefined)
|
|
220
|
+
lines.push(` const params = ${validated};`);
|
|
221
|
+
let maskExpression = '0';
|
|
222
|
+
if (metadata.plan.backend === 'variants') {
|
|
223
|
+
lines.push(' let mask = 0;');
|
|
224
|
+
metadata.plan.activationControls.forEach((control, index) => {
|
|
225
|
+
lines.push(` if (${syqlControlActive(query, control, 'params')}) mask |= ${2 ** index};`);
|
|
226
|
+
});
|
|
227
|
+
maskExpression = 'mask';
|
|
228
|
+
}
|
|
229
|
+
let sortExpression = '0';
|
|
230
|
+
const profileCount = sort?.kind === 'sort' ? sort.profiles.length : 1;
|
|
231
|
+
if (sort?.kind === 'sort') {
|
|
232
|
+
sortExpression = `${query.name}SortIndexes[params.${propertyKey(sort.langName)} ?? ${quote(sort.profiles.find((profile) => profile.name === sort.defaultProfile)?.langName ?? sort.defaultProfile)}]`;
|
|
233
|
+
}
|
|
234
|
+
const indexExpression = metadata.plan.backend === 'variants'
|
|
235
|
+
? `${maskExpression} * ${profileCount} + ${sortExpression}`
|
|
236
|
+
: sortExpression;
|
|
237
|
+
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' : ''}) };`, '}', '');
|
|
238
|
+
const runnerParam = !hasParams
|
|
239
|
+
? ''
|
|
240
|
+
: requiresParams
|
|
241
|
+
? `, params: ${Params}`
|
|
242
|
+
: `, params?: ${Params}`;
|
|
243
|
+
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}[];`, '}', '');
|
|
244
|
+
const paramsType = hasParams ? Params : 'undefined';
|
|
245
|
+
const defaultStatement = metadata.plan.statements.find((statement) => (statement.activationMask === undefined ||
|
|
246
|
+
statement.activationMask === 0) &&
|
|
247
|
+
(sort?.kind !== 'sort' || statement.sortProfile === sort.defaultProfile));
|
|
248
|
+
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)},`);
|
|
249
|
+
if (hasParams) {
|
|
250
|
+
lines.push(` sqlFor: (params: ${Params}) => ${query.name}Select(params).sql,`);
|
|
251
|
+
}
|
|
252
|
+
lines.push(` tables: ${query.name}Tables,`);
|
|
253
|
+
const reactiveUsesParams = query.reactive.dependencies.some((dependency) => dependency.scopes.some((scope) => scope.params.length > 0));
|
|
254
|
+
lines.push(` dependencies: (${reactiveUsesParams ? 'params' : ''}) => [`);
|
|
255
|
+
for (const dependency of query.reactive.dependencies) {
|
|
256
|
+
const keys = dependency.scopes.flatMap((scope) => scope.params.map((param) => scopeKeyExpression(query, scope.pattern, scope.variable, param)));
|
|
257
|
+
lines.push(` { table: ${quote(dependency.table)}${keys.length > 0 ? `, scopeKeys: [${keys.join(', ')}]` : ''} },`);
|
|
258
|
+
}
|
|
259
|
+
lines.push(' ],');
|
|
260
|
+
const coverageUsesParams = query.reactive.coverage.some((coverage) => coverage.units.length > 0 ||
|
|
261
|
+
coverage.fixedScopes.some((scope) => scope.params.length > 0));
|
|
262
|
+
lines.push(` coverage: (${coverageUsesParams ? 'params' : ''}) => [`);
|
|
263
|
+
for (const coverage of query.reactive.coverage) {
|
|
264
|
+
const fixed = coverage.fixedScopes
|
|
265
|
+
.map((scope) => `${propertyKey(scope.variable)}: [${scope.params.map((param) => reactiveParam(query, param)).join(', ')}]`)
|
|
266
|
+
.join(', ');
|
|
267
|
+
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(', ')}] },`);
|
|
268
|
+
}
|
|
269
|
+
lines.push(' ],', hasParams
|
|
270
|
+
? ` bind: (params: ${Params}) => ${query.name}Select(params).bind,`
|
|
271
|
+
: ` bind: () => ${query.name}Select().bind,`);
|
|
272
|
+
if (query.reactive.rowKey !== undefined) {
|
|
273
|
+
lines.push(` rowKey: (row) => [${query.reactive.rowKey.map((key) => `row.${propertyKey(key)}`).join(', ')}],`);
|
|
274
|
+
}
|
|
275
|
+
lines.push('};');
|
|
276
|
+
return lines.join('\n');
|
|
277
|
+
}
|
|
42
278
|
function scopeKeyExpression(query, pattern, variable, param) {
|
|
43
279
|
const marker = `{${variable}}`;
|
|
44
280
|
const index = pattern.indexOf(marker);
|
|
@@ -49,11 +285,11 @@ function scopeKeyExpression(query, pattern, variable, param) {
|
|
|
49
285
|
return `${quote(prefix)} + ${reactiveParam(query, param)} + ${quote(suffix)}`;
|
|
50
286
|
}
|
|
51
287
|
function emitQuery(query, hash) {
|
|
288
|
+
if (query.syql !== undefined)
|
|
289
|
+
return emitSyqlQuery(query, hash);
|
|
52
290
|
const Row = `${pascalCase(query.name)}Row`;
|
|
53
291
|
const Params = `${pascalCase(query.name)}Params`;
|
|
54
292
|
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
293
|
lines.push(`/** One row of the ${quote(query.name)} query (its projection). */`);
|
|
58
294
|
lines.push(`export interface ${Row} {`);
|
|
59
295
|
for (const column of query.columns) {
|
|
@@ -61,138 +297,42 @@ function emitQuery(query, hash) {
|
|
|
61
297
|
}
|
|
62
298
|
lines.push('}');
|
|
63
299
|
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).
|
|
300
|
+
const hasParams = query.params.length > 0;
|
|
67
301
|
if (hasParams) {
|
|
68
302
|
lines.push(`/** Named parameters for ${quote(query.name)}. */`);
|
|
69
303
|
lines.push(`export interface ${Params} {`);
|
|
70
304
|
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;');
|
|
305
|
+
lines.push(` ${propertyKey(param.langName)}: ${PARAM_TS_TYPE[param.type]};`);
|
|
88
306
|
}
|
|
89
307
|
lines.push('}');
|
|
90
308
|
lines.push('');
|
|
91
309
|
}
|
|
92
|
-
// Tables dependency set (for useRawSql {tables} / useQuery).
|
|
93
310
|
lines.push(`/** Tables ${quote(query.name)} reads (compatibility/export surface). */`);
|
|
94
311
|
lines.push(`export const ${query.name}Tables = [${query.tables.map(quote).join(', ')}] as const;`);
|
|
95
312
|
lines.push('');
|
|
96
|
-
// SQL constants: the static default statement, plus (orderBy knob) the
|
|
97
|
-
// baked base + column map + compose function.
|
|
98
313
|
const sqlConst = `${query.name}Sql`;
|
|
99
314
|
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
315
|
const positional = hasParams
|
|
119
|
-
? `[${query.params.map(
|
|
316
|
+
? `[${query.params.map(bindExpr).join(', ')}]`
|
|
120
317
|
: '[]';
|
|
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
318
|
lines.push('');
|
|
160
|
-
// The runner.
|
|
161
|
-
const paramArg = !hasParams ? '' : paramArgDecl;
|
|
162
319
|
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);');
|
|
320
|
+
lines.push(`export async function ${query.name}(client: QueryClient${hasParams ? `, params: ${Params}` : ''}): Promise<${Row}[]> {`);
|
|
321
|
+
if (hasParams) {
|
|
322
|
+
lines.push(` const rows = await client.query(${sqlConst}, ${positional});`);
|
|
167
323
|
}
|
|
168
324
|
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
|
-
}
|
|
325
|
+
lines.push(` const rows = await client.query(${sqlConst});`);
|
|
176
326
|
}
|
|
177
327
|
lines.push(` return rows as unknown as ${Row}[];`);
|
|
178
328
|
lines.push('}');
|
|
179
329
|
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
330
|
lines.push(`/** Revisioned reactive descriptor for \`useQuery(${query.name}Query${hasParams ? ', params' : ''})\`. */`);
|
|
185
331
|
const paramsTypeArg = hasParams ? Params : 'undefined';
|
|
186
332
|
lines.push(`export const ${query.name}Query: NamedQuery<${Row}, ${paramsTypeArg}> = {`);
|
|
187
333
|
lines.push(` id: ${quote(`${hash}/${query.name}`)},`);
|
|
188
334
|
lines.push(` hasParams: ${hasParams},`);
|
|
189
335
|
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
336
|
lines.push(` tables: ${query.name}Tables,`);
|
|
197
337
|
const reactiveUsesParams = query.reactive.dependencies.some((dependency) => dependency.scopes.some((scope) => scope.params.length > 0));
|
|
198
338
|
lines.push(` dependencies: (${reactiveUsesParams ? 'params' : ''}) => [`);
|
|
@@ -212,10 +352,7 @@ function emitQuery(query, hash) {
|
|
|
212
352
|
lines.push(` { base: { table: ${quote(coverage.table)}, variable: ${quote(coverage.variable)}${fixedPart} }, units: [${coverage.units.map((param) => reactiveParam(query, param)).join(', ')}] },`);
|
|
213
353
|
}
|
|
214
354
|
lines.push(' ],');
|
|
215
|
-
if (
|
|
216
|
-
lines.push(` bind: (params: ${Params}) => ${selectFn}(params).bind,`);
|
|
217
|
-
}
|
|
218
|
-
else if (hasParams) {
|
|
355
|
+
if (hasParams) {
|
|
219
356
|
lines.push(` bind: (params: ${Params}) => ${positional},`);
|
|
220
357
|
}
|
|
221
358
|
else {
|
|
@@ -234,6 +371,33 @@ export function emitQueriesModule(queries, hash, irVersion) {
|
|
|
234
371
|
`// irVersion: ${irVersion}`,
|
|
235
372
|
`// irHash: ${hash}`,
|
|
236
373
|
].join('\n'));
|
|
374
|
+
if (queries.some((query) => query.syql !== undefined)) {
|
|
375
|
+
parts.push([
|
|
376
|
+
'export type SyqlRuntimeErrorCode =',
|
|
377
|
+
" | 'SYQL_RUNTIME_MISSING_REQUIRED_INPUT'",
|
|
378
|
+
" | 'SYQL_RUNTIME_UNKNOWN_INPUT'",
|
|
379
|
+
" | 'SYQL_RUNTIME_INVALID_INPUT'",
|
|
380
|
+
" | 'SYQL_RUNTIME_INVALID_GROUP'",
|
|
381
|
+
" | 'SYQL_RUNTIME_INVALID_SORT'",
|
|
382
|
+
" | 'SYQL_RUNTIME_INVALID_LIMIT';",
|
|
383
|
+
'',
|
|
384
|
+
'export class SyqlInputError extends Error {',
|
|
385
|
+
" readonly name = 'SyqlInputError';",
|
|
386
|
+
' constructor(readonly code: SyqlRuntimeErrorCode, message: string) {',
|
|
387
|
+
' super(message);',
|
|
388
|
+
' }',
|
|
389
|
+
'}',
|
|
390
|
+
'',
|
|
391
|
+
'export interface SyqlPresent<T> {',
|
|
392
|
+
' readonly present: true;',
|
|
393
|
+
' readonly value: T;',
|
|
394
|
+
'}',
|
|
395
|
+
'',
|
|
396
|
+
'export function syqlPresent<T>(value: T): SyqlPresent<T> {',
|
|
397
|
+
' return { present: true, value };',
|
|
398
|
+
'}',
|
|
399
|
+
].join('\n'));
|
|
400
|
+
}
|
|
237
401
|
parts.push([
|
|
238
402
|
"/** A bindable SQL param/row value (the wrapper's SqlValue subset). */",
|
|
239
403
|
'export type QueryValue =',
|
|
@@ -258,8 +422,7 @@ export function emitQueriesModule(queries, hash, irVersion) {
|
|
|
258
422
|
' * `bind(params)` → positional args. Consumed by',
|
|
259
423
|
" * `@syncular/react`'s `useQuery`. `Row` is the projection row",
|
|
260
424
|
' * type; `Params` is `undefined` for a param-less query. `sqlFor`',
|
|
261
|
-
' *
|
|
262
|
-
' * generate-time-checked column allowlist. */',
|
|
425
|
+
' * selects a checked revision-1 SYQL physical statement when needed. */',
|
|
263
426
|
'export interface NamedQuery<Row, Params = undefined> {',
|
|
264
427
|
' readonly id: string;',
|
|
265
428
|
' 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;
|