@an5/orm 1.1.1 → 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.
- package/README.md +5 -1
- package/dist/generator/src/api.d.ts +5 -0
- package/dist/generator/src/api.js +11 -1
- package/dist/generator/src/code-generator.js +12 -1
- package/dist/generator/src/code-request.d.ts +26 -0
- package/dist/generator/src/code-request.js +122 -0
- package/dist/generator/src/config.d.ts +18 -0
- package/dist/generator/src/config.js +24 -0
- package/dist/generator/src/dialect.js +4 -2
- package/dist/generator/src/dotnet-generator.js +97 -1
- package/dist/generator/src/field-types.js +3 -0
- package/dist/generator/src/golang-generator.d.ts +8 -0
- package/dist/generator/src/golang-generator.js +183 -9
- package/dist/generator/src/index.js +18 -0
- package/dist/generator/src/java-generator.d.ts +56 -0
- package/dist/generator/src/java-generator.js +1406 -0
- package/dist/generator/src/kotlin-generator.d.ts +34 -0
- package/dist/generator/src/kotlin-generator.js +753 -0
- package/dist/generator/src/metadata-generator.js +3 -1
- package/dist/generator/src/rust-generator.js +36 -1
- package/dist/generator/src/swift-generator.d.ts +42 -0
- package/dist/generator/src/swift-generator.js +768 -0
- package/dist/migration-core.d.ts +1 -1
- package/dist/migration-core.js +6 -4
- package/generator/src/api.ts +5 -0
- package/generator/src/code-generator.ts +13 -1
- package/generator/src/code-request.ts +95 -0
- package/generator/src/config.ts +48 -0
- package/generator/src/dialect.ts +5 -3
- package/generator/src/dotnet-generator.ts +97 -1
- package/generator/src/field-types.ts +3 -0
- package/generator/src/golang-generator.ts +184 -9
- package/generator/src/index.ts +21 -0
- package/generator/src/java-generator.ts +1457 -0
- package/generator/src/kotlin-generator.ts +798 -0
- package/generator/src/metadata-generator.ts +2 -1
- package/generator/src/rust-generator.ts +37 -1
- package/generator/src/swift-generator.ts +830 -0
- 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
|
+
}
|