@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
@@ -13,7 +13,12 @@
13
13
  */
14
14
  import type { IrColumnType } from './ir';
15
15
  import { snakeToCamel } from './naming';
16
- import type { AnalyzedQuery, QueryColumn, QueryParam } from './query';
16
+ import type {
17
+ AnalyzedQuery,
18
+ QueryColumn,
19
+ QuerySyqlPlanBind,
20
+ QuerySyqlPublicInput,
21
+ } from './query';
17
22
 
18
23
  const KOTLIN_TYPE: Readonly<Record<IrColumnType, string>> = {
19
24
  string: 'String',
@@ -89,28 +94,179 @@ function optionalParamValue(type: IrColumnType, name: string): string {
89
94
  }
90
95
  }
91
96
 
92
- function isOptionalParam(query: AnalyzedQuery, p: QueryParam): boolean {
93
- return (
94
- p.optional === true ||
95
- p.flag === true ||
96
- (query.limit !== undefined && p.name === 'limit')
97
- );
97
+ function syqlInput(query: AnalyzedQuery, name: string): QuerySyqlPublicInput {
98
+ const input = query.syql?.inputs.find((candidate) => candidate.name === name);
99
+ if (input === undefined) throw new Error(`unknown SYQL input ${name}`);
100
+ return input;
101
+ }
102
+
103
+ function syqlKotlinType(type: IrColumnType, nullable: boolean): string {
104
+ return `${KOTLIN_TYPE[type]}${nullable ? '?' : ''}`;
105
+ }
106
+
107
+ function syqlControlActive(query: AnalyzedQuery, name: string): string {
108
+ const input = syqlInput(query, name);
109
+ const access = camelCase(input.langName);
110
+ if (input.kind === 'value' && input.default === false) return access;
111
+ if (input.kind === 'value' && input.nullable) {
112
+ return `${access} is SyncularQueryPresence.Present`;
113
+ }
114
+ if (input.kind === 'value' || input.kind === 'group') {
115
+ return `${access} != null`;
116
+ }
117
+ throw new Error(`${name} is not an activation control`);
118
+ }
119
+
120
+ function syqlNullablePresenceBind(type: IrColumnType, access: string): string {
121
+ return `when (val presence = ${access}) { is SyncularQueryPresence.Present -> ${optionalParamValue(type, 'presence.value')}; SyncularQueryPresence.Absent -> JsonValue.Null }`;
122
+ }
123
+
124
+ function syqlBindExpr(query: AnalyzedQuery, bind: QuerySyqlPlanBind): string {
125
+ if (bind.kind === 'condition-active') {
126
+ const active = bind.controls
127
+ .map((control) => syqlControlActive(query, control))
128
+ .join(' && ');
129
+ return `JsonValue.of(${active})`;
130
+ }
131
+ const input = syqlInput(query, bind.input);
132
+ const access = camelCase(input.langName);
133
+ if (bind.kind === 'limit')
134
+ return `JsonValue.of(effective${typeName(access)}.toDouble())`;
135
+ if (bind.kind === 'group-member') {
136
+ if (input.kind !== 'group') throw new Error('group bind/input mismatch');
137
+ const member = input.members.find(
138
+ (candidate) => candidate.name === bind.member,
139
+ );
140
+ if (member === undefined)
141
+ throw new Error(`unknown group member ${bind.member}`);
142
+ const memberName = camelCase(member.langName);
143
+ const present = member.nullable
144
+ ? optionalParamValue(member.type, `value.${memberName}`)
145
+ : paramValue(member.type, `value.${memberName}`);
146
+ return `${access}?.let { value -> ${present} } ?: JsonValue.Null`;
147
+ }
148
+ if (input.kind !== 'value') throw new Error('value bind/input mismatch');
149
+ if (input.default === false) return paramValue(input.type, access);
150
+ if (input.required) {
151
+ return input.nullable
152
+ ? optionalParamValue(input.type, access)
153
+ : paramValue(input.type, access);
154
+ }
155
+ return input.nullable
156
+ ? syqlNullablePresenceBind(input.type, access)
157
+ : optionalParamValue(input.type, access);
158
+ }
159
+
160
+ function emitSyqlKotlinTypes(query: AnalyzedQuery): string[] {
161
+ const lines: string[] = [];
162
+ for (const input of query.syql?.inputs ?? []) {
163
+ if (input.kind === 'group') {
164
+ const name = `${typeName(query.name)}${typeName(input.langName)}`;
165
+ lines.push(`data class ${name}(`);
166
+ for (const member of input.members) {
167
+ lines.push(
168
+ ` val ${camelCase(member.langName)}: ${syqlKotlinType(member.type, member.nullable)},`,
169
+ );
170
+ }
171
+ lines.push(')', '');
172
+ } else if (input.kind === 'sort') {
173
+ const name = `${typeName(query.name)}${typeName(input.langName)}`;
174
+ lines.push(`enum class ${name}(val index: Int) {`);
175
+ lines.push(
176
+ `${input.profiles
177
+ .map(
178
+ (profile, index) => ` ${camelCase(profile.langName)}(${index})`,
179
+ )
180
+ .join(',\n')};`,
181
+ );
182
+ lines.push('}', '');
183
+ }
184
+ }
185
+ if (lines[lines.length - 1] === '') lines.pop();
186
+ return lines;
98
187
  }
99
188
 
100
- /** Per-query orderBy allowlist enum (column = the checked SQL column). */
101
- function emitOrderByEnum(query: AnalyzedQuery): string[] {
102
- if (query.orderBy === undefined) return [];
189
+ function emitSyqlKotlinRunner(query: AnalyzedQuery): string[] {
190
+ const metadata = query.syql;
191
+ if (metadata === undefined) throw new Error('missing SYQL metadata');
192
+ const Row = `${typeName(query.name)}Row`;
103
193
  const lines: string[] = [];
104
194
  lines.push(
105
- `/** §6 orderBy allowlist for ${query.name} checked at generate time. */`,
195
+ ` val ${query.name}Tables = listOf(${query.tables.map(quote).join(', ')})`,
196
+ '',
197
+ ` /** Run the ${query.name} revision-1 SYQL query. */`,
106
198
  );
107
- lines.push(`enum class ${typeName(query.name)}OrderBy(val column: String) {`);
199
+ const args = ['client: SyncularClient'];
200
+ for (const input of metadata.inputs) {
201
+ const name = camelCase(input.langName);
202
+ if (input.kind === 'value') {
203
+ const type = syqlKotlinType(input.type, input.nullable);
204
+ if (input.required) args.push(`${name}: ${type}`);
205
+ else if (input.default === false) args.push(`${name}: Boolean = false`);
206
+ else if (input.nullable) {
207
+ args.push(
208
+ `${name}: SyncularQueryPresence<${type}> = SyncularQueryPresence.Absent`,
209
+ );
210
+ } else args.push(`${name}: ${type}? = null`);
211
+ } else if (input.kind === 'group') {
212
+ args.push(
213
+ `${name}: ${typeName(query.name)}${typeName(input.langName)}? = null`,
214
+ );
215
+ } else if (input.kind === 'sort') {
216
+ const defaultCase =
217
+ input.profiles.find((profile) => profile.name === input.defaultProfile)
218
+ ?.langName ?? input.defaultProfile;
219
+ const type = `${typeName(query.name)}${typeName(input.langName)}`;
220
+ args.push(`${name}: ${type} = ${type}.${camelCase(defaultCase)}`);
221
+ } else {
222
+ args.push(`${name}: Long? = null`);
223
+ }
224
+ }
225
+ lines.push(` fun ${query.name}(${args.join(', ')}): List<${Row}> {`);
226
+ const page = metadata.inputs.find((input) => input.kind === 'limit');
227
+ if (page?.kind === 'limit') {
228
+ const name = camelCase(page.langName);
229
+ lines.push(
230
+ ` val effective${typeName(name)} = ${name} ?: ${page.defaultSize}L`,
231
+ ` if (effective${typeName(name)} < 1L || effective${typeName(name)} > ${page.maxSize}L) {`,
232
+ ` throw SyncularQueryInputException("SYQL_RUNTIME_INVALID_LIMIT", ${quote(`${query.name}: invalid limit`)})`,
233
+ ' }',
234
+ );
235
+ }
236
+ if (metadata.plan.backend === 'variants') {
237
+ lines.push(' var activationMask = 0');
238
+ metadata.plan.activationControls.forEach((control, index) => {
239
+ lines.push(
240
+ ` if (${syqlControlActive(query, control)}) activationMask = activationMask or ${2 ** index}`,
241
+ );
242
+ });
243
+ }
244
+ const sort = metadata.inputs.find((input) => input.kind === 'sort');
245
+ const profileCount = sort?.kind === 'sort' ? sort.profiles.length : 1;
246
+ const sortIndex =
247
+ sort?.kind === 'sort' ? `${camelCase(sort.langName)}.index` : '0';
248
+ const index =
249
+ metadata.plan.backend === 'variants'
250
+ ? `activationMask * ${profileCount} + ${sortIndex}`
251
+ : sortIndex;
108
252
  lines.push(
109
- `${query.orderBy.allowed
110
- .map((col) => ` ${camelCase(col.langName)}(${quote(col.name)})`)
111
- .join(',\n')};`,
253
+ ` val statementIndex = ${index}`,
254
+ ' val selected: Pair<String, List<JsonValue>> = when (statementIndex) {',
255
+ );
256
+ metadata.plan.statements.forEach((statement, statementIndex) => {
257
+ const binds = statement.binds
258
+ .map((bind) => syqlBindExpr(query, bind))
259
+ .join(', ');
260
+ lines.push(
261
+ ` ${statementIndex} -> Pair(${quote(statement.positionalSql)}, listOf(${binds}))`,
262
+ );
263
+ });
264
+ lines.push(
265
+ ' else -> error("invalid generated SYQL statement index")',
266
+ ' }',
267
+ ` return client.query(selected.first, selected.second).mapNotNull { ${Row}.fromRow(it) }`,
268
+ ' }',
112
269
  );
113
- lines.push('}');
114
270
  return lines;
115
271
  }
116
272
 
@@ -146,65 +302,33 @@ function emitDataClass(query: AnalyzedQuery): string[] {
146
302
  }
147
303
 
148
304
  function emitRunner(query: AnalyzedQuery): string[] {
305
+ if (query.syql !== undefined) return emitSyqlKotlinRunner(query);
149
306
  const Row = `${typeName(query.name)}Row`;
150
307
  const lines: string[] = [];
151
308
  lines.push(
152
309
  ` val ${query.name}Tables = listOf(${query.tables.map(quote).join(', ')})`,
153
310
  );
154
- if (query.orderBy !== undefined) {
155
- lines.push(
156
- ` private const val ${query.name}SqlBase = ${quote(query.positionalSqlBase ?? '')}`,
157
- );
158
- } else {
159
- lines.push(
160
- ` private const val ${query.name}Sql = ${quote(query.positionalSql)}`,
161
- );
162
- }
311
+ lines.push(
312
+ ` private const val ${query.name}Sql = ${quote(query.positionalSql)}`,
313
+ );
163
314
  lines.push('');
164
315
  lines.push(` /** Run the ${query.name} named query (SELECT-only). */`);
165
316
  const args: string[] = [];
166
317
  for (const p of query.params) {
167
318
  const name = camelCase(p.langName);
168
- if (isOptionalParam(query, p)) {
169
- args.push(`${name}: ${KOTLIN_TYPE[p.type]}? = null`);
170
- } else {
171
- args.push(`${name}: ${KOTLIN_TYPE[p.type]}`);
172
- }
173
- }
174
- if (query.orderBy !== undefined) {
175
- const defaultCase = camelCase(
176
- query.orderBy.allowed.find((c) => c.name === query.orderBy?.defaultColumn)
177
- ?.langName ?? query.orderBy.defaultColumn,
178
- );
179
- args.push(
180
- `orderBy: ${typeName(query.name)}OrderBy = ${typeName(query.name)}OrderBy.${defaultCase}`,
181
- );
182
- args.push(
183
- `dir: SyncularQueryDir = SyncularQueryDir.${query.orderBy.defaultDir.toUpperCase()}`,
184
- );
319
+ args.push(`${name}: ${KOTLIN_TYPE[p.type]}`);
185
320
  }
186
321
  const signature =
187
322
  args.length > 0
188
323
  ? `client: SyncularClient, ${args.join(', ')}`
189
324
  : 'client: SyncularClient';
190
325
  lines.push(` fun ${query.name}(${signature}): List<${Row}> {`);
191
- if (query.orderBy !== undefined) {
192
- const limitTail =
193
- query.positionalLimitTail !== undefined
194
- ? ` + ${quote(query.positionalLimitTail)}`
195
- : '';
196
- lines.push(
197
- ` val sql = ${query.name}SqlBase + " order by " + orderBy.column + " " + dir.sql${limitTail}`,
198
- );
199
- }
200
- const sqlRef = query.orderBy !== undefined ? 'sql' : `${query.name}Sql`;
326
+ const sqlRef = `${query.name}Sql`;
201
327
  if (query.params.length > 0) {
202
328
  const binds = query.params
203
329
  .map((p) => {
204
330
  const name = camelCase(p.langName);
205
- return isOptionalParam(query, p)
206
- ? optionalParamValue(p.type, name)
207
- : paramValue(p.type, name);
331
+ return paramValue(p.type, name);
208
332
  })
209
333
  .join(', ');
210
334
  lines.push(` val params = listOf(${binds})`);
@@ -241,6 +365,22 @@ export function emitQueriesKotlinModule(
241
365
  ].join('\n'),
242
366
  );
243
367
 
368
+ if (queries.some((query) => query.syql !== undefined)) {
369
+ parts.push(
370
+ [
371
+ 'sealed class SyncularQueryPresence<out T> {',
372
+ ' object Absent : SyncularQueryPresence<Nothing>()',
373
+ ' data class Present<T>(val value: T) : SyncularQueryPresence<T>()',
374
+ '}',
375
+ '',
376
+ 'class SyncularQueryInputException(',
377
+ ' val code: String,',
378
+ ' override val message: String,',
379
+ ') : IllegalArgumentException(message)',
380
+ ].join('\n'),
381
+ );
382
+ }
383
+
244
384
  // Row-decode + bind helpers (package-private; distinct names so they don't
245
385
  // clash with the schema file's rowBool/rowBytes in the same package).
246
386
  parts.push(
@@ -266,22 +406,10 @@ export function emitQueriesKotlinModule(
266
406
  ].join('\n'),
267
407
  );
268
408
 
269
- if (queries.some((q) => q.orderBy !== undefined)) {
270
- parts.push(
271
- [
272
- '/** §6 orderBy direction (shared by every orderBy-knob query). */',
273
- 'enum class SyncularQueryDir(val sql: String) {',
274
- ' ASC("asc"),',
275
- ' DESC("desc");',
276
- '}',
277
- ].join('\n'),
278
- );
279
- }
280
-
281
409
  for (const query of queries) {
410
+ const syqlTypes = emitSyqlKotlinTypes(query);
411
+ if (syqlTypes.length > 0) parts.push(syqlTypes.join('\n'));
282
412
  parts.push(emitDataClass(query).join('\n'));
283
- const orderByEnum = emitOrderByEnum(query);
284
- if (orderByEnum.length > 0) parts.push(orderByEnum.join('\n'));
285
413
  }
286
414
 
287
415
  const objLines: string[] = [];
@@ -14,7 +14,12 @@
14
14
  */
15
15
  import type { IrColumnType } from './ir';
16
16
  import { snakeToCamel } from './naming';
17
- import type { AnalyzedQuery, QueryColumn, QueryParam } from './query';
17
+ import type {
18
+ AnalyzedQuery,
19
+ QueryColumn,
20
+ QuerySyqlPlanBind,
21
+ QuerySyqlPublicInput,
22
+ } from './query';
18
23
 
19
24
  const SWIFT_TYPE: Readonly<Record<IrColumnType, string>> = {
20
25
  string: 'String',
@@ -26,6 +31,10 @@ const SWIFT_TYPE: Readonly<Record<IrColumnType, string>> = {
26
31
  blob_ref: 'String',
27
32
  crdt: '[UInt8]',
28
33
  };
34
+ const SYQL_SWIFT_TYPE: Readonly<Record<IrColumnType, string>> = {
35
+ ...SWIFT_TYPE,
36
+ integer: 'Int64',
37
+ };
29
38
 
30
39
  /** Language-facing field name — the pinned §12 naming map. */
31
40
  function camelCase(name: string): string {
@@ -93,12 +102,192 @@ function optionalParamValue(type: IrColumnType, name: string): string {
93
102
  }
94
103
  }
95
104
 
96
- function isOptionalParam(query: AnalyzedQuery, p: QueryParam): boolean {
97
- return (
98
- p.optional === true ||
99
- p.flag === true ||
100
- (query.limit !== undefined && p.name === 'limit')
105
+ function syqlInput(query: AnalyzedQuery, name: string): QuerySyqlPublicInput {
106
+ const input = query.syql?.inputs.find((candidate) => candidate.name === name);
107
+ if (input === undefined) throw new Error(`unknown SYQL input ${name}`);
108
+ return input;
109
+ }
110
+
111
+ function syqlSwiftType(type: IrColumnType, nullable: boolean): string {
112
+ return `${SYQL_SWIFT_TYPE[type]}${nullable ? '?' : ''}`;
113
+ }
114
+
115
+ function syqlControlActive(query: AnalyzedQuery, name: string): string {
116
+ const input = syqlInput(query, name);
117
+ const access = camelCase(input.langName);
118
+ if (input.kind === 'value' && input.default === false) return access;
119
+ if (input.kind === 'value' && input.nullable) {
120
+ return `{ if case .present = ${access} { return true }; return false }()`;
121
+ }
122
+ if (input.kind === 'value' || input.kind === 'group') {
123
+ return `${access} != nil`;
124
+ }
125
+ throw new Error(`${name} is not an activation control`);
126
+ }
127
+
128
+ function syqlNullablePresenceBind(type: IrColumnType, access: string): string {
129
+ return `{ () -> JSONValue in switch ${access} { case .absent: return .null; case .present(let value): return ${optionalParamValue(type, 'value')} } }()`;
130
+ }
131
+
132
+ function syqlBindExpr(query: AnalyzedQuery, bind: QuerySyqlPlanBind): string {
133
+ if (bind.kind === 'condition-active') {
134
+ const active = bind.controls
135
+ .map((control) => syqlControlActive(query, control))
136
+ .join(' && ');
137
+ return `.bool(${active})`;
138
+ }
139
+ const input = syqlInput(query, bind.input);
140
+ const access = camelCase(input.langName);
141
+ if (bind.kind === 'limit')
142
+ return `.number(Double(effective${typeName(access)}))`;
143
+ if (bind.kind === 'group-member') {
144
+ if (input.kind !== 'group') throw new Error('group bind/input mismatch');
145
+ const member = input.members.find(
146
+ (candidate) => candidate.name === bind.member,
147
+ );
148
+ if (member === undefined)
149
+ throw new Error(`unknown group member ${bind.member}`);
150
+ const memberName = camelCase(member.langName);
151
+ const present = member.nullable
152
+ ? optionalParamValue(member.type, `value.${memberName}`)
153
+ : paramValue(member.type, `value.${memberName}`);
154
+ return `${access}.map { value in ${present} } ?? .null`;
155
+ }
156
+ if (input.kind !== 'value') throw new Error('value bind/input mismatch');
157
+ if (input.default === false) return paramValue(input.type, access);
158
+ if (input.required) {
159
+ return input.nullable
160
+ ? optionalParamValue(input.type, access)
161
+ : paramValue(input.type, access);
162
+ }
163
+ return input.nullable
164
+ ? syqlNullablePresenceBind(input.type, access)
165
+ : optionalParamValue(input.type, access);
166
+ }
167
+
168
+ function emitSyqlSwiftTypes(query: AnalyzedQuery): string[] {
169
+ const lines: string[] = [];
170
+ for (const input of query.syql?.inputs ?? []) {
171
+ if (input.kind === 'group') {
172
+ const name = `${typeName(query.name)}${typeName(input.langName)}`;
173
+ lines.push(`public struct ${name}: Sendable {`);
174
+ for (const member of input.members) {
175
+ lines.push(
176
+ ` public let ${camelCase(member.langName)}: ${syqlSwiftType(member.type, member.nullable)}`,
177
+ );
178
+ }
179
+ const args = input.members
180
+ .map(
181
+ (member) =>
182
+ `${camelCase(member.langName)}: ${syqlSwiftType(member.type, member.nullable)}`,
183
+ )
184
+ .join(', ');
185
+ lines.push(` public init(${args}) {`);
186
+ for (const member of input.members) {
187
+ const name = camelCase(member.langName);
188
+ lines.push(` self.${name} = ${name}`);
189
+ }
190
+ lines.push(' }', '}', '');
191
+ } else if (input.kind === 'sort') {
192
+ const name = `${typeName(query.name)}${typeName(input.langName)}`;
193
+ lines.push(`public enum ${name}: Int, Sendable {`);
194
+ input.profiles.forEach((profile, index) => {
195
+ lines.push(` case ${camelCase(profile.langName)} = ${index}`);
196
+ });
197
+ lines.push('}', '');
198
+ }
199
+ }
200
+ if (lines[lines.length - 1] === '') lines.pop();
201
+ return lines;
202
+ }
203
+
204
+ function emitSyqlSwiftRunner(query: AnalyzedQuery): string[] {
205
+ const metadata = query.syql;
206
+ if (metadata === undefined) throw new Error('missing SYQL metadata');
207
+ const Row = `${typeName(query.name)}Row`;
208
+ const lines: string[] = [];
209
+ lines.push(
210
+ ` public static let ${query.name}Tables = [${query.tables.map(quote).join(', ')}]`,
211
+ '',
212
+ ` /// Run the ${query.name} revision-1 SYQL query.`,
213
+ );
214
+ const args = ['client: SyncularClient'];
215
+ for (const input of metadata.inputs) {
216
+ const name = camelCase(input.langName);
217
+ if (input.kind === 'value') {
218
+ const type = syqlSwiftType(input.type, input.nullable);
219
+ if (input.required) args.push(`${name}: ${type}`);
220
+ else if (input.default === false) args.push(`${name}: Bool = false`);
221
+ else if (input.nullable)
222
+ args.push(`${name}: SyncularQueryPresence<${type}> = .absent`);
223
+ else args.push(`${name}: ${type}? = nil`);
224
+ } else if (input.kind === 'group') {
225
+ args.push(
226
+ `${name}: ${typeName(query.name)}${typeName(input.langName)}? = nil`,
227
+ );
228
+ } else if (input.kind === 'sort') {
229
+ const defaultCase =
230
+ input.profiles.find((profile) => profile.name === input.defaultProfile)
231
+ ?.langName ?? input.defaultProfile;
232
+ args.push(
233
+ `${name}: ${typeName(query.name)}${typeName(input.langName)} = .${camelCase(defaultCase)}`,
234
+ );
235
+ } else {
236
+ args.push(`${name}: Int? = nil`);
237
+ }
238
+ }
239
+ lines.push(
240
+ ` public static func ${query.name}(${args.join(', ')}) throws -> [${Row}] {`,
241
+ );
242
+ const page = metadata.inputs.find((input) => input.kind === 'limit');
243
+ if (page?.kind === 'limit') {
244
+ const name = camelCase(page.langName);
245
+ lines.push(
246
+ ` let effective${typeName(name)} = ${name} ?? ${page.defaultSize}`,
247
+ ` guard effective${typeName(name)} >= 1 && effective${typeName(name)} <= ${page.maxSize} else {`,
248
+ ` throw SyncularQueryInputError(code: "SYQL_RUNTIME_INVALID_LIMIT", message: ${quote(`${query.name}: invalid limit`)})`,
249
+ ' }',
250
+ );
251
+ }
252
+ if (metadata.plan.backend === 'variants') {
253
+ lines.push(' var activationMask = 0');
254
+ metadata.plan.activationControls.forEach((control, index) => {
255
+ lines.push(
256
+ ` if ${syqlControlActive(query, control)} { activationMask |= ${2 ** index} }`,
257
+ );
258
+ });
259
+ }
260
+ const sort = metadata.inputs.find((input) => input.kind === 'sort');
261
+ const profileCount = sort?.kind === 'sort' ? sort.profiles.length : 1;
262
+ const sortIndex =
263
+ sort?.kind === 'sort' ? `${camelCase(sort.langName)}.rawValue` : '0';
264
+ const index =
265
+ metadata.plan.backend === 'variants'
266
+ ? `activationMask * ${profileCount} + ${sortIndex}`
267
+ : sortIndex;
268
+ lines.push(
269
+ ` let statementIndex = ${index}`,
270
+ ' let selected: (String, [JSONValue])',
271
+ ' switch statementIndex {',
101
272
  );
273
+ metadata.plan.statements.forEach((statement, statementIndex) => {
274
+ const binds = statement.binds
275
+ .map((bind) => syqlBindExpr(query, bind))
276
+ .join(', ');
277
+ lines.push(
278
+ ` case ${statementIndex}: selected = (${quote(statement.positionalSql)}, [${binds}])`,
279
+ );
280
+ });
281
+ lines.push(
282
+ ' default: preconditionFailure("invalid generated SYQL statement index")',
283
+ ' }',
284
+ ' return try client.query(selected.0, params: selected.1).compactMap { row in',
285
+ ' guard case let .object(fields) = row else { return nil }',
286
+ ` return ${Row}(row: fields)`,
287
+ ' }',
288
+ ' }',
289
+ );
290
+ return lines;
102
291
  }
103
292
 
104
293
  function emitRowStruct(query: AnalyzedQuery): string[] {
@@ -129,54 +318,22 @@ function emitRowStruct(query: AnalyzedQuery): string[] {
129
318
  return lines;
130
319
  }
131
320
 
132
- /** Per-query orderBy allowlist enum (rawValue = the checked SQL column). */
133
- function emitOrderByEnum(query: AnalyzedQuery): string[] {
134
- if (query.orderBy === undefined) return [];
135
- const lines: string[] = [];
136
- lines.push(
137
- `/// §6 orderBy allowlist for ${query.name} — checked at generate time.`,
138
- );
139
- lines.push(`public enum ${typeName(query.name)}OrderBy: String, Sendable {`);
140
- for (const col of query.orderBy.allowed) {
141
- lines.push(` case ${camelCase(col.langName)} = ${quote(col.name)}`);
142
- }
143
- lines.push('}');
144
- return lines;
145
- }
146
-
147
321
  function emitRunner(query: AnalyzedQuery): string[] {
322
+ if (query.syql !== undefined) return emitSyqlSwiftRunner(query);
148
323
  const Row = `${typeName(query.name)}Row`;
149
324
  const lines: string[] = [];
150
325
  lines.push(
151
326
  ` public static let ${query.name}Tables = [${query.tables.map(quote).join(', ')}]`,
152
327
  );
153
- if (query.orderBy !== undefined) {
154
- lines.push(
155
- ` private static let ${query.name}SqlBase = ${quote(query.positionalSqlBase ?? '')}`,
156
- );
157
- } else {
158
- lines.push(
159
- ` private static let ${query.name}Sql = ${quote(query.positionalSql)}`,
160
- );
161
- }
328
+ lines.push(
329
+ ` private static let ${query.name}Sql = ${quote(query.positionalSql)}`,
330
+ );
162
331
  lines.push('');
163
332
  lines.push(` /// Run the ${query.name} named query (SELECT-only).`);
164
333
  const args: string[] = [];
165
334
  for (const p of query.params) {
166
335
  const name = camelCase(p.langName);
167
- if (isOptionalParam(query, p)) {
168
- args.push(`${name}: ${SWIFT_TYPE[p.type]}? = nil`);
169
- } else {
170
- args.push(`${name}: ${SWIFT_TYPE[p.type]}`);
171
- }
172
- }
173
- if (query.orderBy !== undefined) {
174
- const defaultCase = camelCase(
175
- query.orderBy.allowed.find((c) => c.name === query.orderBy?.defaultColumn)
176
- ?.langName ?? query.orderBy.defaultColumn,
177
- );
178
- args.push(`orderBy: ${typeName(query.name)}OrderBy = .${defaultCase}`);
179
- args.push(`dir: SyncularQueryDir = .${query.orderBy.defaultDir}`);
336
+ args.push(`${name}: ${SWIFT_TYPE[p.type]}`);
180
337
  }
181
338
  const signature =
182
339
  args.length > 0
@@ -185,21 +342,12 @@ function emitRunner(query: AnalyzedQuery): string[] {
185
342
  lines.push(
186
343
  ` public static func ${query.name}(${signature}) throws -> [${Row}] {`,
187
344
  );
188
- const sqlExpr =
189
- query.orderBy !== undefined
190
- ? `${query.name}SqlBase + " order by " + orderBy.rawValue + " " + dir.rawValue${query.positionalLimitTail !== undefined ? ` + ${quote(query.positionalLimitTail)}` : ''}`
191
- : `${query.name}Sql`;
192
- if (query.orderBy !== undefined) {
193
- lines.push(` let sql = ${sqlExpr}`);
194
- }
195
- const sqlRef = query.orderBy !== undefined ? 'sql' : `${query.name}Sql`;
345
+ const sqlRef = `${query.name}Sql`;
196
346
  if (query.params.length > 0) {
197
347
  const binds = query.params
198
348
  .map((p) => {
199
349
  const name = camelCase(p.langName);
200
- return isOptionalParam(query, p)
201
- ? optionalParamValue(p.type, name)
202
- : paramValue(p.type, name);
350
+ return paramValue(p.type, name);
203
351
  })
204
352
  .join(', ');
205
353
  lines.push(` let params: [JSONValue] = [${binds}]`);
@@ -238,6 +386,26 @@ export function emitQueriesSwiftModule(
238
386
  ].join('\n'),
239
387
  );
240
388
 
389
+ if (queries.some((query) => query.syql !== undefined)) {
390
+ parts.push(
391
+ [
392
+ 'public enum SyncularQueryPresence<Value: Sendable>: Sendable {',
393
+ ' case absent',
394
+ ' case present(Value)',
395
+ '}',
396
+ '',
397
+ 'public struct SyncularQueryInputError: Error, Sendable {',
398
+ ' public let code: String',
399
+ ' public let message: String',
400
+ ' public init(code: String, message: String) {',
401
+ ' self.code = code',
402
+ ' self.message = message',
403
+ ' }',
404
+ '}',
405
+ ].join('\n'),
406
+ );
407
+ }
408
+
241
409
  parts.push(
242
410
  [
243
411
  '/// Param-bind helpers shared by the generated query runners.',
@@ -250,22 +418,10 @@ export function emitQueriesSwiftModule(
250
418
  ].join('\n'),
251
419
  );
252
420
 
253
- if (queries.some((q) => q.orderBy !== undefined)) {
254
- parts.push(
255
- [
256
- '/// §6 orderBy direction (shared by every orderBy-knob query).',
257
- 'public enum SyncularQueryDir: String, Sendable {',
258
- ' case asc = "asc"',
259
- ' case desc = "desc"',
260
- '}',
261
- ].join('\n'),
262
- );
263
- }
264
-
265
421
  for (const query of queries) {
422
+ const syqlTypes = emitSyqlSwiftTypes(query);
423
+ if (syqlTypes.length > 0) parts.push(syqlTypes.join('\n'));
266
424
  parts.push(emitRowStruct(query).join('\n'));
267
- const orderByEnum = emitOrderByEnum(query);
268
- if (orderByEnum.length > 0) parts.push(orderByEnum.join('\n'));
269
425
  }
270
426
 
271
427
  const enumLines: string[] = [];