@an5/orm 1.2.0 → 1.3.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 (35) hide show
  1. package/README.md +1 -1
  2. package/dist/generator/src/api.d.ts +3 -0
  3. package/dist/generator/src/api.js +7 -1
  4. package/dist/generator/src/code-generator.js +12 -1
  5. package/dist/generator/src/code-request.d.ts +2 -2
  6. package/dist/generator/src/code-request.js +24 -3
  7. package/dist/generator/src/config.d.ts +18 -0
  8. package/dist/generator/src/config.js +24 -0
  9. package/dist/generator/src/dialect.js +4 -2
  10. package/dist/generator/src/dotnet-generator.js +97 -1
  11. package/dist/generator/src/field-types.js +3 -0
  12. package/dist/generator/src/golang-generator.d.ts +8 -0
  13. package/dist/generator/src/golang-generator.js +183 -9
  14. package/dist/generator/src/index.js +18 -0
  15. package/dist/generator/src/java-generator.d.ts +56 -0
  16. package/dist/generator/src/java-generator.js +1406 -0
  17. package/dist/generator/src/kotlin-generator.d.ts +34 -0
  18. package/dist/generator/src/kotlin-generator.js +753 -0
  19. package/dist/generator/src/rust-generator.js +36 -1
  20. package/dist/generator/src/swift-generator.d.ts +42 -0
  21. package/dist/generator/src/swift-generator.js +768 -0
  22. package/generator/src/api.ts +3 -0
  23. package/generator/src/code-generator.ts +13 -1
  24. package/generator/src/code-request.ts +17 -3
  25. package/generator/src/config.ts +48 -0
  26. package/generator/src/dialect.ts +5 -3
  27. package/generator/src/dotnet-generator.ts +97 -1
  28. package/generator/src/field-types.ts +3 -0
  29. package/generator/src/golang-generator.ts +184 -9
  30. package/generator/src/index.ts +21 -0
  31. package/generator/src/java-generator.ts +1457 -0
  32. package/generator/src/kotlin-generator.ts +798 -0
  33. package/generator/src/rust-generator.ts +37 -1
  34. package/generator/src/swift-generator.ts +830 -0
  35. package/package.json +2 -2
@@ -0,0 +1,830 @@
1
+ /**
2
+ * The Swift client generator.
3
+ *
4
+ * Emits `an5Client/swift` sources that depend on the runtime in `an5Adapters/swift`:
5
+ * a `struct` per model, typed filter builders, the metadata the runtime reads its tables
6
+ * and columns from, and an `An5Db` that hands out one client per model.
7
+ *
8
+ * Models are value types with an explicit `init(row:)` rather than anything Codable: the
9
+ * runtime's rows are keyed by column label and carry their own nullability, and a Codable
10
+ * conformance would have to invent a second shape that agrees with neither.
11
+ */
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { Model, Field } from './types';
15
+ import { fieldKind } from './type-kinds';
16
+ import { Provider } from './field-types';
17
+
18
+ /** `DECIMAL(10,2)` → `DECIMAL`. */
19
+ function declaredBase(sqlType: string): string {
20
+ return sqlType.replace(/\([^)]*\)/g, ' ').trim().replace(/\s+/g, ' ').toUpperCase();
21
+ }
22
+
23
+ /** Acronyms that must keep their capitals when a name becomes camelCase. */
24
+ const ACRONYMS = ['LLM', 'AI', 'MCP', 'IT', 'QC', 'HR', 'MR', 'WH', 'SSIS', 'API', 'URL', 'ID', 'JSON'];
25
+
26
+ /** Lower-cased words that would collide with a `CaseIterable` member or read oddly. */
27
+ const SWIFT_KEYWORDS = new Set([
28
+ 'associatedtype', 'class', 'deinit', 'enum', 'extension', 'fileprivate', 'func', 'import',
29
+ 'init', 'inout', 'internal', 'let', 'open', 'operator', 'private', 'protocol', 'public',
30
+ 'rethrows', 'static', 'struct', 'subscript', 'typealias', 'var', 'break', 'case', 'continue',
31
+ 'default', 'defer', 'do', 'else', 'fallthrough', 'for', 'guard', 'if', 'in', 'repeat',
32
+ 'return', 'switch', 'where', 'while', 'as', 'catch', 'false', 'is', 'nil', 'super', 'self',
33
+ 'throw', 'throws', 'true', 'try',
34
+ ]);
35
+
36
+ export class SwiftGenerator {
37
+ /** The database being generated for; decides types like `TIMESTAMP`. */
38
+ private provider: Provider | undefined;
39
+
40
+ constructor(private outputDir: string) {
41
+ if (!fs.existsSync(this.outputDir)) {
42
+ fs.mkdirSync(this.outputDir, { recursive: true });
43
+ }
44
+ }
45
+
46
+ public generate(models: Model[]): void {
47
+ this.provider = models[0]?.provider;
48
+
49
+ this.generatePackageManifest();
50
+ this.generateModels(models);
51
+ this.generateOrmTypes(models);
52
+ this.generateMetadata(models);
53
+ this.generateConfig();
54
+ this.generateClient(models);
55
+ }
56
+
57
+ /**
58
+ * The SwiftPM manifest for the generated client.
59
+ *
60
+ * <p>SwiftPM has no "point a build at this folder" story the way a csproj or a go.mod
61
+ * does: a target's sources have to live under `Sources/<Target>/`. Emitting the manifest
62
+ * and that layout together is what makes the output a package an app can depend on,
63
+ * rather than a directory a consumer has to restructure by hand.
64
+ */
65
+ private generatePackageManifest(): void {
66
+ fs.mkdirSync(this.sourceDir, { recursive: true });
67
+ const content = `// swift-tools-version: 5.9
68
+ // This file is auto-generated. Do not edit deliberately.
69
+ import PackageDescription
70
+
71
+ let package = Package(
72
+ name: "An5Client",
73
+ products: [
74
+ .library(name: "An5Client", targets: ["An5Client"])
75
+ ],
76
+ dependencies: [
77
+ .package(url: "https://github.com/an5ORM/an5Adapters.git", from: "${ADAPTER_VERSION}")
78
+ ],
79
+ targets: [
80
+ .target(name: "An5Client", dependencies: [
81
+ .product(name: "An5Adapters", package: "an5Adapters")
82
+ ])
83
+ ]
84
+ )
85
+ `;
86
+ fs.writeFileSync(path.join(this.outputDir, 'Package.swift'), content);
87
+ }
88
+
89
+ /** Where SwiftPM expects a target's sources. */
90
+ private get sourceDir(): string {
91
+ return path.join(this.outputDir, 'Sources', 'An5Client');
92
+ }
93
+
94
+ // ─── Naming ─────────────────────────────────────────────────────────────────────
95
+
96
+ private capitalize(value: string): string {
97
+ return value ? value.charAt(0).toUpperCase() + value.slice(1) : value;
98
+ }
99
+
100
+ /**
101
+ * The property name for a column.
102
+ *
103
+ * Backticked rather than renamed where possible: `default` and `class` are valid column
104
+ * names, and renaming them in the model but not in the SQL is a mismatch nobody notices.
105
+ */
106
+ private property(name: string): string {
107
+ const camel = SwiftGenerator.toCamelCase(name);
108
+ return SWIFT_KEYWORDS.has(camel) ? `\`${camel}\`` : camel;
109
+ }
110
+
111
+ private static toCamelCase(value: string): string {
112
+ if (!value) return value;
113
+ const spaced = ACRONYMS.reduce(
114
+ (text, acronym) => text.replace(new RegExp(`\\b${acronym}\\b`, 'g'), ` ${acronym} `),
115
+ value
116
+ );
117
+ const words = spaced.split(/[\s_\-.]+/).filter(Boolean);
118
+ if (words.length === 0) return value;
119
+ const [first, ...rest] = words;
120
+ return (
121
+ first.charAt(0).toLowerCase() +
122
+ first.slice(1) +
123
+ rest.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join('')
124
+ );
125
+ }
126
+
127
+ private static toLowerCamelCase(value: string): string {
128
+ return SwiftGenerator.toCamelCase(value);
129
+ }
130
+
131
+ // ─── Types ───────────────────────────────────────────────────────────────────────
132
+
133
+ /** The Swift type for a column. */
134
+ private mapType(field: Field): string {
135
+ const base = declaredBase(field.sqlType ?? field.type);
136
+ switch (fieldKind(field, this.provider)) {
137
+ case 'int':
138
+ return 'Int';
139
+ case 'bigint':
140
+ return 'Int64';
141
+ case 'float':
142
+ // `DECIMAL`/`NUMERIC`/`MONEY` stay exact; Swift's `Double` would round money.
143
+ return ['DECIMAL', 'NUMERIC', 'MONEY', 'SMALLMONEY', 'FIXED'].includes(base)
144
+ ? 'Decimal'
145
+ : 'Double';
146
+ case 'bool':
147
+ return 'Bool';
148
+ case 'date':
149
+ if (base === 'DATE') return 'Date';
150
+ return 'Date';
151
+ case 'bytes':
152
+ return 'Data';
153
+ case 'vector':
154
+ return '[Double]';
155
+ case 'json':
156
+ return 'String';
157
+ default:
158
+ return 'String';
159
+ }
160
+ }
161
+
162
+ private mapOptionalType(field: Field): string {
163
+ const mapped = this.mapType(field);
164
+ return `${mapped}?`;
165
+ }
166
+
167
+ /** How a column is read out of a row, as a Swift expression. */
168
+ private readExpression(field: Field): string {
169
+ const base = declaredBase(field.sqlType ?? field.type);
170
+ switch (fieldKind(field, this.provider)) {
171
+ case 'int':
172
+ return `row.int("${field.name}")`;
173
+ case 'bigint':
174
+ return `row.int64("${field.name}")`;
175
+ case 'float':
176
+ return ['DECIMAL', 'NUMERIC', 'MONEY', 'SMALLMONEY', 'FIXED'].includes(base)
177
+ ? `row.decimal("${field.name}")`
178
+ : `row.double("${field.name}")`;
179
+ case 'bool':
180
+ return `row.bool("${field.name}")`;
181
+ case 'date':
182
+ return `row.date("${field.name}")`;
183
+ case 'bytes':
184
+ return `row.data("${field.name}")`;
185
+ case 'vector':
186
+ // A `VECTOR(n)` column is nullable like any other column, so the generated
187
+ // property is `[Double]?` and a row without one reads as `nil`.
188
+ return `row.vector("${field.name}")`;
189
+ default:
190
+ return `row.string("${field.name}")`;
191
+ }
192
+ }
193
+
194
+ private filterType(field: Field): string {
195
+ switch (fieldKind(field, this.provider)) {
196
+ case 'date':
197
+ return 'DateFilter';
198
+ case 'bool':
199
+ return 'BoolFilter';
200
+ case 'int':
201
+ case 'bigint':
202
+ case 'float':
203
+ return 'NumberFilter';
204
+ default:
205
+ return 'StringFilter';
206
+ }
207
+ }
208
+
209
+ // ─── Models ──────────────────────────────────────────────────────────────────────
210
+
211
+ private generateModels(models: Model[]): void {
212
+ for (const model of models) {
213
+ fs.writeFileSync(path.join(this.sourceDir, `${model.name}.swift`), this.modelSource(model));
214
+ }
215
+ }
216
+
217
+ private modelSource(model: Model): string {
218
+ const name = model.name;
219
+ let content = `// This file is auto-generated. Do not edit deliberately.
220
+ import Foundation
221
+ import An5Adapters
222
+
223
+ /// ${model.description ? escapeDoc(model.description) : `The \`${name}\` model.`}
224
+ public struct ${name} {
225
+ `;
226
+
227
+ for (const field of model.fields) {
228
+ const property = this.property(field.name);
229
+ const type = this.mapOptionalType(field);
230
+ content += ` /// \`${field.sqlType}\`${field.isId ? ', primary key' : ''}${field.description ? ` — ${escapeDoc(field.description)}` : ''}\n`;
231
+ content += ` public let ${property}: ${type}\n`;
232
+ }
233
+
234
+ for (const rel of model.relations) {
235
+ const property = this.property(rel.name);
236
+ const type = rel.isArray ? `[${rel.type}]` : `${rel.type}?`;
237
+ content += `\n /// Eager-loaded with \`include: ["${rel.name}": true]\`.\n`;
238
+ content += ` public var ${property}: ${type} = ${rel.isArray ? '[]' : 'nil'}\n`;
239
+ }
240
+
241
+ content += `\n public init(\n`;
242
+ content += model.fields
243
+ .map(
244
+ (field) =>
245
+ ` ${this.property(field.name)}: ${this.mapOptionalType(field)} = nil${field === model.fields[model.fields.length - 1] ? '' : ','}`
246
+ )
247
+ .join('\n');
248
+ content += `\n ) {\n`;
249
+ content += model.fields
250
+ .map((field) => ` self.${this.property(field.name)} = ${this.property(field.name)}`)
251
+ .join('\n');
252
+ content += `\n }\n\n`;
253
+
254
+ content += ` /// Reads \`${name}\` from a database row.\n`;
255
+ content += ` ///\n`;
256
+ content += ` /// Every column goes through a converter rather than a cast: a \`BOOL\` arrives as an\n`;
257
+ content += ` /// \`Int\` on one driver and \`Bool\` on another, and a \`NUMERIC\` column still arrives\n`;
258
+ content += ` /// as a \`Decimal\` even when it would fit in an \`Int\`.\n`;
259
+ content += ` ///\n`;
260
+ content += ` /// Eager-loaded relations arrive as nested rows and are converted the same way; one\n`;
261
+ content += ` /// that was not asked for keeps its default, so an empty \`orders\` does not say\n`;
262
+ content += ` /// whether the query included it.\n`;
263
+ content += ` public init(row: Row) {\n`;
264
+ content += model.fields
265
+ .map((field) => ` self.${this.property(field.name)} = ${this.readExpression(field)}`)
266
+ .join('\n');
267
+ if (model.relations.length > 0) {
268
+ content += '\n';
269
+ for (const rel of model.relations) {
270
+ const property = this.property(rel.name);
271
+ if (rel.isArray) {
272
+ content += ` self.${property} = row.related("${rel.name}").map { ${rel.type}(row: $0) }\n`;
273
+ } else {
274
+ content += ` self.${property} = row.relatedOne("${rel.name}").map { ${rel.type}(row: $0) }\n`;
275
+ }
276
+ }
277
+ }
278
+ content += `\n }\n\n`;
279
+
280
+ content += ` /// The columns to write, in declaration order, with \`nil\` left out.\n`;
281
+ content += ` ///\n`;
282
+ content += ` /// An unset column takes the schema's DEFAULT, which is what leaving it out means —\n`;
283
+ content += ` /// and what \`update\` needs so a partial value does not blank every other column.\n`;
284
+ content += ` public var values: Values {\n`;
285
+ content += ` var values = Values()\n`;
286
+ content += model.fields
287
+ .map(
288
+ (field) =>
289
+ ` if let value = self.${this.property(field.name)} { values["${field.name}"] = value }`
290
+ )
291
+ .join('\n');
292
+ content += `\n return values\n }\n}\n`;
293
+ return content;
294
+ }
295
+
296
+ // ─── ORM types ───────────────────────────────────────────────────────────────────
297
+
298
+ private generateOrmTypes(models: Model[]): void {
299
+ let content = `// This file is auto-generated. Do not edit deliberately.
300
+ import Foundation
301
+ import An5Adapters
302
+
303
+ /// Typed filters for the generated models.
304
+ ///
305
+ /// A filter is a small builder whose \`build()\` produces the filter tree the runtime's SQL
306
+ /// builder reads, so a query is checked at the call site and still compiles down to bind
307
+ /// parameters rather than interpolated SQL.
308
+ public enum An5Orm {
309
+
310
+ /// A filter on a string column.
311
+ public struct StringFilter {
312
+ private var operators: Where = [:]
313
+
314
+ public static func \`is\`(_ value: String?) -> StringFilter {
315
+ var filter = StringFilter()
316
+ filter.operators["equals"] = value
317
+ return filter
318
+ }
319
+
320
+ public static func isNot(_ value: String?) -> StringFilter {
321
+ var filter = StringFilter()
322
+ filter.operators["not"] = value
323
+ return filter
324
+ }
325
+
326
+ public static func has(_ value: String) -> StringFilter {
327
+ var filter = StringFilter()
328
+ filter.operators["contains"] = value
329
+ return filter
330
+ }
331
+
332
+ public static func starts(_ value: String) -> StringFilter {
333
+ var filter = StringFilter()
334
+ filter.operators["startsWith"] = value
335
+ return filter
336
+ }
337
+
338
+ public static func ends(_ value: String) -> StringFilter {
339
+ var filter = StringFilter()
340
+ filter.operators["endsWith"] = value
341
+ return filter
342
+ }
343
+
344
+ public func inList(_ values: [String]) -> StringFilter {
345
+ var filter = self
346
+ filter.operators["in"] = values
347
+ return filter
348
+ }
349
+
350
+ public func notInList(_ values: [String]) -> StringFilter {
351
+ var filter = self
352
+ filter.operators["notIn"] = values
353
+ return filter
354
+ }
355
+
356
+ /// The filter as the runtime reads it.
357
+ public func build() -> Where { operators }
358
+ }
359
+
360
+ /// A filter on an integer or floating-point column.
361
+ public struct NumberFilter {
362
+ private var operators: Where = [:]
363
+
364
+ public static func \`is\`(_ value: Double?) -> NumberFilter {
365
+ var filter = NumberFilter()
366
+ filter.operators["equals"] = value
367
+ return filter
368
+ }
369
+
370
+ public static func atLeast(_ value: Double?) -> NumberFilter {
371
+ var filter = NumberFilter()
372
+ filter.operators["gte"] = value
373
+ return filter
374
+ }
375
+
376
+ public static func atMost(_ value: Double?) -> NumberFilter {
377
+ var filter = NumberFilter()
378
+ filter.operators["lte"] = value
379
+ return filter
380
+ }
381
+
382
+ public static func greater(_ value: Double?) -> NumberFilter {
383
+ var filter = NumberFilter()
384
+ filter.operators["gt"] = value
385
+ return filter
386
+ }
387
+
388
+ public static func less(_ value: Double?) -> NumberFilter {
389
+ var filter = NumberFilter()
390
+ filter.operators["lt"] = value
391
+ return filter
392
+ }
393
+
394
+ public func inList(_ values: [Double]) -> NumberFilter {
395
+ var filter = self
396
+ filter.operators["in"] = values
397
+ return filter
398
+ }
399
+
400
+ /// The filter as the runtime reads it.
401
+ public func build() -> Where { operators }
402
+ }
403
+
404
+ /// A filter on a boolean column.
405
+ public struct BoolFilter {
406
+ private var operators: Where = [:]
407
+
408
+ public static func \`is\`(_ value: Bool?) -> BoolFilter {
409
+ var filter = BoolFilter()
410
+ filter.operators["equals"] = value
411
+ return filter
412
+ }
413
+
414
+ /// The filter as the runtime reads it.
415
+ public func build() -> Where { operators }
416
+ }
417
+
418
+ /// A filter on a date column.
419
+ public struct DateFilter {
420
+ private var operators: Where = [:]
421
+
422
+ public static func \`is\`(_ value: Date?) -> DateFilter {
423
+ var filter = DateFilter()
424
+ filter.operators["equals"] = value
425
+ return filter
426
+ }
427
+
428
+ public static func atLeast(_ value: Date?) -> DateFilter {
429
+ var filter = DateFilter()
430
+ filter.operators["gte"] = value
431
+ return filter
432
+ }
433
+
434
+ public static func atMost(_ value: Date?) -> DateFilter {
435
+ var filter = DateFilter()
436
+ filter.operators["lte"] = value
437
+ return filter
438
+ }
439
+
440
+ /// The filter as the runtime reads it.
441
+ public func build() -> Where { operators }
442
+ }
443
+ `;
444
+
445
+ for (const model of models) {
446
+ const name = model.name;
447
+ content += `
448
+ /// A \`WHERE\` for ${name}. Unset filters are left out.
449
+ public struct ${name}Where {
450
+ private var operators: Where = [:]
451
+ ${model.fields.map((field) => ` public var ${this.property(field.name)}: ${this.filterType(field)}?`).join('\n')}
452
+
453
+ /// Creates a filter with every column unset; assign the ones you want to match.
454
+ public init() {}
455
+
456
+ public func and(_ clauses: ${name}Where...) -> ${name}Where {
457
+ var filter = self
458
+ filter.operators["AND"] = clauses.map { $0.build() }
459
+ return filter
460
+ }
461
+
462
+ public func or(_ clauses: ${name}Where...) -> ${name}Where {
463
+ var filter = self
464
+ filter.operators["OR"] = clauses.map { $0.build() }
465
+ return filter
466
+ }
467
+
468
+ public func not(_ clause: ${name}Where) -> ${name}Where {
469
+ var filter = self
470
+ filter.operators["NOT"] = [clause.build()]
471
+ return filter
472
+ }
473
+
474
+ /// The filter tree as the runtime reads it.
475
+ ///
476
+ /// Column conditions are read from the properties here rather than written into
477
+ /// \`operators\` as they are assigned, because a condition dropped on the way in is not
478
+ /// an error — it is a filter that quietly matches every row.
479
+ public func build() -> Where {
480
+ var filter = operators
481
+ ${model.fields
482
+ .map((field) => {
483
+ const property = this.property(field.name);
484
+ return ` if let ${property} = ${property} { filter["${field.name}"] = ${property}.build() }`;
485
+ })
486
+ .join('\n')}
487
+ return filter
488
+ }
489
+ }
490
+
491
+ /// An \`ORDER BY\` for ${name}.
492
+ public struct ${name}OrderBy {
493
+ private var entries: [[String: String]] = []
494
+
495
+ public init() {}
496
+
497
+ /// Orders by the columns, ascending.
498
+ public func asc(_ columns: String...) -> ${name}OrderBy {
499
+ var order = self
500
+ for column in columns { order.entries.append([column: "asc"]) }
501
+ return order
502
+ }
503
+
504
+ /// Orders by the column, descending.
505
+ public func desc(_ column: String) -> ${name}OrderBy {
506
+ var order = self
507
+ order.entries.append([column: "desc"])
508
+ return order
509
+ }
510
+
511
+ /// The sort as the runtime reads it.
512
+ public func build() -> [[String: String]] { entries }
513
+ }
514
+ `;
515
+ }
516
+
517
+ content += `}\n`;
518
+ fs.writeFileSync(path.join(this.sourceDir, 'An5OrmTypes.swift'), content);
519
+ }
520
+
521
+ // ─── Metadata ────────────────────────────────────────────────────────────────────
522
+
523
+ private generateMetadata(models: Model[]): void {
524
+ let content = `// This file is auto-generated. Do not edit deliberately.
525
+ import Foundation
526
+ import An5Adapters
527
+
528
+ /// The generated models' tables, columns and relations.
529
+ ///
530
+ /// Passed to the runtime when a database is opened. Without it the table clients have no
531
+ /// table names to work with and no primary key to fill in.
532
+ public enum An5Metadata {
533
+
534
+ /// Model name to table name, schema-qualified.
535
+ public static let modelToTable: [String: String] = [
536
+ ${models.map((model) => ` "${model.name}": "${this.tableName(model)}",`).join('\n')}
537
+ ]
538
+
539
+ /// Model name to its columns, as the runtime reads them.
540
+ public static let modelFields: [String: [[String: Any?]]] = [
541
+ ${models
542
+ .map(
543
+ (model) => ` "${model.name}": [
544
+ ${model.fields
545
+ .map(
546
+ (field) =>
547
+ ` ["name": "${field.name}", "type": "${field.type}", "sql": "${field.sqlType}", "isOptional": ${field.isOptional}, "hasDefault": ${field.hasDefault}, "isId": ${field.isId}${field.description ? `, "description": ${swiftString(field.description)}` : ''}],`
548
+ )
549
+ .join('\n')}
550
+ ],`
551
+ )
552
+ .join('\n')}
553
+ ]
554
+
555
+ /// Model name to its relations, keyed by relation name.
556
+ public static let relationMap: [String: [String: [String: String]]] = ${
557
+ models.some((model) => model.relations.length > 0) ? '[' : '[:]'
558
+ }
559
+ ${
560
+ models.some((model) => model.relations.length > 0)
561
+ ? models
562
+ .filter((model) => model.relations.length > 0)
563
+ .map(
564
+ (model) => ` "${model.name}": [
565
+ ${model.relations
566
+ .map(
567
+ (rel) =>
568
+ ` "${rel.name}": ["modelName": "${rel.type}", "relationType": "${rel.relationName}", "foreignKey": "${rel.foreignKey}", "localKey": "${rel.localKey}"],`
569
+ )
570
+ .join('\n')}
571
+ ],`
572
+ )
573
+ .join('\n') + '\n ]'
574
+ : ''
575
+ }
576
+
577
+ /// This schema, in the shape the runtime reads.
578
+ public static var metadata: Metadata {
579
+ Metadata([
580
+ "modelToTable": modelToTable,
581
+ "modelFields": modelFields,
582
+ "relationMap": relationMap,
583
+ ]) ?? .empty
584
+ }
585
+ }
586
+ `;
587
+ fs.writeFileSync(path.join(this.sourceDir, 'An5Metadata.swift'), content);
588
+ }
589
+
590
+ // ─── Config ──────────────────────────────────────────────────────────────────────
591
+
592
+ private generateConfig(): void {
593
+ const content = `// This file is auto-generated. Do not edit deliberately.
594
+ import Foundation
595
+ import An5Adapters
596
+
597
+ /// Where the connection string comes from.
598
+ ///
599
+ /// The \`AN5_DATABASE_URL\` environment variable first, then the \`AN5_CONNECTION_STRING\`
600
+ /// Info.plist entry an app bundle carries, so one build can be pointed at a different
601
+ /// database without a recompile.
602
+ public enum An5Config {
603
+
604
+ public static func connectionString() throws -> String {
605
+ if let fromEnvironment = ProcessInfo.processInfo.environment["AN5_DATABASE_URL"],
606
+ !fromEnvironment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
607
+ return fromEnvironment.trimmingCharacters(in: .whitespacesAndNewlines)
608
+ }
609
+ if let fromBundle = Bundle.main.object(forInfoDictionaryKey: "AN5_CONNECTION_STRING") as? String,
610
+ !fromBundle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
611
+ return fromBundle.trimmingCharacters(in: .whitespacesAndNewlines)
612
+ }
613
+ throw An5Error.configuration(
614
+ "no connection string: set AN5_DATABASE_URL or the AN5_CONNECTION_STRING Info.plist entry")
615
+ }
616
+ }
617
+ `;
618
+ fs.writeFileSync(path.join(this.sourceDir, 'An5Config.swift'), content);
619
+ }
620
+
621
+ // ─── Client ──────────────────────────────────────────────────────────────────────
622
+
623
+ private generateClient(models: Model[]): void {
624
+ let accessors = models.map((model) => ({
625
+ property: SwiftGenerator.toLowerCamelCase(model.name),
626
+ name: model.name,
627
+ }));
628
+ const unique = new Map<string, { property: string; name: string }>();
629
+ for (const entry of accessors) {
630
+ const key = SwiftGenerator.toLowerCamelCase(entry.name);
631
+ if (!unique.has(key)) unique.set(key, { property: key, name: entry.name });
632
+ }
633
+
634
+ let content = `// This file is auto-generated. Do not edit deliberately.
635
+ import Foundation
636
+ import An5Adapters
637
+
638
+ /// A typed client for one model.
639
+ ///
640
+ /// The runtime's \`TableClient\` works on untyped rows so it stays independent of the
641
+ /// schema; this adds the model's own type on top, which is what makes
642
+ /// \`db.${Array.from(unique.values())[0]?.property ?? 'user'}.findMany()\` come back as
643
+ /// \`[${models[0]?.name ?? 'User'}]\` rather than \`[Row]\`.
644
+ public struct ModelClient<T> {
645
+
646
+ private let table: TableClient
647
+ private let read: (Row) -> T
648
+ private let write: (T) -> Values
649
+
650
+ init(table: TableClient, read: @escaping (Row) -> T, write: @escaping (T) -> Values) {
651
+ self.table = table
652
+ self.read = read
653
+ self.write = write
654
+ }
655
+
656
+ /// The model this client reads and writes.
657
+ public var model: String { table.model }
658
+
659
+ public func findMany() throws -> [T] { try table.findMany().map { read($0) } }
660
+
661
+ public func findMany(_ query: Query) throws -> [T] { try table.findMany(query).map { read($0) } }
662
+
663
+ public func findMany(filter: Where) throws -> [T] { try table.findMany(filter: filter).map { read($0) } }
664
+
665
+ public func findFirst(_ query: Query = Query()) throws -> T? { try table.findFirst(query).map { read($0) } }
666
+
667
+ public func findFirst(filter: Where) throws -> T? { try table.findFirst(filter: filter).map { read($0) } }
668
+
669
+ public func findUnique(filter: Where) throws -> T? { try table.findUnique(filter: filter).map { read($0) } }
670
+
671
+ public func count(_ filter: Where? = nil) throws -> Int { try table.count(filter) }
672
+
673
+ /// Inserts a value and returns it as stored.
674
+ @discardableResult
675
+ public func create(_ value: T) throws -> T { read(try table.create(write(value))) }
676
+
677
+ /// Inserts a value and returns it with the relations the query asked for.
678
+ @discardableResult
679
+ public func create(_ value: T, _ query: Query) throws -> T { read(try table.create(write(value), query)) }
680
+
681
+ /// Inserts many values, one statement each.
682
+ @discardableResult
683
+ public func createMany(_ values: [T], skipDuplicates: Bool = false) throws -> Int {
684
+ try table.createMany(values.map(write), skipDuplicates: skipDuplicates)
685
+ }
686
+
687
+ /// Updates the matching rows and returns the first of them re-read.
688
+ @discardableResult
689
+ public func update(filter: Where, data: T) throws -> T? { try table.update(filter: filter, data: write(data)).map { read($0) } }
690
+
691
+ /// Updates every matching row and gives back how many changed.
692
+ @discardableResult
693
+ public func updateMany(filter: Where?, data: T) throws -> Int { try table.updateMany(filter: filter, data: write(data)) }
694
+
695
+ /// Deletes the matching rows and returns the one that was there first.
696
+ @discardableResult
697
+ public func delete(filter: Where) throws -> T? { try table.delete(filter: filter).map { read($0) } }
698
+
699
+ /// Deletes every matching row, or the whole table when \`filter\` is \`nil\`.
700
+ @discardableResult
701
+ public func deleteMany(filter: Where? = nil) throws -> Int { try table.deleteMany(filter: filter) }
702
+
703
+ /// Updates the matching row when it exists, creates it otherwise.
704
+ @discardableResult
705
+ public func upsert(filter: Where, create: T, update: T) throws -> T {
706
+ read(try table.upsert(filter: filter, create: write(create), update: write(update)))
707
+ }
708
+
709
+ /// One row of aggregate values, keyed \`_count\`, \`_sum_<field>\` and so on.
710
+ public func aggregate(_ aggregate: Aggregate) throws -> Row { try table.aggregate(aggregate) }
711
+
712
+ /// One row per group, each carrying the group's count and aggregates.
713
+ public func groupBy(_ aggregate: Aggregate) throws -> [Row] { try table.groupBy(aggregate) }
714
+
715
+ /// The rows nearest a vector.
716
+ public func vectorSearch(
717
+ _ vector: [Double],
718
+ take: Int = 10,
719
+ filter: Where? = nil,
720
+ vectorField: String = "embedding",
721
+ metric: DistanceMetric = .cosine
722
+ ) throws -> [T] {
723
+ try table.vectorSearch(vector, take: take, filter: filter, vectorField: vectorField, metric: metric).map { read($0) }
724
+ }
725
+ }
726
+
727
+ /// The AN5 entry point: one typed client per model, over one connection.
728
+ ///
729
+ /// \`\`\`
730
+ /// let db = try An5Db(path: ":memory:")
731
+ /// let ada = try db.user.findUnique(filter: ["name": "Ada"])
732
+ /// \`\`\`
733
+ public final class An5Db {
734
+
735
+ /// The runtime underneath, for raw SQL and for anything the typed clients miss.
736
+ public let an5: An5Adapter
737
+
738
+ ${Array.from(unique.values())
739
+ .map((entry) => ` /// Queries for \`${entry.name}\`.\n public let ${entry.property}: ModelClient<${entry.name}>`)
740
+ .join('\n\n')}
741
+
742
+ /// Opens a database from a connection string, registering this schema with the runtime.
743
+ public convenience init(connectionString: String) throws {
744
+ let driver = try SQLiteDriver(path: An5Db.path(connectionString))
745
+ self.init(adapter: An5Adapter(driver: driver,
746
+ connectionString: connectionString,
747
+ metadata: An5Metadata.metadata))
748
+ }
749
+
750
+ /// Opens an in-memory or on-disk database, registering this schema with the runtime.
751
+ public convenience init(path: String) throws {
752
+ let driver = try SQLiteDriver(path: path)
753
+ self.init(adapter: An5Adapter(driver: driver,
754
+ connectionString: "sqlite::memory:",
755
+ metadata: An5Metadata.metadata))
756
+ }
757
+
758
+ /// Wraps a runtime the caller already opened.
759
+ public init(adapter: An5Adapter) {
760
+ self.an5 = adapter
761
+ ${Array.from(unique.values())
762
+ .map(
763
+ (entry) =>
764
+ ` self.${entry.property} = ModelClient(\n table: adapter.table("${entry.name}"),\n read: { ${entry.name}(row: $0) },\n write: { $0.values })`
765
+ )
766
+ .join('\n')}
767
+ }
768
+
769
+ /// A typed client for a table outside this schema.
770
+ public func table<T>(_ model: String, read: @escaping (Row) -> T, write: @escaping (T) -> Values) -> ModelClient<T> {
771
+ ModelClient(table: an5.table(model), read: read, write: write)
772
+ }
773
+
774
+ /// A read-only client for a database view.
775
+ public func view(_ name: String) -> ViewClient { an5.view(name) }
776
+
777
+ /// The dialect the connection string points at.
778
+ public var dialect: Dialect { an5.dialect }
779
+
780
+ /// Runs a query and returns its rows keyed by column label.
781
+ public func query(_ sql: String, _ parameters: [Any?] = []) throws -> [Row] {
782
+ try an5.query(sql, parameters)
783
+ }
784
+
785
+ /// Runs a statement that returns no rows and gives back the affected row count.
786
+ @discardableResult
787
+ public func execute(_ sql: String, _ parameters: [Any?] = []) throws -> Int {
788
+ try an5.execute(sql, parameters)
789
+ }
790
+
791
+ /// Runs \`body\` inside a transaction, committing on return and rolling back on failure.
792
+ public func transaction<T>(_ body: (An5Adapter) throws -> T) throws -> T {
793
+ try an5.transaction(body)
794
+ }
795
+
796
+ private static func path(_ connectionString: String) -> String {
797
+ for prefix in ["sqlite:///", "sqlite://", "sqlite:"] where connectionString.hasPrefix(prefix) {
798
+ return String(connectionString.dropFirst(prefix.count))
799
+ }
800
+ return connectionString
801
+ }
802
+ }
803
+ `;
804
+ fs.writeFileSync(path.join(this.sourceDir, 'An5Db.swift'), content);
805
+ }
806
+
807
+ // ─── Helpers ─────────────────────────────────────────────────────────────────────
808
+
809
+ private tableName(model: Model): string {
810
+ return model.schemaName ? `${model.schemaName}.${model.tableName}` : model.tableName;
811
+ }
812
+ }
813
+
814
+ /** The runtime version the generated client declares a dependency on. */
815
+ const ADAPTER_VERSION = '0.2.11';
816
+
817
+ /** A Swift string literal for a schema description. */
818
+ function swiftString(value: string): string {
819
+ return JSON.stringify(value);
820
+ }
821
+
822
+ /**
823
+ * Doc-comment text.
824
+ *
825
+ * A comment terminator inside a description would end this comment early and leave the rest
826
+ * of the line as code, so it is split.
827
+ */
828
+ function escapeDoc(value: string): string {
829
+ return value.replace(/\*\//g, '*\\/').replace(/\r?\n/g, ' ');
830
+ }