@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.
Files changed (56) hide show
  1. package/README.md +79 -32
  2. package/dist/cli.js +40 -17
  3. package/dist/emit-queries-dart.js +168 -55
  4. package/dist/emit-queries-kotlin.js +167 -55
  5. package/dist/emit-queries-swift.js +183 -57
  6. package/dist/emit-queries.js +286 -123
  7. package/dist/fmt.d.ts +2 -2
  8. package/dist/fmt.js +348 -316
  9. package/dist/generate.d.ts +1 -1
  10. package/dist/generate.js +28 -9
  11. package/dist/index.d.ts +3 -1
  12. package/dist/index.js +3 -1
  13. package/dist/lsp.d.ts +1 -3
  14. package/dist/lsp.js +349 -187
  15. package/dist/manifest.d.ts +1 -3
  16. package/dist/manifest.js +1 -1
  17. package/dist/query-ir.js +53 -42
  18. package/dist/query.d.ts +110 -63
  19. package/dist/query.js +89 -11
  20. package/dist/syql-ast.d.ts +12 -13
  21. package/dist/syql-ast.js +26 -20
  22. package/dist/syql-lexer.d.ts +2 -0
  23. package/dist/syql-lexer.js +9 -2
  24. package/dist/syql-lowering.d.ts +22 -0
  25. package/dist/syql-lowering.js +434 -0
  26. package/dist/syql-parser.d.ts +19 -18
  27. package/dist/syql-parser.js +341 -93
  28. package/dist/syql-semantics.d.ts +73 -0
  29. package/dist/syql-semantics.js +607 -0
  30. package/dist/syql-template-parser.d.ts +5 -20
  31. package/dist/syql-template-parser.js +116 -109
  32. package/dist/syql-validator.d.ts +35 -0
  33. package/dist/syql-validator.js +993 -0
  34. package/package.json +3 -3
  35. package/src/cli.ts +49 -21
  36. package/src/emit-queries-dart.ts +195 -69
  37. package/src/emit-queries-kotlin.ts +197 -69
  38. package/src/emit-queries-swift.ts +224 -68
  39. package/src/emit-queries.ts +410 -165
  40. package/src/fmt.ts +405 -303
  41. package/src/generate.ts +43 -9
  42. package/src/index.ts +3 -1
  43. package/src/lsp.ts +414 -216
  44. package/src/manifest.ts +2 -4
  45. package/src/query-ir.ts +52 -42
  46. package/src/query.ts +229 -79
  47. package/src/syql-ast.ts +38 -34
  48. package/src/syql-lexer.ts +12 -1
  49. package/src/syql-lowering.ts +664 -0
  50. package/src/syql-parser.ts +472 -134
  51. package/src/syql-semantics.ts +930 -0
  52. package/src/syql-template-parser.ts +119 -163
  53. package/src/syql-validator.ts +1486 -0
  54. package/dist/syql.d.ts +0 -102
  55. package/dist/syql.js +0 -1141
  56. package/src/syql.ts +0 -1470
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/typegen",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "Syncular schema-to-TypeScript type generator and the `syncular` CLI",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -48,7 +48,7 @@
48
48
  "!dist/**/*.test.d.ts"
49
49
  ],
50
50
  "devDependencies": {
51
- "@syncular/core": "0.5.1",
52
- "@syncular/server": "0.5.1"
51
+ "@syncular/core": "0.7.0",
52
+ "@syncular/server": "0.7.0"
53
53
  }
54
54
  }
package/src/cli.ts CHANGED
@@ -117,33 +117,61 @@ function runGeneratePrint(args: GenerateArgs, name: string): void {
117
117
  }
118
118
  console.log(`-- ${query.name} (${query.file})`);
119
119
  console.log(`-- tables: ${query.tables.join(', ')}`);
120
- if (query.params.length > 0) {
121
- console.log('-- params:');
122
- for (const param of query.params) {
123
- const marks = [
124
- param.optional === true ? 'optional' : 'required',
125
- ...(param.flag === true ? ['flag'] : []),
126
- ...(param.group !== undefined ? [`group ${param.group}`] : []),
127
- ].join(', ');
128
- console.log(`-- :${param.name} ${param.type} (${marks})`);
120
+ if (query.syql !== undefined) {
121
+ console.log('-- revision: SYQL 1');
122
+ console.log(`-- backend: ${query.syql.plan.backend}`);
123
+ if (query.syql.inputs.length > 0) {
124
+ console.log('-- public inputs:');
125
+ for (const input of query.syql.inputs) {
126
+ if (input.kind === 'value') {
127
+ console.log(
128
+ `-- ${input.name}: ${input.type}${input.nullable ? ' | null' : ''} (${input.required ? 'required' : 'optional'})`,
129
+ );
130
+ } else if (input.kind === 'group') {
131
+ console.log(
132
+ `-- ${input.name}: optional group (${input.members.map((member) => `${member.name}: ${member.type}${member.nullable ? ' | null' : ''}`).join(', ')})`,
133
+ );
134
+ } else if (input.kind === 'sort') {
135
+ console.log(
136
+ `-- ${input.name}: sort [${input.profiles.map((profile) => profile.name).join(', ')}] (default ${input.defaultProfile})`,
137
+ );
138
+ } else {
139
+ console.log(
140
+ `-- ${input.name}: limit 1..${input.maxSize} (default ${input.defaultSize})`,
141
+ );
142
+ }
143
+ }
129
144
  }
130
- }
131
- console.log(query.sql);
132
- if (query.orderBy !== undefined) {
133
- console.log('-- orderBy variants (each checked against the schema):');
134
- const base = query.positionalSqlBase ?? '';
135
- for (const col of query.orderBy.allowed) {
136
- const isDefault = col.name === query.orderBy.defaultColumn;
145
+ if (query.syql.identity !== undefined) {
146
+ console.log(`-- identity: ${query.syql.identity.join(', ')}`);
147
+ }
148
+ console.log(
149
+ `-- checked statements: ${query.syql.plan.statements.length} (activation controls: ${query.syql.plan.activationControls.join(', ') || 'none'})`,
150
+ );
151
+ for (const statement of query.syql.plan.statements) {
152
+ const selectors = [
153
+ ...(statement.activationMask === undefined
154
+ ? []
155
+ : [`mask=${statement.activationMask}`]),
156
+ ...(statement.sortProfile === undefined
157
+ ? []
158
+ : [`sort=${statement.sortProfile}`]),
159
+ ];
160
+ console.log(`-- [${selectors.join(', ') || 'default'}]`);
161
+ console.log(statement.sql);
137
162
  console.log(
138
- `-- ${col.langName}: ${base} order by ${col.name} asc|desc${query.positionalLimitTail ?? ''}${isDefault ? ' (default)' : ''}`,
163
+ `-- binds: ${statement.binds.map((bind) => `:${bind.name}`).join(', ') || '(none)'}`,
139
164
  );
140
165
  }
166
+ return;
141
167
  }
142
- if (query.limit !== undefined) {
143
- console.log(
144
- `-- limit: bound value, default ${query.limit.default ?? query.limit.max}${query.limit.max !== undefined ? `, clamped to ${query.limit.max}` : ''}`,
145
- );
168
+ if (query.params.length > 0) {
169
+ console.log('-- params:');
170
+ for (const param of query.params) {
171
+ console.log(`-- :${param.name} ${param.type} (required)`);
172
+ }
146
173
  }
174
+ console.log(query.sql);
147
175
  }
148
176
 
149
177
  function parseManifestDir(argv: readonly string[]): string {
@@ -9,7 +9,12 @@
9
9
  */
10
10
  import type { IrColumnType } from './ir';
11
11
  import { snakeToCamel } from './naming';
12
- import type { AnalyzedQuery, QueryColumn, QueryParam } from './query';
12
+ import type {
13
+ AnalyzedQuery,
14
+ QueryColumn,
15
+ QuerySyqlPlanBind,
16
+ QuerySyqlPublicInput,
17
+ } from './query';
13
18
 
14
19
  const DART_TYPE: Readonly<Record<IrColumnType, string>> = {
15
20
  string: 'String',
@@ -85,31 +90,174 @@ function optionalParamValue(type: IrColumnType, name: string): string {
85
90
  }
86
91
  }
87
92
 
88
- function isOptionalParam(query: AnalyzedQuery, p: QueryParam): boolean {
89
- return (
90
- p.optional === true ||
91
- p.flag === true ||
92
- (query.limit !== undefined && p.name === 'limit')
93
- );
93
+ function syqlInput(query: AnalyzedQuery, name: string): QuerySyqlPublicInput {
94
+ const input = query.syql?.inputs.find((candidate) => candidate.name === name);
95
+ if (input === undefined) throw new Error(`unknown SYQL input ${name}`);
96
+ return input;
97
+ }
98
+
99
+ function syqlDartType(type: IrColumnType, nullable: boolean): string {
100
+ return `${DART_TYPE[type]}${nullable ? '?' : ''}`;
101
+ }
102
+
103
+ function syqlControlActive(query: AnalyzedQuery, name: string): string {
104
+ const input = syqlInput(query, name);
105
+ const access = camelCase(input.langName);
106
+ if (input.kind === 'value' && input.default === false) return access;
107
+ if (input.kind === 'value' && input.nullable) return `${access}.isPresent`;
108
+ if (input.kind === 'value' || input.kind === 'group') {
109
+ return `${access} != null`;
110
+ }
111
+ throw new Error(`${name} is not an activation control`);
112
+ }
113
+
114
+ function syqlBindExpr(query: AnalyzedQuery, bind: QuerySyqlPlanBind): string {
115
+ if (bind.kind === 'condition-active') {
116
+ return bind.controls
117
+ .map((control) => syqlControlActive(query, control))
118
+ .join(' && ');
119
+ }
120
+ const input = syqlInput(query, bind.input);
121
+ const access = camelCase(input.langName);
122
+ if (bind.kind === 'limit') return `effective${typeName(access)}`;
123
+ if (bind.kind === 'group-member') {
124
+ if (input.kind !== 'group') throw new Error('group bind/input mismatch');
125
+ const member = input.members.find(
126
+ (candidate) => candidate.name === bind.member,
127
+ );
128
+ if (member === undefined)
129
+ throw new Error(`unknown group member ${bind.member}`);
130
+ const memberAccess = `${access}.${camelCase(member.langName)}`;
131
+ const value = member.nullable
132
+ ? optionalParamValue(member.type, memberAccess)
133
+ : paramValue(member.type, memberAccess);
134
+ return `${access} == null ? null : ${value}`;
135
+ }
136
+ if (input.kind !== 'value') throw new Error('value bind/input mismatch');
137
+ if (input.default === false) return paramValue(input.type, access);
138
+ if (input.required) {
139
+ return input.nullable
140
+ ? optionalParamValue(input.type, access)
141
+ : paramValue(input.type, access);
142
+ }
143
+ return input.nullable
144
+ ? `${access}.isPresent ? ${optionalParamValue(input.type, `${access}.value`)} : null`
145
+ : optionalParamValue(input.type, access);
146
+ }
147
+
148
+ function emitSyqlDartTypes(query: AnalyzedQuery): string[] {
149
+ const lines: string[] = [];
150
+ for (const input of query.syql?.inputs ?? []) {
151
+ if (input.kind === 'group') {
152
+ const name = `${typeName(query.name)}${typeName(input.langName)}`;
153
+ lines.push(`class ${name} {`);
154
+ for (const member of input.members) {
155
+ lines.push(
156
+ ` final ${syqlDartType(member.type, member.nullable)} ${camelCase(member.langName)};`,
157
+ );
158
+ }
159
+ const args = input.members
160
+ .map((member) => `required this.${camelCase(member.langName)}`)
161
+ .join(', ');
162
+ lines.push(` const ${name}({${args}});`, '}', '');
163
+ } else if (input.kind === 'sort') {
164
+ const name = `${typeName(query.name)}${typeName(input.langName)}`;
165
+ lines.push(
166
+ `enum ${name} { ${input.profiles.map((profile) => camelCase(profile.langName)).join(', ')} }`,
167
+ '',
168
+ );
169
+ }
170
+ }
171
+ if (lines[lines.length - 1] === '') lines.pop();
172
+ return lines;
94
173
  }
95
174
 
96
- /** Per-query orderBy allowlist enum (column = the checked SQL column). */
97
- function emitOrderByEnum(query: AnalyzedQuery): string[] {
98
- if (query.orderBy === undefined) return [];
175
+ function emitSyqlDartRunner(query: AnalyzedQuery): string[] {
176
+ const metadata = query.syql;
177
+ if (metadata === undefined) throw new Error('missing SYQL metadata');
178
+ const Row = `${typeName(query.name)}Row`;
179
+ const Pascal = pascalCase(query.name);
99
180
  const lines: string[] = [];
100
181
  lines.push(
101
- `/// §6 orderBy allowlist for ${query.name} checked at generate time.`,
182
+ `/// Tables the ${query.name} query reads (exact invalidation set).`,
183
+ `const List<String> syncular${Pascal}QueryTables = [${query.tables.map(quote).join(', ')}];`,
184
+ '',
185
+ `/// Run the ${query.name} revision-1 SYQL query.`,
102
186
  );
103
- lines.push(`enum ${typeName(query.name)}OrderBy {`);
187
+ const args: string[] = [];
188
+ for (const input of metadata.inputs) {
189
+ const name = camelCase(input.langName);
190
+ if (input.kind === 'value') {
191
+ const type = syqlDartType(input.type, input.nullable);
192
+ if (input.required) args.push(`required ${type} ${name}`);
193
+ else if (input.default === false) args.push(`bool ${name} = false`);
194
+ else if (input.nullable) {
195
+ args.push(
196
+ `SyqlQueryPresence<${type}> ${name} = const SyqlQueryPresence.absent()`,
197
+ );
198
+ } else args.push(`${type}? ${name}`);
199
+ } else if (input.kind === 'group') {
200
+ args.push(`${typeName(query.name)}${typeName(input.langName)}? ${name}`);
201
+ } else if (input.kind === 'sort') {
202
+ const defaultCase =
203
+ input.profiles.find((profile) => profile.name === input.defaultProfile)
204
+ ?.langName ?? input.defaultProfile;
205
+ const type = `${typeName(query.name)}${typeName(input.langName)}`;
206
+ args.push(`${type} ${name} = ${type}.${camelCase(defaultCase)}`);
207
+ } else {
208
+ args.push(`int? ${name}`);
209
+ }
210
+ }
211
+ const argsClause = args.length > 0 ? `, {${args.join(', ')}}` : '';
104
212
  lines.push(
105
- `${query.orderBy.allowed
106
- .map((col) => ` ${camelCase(col.langName)}(${quote(col.name)})`)
107
- .join(',\n')};`,
213
+ `List<${Row}> syncular${Pascal}Query(SyncularClient client${argsClause}) {`,
214
+ );
215
+ const page = metadata.inputs.find((input) => input.kind === 'limit');
216
+ if (page?.kind === 'limit') {
217
+ const name = camelCase(page.langName);
218
+ lines.push(
219
+ ` final effective${typeName(name)} = ${name} ?? ${page.defaultSize};`,
220
+ ` if (effective${typeName(name)} < 1 || effective${typeName(name)} > ${page.maxSize}) {`,
221
+ ` throw SyqlQueryInputException('SYQL_RUNTIME_INVALID_LIMIT', ${quote(`${query.name}: invalid limit`)});`,
222
+ ' }',
223
+ );
224
+ }
225
+ if (metadata.plan.backend === 'variants') {
226
+ lines.push(' var activationMask = 0;');
227
+ metadata.plan.activationControls.forEach((control, index) => {
228
+ lines.push(
229
+ ` if (${syqlControlActive(query, control)}) activationMask |= ${2 ** index};`,
230
+ );
231
+ });
232
+ }
233
+ const sort = metadata.inputs.find((input) => input.kind === 'sort');
234
+ const profileCount = sort?.kind === 'sort' ? sort.profiles.length : 1;
235
+ const sortIndex =
236
+ sort?.kind === 'sort' ? `${camelCase(sort.langName)}.index` : '0';
237
+ const index =
238
+ metadata.plan.backend === 'variants'
239
+ ? `activationMask * ${profileCount} + ${sortIndex}`
240
+ : sortIndex;
241
+ lines.push(
242
+ ` final statementIndex = ${index};`,
243
+ ' late final String sql;',
244
+ ' late final List<Object?> params;',
245
+ ' switch (statementIndex) {',
246
+ );
247
+ metadata.plan.statements.forEach((statement, statementIndex) => {
248
+ const binds = statement.binds
249
+ .map((bind) => syqlBindExpr(query, bind))
250
+ .join(', ');
251
+ lines.push(
252
+ ` case ${statementIndex}: sql = ${quote(statement.positionalSql)}; params = <Object?>[${binds}]; break;`,
253
+ );
254
+ });
255
+ lines.push(
256
+ " default: throw StateError('invalid generated SYQL statement index');",
257
+ ' }',
258
+ ` return client.query(sql, params: params).map(${Row}.fromRow).whereType<${Row}>().toList();`,
259
+ '}',
108
260
  );
109
- lines.push('');
110
- lines.push(` const ${typeName(query.name)}OrderBy(this.column);`);
111
- lines.push(' final String column;');
112
- lines.push('}');
113
261
  return lines;
114
262
  }
115
263
 
@@ -151,6 +299,7 @@ function emitClass(query: AnalyzedQuery): string[] {
151
299
  }
152
300
 
153
301
  function emitRunner(query: AnalyzedQuery): string[] {
302
+ if (query.syql !== undefined) return emitSyqlDartRunner(query);
154
303
  const Row = `${typeName(query.name)}Row`;
155
304
  const Pascal = pascalCase(query.name);
156
305
  const lines: string[] = [];
@@ -161,59 +310,24 @@ function emitRunner(query: AnalyzedQuery): string[] {
161
310
  `const List<String> syncular${Pascal}QueryTables = [${query.tables.map(quote).join(', ')}];`,
162
311
  );
163
312
  lines.push('');
164
- if (query.orderBy !== undefined) {
165
- lines.push(
166
- `const String _${query.name}SqlBase = ${quote(query.positionalSqlBase ?? '')};`,
167
- );
168
- } else {
169
- lines.push(
170
- `const String _${query.name}Sql = ${quote(query.positionalSql)};`,
171
- );
172
- }
313
+ lines.push(`const String _${query.name}Sql = ${quote(query.positionalSql)};`);
173
314
  lines.push('');
174
315
  lines.push(`/// Run the ${query.name} named query (SELECT-only).`);
175
316
  const args: string[] = [];
176
317
  for (const p of query.params) {
177
318
  const name = camelCase(p.langName);
178
- if (isOptionalParam(query, p)) {
179
- args.push(`${DART_TYPE[p.type]}? ${name}`);
180
- } else {
181
- args.push(`required ${DART_TYPE[p.type]} ${name}`);
182
- }
183
- }
184
- if (query.orderBy !== undefined) {
185
- const defaultCase = camelCase(
186
- query.orderBy.allowed.find((c) => c.name === query.orderBy?.defaultColumn)
187
- ?.langName ?? query.orderBy.defaultColumn,
188
- );
189
- args.push(
190
- `${typeName(query.name)}OrderBy orderBy = ${typeName(query.name)}OrderBy.${defaultCase}`,
191
- );
192
- args.push(
193
- `SyncularQueryDir dir = SyncularQueryDir.${query.orderBy.defaultDir}`,
194
- );
319
+ args.push(`required ${DART_TYPE[p.type]} ${name}`);
195
320
  }
196
321
  const argsClause = args.length > 0 ? `, {${args.join(', ')}}` : '';
197
322
  lines.push(
198
323
  `List<${Row}> syncular${Pascal}Query(SyncularClient client${argsClause}) {`,
199
324
  );
200
- if (query.orderBy !== undefined) {
201
- const limitTail =
202
- query.positionalLimitTail !== undefined
203
- ? ` ${quote(query.positionalLimitTail.trim())}`
204
- : '';
205
- lines.push(
206
- ` final sql = '$_${query.name}SqlBase order by \${orderBy.column} \${dir.name}'${limitTail === '' ? '' : `\n ' ' ${limitTail.trim()}`};`,
207
- );
208
- }
209
- const sqlRef = query.orderBy !== undefined ? 'sql' : `_${query.name}Sql`;
325
+ const sqlRef = `_${query.name}Sql`;
210
326
  if (query.params.length > 0) {
211
327
  const binds = query.params
212
328
  .map((p) => {
213
329
  const name = camelCase(p.langName);
214
- return isOptionalParam(query, p)
215
- ? optionalParamValue(p.type, name)
216
- : paramValue(p.type, name);
330
+ return paramValue(p.type, name);
217
331
  })
218
332
  .join(', ');
219
333
  lines.push(` final params = <Object?>[${binds}];`);
@@ -246,6 +360,27 @@ export function emitQueriesDartModule(
246
360
  ].join('\n'),
247
361
  );
248
362
 
363
+ if (queries.some((query) => query.syql !== undefined)) {
364
+ parts.push(
365
+ [
366
+ 'class SyqlQueryPresence<T> {',
367
+ ' final bool isPresent;',
368
+ ' final T? value;',
369
+ ' const SyqlQueryPresence.absent() : isPresent = false, value = null;',
370
+ ' const SyqlQueryPresence.present(this.value) : isPresent = true;',
371
+ '}',
372
+ '',
373
+ 'class SyqlQueryInputException implements Exception {',
374
+ ' final String code;',
375
+ ' final String message;',
376
+ ' const SyqlQueryInputException(this.code, this.message);',
377
+ ' @override',
378
+ " String toString() => '$code: $message';",
379
+ '}',
380
+ ].join('\n'),
381
+ );
382
+ }
383
+
249
384
  parts.push(
250
385
  [
251
386
  '/// Lift a SQLite boolean: a real bool, or 0/1 as a number.',
@@ -274,19 +409,10 @@ export function emitQueriesDartModule(
274
409
  ].join('\n'),
275
410
  );
276
411
 
277
- if (queries.some((q) => q.orderBy !== undefined)) {
278
- parts.push(
279
- [
280
- '/// §6 orderBy direction (shared by every orderBy-knob query).',
281
- 'enum SyncularQueryDir { asc, desc }',
282
- ].join('\n'),
283
- );
284
- }
285
-
286
412
  for (const query of queries) {
413
+ const syqlTypes = emitSyqlDartTypes(query);
414
+ if (syqlTypes.length > 0) parts.push(syqlTypes.join('\n'));
287
415
  parts.push(emitClass(query).join('\n'));
288
- const orderByEnum = emitOrderByEnum(query);
289
- if (orderByEnum.length > 0) parts.push(orderByEnum.join('\n'));
290
416
  }
291
417
  for (const query of queries) {
292
418
  parts.push(emitRunner(query).join('\n'));