@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,1457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Java client generator.
|
|
3
|
+
*
|
|
4
|
+
* Emits plain `an5Client/java` sources that depend on `@an5/adapters`'s JVM runtime
|
|
5
|
+
* (`an5Adapters/java`): a model class per schema model, typed filter inputs, the metadata
|
|
6
|
+
* the adapter reads its tables and columns from, and a `An5DbContext` that hands out one
|
|
7
|
+
* typed client per model.
|
|
8
|
+
*
|
|
9
|
+
* Two decisions shape the output. The model classes are mutable JavaBeans rather than
|
|
10
|
+
* records, because `update` and `upsert` take a partly filled instance and a record has no
|
|
11
|
+
* way to say "this column is not being changed". And every column is read through a
|
|
12
|
+
* converter rather than cast, because JDBC hands an `INT` back as `Integer` on one driver
|
|
13
|
+
* and `Long` on another, and a `NUMERIC` column still arrives as `BigDecimal`.
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import { Model, Field } from './types';
|
|
18
|
+
import { fieldKind } from './type-kinds';
|
|
19
|
+
import { Provider } from './field-types';
|
|
20
|
+
|
|
21
|
+
/** `DECIMAL(10,2)` → `DECIMAL`. */
|
|
22
|
+
function declaredBase(sqlType: string): string {
|
|
23
|
+
return sqlType.replace(/\([^)]*\)/g, ' ').trim().replace(/\s+/g, ' ').toUpperCase();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Acronyms that must keep their capitals when a name becomes camelCase. */
|
|
27
|
+
const ACRONYMS = ['LLM', 'AI', 'MCP', 'IT', 'QC', 'HR', 'MR', 'WH', 'SSIS', 'API', 'URL', 'ID', 'JSON'];
|
|
28
|
+
|
|
29
|
+
export class JavaGenerator {
|
|
30
|
+
/** The database being generated for; decides types like `TIMESTAMP`. */
|
|
31
|
+
private provider: Provider | undefined;
|
|
32
|
+
|
|
33
|
+
constructor(private outputDir: string) {
|
|
34
|
+
if (!fs.existsSync(this.outputDir)) {
|
|
35
|
+
fs.mkdirSync(this.outputDir, { recursive: true });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public generate(models: Model[]): void {
|
|
40
|
+
this.provider = models[0]?.provider;
|
|
41
|
+
|
|
42
|
+
this.generateModels(models);
|
|
43
|
+
this.generateValues(models);
|
|
44
|
+
this.generateOrmTypes(models);
|
|
45
|
+
this.generateMetadata(models);
|
|
46
|
+
this.generateConfig();
|
|
47
|
+
this.generateDbContext(models);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Naming ─────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
private capitalize(value: string): string {
|
|
53
|
+
if (!value) return value;
|
|
54
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The Java field name for a schema column.
|
|
59
|
+
*
|
|
60
|
+
* A field named `class`, `new` or `from` would not compile as an identifier, so those are
|
|
61
|
+
* suffixed rather than left to fail at the call site.
|
|
62
|
+
*/
|
|
63
|
+
private fieldName(name: string): string {
|
|
64
|
+
const pascal = this.capitalize(name);
|
|
65
|
+
return JAVA_RESERVED.has(pascal) ? `${pascal}Field` : pascal;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private getterName(field: Field): string {
|
|
69
|
+
const pascal = this.fieldName(field.name);
|
|
70
|
+
return JAVA_RESERVED.has(pascal) ? `get${pascal}` : `get${pascal}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private static toCamelCase(value: string): string {
|
|
74
|
+
if (!value) return value;
|
|
75
|
+
const spaced = ACRONYMS.reduce(
|
|
76
|
+
(text, acronym) => text.replace(new RegExp(`\\b${acronym}\\b`, 'g'), ` ${acronym} `),
|
|
77
|
+
value
|
|
78
|
+
);
|
|
79
|
+
const words = spaced.split(/[\s_\-.]+/).filter(Boolean);
|
|
80
|
+
if (words.length === 0) return value;
|
|
81
|
+
const [first, ...rest] = words;
|
|
82
|
+
return (
|
|
83
|
+
first.charAt(0).toLowerCase() +
|
|
84
|
+
first.slice(1) +
|
|
85
|
+
rest.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join('')
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private static toLowerCamelCase(value: string): string {
|
|
90
|
+
return JavaGenerator.toCamelCase(value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Every spelling of a model name the client registers it under.
|
|
95
|
+
*
|
|
96
|
+
* The metadata is keyed in camelCase while the model class is PascalCase, and the adapter
|
|
97
|
+
* resolves the difference; these are the names the context exposes so either spelling
|
|
98
|
+
* works at the call site.
|
|
99
|
+
*/
|
|
100
|
+
private propertyNames(modelName: string): string[] {
|
|
101
|
+
const pascal = this.capitalize(modelName);
|
|
102
|
+
const camel = JavaGenerator.toLowerCamelCase(modelName);
|
|
103
|
+
const snake = pascal.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();
|
|
104
|
+
return Array.from(new Set([camel, pascal, snake]));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Types ───────────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The Java type for a column.
|
|
111
|
+
*
|
|
112
|
+
* Driven by the declared type, so a `DECIMAL` column is a `BigDecimal` and not an `int`:
|
|
113
|
+
* both arrive from the parser as `number`, which is how the earlier clients turned
|
|
114
|
+
* decimal money into integers.
|
|
115
|
+
*/
|
|
116
|
+
private mapType(field: Field): string {
|
|
117
|
+
const base = declaredBase(field.sqlType ?? field.type);
|
|
118
|
+
switch (fieldKind(field, this.provider)) {
|
|
119
|
+
case 'int':
|
|
120
|
+
return 'int';
|
|
121
|
+
case 'bigint':
|
|
122
|
+
return 'long';
|
|
123
|
+
case 'float':
|
|
124
|
+
// `DECIMAL`/`NUMERIC`/`MONEY` are exact; a floating-point column is not. Truncating
|
|
125
|
+
// a decimal through a `double` is a rounding bug waiting to reach an invoice.
|
|
126
|
+
return ['DECIMAL', 'NUMERIC', 'MONEY', 'SMALLMONEY', 'FIXED'].includes(base)
|
|
127
|
+
? 'BigDecimal'
|
|
128
|
+
: 'double';
|
|
129
|
+
case 'bool':
|
|
130
|
+
return 'boolean';
|
|
131
|
+
case 'date':
|
|
132
|
+
if (base === 'DATETIMEOFFSET' || base === 'TIMESTAMPTZ') return 'OffsetDateTime';
|
|
133
|
+
if (base === 'TIME' || base === 'TIMETZ') return 'LocalTime';
|
|
134
|
+
if (base === 'DATE') return 'LocalDate';
|
|
135
|
+
return 'LocalDateTime';
|
|
136
|
+
case 'bytes':
|
|
137
|
+
return 'byte[]';
|
|
138
|
+
case 'vector':
|
|
139
|
+
return 'double[]';
|
|
140
|
+
case 'json':
|
|
141
|
+
return 'String';
|
|
142
|
+
default:
|
|
143
|
+
return ['GUID', 'UUID', 'UNIQUEIDENTIFIER'].includes(base) ? 'UUID' : 'String';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The boxed form used in a model.
|
|
149
|
+
*
|
|
150
|
+
* Every field is boxed, primitives included: `toValues` skips the columns left null, and a
|
|
151
|
+
* column the schema does not mark optional can still read NULL until a row is written. A
|
|
152
|
+
* primitive cannot represent "unset", so the skip would have to give the column a default
|
|
153
|
+
* and quietly write it.
|
|
154
|
+
*/
|
|
155
|
+
private mapNullableType(field: Field): string {
|
|
156
|
+
return box(this.mapType(field));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private getFilterType(field: Field): string {
|
|
160
|
+
switch (fieldKind(field, this.provider)) {
|
|
161
|
+
case 'date':
|
|
162
|
+
return 'DateFilter';
|
|
163
|
+
case 'bool':
|
|
164
|
+
return 'BoolFilter';
|
|
165
|
+
case 'int':
|
|
166
|
+
case 'bigint':
|
|
167
|
+
return 'NumberFilter';
|
|
168
|
+
case 'float':
|
|
169
|
+
return 'NumberFilter';
|
|
170
|
+
default:
|
|
171
|
+
return 'StringFilter';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── Files ───────────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
private generateModels(models: Model[]): void {
|
|
178
|
+
for (const model of models) {
|
|
179
|
+
fs.writeFileSync(path.join(this.outputDir, `${model.name}.java`), this.modelSource(model));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private modelSource(model: Model): string {
|
|
184
|
+
const name = model.name;
|
|
185
|
+
// Only the imports this model's columns can reach: an unused import in a generated file
|
|
186
|
+
// reads as something the generator forgot rather than as what it is.
|
|
187
|
+
const types = new Set(model.fields.map((field) => this.mapType(field)));
|
|
188
|
+
const imports = new Set<string>();
|
|
189
|
+
if (types.has('BigDecimal')) imports.add('java.math.BigDecimal');
|
|
190
|
+
for (const type of ['LocalDateTime', 'LocalDate', 'LocalTime', 'OffsetDateTime']) {
|
|
191
|
+
if (types.has(type)) imports.add(`java.time.${type}`);
|
|
192
|
+
}
|
|
193
|
+
if (types.has('byte[]')) imports.add('java.nio.charset.StandardCharsets');
|
|
194
|
+
if (model.relations.length > 0) imports.add('java.util.List');
|
|
195
|
+
if (model.relations.some((rel) => rel.isArray)) imports.add('java.util.ArrayList');
|
|
196
|
+
imports.add('java.util.LinkedHashMap');
|
|
197
|
+
imports.add('java.util.Map');
|
|
198
|
+
|
|
199
|
+
let content = `// This file is auto-generated. Do not edit directly.
|
|
200
|
+
package an5.client;
|
|
201
|
+
|
|
202
|
+
${Array.from(imports)
|
|
203
|
+
.sort()
|
|
204
|
+
.map((entry) => `import ${entry};`)
|
|
205
|
+
.join('\n')}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* ${model.description ? escapeDoc(model.description) : `The \`${name}\` model.`}
|
|
209
|
+
*
|
|
210
|
+
* <p>Mutable with getters and setters rather than a record, because \`update\` and \`upsert\`
|
|
211
|
+
* take a partly filled instance and a record cannot say "this column is unchanged".
|
|
212
|
+
*/
|
|
213
|
+
public class ${name} {
|
|
214
|
+
`;
|
|
215
|
+
|
|
216
|
+
if (model.relations.length > 0) {
|
|
217
|
+
content += `\n // ── Relations ────────────────────────────────────────────────────────────\n`;
|
|
218
|
+
for (const rel of model.relations) {
|
|
219
|
+
const javaType = rel.isArray ? `List<${rel.type}>` : rel.type;
|
|
220
|
+
content += ` private ${javaType} ${JavaGenerator.toLowerCamelCase(rel.name)};\n`;
|
|
221
|
+
}
|
|
222
|
+
content += '\n';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
for (const field of model.fields) {
|
|
226
|
+
content += ` private ${this.mapNullableType(field)} ${JavaGenerator.toLowerCamelCase(field.name)};\n`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
content += `\n`;
|
|
230
|
+
for (const field of model.fields) {
|
|
231
|
+
const property = JavaGenerator.toLowerCamelCase(field.name);
|
|
232
|
+
const accessor = this.fieldName(field.name);
|
|
233
|
+
const type = this.mapNullableType(field);
|
|
234
|
+
content += ` public ${type} ${this.getterName(field)}() {\n return this.${property};\n }\n\n`;
|
|
235
|
+
content += ` public void set${accessor}(${type} value) {\n this.${property} = value;\n }\n\n`;
|
|
236
|
+
content += ` /** Sets ${accessor} and returns this instance, for chained construction. */\n`;
|
|
237
|
+
content += ` public ${name} with${accessor}(${type} value) {\n this.${property} = value;\n return this;\n }\n\n`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (model.relations.length > 0) {
|
|
241
|
+
for (const rel of model.relations) {
|
|
242
|
+
const property = JavaGenerator.toLowerCamelCase(rel.name);
|
|
243
|
+
const type = rel.isArray ? `List<${rel.type}>` : rel.type;
|
|
244
|
+
const accessor = this.fieldName(rel.name);
|
|
245
|
+
content += ` public ${type} ${this.getterName({ name: rel.name } as Field)}() {\n return this.${property};\n }\n\n`;
|
|
246
|
+
content += ` public void set${accessor}(${type} value) {\n this.${property} = value;\n }\n\n`;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
content += ` /**
|
|
251
|
+
* Reads \`${name}\` from a database row.
|
|
252
|
+
*
|
|
253
|
+
* <p>Every column goes through a converter rather than a cast: JDBC hands an \`INT\` back as
|
|
254
|
+
* \`Integer\` on one driver and \`Long\` on another, and a \`NUMERIC\` column that fits in a
|
|
255
|
+
* \`long\` still arrives as a \`BigDecimal\`.
|
|
256
|
+
*
|
|
257
|
+
* <p>Eager-loaded relations arrive as nested rows and are converted the same way. One that
|
|
258
|
+
* was not asked for is left as it is, so \`getOrders()\` being \`null\` still means the query
|
|
259
|
+
* did not include it.
|
|
260
|
+
*/
|
|
261
|
+
public static ${name} fromRow(Map<String, Object> row) {
|
|
262
|
+
${name} value = new ${name}();
|
|
263
|
+
`;
|
|
264
|
+
|
|
265
|
+
for (const field of model.fields) {
|
|
266
|
+
const property = JavaGenerator.toLowerCamelCase(field.name);
|
|
267
|
+
content += ` value.${property} = ${this.readExpression(field)};\n`;
|
|
268
|
+
}
|
|
269
|
+
for (const rel of model.relations) {
|
|
270
|
+
const property = JavaGenerator.toLowerCamelCase(rel.name);
|
|
271
|
+
if (rel.isArray) {
|
|
272
|
+
content += ` if (row.get("${rel.name}") instanceof List) {
|
|
273
|
+
List<${rel.type}> related = new ArrayList<${rel.type}>();
|
|
274
|
+
for (Object item : (List<?>) row.get("${rel.name}")) {
|
|
275
|
+
if (item instanceof Map) {
|
|
276
|
+
@SuppressWarnings("unchecked")
|
|
277
|
+
Map<String, Object> relationRow = (Map<String, Object>) item;
|
|
278
|
+
related.add(${rel.type}.fromRow(relationRow));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
value.${property} = related;
|
|
282
|
+
}
|
|
283
|
+
`;
|
|
284
|
+
} else {
|
|
285
|
+
content += ` if (row.get("${rel.name}") instanceof Map) {
|
|
286
|
+
@SuppressWarnings("unchecked")
|
|
287
|
+
Map<String, Object> relationRow = (Map<String, Object>) row.get("${rel.name}");
|
|
288
|
+
value.${property} = ${rel.type}.fromRow(relationRow);
|
|
289
|
+
}
|
|
290
|
+
`;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
content += ` return value;\n }\n\n`;
|
|
294
|
+
|
|
295
|
+
content += ` /**
|
|
296
|
+
* The columns to write, in declaration order, with \`null\` left out.
|
|
297
|
+
*
|
|
298
|
+
* <p>An unset column takes the schema's DEFAULT, which is what leaving it out means — and
|
|
299
|
+
* what \`update\` needs so a partial instance does not blank every other column.
|
|
300
|
+
*/
|
|
301
|
+
public Map<String, Object> toValues() {
|
|
302
|
+
Map<String, Object> values = new LinkedHashMap<String, Object>();
|
|
303
|
+
`;
|
|
304
|
+
|
|
305
|
+
for (const field of model.fields) {
|
|
306
|
+
const property = JavaGenerator.toLowerCamelCase(field.name);
|
|
307
|
+
content += ` if (this.${property} != null) {\n values.put("${field.name}", this.${property});\n }\n`;
|
|
308
|
+
}
|
|
309
|
+
content += ` return values;\n }\n\n`;
|
|
310
|
+
|
|
311
|
+
content += ` @Override\n public String toString() {\n return "${name}" + toValues();\n }\n}\n`;
|
|
312
|
+
return content;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The expression that reads one column into its field. */
|
|
316
|
+
private readExpression(field: Field): string {
|
|
317
|
+
const type = this.mapType(field);
|
|
318
|
+
switch (type) {
|
|
319
|
+
case 'int':
|
|
320
|
+
return `An5Values.asInteger(row.get("${field.name}"))`;
|
|
321
|
+
case 'long':
|
|
322
|
+
return `An5Values.asLong(row.get("${field.name}"))`;
|
|
323
|
+
case 'double':
|
|
324
|
+
return `An5Values.asDouble(row.get("${field.name}"))`;
|
|
325
|
+
case 'BigDecimal':
|
|
326
|
+
return `An5Values.asBigDecimal(row.get("${field.name}"))`;
|
|
327
|
+
case 'boolean':
|
|
328
|
+
return `An5Values.asBoolean(row.get("${field.name}"))`;
|
|
329
|
+
case 'LocalDateTime':
|
|
330
|
+
return `An5Values.asLocalDateTime(row.get("${field.name}"))`;
|
|
331
|
+
case 'LocalDate':
|
|
332
|
+
return `An5Values.asLocalDate(row.get("${field.name}"))`;
|
|
333
|
+
case 'LocalTime':
|
|
334
|
+
return `An5Values.asLocalTime(row.get("${field.name}"))`;
|
|
335
|
+
case 'OffsetDateTime':
|
|
336
|
+
return `An5Values.asOffsetDateTime(row.get("${field.name}"))`;
|
|
337
|
+
case 'byte[]':
|
|
338
|
+
return `An5Values.asBytes(row.get("${field.name}"))`;
|
|
339
|
+
case 'double[]':
|
|
340
|
+
return `An5Values.asVector(row.get("${field.name}"))`;
|
|
341
|
+
case 'UUID':
|
|
342
|
+
return `An5Values.asUuid(row.get("${field.name}"))`;
|
|
343
|
+
default:
|
|
344
|
+
return `An5Values.asString(row.get("${field.name}"))`;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ─── An5Values ───────────────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
private generateValues(models: Model[]): void {
|
|
351
|
+
// Only the converters this schema's columns can reach are emitted: a model with no
|
|
352
|
+
// `OffsetDateTime` column should not drag one into every compilation unit.
|
|
353
|
+
const used = new Set<string>();
|
|
354
|
+
for (const model of models) {
|
|
355
|
+
for (const field of model.fields) used.add(this.mapType(field));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const needs = (type: string) => used.has(type);
|
|
359
|
+
|
|
360
|
+
let content = `// This file is auto-generated. Do not edit directly.
|
|
361
|
+
package an5.client;
|
|
362
|
+
|
|
363
|
+
import java.math.BigDecimal;
|
|
364
|
+
import java.nio.charset.StandardCharsets;
|
|
365
|
+
import java.sql.Timestamp;
|
|
366
|
+
import java.time.Instant;
|
|
367
|
+
import java.time.LocalDate;
|
|
368
|
+
import java.time.LocalDateTime;
|
|
369
|
+
import java.time.LocalTime;
|
|
370
|
+
import java.time.OffsetDateTime;
|
|
371
|
+
import java.time.ZoneOffset;
|
|
372
|
+
import java.util.ArrayList;
|
|
373
|
+
import java.util.List;
|
|
374
|
+
import java.util.UUID;
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Column-value converters for JDBC drivers.
|
|
378
|
+
*
|
|
379
|
+
* <p>A cast is not enough. The same \`INT\` column arrives as \`Integer\`, \`Long\` or
|
|
380
|
+
* \`BigDecimal\` depending on the driver and the declared width; a timestamp arrives as
|
|
381
|
+
* \`java.sql.Timestamp\`, a string, or a \`Long\` of epoch milliseconds; and a vector column
|
|
382
|
+
* arrives as text on all three vector stores. Each converter accepts every form, so a
|
|
383
|
+
* generated model never has to know which driver is underneath it.
|
|
384
|
+
*/
|
|
385
|
+
public final class An5Values {
|
|
386
|
+
|
|
387
|
+
private An5Values() {}
|
|
388
|
+
|
|
389
|
+
/** The value as text, or {@code null} for a SQL \`NULL\`. */
|
|
390
|
+
public static String asString(Object value) {
|
|
391
|
+
if (value == null) return null;
|
|
392
|
+
if (value instanceof String) return (String) value;
|
|
393
|
+
if (value instanceof byte[]) return new String((byte[]) value, StandardCharsets.UTF_8);
|
|
394
|
+
if (value instanceof UUID) return value.toString();
|
|
395
|
+
return String.valueOf(value);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
public static Integer asInteger(Object value) {
|
|
399
|
+
if (value == null) return null;
|
|
400
|
+
if (value instanceof Number) return ((Number) value).intValue();
|
|
401
|
+
if (value instanceof Boolean) return ((Boolean) value) ? 1 : 0;
|
|
402
|
+
try {
|
|
403
|
+
return Integer.valueOf(String.valueOf(value).trim());
|
|
404
|
+
} catch (NumberFormatException error) {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
public static Long asLong(Object value) {
|
|
410
|
+
if (value == null) return null;
|
|
411
|
+
if (value instanceof Number) return ((Number) value).longValue();
|
|
412
|
+
if (value instanceof Boolean) return ((Boolean) value) ? 1L : 0L;
|
|
413
|
+
try {
|
|
414
|
+
return Long.valueOf(String.valueOf(value).trim());
|
|
415
|
+
} catch (NumberFormatException error) {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
public static Double asDouble(Object value) {
|
|
421
|
+
if (value == null) return null;
|
|
422
|
+
if (value instanceof Number) return ((Number) value).doubleValue();
|
|
423
|
+
if (value instanceof Boolean) return ((Boolean) value) ? 1.0 : 0.0;
|
|
424
|
+
try {
|
|
425
|
+
return Double.valueOf(String.valueOf(value).trim());
|
|
426
|
+
} catch (NumberFormatException error) {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
public static BigDecimal asBigDecimal(Object value) {
|
|
432
|
+
if (value == null) return null;
|
|
433
|
+
if (value instanceof BigDecimal) return (BigDecimal) value;
|
|
434
|
+
if (value instanceof Double || value instanceof Float) {
|
|
435
|
+
// Through the string form, not \`new BigDecimal(double)\`: that constructor takes the
|
|
436
|
+
// binary expansion, so 0.1 becomes 0.1000000000000000055511151231257827.
|
|
437
|
+
return BigDecimal.valueOf(((Number) value).doubleValue());
|
|
438
|
+
}
|
|
439
|
+
if (value instanceof Number) return BigDecimal.valueOf(((Number) value).longValue());
|
|
440
|
+
try {
|
|
441
|
+
return new BigDecimal(String.valueOf(value).trim());
|
|
442
|
+
} catch (NumberFormatException error) {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
public static Boolean asBoolean(Object value) {
|
|
448
|
+
if (value == null) return null;
|
|
449
|
+
if (value instanceof Boolean) return (Boolean) value;
|
|
450
|
+
if (value instanceof Number) return ((Number) value).intValue() != 0;
|
|
451
|
+
String text = String.valueOf(value).trim();
|
|
452
|
+
if ("1".equals(text) || "true".equalsIgnoreCase(text)) return Boolean.TRUE;
|
|
453
|
+
if ("0".equals(text) || "false".equalsIgnoreCase(text)) return Boolean.FALSE;
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
`;
|
|
457
|
+
|
|
458
|
+
if (needs('LocalDateTime') || needs('LocalDate') || needs('LocalTime') || needs('OffsetDateTime')) {
|
|
459
|
+
content += `
|
|
460
|
+
public static LocalDateTime asLocalDateTime(Object value) {
|
|
461
|
+
if (value == null) return null;
|
|
462
|
+
if (value instanceof LocalDateTime) return (LocalDateTime) value;
|
|
463
|
+
if (value instanceof Timestamp) return ((Timestamp) value).toLocalDateTime();
|
|
464
|
+
if (value instanceof java.sql.Date) return ((java.sql.Date) value).toLocalDate().atStartOfDay();
|
|
465
|
+
if (value instanceof OffsetDateTime) return ((OffsetDateTime) value).toLocalDateTime();
|
|
466
|
+
if (value instanceof Number) {
|
|
467
|
+
return LocalDateTime.ofInstant(Instant.ofEpochMilli(((Number) value).longValue()), ZoneOffset.UTC);
|
|
468
|
+
}
|
|
469
|
+
String text = String.valueOf(value).trim();
|
|
470
|
+
try {
|
|
471
|
+
return LocalDateTime.parse(text.replace(' ', 'T'));
|
|
472
|
+
} catch (RuntimeException error) {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
public static LocalDate asLocalDate(Object value) {
|
|
478
|
+
if (value == null) return null;
|
|
479
|
+
if (value instanceof LocalDate) return (LocalDate) value;
|
|
480
|
+
if (value instanceof java.sql.Date) return ((java.sql.Date) value).toLocalDate();
|
|
481
|
+
if (value instanceof Timestamp) return ((Timestamp) value).toLocalDateTime().toLocalDate();
|
|
482
|
+
if (value instanceof LocalDateTime) return ((LocalDateTime) value).toLocalDate();
|
|
483
|
+
try {
|
|
484
|
+
return LocalDate.parse(String.valueOf(value).trim());
|
|
485
|
+
} catch (RuntimeException error) {
|
|
486
|
+
LocalDateTime parsed = asLocalDateTime(value);
|
|
487
|
+
return parsed == null ? null : parsed.toLocalDate();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
public static LocalTime asLocalTime(Object value) {
|
|
492
|
+
if (value == null) return null;
|
|
493
|
+
if (value instanceof LocalTime) return (LocalTime) value;
|
|
494
|
+
if (value instanceof Timestamp) return ((Timestamp) value).toLocalDateTime().toLocalTime();
|
|
495
|
+
if (value instanceof java.sql.Time) return ((java.sql.Time) value).toLocalTime();
|
|
496
|
+
try {
|
|
497
|
+
return LocalTime.parse(String.valueOf(value).trim());
|
|
498
|
+
} catch (RuntimeException error) {
|
|
499
|
+
LocalDateTime parsed = asLocalDateTime(value);
|
|
500
|
+
return parsed == null ? null : parsed.toLocalTime();
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
public static OffsetDateTime asOffsetDateTime(Object value) {
|
|
505
|
+
if (value == null) return null;
|
|
506
|
+
if (value instanceof OffsetDateTime) return (OffsetDateTime) value;
|
|
507
|
+
if (value instanceof Timestamp) return ((Timestamp) value).toInstant().atOffset(ZoneOffset.UTC);
|
|
508
|
+
try {
|
|
509
|
+
return OffsetDateTime.parse(String.valueOf(value).trim());
|
|
510
|
+
} catch (RuntimeException error) {
|
|
511
|
+
LocalDateTime parsed = asLocalDateTime(value);
|
|
512
|
+
return parsed == null ? null : parsed.atOffset(ZoneOffset.UTC);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
content += `
|
|
519
|
+
public static byte[] asBytes(Object value) {
|
|
520
|
+
if (value == null) return null;
|
|
521
|
+
if (value instanceof byte[]) return (byte[]) value;
|
|
522
|
+
return String.valueOf(value).getBytes(StandardCharsets.UTF_8);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
public static UUID asUuid(Object value) {
|
|
526
|
+
String text = asString(value);
|
|
527
|
+
if (text == null || text.isEmpty()) return null;
|
|
528
|
+
try {
|
|
529
|
+
return UUID.fromString(text);
|
|
530
|
+
} catch (IllegalArgumentException error) {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* A stored vector as a double array.
|
|
537
|
+
*
|
|
538
|
+
* <p>Delegates to the runtime codec rather than parsing text here: SQLite stores a
|
|
539
|
+
* {@code VECTOR(n)} column as float32 bytes, while SQL Server's {@code VECTOR} and
|
|
540
|
+
* PostgreSQL's {@code vector} hand back text, and a column written before the byte
|
|
541
|
+
* encoding holds JSON. The runtime already reads all three.
|
|
542
|
+
*/
|
|
543
|
+
public static double[] asVector(Object value) {
|
|
544
|
+
return an5.adapters.base.SqliteVectors.decodeVector(value, 0);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Renders a vector the way the vector stores expect it, as \`[0.1, 0.2]\`. */
|
|
548
|
+
public static String formatVector(double[] vector) {
|
|
549
|
+
StringBuilder out = new StringBuilder("[");
|
|
550
|
+
for (int i = 0; i < vector.length; i++) {
|
|
551
|
+
if (i > 0) out.append(", ");
|
|
552
|
+
out.append(vector[i]);
|
|
553
|
+
}
|
|
554
|
+
return out.append(']').toString();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** The value as a list, so a filter's \`in\` list accepts a single item. */
|
|
558
|
+
public static List<Object> asList(Object value) {
|
|
559
|
+
List<Object> list = new ArrayList<Object>();
|
|
560
|
+
if (value == null) return list;
|
|
561
|
+
if (value instanceof List) {
|
|
562
|
+
for (Object entry : (List<?>) value) list.add(entry);
|
|
563
|
+
return list;
|
|
564
|
+
}
|
|
565
|
+
list.add(value);
|
|
566
|
+
return list;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
`;
|
|
570
|
+
fs.writeFileSync(path.join(this.outputDir, 'An5Values.java'), content);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ─── ORM types ───────────────────────────────────────────────────────────────────
|
|
574
|
+
|
|
575
|
+
private generateOrmTypes(models: Model[]): void {
|
|
576
|
+
let content = `// This file is auto-generated. Do not edit directly.
|
|
577
|
+
package an5.client;
|
|
578
|
+
|
|
579
|
+
import java.util.ArrayList;
|
|
580
|
+
import java.util.Arrays;
|
|
581
|
+
import java.util.LinkedHashMap;
|
|
582
|
+
import java.util.List;
|
|
583
|
+
import java.util.Map;
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Typed filters for the generated models.
|
|
587
|
+
*
|
|
588
|
+
* <p>A filter is a small object whose \`toMap()\` produces the operator map the adapter's SQL
|
|
589
|
+
* builder reads, so a query is type-checked at the call site and still compiles down to
|
|
590
|
+
* bind parameters rather than interpolated SQL.
|
|
591
|
+
*/
|
|
592
|
+
public final class An5OrmTypes {
|
|
593
|
+
|
|
594
|
+
private An5OrmTypes() {}
|
|
595
|
+
|
|
596
|
+
/** Shorthands for the operator maps, for a one-off condition. */
|
|
597
|
+
public static final class Filters {
|
|
598
|
+
private Filters() {}
|
|
599
|
+
|
|
600
|
+
public static Map<String, Object> eq(Object value) {
|
|
601
|
+
return operator("equals", value);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
public static Map<String, Object> neq(Object value) {
|
|
605
|
+
return operator("not", value);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
public static Map<String, Object> gt(Object value) {
|
|
609
|
+
return operator("gt", value);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
public static Map<String, Object> gte(Object value) {
|
|
613
|
+
return operator("gte", value);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
public static Map<String, Object> lt(Object value) {
|
|
617
|
+
return operator("lt", value);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
public static Map<String, Object> lte(Object value) {
|
|
621
|
+
return operator("lte", value);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
public static Map<String, Object> contains(String value) {
|
|
625
|
+
return operator("contains", value);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
public static Map<String, Object> startsWith(String value) {
|
|
629
|
+
return operator("startsWith", value);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
public static Map<String, Object> endsWith(String value) {
|
|
633
|
+
return operator("endsWith", value);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** An empty list matches nothing. */
|
|
637
|
+
public static Map<String, Object> in(Object... values) {
|
|
638
|
+
return operator("in", Arrays.asList(values));
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** An empty list matches everything. */
|
|
642
|
+
public static Map<String, Object> notIn(Object... values) {
|
|
643
|
+
return operator("notIn", Arrays.asList(values));
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** The column is \`NULL\`. */
|
|
647
|
+
public static Map<String, Object> isNull() {
|
|
648
|
+
Map<String, Object> filter = new LinkedHashMap<String, Object>();
|
|
649
|
+
filter.put("equals", null);
|
|
650
|
+
return filter;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** The column has a value. */
|
|
654
|
+
public static Map<String, Object> isNotNull() {
|
|
655
|
+
Map<String, Object> filter = new LinkedHashMap<String, Object>();
|
|
656
|
+
filter.put("not", null);
|
|
657
|
+
return filter;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** All of \`clauses\`; an empty array matches everything. */
|
|
661
|
+
@SafeVarargs
|
|
662
|
+
public static Map<String, Object> and(Map<String, Object>... clauses) {
|
|
663
|
+
Map<String, Object> filter = new LinkedHashMap<String, Object>();
|
|
664
|
+
filter.put("AND", copyOf(clauses));
|
|
665
|
+
return filter;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Any of \`clauses\`; an empty array matches nothing. */
|
|
669
|
+
@SafeVarargs
|
|
670
|
+
public static Map<String, Object> or(Map<String, Object>... clauses) {
|
|
671
|
+
Map<String, Object> filter = new LinkedHashMap<String, Object>();
|
|
672
|
+
filter.put("OR", copyOf(clauses));
|
|
673
|
+
return filter;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** None of \`clauses\`; an empty array matches everything. */
|
|
677
|
+
@SafeVarargs
|
|
678
|
+
public static Map<String, Object> not(Map<String, Object>... clauses) {
|
|
679
|
+
Map<String, Object> filter = new LinkedHashMap<String, Object>();
|
|
680
|
+
filter.put("NOT", copyOf(clauses));
|
|
681
|
+
return filter;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private static Map<String, Object> operator(String name, Object value) {
|
|
685
|
+
Map<String, Object> filter = new LinkedHashMap<String, Object>();
|
|
686
|
+
filter.put(name, value);
|
|
687
|
+
return filter;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Type-safe filter for string columns. */
|
|
692
|
+
public static final class StringFilter {
|
|
693
|
+
public String equals;
|
|
694
|
+
public String not;
|
|
695
|
+
public List<String> in;
|
|
696
|
+
public List<String> notIn;
|
|
697
|
+
public String contains;
|
|
698
|
+
public String startsWith;
|
|
699
|
+
public String endsWith;
|
|
700
|
+
public String gt;
|
|
701
|
+
public String gte;
|
|
702
|
+
public String lt;
|
|
703
|
+
public String lte;
|
|
704
|
+
|
|
705
|
+
public Map<String, Object> toMap() {
|
|
706
|
+
Map<String, Object> filter = new LinkedHashMap<String, Object>();
|
|
707
|
+
put(filter, "equals", equals);
|
|
708
|
+
put(filter, "not", not);
|
|
709
|
+
put(filter, "in", in);
|
|
710
|
+
put(filter, "notIn", notIn);
|
|
711
|
+
put(filter, "contains", contains);
|
|
712
|
+
put(filter, "startsWith", startsWith);
|
|
713
|
+
put(filter, "endsWith", endsWith);
|
|
714
|
+
put(filter, "gt", gt);
|
|
715
|
+
put(filter, "gte", gte);
|
|
716
|
+
put(filter, "lt", lt);
|
|
717
|
+
put(filter, "lte", lte);
|
|
718
|
+
return filter;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
public static StringFilter is(String value) {
|
|
722
|
+
StringFilter filter = new StringFilter();
|
|
723
|
+
filter.equals = value;
|
|
724
|
+
return filter;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
public static StringFilter isNot(String value) {
|
|
728
|
+
StringFilter filter = new StringFilter();
|
|
729
|
+
filter.not = value;
|
|
730
|
+
return filter;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
public static StringFilter has(String substring) {
|
|
734
|
+
StringFilter filter = new StringFilter();
|
|
735
|
+
filter.contains = substring;
|
|
736
|
+
return filter;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** Type-safe filter for integer and numeric columns. */
|
|
741
|
+
public static final class NumberFilter {
|
|
742
|
+
public Number equals;
|
|
743
|
+
public Number not;
|
|
744
|
+
public List<Number> in;
|
|
745
|
+
public List<Number> notIn;
|
|
746
|
+
public Number gt;
|
|
747
|
+
public Number gte;
|
|
748
|
+
public Number lt;
|
|
749
|
+
public Number lte;
|
|
750
|
+
|
|
751
|
+
public Map<String, Object> toMap() {
|
|
752
|
+
Map<String, Object> filter = new LinkedHashMap<String, Object>();
|
|
753
|
+
put(filter, "equals", equals);
|
|
754
|
+
put(filter, "not", not);
|
|
755
|
+
put(filter, "in", in);
|
|
756
|
+
put(filter, "notIn", notIn);
|
|
757
|
+
put(filter, "gt", gt);
|
|
758
|
+
put(filter, "gte", gte);
|
|
759
|
+
put(filter, "lt", lt);
|
|
760
|
+
put(filter, "lte", lte);
|
|
761
|
+
return filter;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
public static NumberFilter atLeast(Number value) {
|
|
765
|
+
NumberFilter filter = new NumberFilter();
|
|
766
|
+
filter.gte = value;
|
|
767
|
+
return filter;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
public static NumberFilter atMost(Number value) {
|
|
771
|
+
NumberFilter filter = new NumberFilter();
|
|
772
|
+
filter.lte = value;
|
|
773
|
+
return filter;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
public static NumberFilter is(Number value) {
|
|
777
|
+
NumberFilter filter = new NumberFilter();
|
|
778
|
+
filter.equals = value;
|
|
779
|
+
return filter;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** Type-safe filter for boolean columns. */
|
|
784
|
+
public static final class BoolFilter {
|
|
785
|
+
public Boolean equals;
|
|
786
|
+
public Boolean not;
|
|
787
|
+
|
|
788
|
+
public Map<String, Object> toMap() {
|
|
789
|
+
Map<String, Object> filter = new LinkedHashMap<String, Object>();
|
|
790
|
+
put(filter, "equals", equals);
|
|
791
|
+
put(filter, "not", not);
|
|
792
|
+
return filter;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
public static BoolFilter is(boolean value) {
|
|
796
|
+
BoolFilter filter = new BoolFilter();
|
|
797
|
+
filter.equals = Boolean.valueOf(value);
|
|
798
|
+
return filter;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Type-safe filter for date and time columns. */
|
|
803
|
+
public static final class DateFilter {
|
|
804
|
+
public Object equals;
|
|
805
|
+
public Object not;
|
|
806
|
+
public List<Object> in;
|
|
807
|
+
public List<Object> notIn;
|
|
808
|
+
public Object gt;
|
|
809
|
+
public Object gte;
|
|
810
|
+
public Object lt;
|
|
811
|
+
public Object lte;
|
|
812
|
+
|
|
813
|
+
public Map<String, Object> toMap() {
|
|
814
|
+
Map<String, Object> filter = new LinkedHashMap<String, Object>();
|
|
815
|
+
put(filter, "equals", equals);
|
|
816
|
+
put(filter, "not", not);
|
|
817
|
+
put(filter, "in", in);
|
|
818
|
+
put(filter, "notIn", notIn);
|
|
819
|
+
put(filter, "gt", gt);
|
|
820
|
+
put(filter, "gte", gte);
|
|
821
|
+
put(filter, "lt", lt);
|
|
822
|
+
put(filter, "lte", lte);
|
|
823
|
+
return filter;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
public static DateFilter atLeast(Object value) {
|
|
827
|
+
DateFilter filter = new DateFilter();
|
|
828
|
+
filter.gte = value;
|
|
829
|
+
return filter;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Copies a clause array into a list.
|
|
835
|
+
*
|
|
836
|
+
* <p>Element by element rather than {@code Arrays.asList(clauses)}, which wraps the caller's
|
|
837
|
+
* own array: a caller that later writes into it would change a filter already handed to a
|
|
838
|
+
* query, and {@code @SafeVarargs} forbids letting the array escape at all.
|
|
839
|
+
*/
|
|
840
|
+
@SafeVarargs
|
|
841
|
+
private static List<Object> copyOf(Map<String, Object>... clauses) {
|
|
842
|
+
List<Object> copy = new ArrayList<Object>();
|
|
843
|
+
for (Map<String, Object> clause : clauses) {
|
|
844
|
+
copy.add(clause);
|
|
845
|
+
}
|
|
846
|
+
return copy;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
private static void put(Map<String, Object> filter, String name, Object value) {
|
|
850
|
+
// A field left unset must not become \`col IS NULL\`: the builder treats an explicit null
|
|
851
|
+
// as "match NULL", so an unset filter has to be absent from the map entirely.
|
|
852
|
+
if (value != null) filter.put(name, value);
|
|
853
|
+
}
|
|
854
|
+
`;
|
|
855
|
+
|
|
856
|
+
for (const model of models) {
|
|
857
|
+
const name = model.name;
|
|
858
|
+
content += `
|
|
859
|
+
// ── ${name} ─────────────────────────────────────────────────────────────────────
|
|
860
|
+
|
|
861
|
+
/** Type-safe \`WHERE\` for ${name}. Unset filters are left out. */
|
|
862
|
+
public static final class ${name}Where {
|
|
863
|
+
public List<${name}Where> and;
|
|
864
|
+
public List<${name}Where> or;
|
|
865
|
+
public ${name}Where not;
|
|
866
|
+
`;
|
|
867
|
+
for (const field of model.fields) {
|
|
868
|
+
content += ` public ${this.getFilterType(field)} ${this.fieldName(field.name)};\n`;
|
|
869
|
+
}
|
|
870
|
+
content += `
|
|
871
|
+
public ${name}Where and(${name}Where... clauses) {
|
|
872
|
+
this.and = Arrays.asList(clauses);
|
|
873
|
+
return this;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
public ${name}Where or(${name}Where... clauses) {
|
|
877
|
+
this.or = Arrays.asList(clauses);
|
|
878
|
+
return this;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
public ${name}Where not(${name}Where clause) {
|
|
882
|
+
this.not = clause;
|
|
883
|
+
return this;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** The filter as the adapter reads it, with every unset condition left out. */
|
|
887
|
+
public Map<String, Object> toMap() {
|
|
888
|
+
Map<String, Object> filter = new LinkedHashMap<String, Object>();
|
|
889
|
+
if (and != null) filter.put("AND", toMapValues(and));
|
|
890
|
+
if (or != null) filter.put("OR", toMapValues(or));
|
|
891
|
+
if (not != null) filter.put("NOT", toMapValues(Arrays.asList(not)));
|
|
892
|
+
`;
|
|
893
|
+
for (const field of model.fields) {
|
|
894
|
+
const property = this.fieldName(field.name);
|
|
895
|
+
content += ` if (${property} != null) filter.put("${field.name}", ${property}.toMap());\n`;
|
|
896
|
+
}
|
|
897
|
+
content += ` return filter;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* The filter tree as the adapter reads it.
|
|
902
|
+
*
|
|
903
|
+
* <p>Nested clauses have to be converted here rather than handed over as objects: the
|
|
904
|
+
* SQL builder only understands maps, and an object in the list parses as an empty
|
|
905
|
+
* clause, which matches every row instead of failing.
|
|
906
|
+
*/
|
|
907
|
+
private static Object toMapValue(Object value) {
|
|
908
|
+
if (value instanceof ${name}Where) return ((${name}Where) value).toMap();
|
|
909
|
+
return value;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** Converts a group of nested clauses to the map form the adapter reads. */
|
|
913
|
+
private static List<Object> toMapValues(List<${name}Where> clauses) {
|
|
914
|
+
List<Object> values = new ArrayList<Object>(clauses.size());
|
|
915
|
+
for (${name}Where clause : clauses) values.add(toMapValue(clause));
|
|
916
|
+
return values;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/** Type-safe \`ORDER BY\` for ${name}. */
|
|
921
|
+
public static final class ${name}OrderBy {
|
|
922
|
+
private final List<Map<String, String>> entries = new ArrayList<Map<String, String>>();
|
|
923
|
+
|
|
924
|
+
public ${name}OrderBy by(String... columns) {
|
|
925
|
+
for (String column : columns) {
|
|
926
|
+
entries.add(single(column, "asc"));
|
|
927
|
+
}
|
|
928
|
+
return this;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
public ${name}OrderBy desc(String column) {
|
|
932
|
+
entries.add(single(column, "desc"));
|
|
933
|
+
return this;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
private static Map<String, String> single(String column, String direction) {
|
|
937
|
+
Map<String, String> entry = new LinkedHashMap<String, String>();
|
|
938
|
+
entry.put(column, direction);
|
|
939
|
+
return entry;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
public List<Map<String, String>> toList() {
|
|
943
|
+
return entries;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
`;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
content += `}\n`;
|
|
950
|
+
fs.writeFileSync(path.join(this.outputDir, 'An5OrmTypes.java'), content);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// ─── Metadata ────────────────────────────────────────────────────────────────────
|
|
954
|
+
|
|
955
|
+
private generateMetadata(models: Model[]): void {
|
|
956
|
+
let content = `// This file is auto-generated. Do not edit directly.
|
|
957
|
+
package an5.client;
|
|
958
|
+
|
|
959
|
+
import java.util.Arrays;
|
|
960
|
+
import java.util.Collections;
|
|
961
|
+
import java.util.LinkedHashMap;
|
|
962
|
+
import java.util.List;
|
|
963
|
+
import java.util.Map;
|
|
964
|
+
|
|
965
|
+
import an5.adapters.base.Metadata;
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* The generated models' tables, columns and relations.
|
|
969
|
+
*
|
|
970
|
+
* <p>Registered with the adapter when a context is constructed. Without it the table clients
|
|
971
|
+
* have no table names to work with and no primary key to fill in.
|
|
972
|
+
*/
|
|
973
|
+
public final class An5Metadata {
|
|
974
|
+
|
|
975
|
+
private An5Metadata() {}
|
|
976
|
+
|
|
977
|
+
/** Model name to table name, schema-qualified. */
|
|
978
|
+
public static final Map<String, String> MODEL_TO_TABLE;
|
|
979
|
+
|
|
980
|
+
/** Model name to its columns, as the adapter reads them. */
|
|
981
|
+
public static final Map<String, List<Map<String, Object>>> MODEL_FIELDS;
|
|
982
|
+
|
|
983
|
+
/** Model name to its relations, keyed by relation name. */
|
|
984
|
+
public static final Map<String, Map<String, Map<String, String>>> RELATION_MAP;
|
|
985
|
+
|
|
986
|
+
static {
|
|
987
|
+
Map<String, String> tables = new LinkedHashMap<String, String>();
|
|
988
|
+
Map<String, List<Map<String, Object>>> fields = new LinkedHashMap<String, List<Map<String, Object>>>();
|
|
989
|
+
Map<String, Map<String, Map<String, String>>> relations =
|
|
990
|
+
new LinkedHashMap<String, Map<String, Map<String, String>>>();
|
|
991
|
+
|
|
992
|
+
`;
|
|
993
|
+
|
|
994
|
+
for (const model of models) {
|
|
995
|
+
content += ` tables.put("${model.name}", "${this.tableName(model)}");\n`;
|
|
996
|
+
content += ` fields.put("${model.name}", Arrays.asList(\n`;
|
|
997
|
+
for (const field of model.fields) {
|
|
998
|
+
content += ` field("${field.name}", "${field.type}", "${field.sqlType}", ${field.isOptional}, ${field.hasDefault}, ${field.isId}${field.description ? `, ${javaString(field.description)}` : ''})${field === model.fields[model.fields.length - 1] ? '' : ','}\n`;
|
|
999
|
+
}
|
|
1000
|
+
content += ` ));\n`;
|
|
1001
|
+
if (model.relations.length > 0) {
|
|
1002
|
+
content += ` Map<String, Map<String, String>> ${lowerFirst(model.name)}Relations =\n`;
|
|
1003
|
+
content += ` new LinkedHashMap<String, Map<String, String>>();\n`;
|
|
1004
|
+
for (const rel of model.relations) {
|
|
1005
|
+
content += ` ${lowerFirst(model.name)}Relations.put("${rel.name}",\n`;
|
|
1006
|
+
content += ` relation("${rel.type}", "${rel.relationName}", "${rel.foreignKey}", "${rel.localKey}"));\n`;
|
|
1007
|
+
}
|
|
1008
|
+
content += ` relations.put("${model.name}", ${lowerFirst(model.name)}Relations);\n`;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
content += `
|
|
1013
|
+
MODEL_TO_TABLE = Collections.unmodifiableMap(tables);
|
|
1014
|
+
MODEL_FIELDS = Collections.unmodifiableMap(fields);
|
|
1015
|
+
RELATION_MAP = Collections.unmodifiableMap(relations);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
private static Map<String, Object> field(
|
|
1019
|
+
String name, String type, String sql, boolean optional, boolean hasDefault, boolean isId) {
|
|
1020
|
+
return field(name, type, sql, optional, hasDefault, isId, null);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
private static Map<String, Object> field(
|
|
1024
|
+
String name,
|
|
1025
|
+
String type,
|
|
1026
|
+
String sql,
|
|
1027
|
+
boolean optional,
|
|
1028
|
+
boolean hasDefault,
|
|
1029
|
+
boolean isId,
|
|
1030
|
+
String description) {
|
|
1031
|
+
Map<String, Object> field = new LinkedHashMap<String, Object>();
|
|
1032
|
+
field.put("name", name);
|
|
1033
|
+
field.put("type", type);
|
|
1034
|
+
field.put("sql", sql);
|
|
1035
|
+
field.put("isOptional", Boolean.valueOf(optional));
|
|
1036
|
+
field.put("hasDefault", Boolean.valueOf(hasDefault));
|
|
1037
|
+
field.put("isId", Boolean.valueOf(isId));
|
|
1038
|
+
field.put("description", description);
|
|
1039
|
+
return field;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
private static Map<String, String> relation(
|
|
1043
|
+
String modelName, String relationName, String foreignKey, String localKey) {
|
|
1044
|
+
Map<String, String> relation = new LinkedHashMap<String, String>();
|
|
1045
|
+
relation.put("modelName", modelName);
|
|
1046
|
+
relation.put("relationType", relationName);
|
|
1047
|
+
relation.put("foreignKey", foreignKey);
|
|
1048
|
+
relation.put("localKey", localKey);
|
|
1049
|
+
return relation;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** Registers this schema with the adapter runtime. */
|
|
1053
|
+
public static void register() {
|
|
1054
|
+
Map<String, Object> metadata = new LinkedHashMap<String, Object>();
|
|
1055
|
+
metadata.put("modelToTable", MODEL_TO_TABLE);
|
|
1056
|
+
metadata.put("modelFields", MODEL_FIELDS);
|
|
1057
|
+
metadata.put("relationMap", RELATION_MAP);
|
|
1058
|
+
Metadata.setAdapterMetadata(metadata);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
`;
|
|
1062
|
+
fs.writeFileSync(path.join(this.outputDir, 'An5Metadata.java'), content);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// ─── Config ──────────────────────────────────────────────────────────────────────
|
|
1066
|
+
|
|
1067
|
+
private generateConfig(): void {
|
|
1068
|
+
const content = `// This file is auto-generated. Do not edit directly.
|
|
1069
|
+
package an5.client;
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Where the connection string comes from.
|
|
1073
|
+
*
|
|
1074
|
+
* <p>\`DATABASE_URL\` first, then the system property, so one deployment can override a
|
|
1075
|
+
* checked-in default without the file changing.
|
|
1076
|
+
*/
|
|
1077
|
+
public final class An5Config {
|
|
1078
|
+
|
|
1079
|
+
private An5Config() {}
|
|
1080
|
+
|
|
1081
|
+
/** The connection string, from the environment or the \`an5.connectionString\` property. */
|
|
1082
|
+
public static String connectionString() {
|
|
1083
|
+
String fromEnvironment = System.getenv("DATABASE_URL");
|
|
1084
|
+
if (fromEnvironment != null && !fromEnvironment.trim().isEmpty()) {
|
|
1085
|
+
return fromEnvironment.trim();
|
|
1086
|
+
}
|
|
1087
|
+
String fromProperty = System.getProperty("an5.connectionString");
|
|
1088
|
+
if (fromProperty != null && !fromProperty.trim().isEmpty()) {
|
|
1089
|
+
return fromProperty.trim();
|
|
1090
|
+
}
|
|
1091
|
+
throw new IllegalStateException(
|
|
1092
|
+
"No connection string: set DATABASE_URL or the an5.connectionString system property.");
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
`;
|
|
1096
|
+
fs.writeFileSync(path.join(this.outputDir, 'An5Config.java'), content);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// ─── DbContext ───────────────────────────────────────────────────────────────────
|
|
1100
|
+
|
|
1101
|
+
private modelClientSource(): string {
|
|
1102
|
+
return `// This file is auto-generated. Do not edit directly.
|
|
1103
|
+
package an5.client;
|
|
1104
|
+
|
|
1105
|
+
import java.sql.SQLException;
|
|
1106
|
+
import java.util.ArrayList;
|
|
1107
|
+
import java.util.List;
|
|
1108
|
+
import java.util.Map;
|
|
1109
|
+
import java.util.function.Function;
|
|
1110
|
+
|
|
1111
|
+
import an5.adapters.An5Aggregate;
|
|
1112
|
+
import an5.adapters.An5GroupBy;
|
|
1113
|
+
import an5.adapters.An5Query;
|
|
1114
|
+
import an5.adapters.An5TableClient;
|
|
1115
|
+
|
|
1116
|
+
import java.util.ArrayList;
|
|
1117
|
+
import java.util.LinkedHashMap;
|
|
1118
|
+
import java.util.List;
|
|
1119
|
+
import java.util.Map;
|
|
1120
|
+
import java.util.function.Function;
|
|
1121
|
+
|
|
1122
|
+
import an5.adapters.An5Adapter;
|
|
1123
|
+
import an5.adapters.An5Aggregate;
|
|
1124
|
+
import an5.adapters.An5GroupBy;
|
|
1125
|
+
import an5.adapters.An5Query;
|
|
1126
|
+
import an5.adapters.An5TableClient;
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* A typed client for one model.
|
|
1130
|
+
*
|
|
1131
|
+
* <p>The row shape is supplied by the model class, so this stays generic: {@code findMany}
|
|
1132
|
+
* hands back {@code List<User>} rather than untyped maps, and {@code create} takes a
|
|
1133
|
+
* {@code User}.
|
|
1134
|
+
*/
|
|
1135
|
+
public final class ModelClient<T> {
|
|
1136
|
+
|
|
1137
|
+
private final An5TableClient table;
|
|
1138
|
+
private final Function<Map<String, Object>, T> fromRow;
|
|
1139
|
+
private final Function<T, Map<String, Object>> toValues;
|
|
1140
|
+
|
|
1141
|
+
ModelClient(
|
|
1142
|
+
An5TableClient table,
|
|
1143
|
+
Function<Map<String, Object>, T> fromRow,
|
|
1144
|
+
Function<T, Map<String, Object>> toValues) {
|
|
1145
|
+
this.table = table;
|
|
1146
|
+
this.fromRow = fromRow;
|
|
1147
|
+
this.toValues = toValues;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/** The model name this client reads and writes. */
|
|
1151
|
+
public String model() {
|
|
1152
|
+
return table.modelName();
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
public List<T> findMany() throws SQLException {
|
|
1156
|
+
return findMany(new An5Query());
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
public List<T> findMany(An5Query query) throws SQLException {
|
|
1160
|
+
return map(table.findMany(query));
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
public T findFirst(An5Query query) throws SQLException {
|
|
1164
|
+
return one(table.findFirst(query));
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
public T findFirst(Map<String, Object> where) throws SQLException {
|
|
1168
|
+
return one(table.findFirst(where));
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
public T findUnique(Map<String, Object> where) throws SQLException {
|
|
1172
|
+
return one(table.findUnique(where));
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
public long count(Map<String, Object> where) throws SQLException {
|
|
1176
|
+
return table.count(where);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/** Inserts a value and returns it as stored. */
|
|
1180
|
+
public T create(T value) throws SQLException {
|
|
1181
|
+
return one(table.create(toValues.apply(value)));
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/** Inserts a value and returns it with the relations the query asked for. */
|
|
1185
|
+
public T create(T value, An5Query query) throws SQLException {
|
|
1186
|
+
return one(table.create(toValues.apply(value), query));
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/** Inserts many values, one statement each. */
|
|
1190
|
+
public int createMany(List<T> values, boolean skipDuplicates) throws SQLException {
|
|
1191
|
+
List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
|
|
1192
|
+
for (T value : values) {
|
|
1193
|
+
rows.add(toValues.apply(value));
|
|
1194
|
+
}
|
|
1195
|
+
return affected(table.createMany(rows, skipDuplicates));
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/** Updates the matching rows and returns the first of them re-read. */
|
|
1199
|
+
public T update(Map<String, Object> where, T value) throws SQLException {
|
|
1200
|
+
return one(table.update(where, toValues.apply(value)));
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
public T update(Map<String, Object> where, T value, An5Query query) throws SQLException {
|
|
1204
|
+
return one(table.update(new An5Query().where(where), toValues.apply(value), query));
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** Updates every matching row and gives back how many changed. */
|
|
1208
|
+
public int updateMany(Map<String, Object> where, T value) throws SQLException {
|
|
1209
|
+
return affected(table.updateMany(where, toValues.apply(value)));
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/** Deletes the matching rows and returns the one that was there first. */
|
|
1213
|
+
public T delete(Map<String, Object> where) throws SQLException {
|
|
1214
|
+
return one(table.delete(where));
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/** Deletes every matching row, or the whole table when \`where\` is \`null\`. */
|
|
1218
|
+
public int deleteMany(Map<String, Object> where) throws SQLException {
|
|
1219
|
+
return affected(table.deleteMany(where));
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/** Updates the matching row when it exists, creates it otherwise. */
|
|
1223
|
+
public T upsert(Map<String, Object> where, T create, T update) throws SQLException {
|
|
1224
|
+
return one(table.upsert(where, toValues.apply(create), toValues.apply(update)));
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/** One row of aggregate values, keyed \`_count\`, \`_sum_<field>\` and so on. */
|
|
1228
|
+
public Map<String, Object> aggregate(An5Aggregate aggregate) throws SQLException {
|
|
1229
|
+
return table.aggregate(aggregate);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/** One row per group. */
|
|
1233
|
+
public List<Map<String, Object>> groupBy(An5GroupBy group) throws SQLException {
|
|
1234
|
+
return table.groupBy(group);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/** The rows nearest a vector. */
|
|
1238
|
+
public List<T> vectorSearch(
|
|
1239
|
+
double[] vector, Integer take, Map<String, Object> where, String vectorField, String metric)
|
|
1240
|
+
throws SQLException {
|
|
1241
|
+
return map(table.vectorSearch(vector, take, where, vectorField, metric));
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
public List<T> vectorSearch(double[] vector, Integer take) throws SQLException {
|
|
1245
|
+
return vectorSearch(vector, take, null, "embedding", "cosine");
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
private List<T> map(List<Map<String, Object>> rows) {
|
|
1249
|
+
List<T> mapped = new ArrayList<T>();
|
|
1250
|
+
for (Map<String, Object> row : rows) {
|
|
1251
|
+
mapped.add(fromRow.apply(row));
|
|
1252
|
+
}
|
|
1253
|
+
return mapped;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
private T one(Map<String, Object> row) {
|
|
1257
|
+
return row == null ? null : fromRow.apply(row);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
private static int affected(Map<String, Object> result) {
|
|
1261
|
+
Object value = result.get("count");
|
|
1262
|
+
return value instanceof Number ? ((Number) value).intValue() : 0;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
`;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
private generateDbContext(models: Model[]): void {
|
|
1270
|
+
fs.writeFileSync(path.join(this.outputDir, 'ModelClient.java'), this.modelClientSource());
|
|
1271
|
+
|
|
1272
|
+
let content = `// This file is auto-generated. Do not edit directly.
|
|
1273
|
+
package an5.client;
|
|
1274
|
+
|
|
1275
|
+
import java.sql.SQLException;
|
|
1276
|
+
import java.util.List;
|
|
1277
|
+
import java.util.Map;
|
|
1278
|
+
import java.util.function.Function;
|
|
1279
|
+
|
|
1280
|
+
import an5.adapters.An5Adapter;
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* The AN5 entry point: one typed client per model, over one connection.
|
|
1284
|
+
*
|
|
1285
|
+
* <pre>
|
|
1286
|
+
* try (An5DbContext db = new An5DbContext(An5Config.connectionString())) {
|
|
1287
|
+
* User ada = db.getUser().findUnique(Filters.eq("name", "Ada"));
|
|
1288
|
+
* }
|
|
1289
|
+
* </pre>
|
|
1290
|
+
*/
|
|
1291
|
+
public class An5DbContext implements AutoCloseable {
|
|
1292
|
+
|
|
1293
|
+
private final An5Adapter adapter;
|
|
1294
|
+
`;
|
|
1295
|
+
const declared = new Set<string>();
|
|
1296
|
+
for (const model of models) {
|
|
1297
|
+
const property = this.propertyNames(model.name)[0]!;
|
|
1298
|
+
if (declared.has(property)) continue;
|
|
1299
|
+
declared.add(property);
|
|
1300
|
+
content += ` private final ModelClient<${model.name}> ${property}Client;\n`;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
content += `
|
|
1304
|
+
/** Opens a context, registering this schema with the adapter. */
|
|
1305
|
+
public An5DbContext(String connectionString) {
|
|
1306
|
+
An5Metadata.register();
|
|
1307
|
+
this.adapter = new An5Adapter(connectionString);
|
|
1308
|
+
`;
|
|
1309
|
+
|
|
1310
|
+
const assigned = new Set<string>();
|
|
1311
|
+
for (const model of models) {
|
|
1312
|
+
const property = this.propertyNames(model.name)[0]!;
|
|
1313
|
+
if (assigned.has(property)) continue;
|
|
1314
|
+
assigned.add(property);
|
|
1315
|
+
content += ` this.${property}Client = new ModelClient<${model.name}>(\n`;
|
|
1316
|
+
content += ` adapter.table("${model.name}"), ${model.name}::fromRow, ${model.name}::toValues);\n`;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
content += ` }
|
|
1320
|
+
|
|
1321
|
+
/** Wraps an adapter the caller already opened. */
|
|
1322
|
+
public An5DbContext(An5Adapter adapter) {
|
|
1323
|
+
An5Metadata.register();
|
|
1324
|
+
this.adapter = adapter;
|
|
1325
|
+
`;
|
|
1326
|
+
|
|
1327
|
+
const wrapped = new Set<string>();
|
|
1328
|
+
for (const model of models) {
|
|
1329
|
+
const property = this.propertyNames(model.name)[0]!;
|
|
1330
|
+
if (wrapped.has(property)) continue;
|
|
1331
|
+
wrapped.add(property);
|
|
1332
|
+
content += ` this.${property}Client = new ModelClient<${model.name}>(\n`;
|
|
1333
|
+
content += ` adapter.table("${model.name}"), ${model.name}::fromRow, ${model.name}::toValues);\n`;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
content += ` }
|
|
1337
|
+
|
|
1338
|
+
/** The underlying adapter, for raw SQL and for tables outside this schema. */
|
|
1339
|
+
public An5Adapter adapter() {
|
|
1340
|
+
return adapter;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** The dialect the connection string points at. */
|
|
1344
|
+
public an5.adapters.base.Dialect dialect() {
|
|
1345
|
+
return adapter.dialect();
|
|
1346
|
+
}
|
|
1347
|
+
`;
|
|
1348
|
+
|
|
1349
|
+
const exposed = new Set<string>();
|
|
1350
|
+
for (const model of models) {
|
|
1351
|
+
const property = this.propertyNames(model.name)[0]!;
|
|
1352
|
+
if (exposed.has(property)) continue;
|
|
1353
|
+
exposed.add(property);
|
|
1354
|
+
const accessor = `get${this.capitalize(property)}`;
|
|
1355
|
+
content += `
|
|
1356
|
+
/** Queries for \`${model.name}\`. */
|
|
1357
|
+
public ModelClient<${model.name}> ${accessor}() {
|
|
1358
|
+
return this.${property}Client;
|
|
1359
|
+
}
|
|
1360
|
+
`;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
content += `
|
|
1364
|
+
/** A typed client for a table outside this schema. */
|
|
1365
|
+
public <T> ModelClient<T> table(
|
|
1366
|
+
String model, Function<Map<String, Object>, T> fromRow, Function<T, Map<String, Object>> toValues) {
|
|
1367
|
+
return new ModelClient<T>(adapter.table(model), fromRow, toValues);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/** A read-only client for a database view. */
|
|
1371
|
+
public an5.adapters.An5ViewClient view(String viewName) {
|
|
1372
|
+
return adapter.view(viewName);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/** Runs a query and returns its rows keyed by column label. */
|
|
1376
|
+
public List<Map<String, Object>> queryRaw(String sql, Object... parameters) throws SQLException {
|
|
1377
|
+
return adapter.queryRaw(sql, parameters);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/** Runs a statement that returns no rows. */
|
|
1381
|
+
public int executeRaw(String sql, Object... parameters) throws SQLException {
|
|
1382
|
+
return adapter.executeRaw(sql, parameters);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/** Runs work inside a transaction, committing on return and rolling back on failure. */
|
|
1386
|
+
public <T> T transaction(An5Adapter.TransactionWork<T> work) throws SQLException {
|
|
1387
|
+
return adapter.transaction(work);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
@Override
|
|
1391
|
+
public void close() {
|
|
1392
|
+
adapter.close();
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
`;
|
|
1396
|
+
fs.writeFileSync(path.join(this.outputDir, 'An5DbContext.java'), content);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────────
|
|
1400
|
+
|
|
1401
|
+
private tableName(model: Model): string {
|
|
1402
|
+
return model.schemaName ? `${model.schemaName}.${model.tableName}` : model.tableName;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/** Java keywords and literals that cannot be used as identifiers. */
|
|
1407
|
+
const JAVA_RESERVED = new Set([
|
|
1408
|
+
'Abstract', 'Assert', 'Boolean', 'Break', 'Byte', 'Case', 'Catch', 'Char', 'Class', 'Const',
|
|
1409
|
+
'Continue', 'Default', 'Do', 'Double', 'Else', 'Enum', 'Equals', 'Extends', 'False', 'Final',
|
|
1410
|
+
'Finally', 'Float', 'For', 'Goto', 'If', 'Implements', 'Import', 'Instanceof', 'Int',
|
|
1411
|
+
'Interface', 'Long', 'Native', 'New', 'Null', 'Package', 'Private', 'Protected', 'Public',
|
|
1412
|
+
'Return', 'Short', 'Static', 'Strictfp', 'Super', 'Switch', 'Synchronized', 'This', 'Throw',
|
|
1413
|
+
'Throws', 'Transient', 'True', 'Try', 'Void', 'Volatile', 'While',
|
|
1414
|
+
]);
|
|
1415
|
+
|
|
1416
|
+
function capitalize(value: string): string {
|
|
1417
|
+
return value ? value.charAt(0).toUpperCase() + value.slice(1) : value;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* The boxed form of a Java type.
|
|
1422
|
+
*
|
|
1423
|
+
* Capitalising is not boxing: `int` becomes `Integer`, not `Int`, and a generated model full
|
|
1424
|
+
* of `Int` does not compile.
|
|
1425
|
+
*/
|
|
1426
|
+
function box(type: string): string {
|
|
1427
|
+
const boxed: Record<string, string> = {
|
|
1428
|
+
int: 'Integer',
|
|
1429
|
+
long: 'Long',
|
|
1430
|
+
short: 'Short',
|
|
1431
|
+
byte: 'Byte',
|
|
1432
|
+
double: 'Double',
|
|
1433
|
+
float: 'Float',
|
|
1434
|
+
boolean: 'Boolean',
|
|
1435
|
+
char: 'Character',
|
|
1436
|
+
};
|
|
1437
|
+
return boxed[type] ?? type;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function lowerFirst(value: string): string {
|
|
1441
|
+
return value ? value.charAt(0).toLowerCase() + value.slice(1) : value;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/** A Java string literal for a schema description. */
|
|
1445
|
+
function javaString(value: string): string {
|
|
1446
|
+
return JSON.stringify(value);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* Doc-comment text.
|
|
1451
|
+
*
|
|
1452
|
+
* A comment terminator inside a description would end this comment early and leave the
|
|
1453
|
+
* rest of the line as code, so it is split.
|
|
1454
|
+
*/
|
|
1455
|
+
function escapeDoc(value: string): string {
|
|
1456
|
+
return value.replace(/\*\//g, '*\\/').replace(/\r?\n/g, ' ');
|
|
1457
|
+
}
|