@syncular/typegen 0.5.0 → 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.
Files changed (47) hide show
  1. package/README.md +75 -33
  2. package/dist/cli.js +43 -17
  3. package/dist/emit-queries-dart.js +167 -55
  4. package/dist/emit-queries-kotlin.js +166 -55
  5. package/dist/emit-queries-swift.js +182 -57
  6. package/dist/emit-queries.js +289 -123
  7. package/dist/fmt.d.ts +2 -2
  8. package/dist/fmt.js +323 -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 +359 -185
  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 +115 -63
  19. package/dist/query.js +60 -11
  20. package/dist/syql-lexer.d.ts +2 -0
  21. package/dist/syql-lexer.js +9 -2
  22. package/dist/syql-lowering.d.ts +22 -0
  23. package/dist/syql-lowering.js +411 -0
  24. package/dist/syql-semantics.d.ts +76 -0
  25. package/dist/syql-semantics.js +551 -0
  26. package/dist/syql-validator.d.ts +35 -0
  27. package/dist/syql-validator.js +966 -0
  28. package/package.json +3 -3
  29. package/src/cli.ts +51 -21
  30. package/src/emit-queries-dart.ts +195 -69
  31. package/src/emit-queries-kotlin.ts +197 -69
  32. package/src/emit-queries-swift.ts +224 -68
  33. package/src/emit-queries.ts +413 -165
  34. package/src/fmt.ts +377 -303
  35. package/src/generate.ts +43 -9
  36. package/src/index.ts +3 -1
  37. package/src/lsp.ts +425 -215
  38. package/src/manifest.ts +2 -4
  39. package/src/query-ir.ts +52 -42
  40. package/src/query.ts +199 -79
  41. package/src/syql-lexer.ts +12 -1
  42. package/src/syql-lowering.ts +638 -0
  43. package/src/syql-semantics.ts +859 -0
  44. package/src/syql-validator.ts +1492 -0
  45. package/dist/syql.d.ts +0 -102
  46. package/dist/syql.js +0 -1141
  47. 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.0",
3
+ "version": "0.6.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.0",
52
- "@syncular/server": "0.5.0"
51
+ "@syncular/core": "0.6.0",
52
+ "@syncular/server": "0.6.0"
53
53
  }
54
54
  }
package/src/cli.ts CHANGED
@@ -117,33 +117,63 @@ 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 === 'switch') {
135
+ console.log(`-- ${input.name}: switch (default false)`);
136
+ } else if (input.kind === 'sort') {
137
+ console.log(
138
+ `-- ${input.name}: sort [${input.profiles.map((profile) => profile.name).join(', ')}] (default ${input.defaultProfile})`,
139
+ );
140
+ } else {
141
+ console.log(
142
+ `-- ${input.name}: page 1..${input.maxSize} (default ${input.defaultSize})`,
143
+ );
144
+ }
145
+ }
129
146
  }
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;
147
+ if (query.syql.identity !== undefined) {
148
+ console.log(`-- identity: ${query.syql.identity.join(', ')}`);
149
+ }
150
+ console.log(
151
+ `-- checked statements: ${query.syql.plan.statements.length} (activation controls: ${query.syql.plan.activationControls.join(', ') || 'none'})`,
152
+ );
153
+ for (const statement of query.syql.plan.statements) {
154
+ const selectors = [
155
+ ...(statement.activationMask === undefined
156
+ ? []
157
+ : [`mask=${statement.activationMask}`]),
158
+ ...(statement.sortProfile === undefined
159
+ ? []
160
+ : [`sort=${statement.sortProfile}`]),
161
+ ];
162
+ console.log(`-- [${selectors.join(', ') || 'default'}]`);
163
+ console.log(statement.sql);
137
164
  console.log(
138
- `-- ${col.langName}: ${base} order by ${col.name} asc|desc${query.positionalLimitTail ?? ''}${isDefault ? ' (default)' : ''}`,
165
+ `-- binds: ${statement.binds.map((bind) => `:${bind.name}`).join(', ') || '(none)'}`,
139
166
  );
140
167
  }
168
+ return;
141
169
  }
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
- );
170
+ if (query.params.length > 0) {
171
+ console.log('-- params:');
172
+ for (const param of query.params) {
173
+ console.log(`-- :${param.name} ${param.type} (required)`);
174
+ }
146
175
  }
176
+ console.log(query.sql);
147
177
  }
148
178
 
149
179
  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 === 'switch') 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 === 'page') 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.required) {
138
+ return input.nullable
139
+ ? optionalParamValue(input.type, access)
140
+ : paramValue(input.type, access);
141
+ }
142
+ return input.nullable
143
+ ? `${access}.isPresent ? ${optionalParamValue(input.type, `${access}.value`)} : null`
144
+ : optionalParamValue(input.type, access);
145
+ }
146
+
147
+ function emitSyqlDartTypes(query: AnalyzedQuery): string[] {
148
+ const lines: string[] = [];
149
+ for (const input of query.syql?.inputs ?? []) {
150
+ if (input.kind === 'group') {
151
+ const name = `${typeName(query.name)}${typeName(input.langName)}`;
152
+ lines.push(`class ${name} {`);
153
+ for (const member of input.members) {
154
+ lines.push(
155
+ ` final ${syqlDartType(member.type, member.nullable)} ${camelCase(member.langName)};`,
156
+ );
157
+ }
158
+ const args = input.members
159
+ .map((member) => `required this.${camelCase(member.langName)}`)
160
+ .join(', ');
161
+ lines.push(` const ${name}({${args}});`, '}', '');
162
+ } else if (input.kind === 'sort') {
163
+ const name = `${typeName(query.name)}${typeName(input.langName)}`;
164
+ lines.push(
165
+ `enum ${name} { ${input.profiles.map((profile) => camelCase(profile.langName)).join(', ')} }`,
166
+ '',
167
+ );
168
+ }
169
+ }
170
+ if (lines[lines.length - 1] === '') lines.pop();
171
+ return lines;
94
172
  }
95
173
 
96
- /** Per-query orderBy allowlist enum (column = the checked SQL column). */
97
- function emitOrderByEnum(query: AnalyzedQuery): string[] {
98
- if (query.orderBy === undefined) return [];
174
+ function emitSyqlDartRunner(query: AnalyzedQuery): string[] {
175
+ const metadata = query.syql;
176
+ if (metadata === undefined) throw new Error('missing SYQL metadata');
177
+ const Row = `${typeName(query.name)}Row`;
178
+ const Pascal = pascalCase(query.name);
99
179
  const lines: string[] = [];
100
180
  lines.push(
101
- `/// §6 orderBy allowlist for ${query.name} checked at generate time.`,
181
+ `/// Tables the ${query.name} query reads (exact invalidation set).`,
182
+ `const List<String> syncular${Pascal}QueryTables = [${query.tables.map(quote).join(', ')}];`,
183
+ '',
184
+ `/// Run the ${query.name} revision-1 SYQL query.`,
102
185
  );
103
- lines.push(`enum ${typeName(query.name)}OrderBy {`);
186
+ const args: string[] = [];
187
+ for (const input of metadata.inputs) {
188
+ const name = camelCase(input.langName);
189
+ if (input.kind === 'value') {
190
+ const type = syqlDartType(input.type, input.nullable);
191
+ if (input.required) args.push(`required ${type} ${name}`);
192
+ else if (input.nullable) {
193
+ args.push(
194
+ `SyqlQueryPresence<${type}> ${name} = const SyqlQueryPresence.absent()`,
195
+ );
196
+ } else args.push(`${type}? ${name}`);
197
+ } else if (input.kind === 'group') {
198
+ args.push(`${typeName(query.name)}${typeName(input.langName)}? ${name}`);
199
+ } else if (input.kind === 'switch') {
200
+ args.push(`bool ${name} = false`);
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 === 'page');
216
+ if (page?.kind === 'page') {
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_PAGE', ${quote(`${query.name}: invalid page size`)});`,
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'));