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