@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
@@ -66,22 +66,152 @@ function optionalParamValue(type, name) {
66
66
  return `${name}?.let { JsonValue.of(it) } ?: JsonValue.Null`;
67
67
  }
68
68
  }
69
- function isOptionalParam(query, p) {
70
- return (p.optional === true ||
71
- p.flag === true ||
72
- (query.limit !== undefined && p.name === 'limit'));
73
- }
74
- /** Per-query orderBy allowlist enum (column = the checked SQL column). */
75
- function emitOrderByEnum(query) {
76
- if (query.orderBy === undefined)
77
- return [];
69
+ function syqlInput(query, name) {
70
+ const input = query.syql?.inputs.find((candidate) => candidate.name === name);
71
+ if (input === undefined)
72
+ throw new Error(`unknown SYQL input ${name}`);
73
+ return input;
74
+ }
75
+ function syqlKotlinType(type, nullable) {
76
+ return `${KOTLIN_TYPE[type]}${nullable ? '?' : ''}`;
77
+ }
78
+ function syqlControlActive(query, name) {
79
+ const input = syqlInput(query, name);
80
+ const access = camelCase(input.langName);
81
+ if (input.kind === 'value' && input.default === false)
82
+ return access;
83
+ if (input.kind === 'value' && input.nullable) {
84
+ return `${access} is SyncularQueryPresence.Present`;
85
+ }
86
+ if (input.kind === 'value' || input.kind === 'group') {
87
+ return `${access} != null`;
88
+ }
89
+ throw new Error(`${name} is not an activation control`);
90
+ }
91
+ function syqlNullablePresenceBind(type, access) {
92
+ return `when (val presence = ${access}) { is SyncularQueryPresence.Present -> ${optionalParamValue(type, 'presence.value')}; SyncularQueryPresence.Absent -> JsonValue.Null }`;
93
+ }
94
+ function syqlBindExpr(query, bind) {
95
+ if (bind.kind === 'condition-active') {
96
+ const active = bind.controls
97
+ .map((control) => syqlControlActive(query, control))
98
+ .join(' && ');
99
+ return `JsonValue.of(${active})`;
100
+ }
101
+ const input = syqlInput(query, bind.input);
102
+ const access = camelCase(input.langName);
103
+ if (bind.kind === 'limit')
104
+ return `JsonValue.of(effective${typeName(access)}.toDouble())`;
105
+ if (bind.kind === 'group-member') {
106
+ if (input.kind !== 'group')
107
+ throw new Error('group bind/input mismatch');
108
+ const member = input.members.find((candidate) => candidate.name === bind.member);
109
+ if (member === undefined)
110
+ throw new Error(`unknown group member ${bind.member}`);
111
+ const memberName = camelCase(member.langName);
112
+ const present = member.nullable
113
+ ? optionalParamValue(member.type, `value.${memberName}`)
114
+ : paramValue(member.type, `value.${memberName}`);
115
+ return `${access}?.let { value -> ${present} } ?: JsonValue.Null`;
116
+ }
117
+ if (input.kind !== 'value')
118
+ throw new Error('value bind/input mismatch');
119
+ if (input.default === false)
120
+ return paramValue(input.type, access);
121
+ if (input.required) {
122
+ return input.nullable
123
+ ? optionalParamValue(input.type, access)
124
+ : paramValue(input.type, access);
125
+ }
126
+ return input.nullable
127
+ ? syqlNullablePresenceBind(input.type, access)
128
+ : optionalParamValue(input.type, access);
129
+ }
130
+ function emitSyqlKotlinTypes(query) {
78
131
  const lines = [];
79
- lines.push(`/** §6 orderBy allowlist for ${query.name} checked at generate time. */`);
80
- lines.push(`enum class ${typeName(query.name)}OrderBy(val column: String) {`);
81
- lines.push(`${query.orderBy.allowed
82
- .map((col) => ` ${camelCase(col.langName)}(${quote(col.name)})`)
83
- .join(',\n')};`);
84
- lines.push('}');
132
+ for (const input of query.syql?.inputs ?? []) {
133
+ if (input.kind === 'group') {
134
+ const name = `${typeName(query.name)}${typeName(input.langName)}`;
135
+ lines.push(`data class ${name}(`);
136
+ for (const member of input.members) {
137
+ lines.push(` val ${camelCase(member.langName)}: ${syqlKotlinType(member.type, member.nullable)},`);
138
+ }
139
+ lines.push(')', '');
140
+ }
141
+ else if (input.kind === 'sort') {
142
+ const name = `${typeName(query.name)}${typeName(input.langName)}`;
143
+ lines.push(`enum class ${name}(val index: Int) {`);
144
+ lines.push(`${input.profiles
145
+ .map((profile, index) => ` ${camelCase(profile.langName)}(${index})`)
146
+ .join(',\n')};`);
147
+ lines.push('}', '');
148
+ }
149
+ }
150
+ if (lines[lines.length - 1] === '')
151
+ lines.pop();
152
+ return lines;
153
+ }
154
+ function emitSyqlKotlinRunner(query) {
155
+ const metadata = query.syql;
156
+ if (metadata === undefined)
157
+ throw new Error('missing SYQL metadata');
158
+ const Row = `${typeName(query.name)}Row`;
159
+ const lines = [];
160
+ lines.push(` val ${query.name}Tables = listOf(${query.tables.map(quote).join(', ')})`, '', ` /** Run the ${query.name} revision-1 SYQL query. */`);
161
+ const args = ['client: SyncularClient'];
162
+ for (const input of metadata.inputs) {
163
+ const name = camelCase(input.langName);
164
+ if (input.kind === 'value') {
165
+ const type = syqlKotlinType(input.type, input.nullable);
166
+ if (input.required)
167
+ args.push(`${name}: ${type}`);
168
+ else if (input.default === false)
169
+ args.push(`${name}: Boolean = false`);
170
+ else if (input.nullable) {
171
+ args.push(`${name}: SyncularQueryPresence<${type}> = SyncularQueryPresence.Absent`);
172
+ }
173
+ else
174
+ args.push(`${name}: ${type}? = null`);
175
+ }
176
+ else if (input.kind === 'group') {
177
+ args.push(`${name}: ${typeName(query.name)}${typeName(input.langName)}? = null`);
178
+ }
179
+ else if (input.kind === 'sort') {
180
+ const defaultCase = input.profiles.find((profile) => profile.name === input.defaultProfile)
181
+ ?.langName ?? input.defaultProfile;
182
+ const type = `${typeName(query.name)}${typeName(input.langName)}`;
183
+ args.push(`${name}: ${type} = ${type}.${camelCase(defaultCase)}`);
184
+ }
185
+ else {
186
+ args.push(`${name}: Long? = null`);
187
+ }
188
+ }
189
+ lines.push(` fun ${query.name}(${args.join(', ')}): List<${Row}> {`);
190
+ const page = metadata.inputs.find((input) => input.kind === 'limit');
191
+ if (page?.kind === 'limit') {
192
+ const name = camelCase(page.langName);
193
+ lines.push(` val effective${typeName(name)} = ${name} ?: ${page.defaultSize}L`, ` if (effective${typeName(name)} < 1L || effective${typeName(name)} > ${page.maxSize}L) {`, ` throw SyncularQueryInputException("SYQL_RUNTIME_INVALID_LIMIT", ${quote(`${query.name}: invalid limit`)})`, ' }');
194
+ }
195
+ if (metadata.plan.backend === 'variants') {
196
+ lines.push(' var activationMask = 0');
197
+ metadata.plan.activationControls.forEach((control, index) => {
198
+ lines.push(` if (${syqlControlActive(query, control)}) activationMask = activationMask or ${2 ** index}`);
199
+ });
200
+ }
201
+ const sort = metadata.inputs.find((input) => input.kind === 'sort');
202
+ const profileCount = sort?.kind === 'sort' ? sort.profiles.length : 1;
203
+ const sortIndex = sort?.kind === 'sort' ? `${camelCase(sort.langName)}.index` : '0';
204
+ const index = metadata.plan.backend === 'variants'
205
+ ? `activationMask * ${profileCount} + ${sortIndex}`
206
+ : sortIndex;
207
+ lines.push(` val statementIndex = ${index}`, ' val selected: Pair<String, List<JsonValue>> = when (statementIndex) {');
208
+ metadata.plan.statements.forEach((statement, statementIndex) => {
209
+ const binds = statement.binds
210
+ .map((bind) => syqlBindExpr(query, bind))
211
+ .join(', ');
212
+ lines.push(` ${statementIndex} -> Pair(${quote(statement.positionalSql)}, listOf(${binds}))`);
213
+ });
214
+ lines.push(' else -> error("invalid generated SYQL statement index")', ' }', ` return client.query(selected.first, selected.second).mapNotNull { ${Row}.fromRow(it) }`, ' }');
85
215
  return lines;
86
216
  }
87
217
  function emitDataClass(query) {
@@ -114,51 +244,29 @@ function emitDataClass(query) {
114
244
  return lines;
115
245
  }
116
246
  function emitRunner(query) {
247
+ if (query.syql !== undefined)
248
+ return emitSyqlKotlinRunner(query);
117
249
  const Row = `${typeName(query.name)}Row`;
118
250
  const lines = [];
119
251
  lines.push(` val ${query.name}Tables = listOf(${query.tables.map(quote).join(', ')})`);
120
- if (query.orderBy !== undefined) {
121
- lines.push(` private const val ${query.name}SqlBase = ${quote(query.positionalSqlBase ?? '')}`);
122
- }
123
- else {
124
- lines.push(` private const val ${query.name}Sql = ${quote(query.positionalSql)}`);
125
- }
252
+ lines.push(` private const val ${query.name}Sql = ${quote(query.positionalSql)}`);
126
253
  lines.push('');
127
254
  lines.push(` /** Run the ${query.name} named query (SELECT-only). */`);
128
255
  const args = [];
129
256
  for (const p of query.params) {
130
257
  const name = camelCase(p.langName);
131
- if (isOptionalParam(query, p)) {
132
- args.push(`${name}: ${KOTLIN_TYPE[p.type]}? = null`);
133
- }
134
- else {
135
- args.push(`${name}: ${KOTLIN_TYPE[p.type]}`);
136
- }
137
- }
138
- if (query.orderBy !== undefined) {
139
- const defaultCase = camelCase(query.orderBy.allowed.find((c) => c.name === query.orderBy?.defaultColumn)
140
- ?.langName ?? query.orderBy.defaultColumn);
141
- args.push(`orderBy: ${typeName(query.name)}OrderBy = ${typeName(query.name)}OrderBy.${defaultCase}`);
142
- args.push(`dir: SyncularQueryDir = SyncularQueryDir.${query.orderBy.defaultDir.toUpperCase()}`);
258
+ args.push(`${name}: ${KOTLIN_TYPE[p.type]}`);
143
259
  }
144
260
  const signature = args.length > 0
145
261
  ? `client: SyncularClient, ${args.join(', ')}`
146
262
  : 'client: SyncularClient';
147
263
  lines.push(` fun ${query.name}(${signature}): List<${Row}> {`);
148
- if (query.orderBy !== undefined) {
149
- const limitTail = query.positionalLimitTail !== undefined
150
- ? ` + ${quote(query.positionalLimitTail)}`
151
- : '';
152
- lines.push(` val sql = ${query.name}SqlBase + " order by " + orderBy.column + " " + dir.sql${limitTail}`);
153
- }
154
- const sqlRef = query.orderBy !== undefined ? 'sql' : `${query.name}Sql`;
264
+ const sqlRef = `${query.name}Sql`;
155
265
  if (query.params.length > 0) {
156
266
  const binds = query.params
157
267
  .map((p) => {
158
268
  const name = camelCase(p.langName);
159
- return isOptionalParam(query, p)
160
- ? optionalParamValue(p.type, name)
161
- : paramValue(p.type, name);
269
+ return paramValue(p.type, name);
162
270
  })
163
271
  .join(', ');
164
272
  lines.push(` val params = listOf(${binds})`);
@@ -182,6 +290,19 @@ export function emitQueriesKotlinModule(queries, hash, irVersion, packageName, o
182
290
  'import dev.syncular.JsonValue',
183
291
  'import dev.syncular.SyncularClient',
184
292
  ].join('\n'));
293
+ if (queries.some((query) => query.syql !== undefined)) {
294
+ parts.push([
295
+ 'sealed class SyncularQueryPresence<out T> {',
296
+ ' object Absent : SyncularQueryPresence<Nothing>()',
297
+ ' data class Present<T>(val value: T) : SyncularQueryPresence<T>()',
298
+ '}',
299
+ '',
300
+ 'class SyncularQueryInputException(',
301
+ ' val code: String,',
302
+ ' override val message: String,',
303
+ ') : IllegalArgumentException(message)',
304
+ ].join('\n'));
305
+ }
185
306
  // Row-decode + bind helpers (package-private; distinct names so they don't
186
307
  // clash with the schema file's rowBool/rowBytes in the same package).
187
308
  parts.push([
@@ -204,20 +325,11 @@ export function emitQueriesKotlinModule(queries, hash, irVersion, packageName, o
204
325
  ' return JsonValue.obj("\\$bytes" to JsonValue.of(hex))',
205
326
  '}',
206
327
  ].join('\n'));
207
- if (queries.some((q) => q.orderBy !== undefined)) {
208
- parts.push([
209
- '/** §6 orderBy direction (shared by every orderBy-knob query). */',
210
- 'enum class SyncularQueryDir(val sql: String) {',
211
- ' ASC("asc"),',
212
- ' DESC("desc");',
213
- '}',
214
- ].join('\n'));
215
- }
216
328
  for (const query of queries) {
329
+ const syqlTypes = emitSyqlKotlinTypes(query);
330
+ if (syqlTypes.length > 0)
331
+ parts.push(syqlTypes.join('\n'));
217
332
  parts.push(emitDataClass(query).join('\n'));
218
- const orderByEnum = emitOrderByEnum(query);
219
- if (orderByEnum.length > 0)
220
- parts.push(orderByEnum.join('\n'));
221
333
  }
222
334
  const objLines = [];
223
335
  objLines.push('/** Typed named queries (the sqlc/SQLDelight tier). */');
@@ -9,6 +9,10 @@ const SWIFT_TYPE = {
9
9
  blob_ref: 'String',
10
10
  crdt: '[UInt8]',
11
11
  };
12
+ const SYQL_SWIFT_TYPE = {
13
+ ...SWIFT_TYPE,
14
+ integer: 'Int64',
15
+ };
12
16
  /** Language-facing field name — the pinned §12 naming map. */
13
17
  function camelCase(name) {
14
18
  return snakeToCamel(name);
@@ -69,10 +73,159 @@ function optionalParamValue(type, name) {
69
73
  return `${name}.map { JSONValue.string($0) } ?? .null`;
70
74
  }
71
75
  }
72
- function isOptionalParam(query, p) {
73
- return (p.optional === true ||
74
- p.flag === true ||
75
- (query.limit !== undefined && p.name === 'limit'));
76
+ function syqlInput(query, name) {
77
+ const input = query.syql?.inputs.find((candidate) => candidate.name === name);
78
+ if (input === undefined)
79
+ throw new Error(`unknown SYQL input ${name}`);
80
+ return input;
81
+ }
82
+ function syqlSwiftType(type, nullable) {
83
+ return `${SYQL_SWIFT_TYPE[type]}${nullable ? '?' : ''}`;
84
+ }
85
+ function syqlControlActive(query, name) {
86
+ const input = syqlInput(query, name);
87
+ const access = camelCase(input.langName);
88
+ if (input.kind === 'value' && input.default === false)
89
+ return access;
90
+ if (input.kind === 'value' && input.nullable) {
91
+ return `{ if case .present = ${access} { return true }; return false }()`;
92
+ }
93
+ if (input.kind === 'value' || input.kind === 'group') {
94
+ return `${access} != nil`;
95
+ }
96
+ throw new Error(`${name} is not an activation control`);
97
+ }
98
+ function syqlNullablePresenceBind(type, access) {
99
+ return `{ () -> JSONValue in switch ${access} { case .absent: return .null; case .present(let value): return ${optionalParamValue(type, 'value')} } }()`;
100
+ }
101
+ function syqlBindExpr(query, bind) {
102
+ if (bind.kind === 'condition-active') {
103
+ const active = bind.controls
104
+ .map((control) => syqlControlActive(query, control))
105
+ .join(' && ');
106
+ return `.bool(${active})`;
107
+ }
108
+ const input = syqlInput(query, bind.input);
109
+ const access = camelCase(input.langName);
110
+ if (bind.kind === 'limit')
111
+ return `.number(Double(effective${typeName(access)}))`;
112
+ if (bind.kind === 'group-member') {
113
+ if (input.kind !== 'group')
114
+ throw new Error('group bind/input mismatch');
115
+ const member = input.members.find((candidate) => candidate.name === bind.member);
116
+ if (member === undefined)
117
+ throw new Error(`unknown group member ${bind.member}`);
118
+ const memberName = camelCase(member.langName);
119
+ const present = member.nullable
120
+ ? optionalParamValue(member.type, `value.${memberName}`)
121
+ : paramValue(member.type, `value.${memberName}`);
122
+ return `${access}.map { value in ${present} } ?? .null`;
123
+ }
124
+ if (input.kind !== 'value')
125
+ throw new Error('value bind/input mismatch');
126
+ if (input.default === false)
127
+ return paramValue(input.type, access);
128
+ if (input.required) {
129
+ return input.nullable
130
+ ? optionalParamValue(input.type, access)
131
+ : paramValue(input.type, access);
132
+ }
133
+ return input.nullable
134
+ ? syqlNullablePresenceBind(input.type, access)
135
+ : optionalParamValue(input.type, access);
136
+ }
137
+ function emitSyqlSwiftTypes(query) {
138
+ const lines = [];
139
+ for (const input of query.syql?.inputs ?? []) {
140
+ if (input.kind === 'group') {
141
+ const name = `${typeName(query.name)}${typeName(input.langName)}`;
142
+ lines.push(`public struct ${name}: Sendable {`);
143
+ for (const member of input.members) {
144
+ lines.push(` public let ${camelCase(member.langName)}: ${syqlSwiftType(member.type, member.nullable)}`);
145
+ }
146
+ const args = input.members
147
+ .map((member) => `${camelCase(member.langName)}: ${syqlSwiftType(member.type, member.nullable)}`)
148
+ .join(', ');
149
+ lines.push(` public init(${args}) {`);
150
+ for (const member of input.members) {
151
+ const name = camelCase(member.langName);
152
+ lines.push(` self.${name} = ${name}`);
153
+ }
154
+ lines.push(' }', '}', '');
155
+ }
156
+ else if (input.kind === 'sort') {
157
+ const name = `${typeName(query.name)}${typeName(input.langName)}`;
158
+ lines.push(`public enum ${name}: Int, Sendable {`);
159
+ input.profiles.forEach((profile, index) => {
160
+ lines.push(` case ${camelCase(profile.langName)} = ${index}`);
161
+ });
162
+ lines.push('}', '');
163
+ }
164
+ }
165
+ if (lines[lines.length - 1] === '')
166
+ lines.pop();
167
+ return lines;
168
+ }
169
+ function emitSyqlSwiftRunner(query) {
170
+ const metadata = query.syql;
171
+ if (metadata === undefined)
172
+ throw new Error('missing SYQL metadata');
173
+ const Row = `${typeName(query.name)}Row`;
174
+ const lines = [];
175
+ lines.push(` public static let ${query.name}Tables = [${query.tables.map(quote).join(', ')}]`, '', ` /// Run the ${query.name} revision-1 SYQL query.`);
176
+ const args = ['client: SyncularClient'];
177
+ for (const input of metadata.inputs) {
178
+ const name = camelCase(input.langName);
179
+ if (input.kind === 'value') {
180
+ const type = syqlSwiftType(input.type, input.nullable);
181
+ if (input.required)
182
+ args.push(`${name}: ${type}`);
183
+ else if (input.default === false)
184
+ args.push(`${name}: Bool = false`);
185
+ else if (input.nullable)
186
+ args.push(`${name}: SyncularQueryPresence<${type}> = .absent`);
187
+ else
188
+ args.push(`${name}: ${type}? = nil`);
189
+ }
190
+ else if (input.kind === 'group') {
191
+ args.push(`${name}: ${typeName(query.name)}${typeName(input.langName)}? = nil`);
192
+ }
193
+ else if (input.kind === 'sort') {
194
+ const defaultCase = input.profiles.find((profile) => profile.name === input.defaultProfile)
195
+ ?.langName ?? input.defaultProfile;
196
+ args.push(`${name}: ${typeName(query.name)}${typeName(input.langName)} = .${camelCase(defaultCase)}`);
197
+ }
198
+ else {
199
+ args.push(`${name}: Int? = nil`);
200
+ }
201
+ }
202
+ lines.push(` public static func ${query.name}(${args.join(', ')}) throws -> [${Row}] {`);
203
+ const page = metadata.inputs.find((input) => input.kind === 'limit');
204
+ if (page?.kind === 'limit') {
205
+ const name = camelCase(page.langName);
206
+ lines.push(` let effective${typeName(name)} = ${name} ?? ${page.defaultSize}`, ` guard effective${typeName(name)} >= 1 && effective${typeName(name)} <= ${page.maxSize} else {`, ` throw SyncularQueryInputError(code: "SYQL_RUNTIME_INVALID_LIMIT", message: ${quote(`${query.name}: invalid limit`)})`, ' }');
207
+ }
208
+ if (metadata.plan.backend === 'variants') {
209
+ lines.push(' var activationMask = 0');
210
+ metadata.plan.activationControls.forEach((control, index) => {
211
+ lines.push(` if ${syqlControlActive(query, control)} { activationMask |= ${2 ** index} }`);
212
+ });
213
+ }
214
+ const sort = metadata.inputs.find((input) => input.kind === 'sort');
215
+ const profileCount = sort?.kind === 'sort' ? sort.profiles.length : 1;
216
+ const sortIndex = sort?.kind === 'sort' ? `${camelCase(sort.langName)}.rawValue` : '0';
217
+ const index = metadata.plan.backend === 'variants'
218
+ ? `activationMask * ${profileCount} + ${sortIndex}`
219
+ : sortIndex;
220
+ lines.push(` let statementIndex = ${index}`, ' let selected: (String, [JSONValue])', ' switch statementIndex {');
221
+ metadata.plan.statements.forEach((statement, statementIndex) => {
222
+ const binds = statement.binds
223
+ .map((bind) => syqlBindExpr(query, bind))
224
+ .join(', ');
225
+ lines.push(` case ${statementIndex}: selected = (${quote(statement.positionalSql)}, [${binds}])`);
226
+ });
227
+ lines.push(' default: preconditionFailure("invalid generated SYQL statement index")', ' }', ' return try client.query(selected.0, params: selected.1).compactMap { row in', ' guard case let .object(fields) = row else { return nil }', ` return ${Row}(row: fields)`, ' }', ' }');
228
+ return lines;
76
229
  }
77
230
  function emitRowStruct(query) {
78
231
  const Row = `${typeName(query.name)}Row`;
@@ -100,65 +253,30 @@ function emitRowStruct(query) {
100
253
  lines.push('}');
101
254
  return lines;
102
255
  }
103
- /** Per-query orderBy allowlist enum (rawValue = the checked SQL column). */
104
- function emitOrderByEnum(query) {
105
- if (query.orderBy === undefined)
106
- return [];
107
- const lines = [];
108
- lines.push(`/// §6 orderBy allowlist for ${query.name} — checked at generate time.`);
109
- lines.push(`public enum ${typeName(query.name)}OrderBy: String, Sendable {`);
110
- for (const col of query.orderBy.allowed) {
111
- lines.push(` case ${camelCase(col.langName)} = ${quote(col.name)}`);
112
- }
113
- lines.push('}');
114
- return lines;
115
- }
116
256
  function emitRunner(query) {
257
+ if (query.syql !== undefined)
258
+ return emitSyqlSwiftRunner(query);
117
259
  const Row = `${typeName(query.name)}Row`;
118
260
  const lines = [];
119
261
  lines.push(` public static let ${query.name}Tables = [${query.tables.map(quote).join(', ')}]`);
120
- if (query.orderBy !== undefined) {
121
- lines.push(` private static let ${query.name}SqlBase = ${quote(query.positionalSqlBase ?? '')}`);
122
- }
123
- else {
124
- lines.push(` private static let ${query.name}Sql = ${quote(query.positionalSql)}`);
125
- }
262
+ lines.push(` private static let ${query.name}Sql = ${quote(query.positionalSql)}`);
126
263
  lines.push('');
127
264
  lines.push(` /// Run the ${query.name} named query (SELECT-only).`);
128
265
  const args = [];
129
266
  for (const p of query.params) {
130
267
  const name = camelCase(p.langName);
131
- if (isOptionalParam(query, p)) {
132
- args.push(`${name}: ${SWIFT_TYPE[p.type]}? = nil`);
133
- }
134
- else {
135
- args.push(`${name}: ${SWIFT_TYPE[p.type]}`);
136
- }
137
- }
138
- if (query.orderBy !== undefined) {
139
- const defaultCase = camelCase(query.orderBy.allowed.find((c) => c.name === query.orderBy?.defaultColumn)
140
- ?.langName ?? query.orderBy.defaultColumn);
141
- args.push(`orderBy: ${typeName(query.name)}OrderBy = .${defaultCase}`);
142
- args.push(`dir: SyncularQueryDir = .${query.orderBy.defaultDir}`);
268
+ args.push(`${name}: ${SWIFT_TYPE[p.type]}`);
143
269
  }
144
270
  const signature = args.length > 0
145
271
  ? `client: SyncularClient, ${args.join(', ')}`
146
272
  : 'client: SyncularClient';
147
273
  lines.push(` public static func ${query.name}(${signature}) throws -> [${Row}] {`);
148
- const sqlExpr = query.orderBy !== undefined
149
- ? `${query.name}SqlBase + " order by " + orderBy.rawValue + " " + dir.rawValue${query.positionalLimitTail !== undefined ? ` + ${quote(query.positionalLimitTail)}` : ''}`
150
- : `${query.name}Sql`;
151
- if (query.orderBy !== undefined) {
152
- lines.push(` let sql = ${sqlExpr}`);
153
- }
154
- const sqlRef = query.orderBy !== undefined ? 'sql' : `${query.name}Sql`;
274
+ const sqlRef = `${query.name}Sql`;
155
275
  if (query.params.length > 0) {
156
276
  const binds = query.params
157
277
  .map((p) => {
158
278
  const name = camelCase(p.langName);
159
- return isOptionalParam(query, p)
160
- ? optionalParamValue(p.type, name)
161
- : paramValue(p.type, name);
279
+ return paramValue(p.type, name);
162
280
  })
163
281
  .join(', ');
164
282
  lines.push(` let params: [JSONValue] = [${binds}]`);
@@ -183,6 +301,23 @@ export function emitQueriesSwiftModule(queries, hash, irVersion, enumName) {
183
301
  'import Foundation',
184
302
  'import Syncular',
185
303
  ].join('\n'));
304
+ if (queries.some((query) => query.syql !== undefined)) {
305
+ parts.push([
306
+ 'public enum SyncularQueryPresence<Value: Sendable>: Sendable {',
307
+ ' case absent',
308
+ ' case present(Value)',
309
+ '}',
310
+ '',
311
+ 'public struct SyncularQueryInputError: Error, Sendable {',
312
+ ' public let code: String',
313
+ ' public let message: String',
314
+ ' public init(code: String, message: String) {',
315
+ ' self.code = code',
316
+ ' self.message = message',
317
+ ' }',
318
+ '}',
319
+ ].join('\n'));
320
+ }
186
321
  parts.push([
187
322
  '/// Param-bind helpers shared by the generated query runners.',
188
323
  'enum SyncularSchemaQueryBind {',
@@ -192,20 +327,11 @@ export function emitQueriesSwiftModule(queries, hash, irVersion, enumName) {
192
327
  ' }',
193
328
  '}',
194
329
  ].join('\n'));
195
- if (queries.some((q) => q.orderBy !== undefined)) {
196
- parts.push([
197
- '/// §6 orderBy direction (shared by every orderBy-knob query).',
198
- 'public enum SyncularQueryDir: String, Sendable {',
199
- ' case asc = "asc"',
200
- ' case desc = "desc"',
201
- '}',
202
- ].join('\n'));
203
- }
204
330
  for (const query of queries) {
331
+ const syqlTypes = emitSyqlSwiftTypes(query);
332
+ if (syqlTypes.length > 0)
333
+ parts.push(syqlTypes.join('\n'));
205
334
  parts.push(emitRowStruct(query).join('\n'));
206
- const orderByEnum = emitOrderByEnum(query);
207
- if (orderByEnum.length > 0)
208
- parts.push(orderByEnum.join('\n'));
209
335
  }
210
336
  const enumLines = [];
211
337
  enumLines.push('/// Typed named queries (the sqlc/SQLDelight tier).');