@syncular/typegen 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -32
- package/dist/cli.js +40 -17
- package/dist/emit-queries-dart.js +168 -55
- package/dist/emit-queries-kotlin.js +167 -55
- package/dist/emit-queries-swift.js +183 -57
- package/dist/emit-queries.js +286 -123
- package/dist/fmt.d.ts +2 -2
- package/dist/fmt.js +348 -316
- package/dist/generate.d.ts +1 -1
- package/dist/generate.js +28 -9
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/lsp.d.ts +1 -3
- package/dist/lsp.js +349 -187
- package/dist/manifest.d.ts +1 -3
- package/dist/manifest.js +1 -1
- package/dist/query-ir.js +53 -42
- package/dist/query.d.ts +110 -63
- package/dist/query.js +89 -11
- package/dist/syql-ast.d.ts +12 -13
- package/dist/syql-ast.js +26 -20
- package/dist/syql-lexer.d.ts +2 -0
- package/dist/syql-lexer.js +9 -2
- package/dist/syql-lowering.d.ts +22 -0
- package/dist/syql-lowering.js +434 -0
- package/dist/syql-parser.d.ts +19 -18
- package/dist/syql-parser.js +341 -93
- package/dist/syql-semantics.d.ts +73 -0
- package/dist/syql-semantics.js +607 -0
- package/dist/syql-template-parser.d.ts +5 -20
- package/dist/syql-template-parser.js +116 -109
- package/dist/syql-validator.d.ts +35 -0
- package/dist/syql-validator.js +993 -0
- package/package.json +3 -3
- package/src/cli.ts +49 -21
- package/src/emit-queries-dart.ts +195 -69
- package/src/emit-queries-kotlin.ts +197 -69
- package/src/emit-queries-swift.ts +224 -68
- package/src/emit-queries.ts +410 -165
- package/src/fmt.ts +405 -303
- package/src/generate.ts +43 -9
- package/src/index.ts +3 -1
- package/src/lsp.ts +414 -216
- package/src/manifest.ts +2 -4
- package/src/query-ir.ts +52 -42
- package/src/query.ts +229 -79
- package/src/syql-ast.ts +38 -34
- package/src/syql-lexer.ts +12 -1
- package/src/syql-lowering.ts +664 -0
- package/src/syql-parser.ts +472 -134
- package/src/syql-semantics.ts +930 -0
- package/src/syql-template-parser.ts +119 -163
- package/src/syql-validator.ts +1486 -0
- package/dist/syql.d.ts +0 -102
- package/dist/syql.js +0 -1141
- package/src/syql.ts +0 -1470
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import type { IrColumnType } from './ir';
|
|
15
15
|
import { snakeToCamel } from './naming';
|
|
16
|
-
import type {
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
if (
|
|
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
|
-
|
|
195
|
+
` val ${query.name}Tables = listOf(${query.tables.map(quote).join(', ')})`,
|
|
196
|
+
'',
|
|
197
|
+
` /** Run the ${query.name} revision-1 SYQL query. */`,
|
|
106
198
|
);
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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[] = [];
|