@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,753 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.KotlinGenerator = void 0;
7
+ /**
8
+ * The Kotlin client generator.
9
+ *
10
+ * Emits `an5Client/kotlin` sources that depend on the Kotlin runtime in
11
+ * `an5Adapters/kotlin` — the typed front door over the JVM adapter. Models are `data class`
12
+ * values, so equality, copying and destructuring come from the language rather than from
13
+ * generated code, and every read goes through the runtime's nullable accessors so a `NULL`
14
+ * column stays `null` instead of becoming `0` or `""` and being written back on the next
15
+ * update.
16
+ */
17
+ const fs_1 = __importDefault(require("fs"));
18
+ const path_1 = __importDefault(require("path"));
19
+ const type_kinds_1 = require("./type-kinds");
20
+ /** `DECIMAL(10,2)` → `DECIMAL`. */
21
+ function declaredBase(sqlType) {
22
+ return sqlType.replace(/\([^)]*\)/g, ' ').trim().replace(/\s+/g, ' ').toUpperCase();
23
+ }
24
+ /** Acronyms that must keep their capitals when a name becomes camelCase. */
25
+ const ACRONYMS = ['LLM', 'AI', 'MCP', 'IT', 'QC', 'HR', 'MR', 'WH', 'SSIS', 'API', 'URL', 'ID', 'JSON'];
26
+ /** Kotlin's hard keywords, which cannot be used as identifiers. */
27
+ const KOTLIN_KEYWORDS = new Set([
28
+ 'as', 'break', 'class', 'continue', 'do', 'else', 'false', 'for', 'fun', 'if', 'in',
29
+ 'interface', 'is', 'null', 'object', 'package', 'return', 'super', 'this', 'throw',
30
+ 'true', 'try', 'typealias', 'typeof', 'val', 'var', 'when', 'while',
31
+ ]);
32
+ /** Members of `Any` and the stdlib that a field named after them would shadow. */
33
+ const KOTLIN_SHADOWED = new Set([
34
+ 'Any', 'Nothing', 'Unit', 'String', 'Int', 'Long', 'Short', 'Byte', 'Double', 'Float',
35
+ 'Boolean', 'Char', 'List', 'Map', 'Set', 'Array', 'Pair', 'Triple', 'Sequence', 'Result',
36
+ 'Companion', 'Enum', 'Throwable', 'Number',
37
+ ]);
38
+ class KotlinGenerator {
39
+ constructor(outputDir) {
40
+ this.outputDir = outputDir;
41
+ if (!fs_1.default.existsSync(this.outputDir)) {
42
+ fs_1.default.mkdirSync(this.outputDir, { recursive: true });
43
+ }
44
+ }
45
+ generate(models) {
46
+ this.provider = models[0]?.provider;
47
+ this.generateModels(models);
48
+ this.generateOrmTypes(models);
49
+ this.generateMetadata(models);
50
+ this.generateConfig();
51
+ this.generateClient(models);
52
+ }
53
+ // ─── Naming ─────────────────────────────────────────────────────────────────────
54
+ capitalize(value) {
55
+ return value ? value.charAt(0).toUpperCase() + value.slice(1) : value;
56
+ }
57
+ /**
58
+ * The property name for a column.
59
+ *
60
+ * Backticked rather than renamed where possible, so `when` stays `when`: renaming would
61
+ * change the name in the generated data class but not in the SQL, and the mismatch is
62
+ * harder to notice than a backtick.
63
+ */
64
+ property(name) {
65
+ const camel = KotlinGenerator.toCamelCase(name);
66
+ return KOTLIN_KEYWORDS.has(camel) || KOTLIN_SHADOWED.has(camel) ? `\`${camel}\`` : camel;
67
+ }
68
+ static toCamelCase(value) {
69
+ if (!value)
70
+ return value;
71
+ const spaced = ACRONYMS.reduce((text, acronym) => text.replace(new RegExp(`\\b${acronym}\\b`, 'g'), ` ${acronym} `), value);
72
+ const words = spaced.split(/[\s_\-.]+/).filter(Boolean);
73
+ if (words.length === 0)
74
+ return value;
75
+ const [first, ...rest] = words;
76
+ return (first.charAt(0).toLowerCase() +
77
+ first.slice(1) +
78
+ rest.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(''));
79
+ }
80
+ static toLowerCamelCase(value) {
81
+ return KotlinGenerator.toCamelCase(value);
82
+ }
83
+ // ─── Types ───────────────────────────────────────────────────────────────────────
84
+ /** The Kotlin type for a column. */
85
+ mapType(field) {
86
+ const base = declaredBase(field.sqlType ?? field.type);
87
+ switch ((0, type_kinds_1.fieldKind)(field, this.provider)) {
88
+ case 'int':
89
+ return 'Int';
90
+ case 'bigint':
91
+ return 'Long';
92
+ case 'float':
93
+ // `DECIMAL`/`NUMERIC`/`MONEY` stay exact; a floating-point column does not need to.
94
+ return ['DECIMAL', 'NUMERIC', 'MONEY', 'SMALLMONEY', 'FIXED'].includes(base)
95
+ ? 'BigDecimal'
96
+ : 'Double';
97
+ case 'bool':
98
+ return 'Boolean';
99
+ case 'date':
100
+ if (base === 'DATETIMEOFFSET' || base === 'TIMESTAMPTZ')
101
+ return 'OffsetDateTime';
102
+ if (base === 'TIME' || base === 'TIMETZ')
103
+ return 'LocalTime';
104
+ if (base === 'DATE')
105
+ return 'LocalDate';
106
+ return 'LocalDateTime';
107
+ case 'bytes':
108
+ return 'ByteArray';
109
+ case 'vector':
110
+ return 'DoubleArray';
111
+ case 'json':
112
+ return 'String';
113
+ default:
114
+ return ['GUID', 'UUID', 'UNIQUEIDENTIFIER'].includes(base) ? 'UUID' : 'String';
115
+ }
116
+ }
117
+ /** The nullable form, which is what every generated property is: a column may be `NULL` until written. */
118
+ mapNullableType(field) {
119
+ const mapped = this.mapType(field);
120
+ return mapped.endsWith('?') ? mapped : `${mapped}?`;
121
+ }
122
+ /** The runtime accessor that reads a column into its type. */
123
+ readAccessor(field) {
124
+ const base = declaredBase(field.sqlType ?? field.type);
125
+ switch ((0, type_kinds_1.fieldKind)(field, this.provider)) {
126
+ case 'int':
127
+ return 'intOrNull';
128
+ case 'bigint':
129
+ return 'longOrNull';
130
+ case 'float':
131
+ return ['DECIMAL', 'NUMERIC', 'MONEY', 'SMALLMONEY', 'FIXED'].includes(base)
132
+ ? 'decimalOrNull'
133
+ : 'doubleOrNull';
134
+ case 'bool':
135
+ return 'boolOrNull';
136
+ case 'date':
137
+ if (base === 'DATETIMEOFFSET' || base === 'TIMESTAMPTZ')
138
+ return 'stringOrNull';
139
+ if (base === 'TIME' || base === 'TIMETZ')
140
+ return 'localDateTimeOrNull';
141
+ if (base === 'DATE')
142
+ return 'localDateOrNull';
143
+ return 'localDateTimeOrNull';
144
+ case 'bytes':
145
+ return 'bytesOrNull';
146
+ case 'vector':
147
+ return 'vectorOrNull';
148
+ default:
149
+ return ['GUID', 'UUID', 'UNIQUEIDENTIFIER'].includes(base) ? 'uuidOrNull' : 'stringOrNull';
150
+ }
151
+ }
152
+ filterType(field) {
153
+ switch ((0, type_kinds_1.fieldKind)(field, this.provider)) {
154
+ case 'date':
155
+ return 'DateFilter';
156
+ case 'bool':
157
+ return 'BoolFilter';
158
+ case 'int':
159
+ case 'bigint':
160
+ case 'float':
161
+ return 'NumberFilter';
162
+ default:
163
+ return 'StringFilter';
164
+ }
165
+ }
166
+ // ─── Models ──────────────────────────────────────────────────────────────────────
167
+ generateModels(models) {
168
+ for (const model of models) {
169
+ fs_1.default.writeFileSync(path_1.default.join(this.outputDir, `${model.name}.kt`), this.modelSource(model));
170
+ }
171
+ }
172
+ modelSource(model) {
173
+ const name = model.name;
174
+ let content = `// This file is auto-generated. Do not edit directly.
175
+ @file:Suppress("RedundantVisibilityModifier", "unused")
176
+
177
+ package an5.client
178
+
179
+ ${[
180
+ ...new Set(model.fields
181
+ .map((field) => this.mapType(field))
182
+ .flatMap((type) => {
183
+ if (type === 'BigDecimal')
184
+ return ['java.math.BigDecimal'];
185
+ if (type === 'UUID')
186
+ return ['java.util.UUID'];
187
+ if (['LocalDateTime', 'LocalDate', 'LocalTime', 'OffsetDateTime'].includes(type)) {
188
+ return [`java.time.${type}`];
189
+ }
190
+ return [];
191
+ })),
192
+ 'an5.adapters.Data as Values',
193
+ 'an5.adapters.Row',
194
+ ...new Set(model.fields.map((field) => `an5.adapters.${this.readAccessor(field)}`)),
195
+ ...(model.relations.some((rel) => rel.isArray) ? ['an5.adapters.related'] : []),
196
+ ...(model.relations.some((rel) => !rel.isArray) ? ['an5.adapters.relatedOne'] : []),
197
+ ]
198
+ .map((entry) => `import ${entry}`)
199
+ .join('\n')}
200
+
201
+ /**
202
+ * ${model.description ? escapeDoc(model.description) : `The \`$name\` model.`}
203
+ *
204
+ * Every property is nullable and defaults to \`null\`, because \`update\` and \`upsert\` take a
205
+ * partly filled value and a property left alone has to stay out of the statement.
206
+ */
207
+ data class ${name}(
208
+ `;
209
+ const properties = [
210
+ ...model.relations.map((rel) => ({ name: rel.name, type: rel.isArray ? `List<${rel.type}>` : `${rel.type}?` })),
211
+ ...model.fields.map((field) => ({ name: field.name, type: this.mapNullableType(field) })),
212
+ ];
213
+ if (properties.length === 0) {
214
+ content += `) {\n`;
215
+ }
216
+ else {
217
+ content += properties
218
+ .map((entry, index) => ` val ${this.property(entry.name)}: ${entry.type} = ${this.defaultFor(entry.type)}${index === properties.length - 1 ? '' : ','}`)
219
+ .join('\n');
220
+ content += `\n) {\n`;
221
+ }
222
+ content += `
223
+ /**
224
+ * The columns to write, in declaration order, with \`null\` left out.
225
+ *
226
+ * An unset column takes the schema's DEFAULT, which is what leaving it out means.
227
+ */
228
+ fun toValues(): Values = buildValues {
229
+ `;
230
+ for (const field of model.fields) {
231
+ content += ` this["${field.name}"] = this@${name}.${this.property(field.name)}\n`;
232
+ }
233
+ content += ` }
234
+
235
+ companion object {
236
+ /**
237
+ * Reads a ${name} out of a database row.
238
+ *
239
+ * Eager-loaded relations arrive as nested rows and are converted the same way; one
240
+ * that was not asked for stays at its default, so an empty \`orders\` does not say
241
+ * whether the query included it.
242
+ */
243
+ fun fromRow(row: Row): ${name} = ${name}(
244
+ `;
245
+ for (const field of model.fields) {
246
+ const accessor = this.readAccessor(field);
247
+ content += ` ${this.property(field.name)} = row.${accessor}("${field.name}"),\n`;
248
+ }
249
+ for (const rel of model.relations) {
250
+ const property = this.property(rel.name);
251
+ if (rel.isArray) {
252
+ content += ` ${property} = row.related("${rel.name}").map { ${rel.type}.fromRow(it) },\n`;
253
+ }
254
+ else {
255
+ content += ` ${property} = row.relatedOne("${rel.name}")?.let { ${rel.type}.fromRow(it) },\n`;
256
+ }
257
+ }
258
+ content += ` )
259
+ }
260
+ }
261
+
262
+ /** Collects column values, skipping the ones left unset. */
263
+ private inline fun buildValues(block: MutableMap<String, Any?>.() -> Unit): Values {
264
+ val values = LinkedHashMap<String, Any?>()
265
+ values.block()
266
+ values.entries.removeAll { it.value == null }
267
+ return values
268
+ }
269
+ `;
270
+ return content;
271
+ }
272
+ defaultFor(type) {
273
+ if (type.startsWith('List<'))
274
+ return 'emptyList()';
275
+ if (type.endsWith('DoubleArray'))
276
+ return 'DoubleArray(0)';
277
+ if (type.endsWith('ByteArray'))
278
+ return 'ByteArray(0)';
279
+ return 'null';
280
+ }
281
+ // ─── ORM types ───────────────────────────────────────────────────────────────────
282
+ generateOrmTypes(models) {
283
+ let content = `// This file is auto-generated. Do not edit directly.
284
+ @file:Suppress("unused")
285
+
286
+ package an5.client
287
+
288
+ import an5.adapters.QueryBuilder
289
+ import an5.adapters.Where
290
+ import an5.adapters.andOf
291
+ import an5.adapters.notOf
292
+ import an5.adapters.orOf
293
+
294
+ /**
295
+ * Typed filters for the generated models.
296
+ *
297
+ * A filter is a small builder whose [build] produces the filter tree the runtime's SQL
298
+ * builder reads, so a query is checked at the call site and still compiles down to bind
299
+ * parameters rather than interpolated SQL.
300
+ */
301
+ object An5Orm {
302
+
303
+ /** A filter on a string column. */
304
+ data class StringFilter(
305
+ val equals: String? = null,
306
+ val not: String? = null,
307
+ val \`in\`: List<String>? = null,
308
+ val notIn: List<String>? = null,
309
+ val contains: String? = null,
310
+ val startsWith: String? = null,
311
+ val endsWith: String? = null,
312
+ ) {
313
+ /** The filter as the runtime reads it, with every unset operator left out. */
314
+ fun build(): Where = operatorMap {
315
+ equals?.let { put("equals", it) }
316
+ not?.let { put("not", it) }
317
+ \`in\`?.let { put("in", it) }
318
+ notIn?.let { put("notIn", it) }
319
+ contains?.let { put("contains", it) }
320
+ startsWith?.let { put("startsWith", it) }
321
+ endsWith?.let { put("endsWith", it) }
322
+ }
323
+
324
+ companion object {
325
+ /** The column equals [value]. */
326
+ fun \`is\`(value: String) = StringFilter(equals = value)
327
+
328
+ /** The column differs from [value]. */
329
+ fun isNot(value: String) = StringFilter(not = value)
330
+
331
+ /** The column contains [value]. */
332
+ fun has(value: String) = StringFilter(contains = value)
333
+ }
334
+ }
335
+
336
+ /** A filter on an integer, long or floating-point column. */
337
+ data class NumberFilter(
338
+ val equals: Number? = null,
339
+ val not: Number? = null,
340
+ val \`in\`: List<Number>? = null,
341
+ val notIn: List<Number>? = null,
342
+ val gt: Number? = null,
343
+ val gte: Number? = null,
344
+ val lt: Number? = null,
345
+ val lte: Number? = null,
346
+ ) {
347
+ fun build(): Where = operatorMap {
348
+ equals?.let { put("equals", it) }
349
+ not?.let { put("not", it) }
350
+ \`in\`?.let { put("in", it) }
351
+ notIn?.let { put("notIn", it) }
352
+ gt?.let { put("gt", it) }
353
+ gte?.let { put("gte", it) }
354
+ lt?.let { put("lt", it) }
355
+ lte?.let { put("lte", it) }
356
+ }
357
+
358
+ companion object {
359
+ fun \`is\`(value: Number) = NumberFilter(equals = value)
360
+ fun isNot(value: Number) = NumberFilter(not = value)
361
+ fun atLeast(value: Number) = NumberFilter(gte = value)
362
+ fun atMost(value: Number) = NumberFilter(lte = value)
363
+ }
364
+ }
365
+
366
+ /** A filter on a boolean column. */
367
+ data class BoolFilter(val equals: Boolean? = null, val not: Boolean? = null) {
368
+ fun build(): Where = operatorMap {
369
+ equals?.let { put("equals", it) }
370
+ not?.let { put("not", it) }
371
+ }
372
+
373
+ companion object {
374
+ fun \`is\`(value: Boolean) = BoolFilter(equals = value)
375
+ }
376
+ }
377
+
378
+ /** A filter on a date or time column. */
379
+ data class DateFilter(
380
+ val equals: Any? = null,
381
+ val not: Any? = null,
382
+ val \`in\`: List<Any>? = null,
383
+ val notIn: List<Any>? = null,
384
+ val gt: Any? = null,
385
+ val gte: Any? = null,
386
+ val lt: Any? = null,
387
+ val lte: Any? = null,
388
+ ) {
389
+ fun build(): Where = operatorMap {
390
+ equals?.let { put("equals", it) }
391
+ not?.let { put("not", it) }
392
+ \`in\`?.let { put("in", it) }
393
+ notIn?.let { put("notIn", it) }
394
+ gt?.let { put("gt", it) }
395
+ gte?.let { put("gte", it) }
396
+ lt?.let { put("lt", it) }
397
+ lte?.let { put("lte", it) }
398
+ }
399
+
400
+ companion object {
401
+ fun \`is\`(value: Any) = DateFilter(equals = value)
402
+ fun atLeast(value: Any) = DateFilter(gte = value)
403
+ fun atMost(value: Any) = DateFilter(lte = value)
404
+ }
405
+ }
406
+
407
+ private fun operatorMap(block: MutableMap<String, Any?>.() -> Unit): Where {
408
+ val map = LinkedHashMap<String, Any?>()
409
+ map.block()
410
+ return map
411
+ }
412
+ `;
413
+ for (const model of models) {
414
+ const name = model.name;
415
+ content += `
416
+ /** A \`WHERE\` for ${name}. Unset filters are left out. */
417
+ data class ${name}Where(
418
+ val and: List<${name}Where>? = null,
419
+ val or: List<${name}Where>? = null,
420
+ val not: List<${name}Where>? = null,
421
+ ${model.fields.map((field) => ` val ${this.property(field.name)}: ${this.filterType(field)}? = null,`).join('\n')}
422
+ ) {
423
+ /** The filter tree as the runtime reads it. */
424
+ fun build(): Where = operatorMap {
425
+ and?.let { put("AND", it.map { clause -> clause.build() }) }
426
+ or?.let { put("OR", it.map { clause -> clause.build() }) }
427
+ not?.let { put("NOT", it.map { clause -> clause.build() }) }
428
+ ${model.fields.map((field) => ` ${this.property(field.name)}?.let { put("${field.name}", it.build()) }`).join('\n')}
429
+ }
430
+
431
+ companion object {
432
+ /** All of [clauses]; an empty list matches everything. */
433
+ fun andOf(vararg clauses: ${name}Where) = ${name}Where(and = clauses.toList())
434
+
435
+ /** Any of [clauses]; an empty list matches nothing. */
436
+ fun orOf(vararg clauses: ${name}Where) = ${name}Where(or = clauses.toList())
437
+
438
+ /** None of [clauses]; an empty list matches everything. */
439
+ fun notOf(vararg clauses: ${name}Where) = ${name}Where(not = clauses.toList())
440
+ }
441
+ }
442
+
443
+ /** An \`ORDER BY\` for ${name}. */
444
+ data class ${name}OrderBy(val entries: List<Map<String, String>> = emptyList()) {
445
+ fun asc(vararg columns: String) = append(columns, "asc")
446
+ fun desc(vararg columns: String) = append(columns, "desc")
447
+
448
+ private fun append(columns: Array<out String>, direction: String) =
449
+ ${name}OrderBy(entries + columns.map { mapOf(it to direction) })
450
+
451
+ /** The sort as the runtime reads it. */
452
+ fun build(): List<Map<String, String>> = entries
453
+ }
454
+ `;
455
+ }
456
+ content += `}\n`;
457
+ fs_1.default.writeFileSync(path_1.default.join(this.outputDir, 'An5OrmTypes.kt'), content);
458
+ }
459
+ // ─── Metadata ────────────────────────────────────────────────────────────────────
460
+ generateMetadata(models) {
461
+ let content = `// This file is auto-generated. Do not edit directly.
462
+ package an5.client
463
+
464
+ import an5.adapters.base.Metadata
465
+
466
+ /**
467
+ * The generated models' tables, columns and relations.
468
+ *
469
+ * Registered with the runtime when a client is constructed. Without it the table clients have
470
+ * no table names to work with and no primary key to fill in.
471
+ */
472
+ object An5Metadata {
473
+
474
+ /** Model name to table name, schema-qualified. */
475
+ val modelToTable: Map<String, String> = linkedMapOf(
476
+ ${models.map((model) => ` "${model.name}" to "${this.tableName(model)}",`).join('\n')}
477
+ )
478
+
479
+ /** Model name to its columns, as the runtime reads them. */
480
+ val modelFields: Map<String, List<Map<String, Any?>>> = linkedMapOf(
481
+ ${models
482
+ .map((model) => ` "${model.name}" to listOf(
483
+ ${model.fields
484
+ .map((field) => ` field("${field.name}", "${field.type}", "${field.sqlType}", ${field.isOptional}, ${field.hasDefault}, ${field.isId}${field.description ? `, ${kotlinString(field.description)}` : ''})`)
485
+ .join(',\n')}
486
+ ),`)
487
+ .join('\n')}
488
+ )
489
+
490
+ /** Model name to its relations, keyed by relation name. */
491
+ val relationMap: Map<String, Map<String, Map<String, String>>> = linkedMapOf(
492
+ ${models
493
+ .filter((model) => model.relations.length > 0)
494
+ .map((model) => ` "${model.name}" to linkedMapOf(
495
+ ${model.relations
496
+ .map((rel) => ` "${rel.name}" to relation("${rel.type}", "${rel.relationName}", "${rel.foreignKey}", "${rel.localKey}")`)
497
+ .join(',\n')}
498
+ ),`)
499
+ .join('\n')}
500
+ )
501
+
502
+ /** Registers this schema with the runtime. */
503
+ fun register() {
504
+ Metadata.setAdapterMetadata(
505
+ linkedMapOf<String, Any?>(
506
+ "modelToTable" to modelToTable,
507
+ "modelFields" to modelFields,
508
+ "relationMap" to relationMap,
509
+ )
510
+ )
511
+ }
512
+
513
+ private fun field(
514
+ name: String,
515
+ type: String,
516
+ sql: String,
517
+ optional: Boolean,
518
+ hasDefault: Boolean,
519
+ isId: Boolean,
520
+ description: String? = null,
521
+ ): Map<String, Any?> = linkedMapOf(
522
+ "name" to name,
523
+ "type" to type,
524
+ "sql" to sql,
525
+ "isOptional" to optional,
526
+ "hasDefault" to hasDefault,
527
+ "isId" to isId,
528
+ "description" to description,
529
+ )
530
+
531
+ private fun relation(
532
+ modelName: String,
533
+ relationType: String,
534
+ foreignKey: String,
535
+ localKey: String,
536
+ ): Map<String, String> = linkedMapOf(
537
+ "modelName" to modelName,
538
+ "relationType" to relationType,
539
+ "foreignKey" to foreignKey,
540
+ "localKey" to localKey,
541
+ )
542
+ }
543
+ `;
544
+ fs_1.default.writeFileSync(path_1.default.join(this.outputDir, 'An5Metadata.kt'), content);
545
+ }
546
+ // ─── Config ──────────────────────────────────────────────────────────────────────
547
+ generateConfig() {
548
+ const content = `// This file is auto-generated. Do not edit directly.
549
+ package an5.client
550
+
551
+ /**
552
+ * Where the connection string comes from.
553
+ *
554
+ * \`DATABASE_URL\` first, then the \`an5.connectionString\` system property, so one deployment
555
+ * can override a checked-in default without the file changing.
556
+ */
557
+ object An5Config {
558
+
559
+ /** The connection string, or a message naming both places it can come from. */
560
+ fun connectionString(): String {
561
+ System.getenv("DATABASE_URL")?.takeIf { it.isNotBlank() }?.let { return it.trim() }
562
+ System.getProperty("an5.connectionString")?.takeIf { it.isNotBlank() }?.let { return it.trim() }
563
+ throw IllegalStateException(
564
+ "No connection string: set DATABASE_URL or the an5.connectionString system property."
565
+ )
566
+ }
567
+ }
568
+ `;
569
+ fs_1.default.writeFileSync(path_1.default.join(this.outputDir, 'An5Config.kt'), content);
570
+ }
571
+ // ─── Client ──────────────────────────────────────────────────────────────────────
572
+ generateClient(models) {
573
+ let content = `// This file is auto-generated. Do not edit directly.
574
+ package an5.client
575
+
576
+ import an5.adapters.AggregateBuilder
577
+ import an5.adapters.An5
578
+ import an5.adapters.Data
579
+ import an5.adapters.DistanceMetric
580
+ import an5.adapters.QueryBuilder
581
+ import an5.adapters.Row
582
+ import an5.adapters.TableClient
583
+ import an5.adapters.Where
584
+ import an5.adapters.andOf
585
+ import an5.adapters.contains
586
+ import an5.adapters.eq
587
+ import an5.adapters.endsWith
588
+ import an5.adapters.gte
589
+ import an5.adapters.gt
590
+ import an5.adapters.lte
591
+ import an5.adapters.lt
592
+ import an5.adapters.notOf
593
+ import an5.adapters.orOf
594
+ import an5.adapters.startsWith
595
+
596
+ /**
597
+ * A typed client for one model.
598
+ *
599
+ * The runtime's [TableClient] works on untyped rows so it stays independent of the schema;
600
+ * this adds the model's own types on top, which is what makes \`db.user.findMany {}\` come
601
+ * back as \`List<User>\`.
602
+ */
603
+ class ModelClient<T : Any>(
604
+ private val delegate: TableClient,
605
+ private val read: (Row) -> T,
606
+ private val write: (T) -> Data,
607
+ ) {
608
+
609
+ /** The model this client reads and writes. */
610
+ val model: String get() = delegate.model
611
+
612
+ fun findMany(): List<T> = delegate.findMany().map(read)
613
+
614
+ fun findMany(block: QueryBuilder.() -> Unit): List<T> = delegate.findMany(block).map(read)
615
+
616
+ fun findMany(filter: Where): List<T> = delegate.findMany(filter).map(read)
617
+
618
+ fun findFirst(block: QueryBuilder.() -> Unit = {}): T? = delegate.findFirst(block)?.let(read)
619
+
620
+ fun findFirst(filter: Where): T? = delegate.findFirst(filter)?.let(read)
621
+
622
+ fun findUnique(filter: Where): T? = delegate.findUnique(filter)?.let(read)
623
+
624
+ fun count(filter: Where? = null): Long = delegate.count(filter)
625
+
626
+ /** Inserts a value and returns it as stored. */
627
+ fun create(value: T): T = delegate.create(write(value)).let(read)
628
+
629
+ /** Inserts many values, one statement each. */
630
+ fun createMany(values: List<T>, skipDuplicates: Boolean = false): Int =
631
+ delegate.createMany(values.map(write), skipDuplicates)
632
+
633
+ /** Updates the matching rows and returns the first of them re-read. */
634
+ fun update(filter: Where, value: T): T? = delegate.update(filter, write(value))?.let(read)
635
+
636
+ /** Updates every matching row and gives back how many changed. */
637
+ fun updateMany(filter: Where?, value: T): Int = delegate.updateMany(filter, write(value))
638
+
639
+ /** Deletes the matching rows and returns the one that was there first. */
640
+ fun delete(filter: Where): T? = delegate.delete(filter)?.let(read)
641
+
642
+ /** Deletes every matching row, or the whole table when [filter] is \`null\`. */
643
+ fun deleteMany(filter: Where? = null): Int = delegate.deleteMany(filter)
644
+
645
+ /** Updates the matching row when it exists, creates it otherwise. */
646
+ fun upsert(filter: Where, create: T, update: T): T =
647
+ delegate.upsert(filter, write(create), write(update)).let(read)
648
+
649
+ /** One row of aggregate values, keyed \`_count\`, \`_sum_<field>\` and so on. */
650
+ fun aggregate(block: AggregateBuilder.() -> Unit): Row = delegate.aggregate(block)
651
+
652
+ /** One row per group, each carrying the group's count and aggregates. */
653
+ fun groupBy(vararg by: String, block: AggregateBuilder.() -> Unit = {}): List<Row> =
654
+ delegate.groupBy(*by, block = block)
655
+
656
+ /** The rows nearest [vector]. */
657
+ fun vectorSearch(
658
+ vector: DoubleArray,
659
+ take: Int = 10,
660
+ filter: Where? = null,
661
+ vectorField: String = "embedding",
662
+ metric: DistanceMetric = DistanceMetric.COSINE,
663
+ ): List<T> = delegate.vectorSearch(vector, take, filter, vectorField, metric).map(read)
664
+ }
665
+
666
+ /**
667
+ * The AN5 entry point: one typed client per model, over one connection.
668
+ *
669
+ * \`\`\`
670
+ * An5Db(An5Config.connectionString()).use { db ->
671
+ * val ada = db.user.findUnique(mapOf("name" to "Ada"))
672
+ * }
673
+ * \`\`\`
674
+ *
675
+ * Not thread-safe, exactly like the runtime underneath: a transaction holds one open
676
+ * connection that every statement in the block has to share.
677
+ */
678
+ class An5Db internal constructor(private val an5: An5) : AutoCloseable {
679
+
680
+ /** Opens a client, registering this schema with the runtime. */
681
+ constructor(connectionString: String) : this(open(connectionString))
682
+
683
+ `;
684
+ const seen = new Set();
685
+ for (const model of models) {
686
+ const property = KotlinGenerator.toLowerCamelCase(model.name);
687
+ if (seen.has(property))
688
+ continue;
689
+ seen.add(property);
690
+ content += ` /** Queries for \`${model.name}\`. */
691
+ `;
692
+ content += ` val ${property}: ModelClient<${model.name}> =
693
+ `;
694
+ content += ` ModelClient(an5.table("${model.name}"), ${model.name}::fromRow, ${model.name}::toValues)
695
+
696
+ `;
697
+ }
698
+ content += ` /** The dialect the connection string points at. */
699
+ val dialect get() = an5.dialect
700
+
701
+ /** A read-only client for a database view. */
702
+ fun view(name: String) = an5.view(name)
703
+
704
+ /** A typed client for a table outside this schema. */
705
+ fun <T : Any> table(
706
+ model: String,
707
+ read: (Row) -> T,
708
+ write: (T) -> Data,
709
+ ): ModelClient<T> = ModelClient(an5.table(model), read, write)
710
+
711
+ /** Runs a query and returns its rows keyed by column label. */
712
+ fun query(sql: String, vararg parameters: Any?): List<Row> = an5.queryRaw(sql, *parameters)
713
+
714
+ /** Runs a statement that returns no rows and gives back the affected row count. */
715
+ fun execute(sql: String, vararg parameters: Any?): Int = an5.executeRaw(sql, *parameters)
716
+
717
+ /** Runs [block] inside a transaction, committing on return and rolling back on failure. */
718
+ fun <T> transaction(block: (An5) -> T): T = an5.transaction(block)
719
+
720
+ /** The runtime underneath, for anything the typed clients do not cover. */
721
+ fun adapter(): An5 = an5
722
+
723
+ override fun close() = an5.close()
724
+
725
+ companion object {
726
+ private fun open(connectionString: String): An5 {
727
+ An5Metadata.register()
728
+ return An5(connectionString)
729
+ }
730
+ }
731
+ }
732
+ `;
733
+ fs_1.default.writeFileSync(path_1.default.join(this.outputDir, 'An5Db.kt'), content);
734
+ }
735
+ // ─── Helpers ─────────────────────────────────────────────────────────────────────
736
+ tableName(model) {
737
+ return model.schemaName ? `${model.schemaName}.${model.tableName}` : model.tableName;
738
+ }
739
+ }
740
+ exports.KotlinGenerator = KotlinGenerator;
741
+ /** A Kotlin string literal for a schema description. */
742
+ function kotlinString(value) {
743
+ return JSON.stringify(value);
744
+ }
745
+ /**
746
+ * Doc-comment text.
747
+ *
748
+ * A comment terminator inside a description would end this comment early and leave the
749
+ * rest of the line as code, so it is split.
750
+ */
751
+ function escapeDoc(value) {
752
+ return value.replace(/\*\//g, '*\\/').replace(/\r?\n/g, ' ');
753
+ }