@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
package/generator/src/api.ts
CHANGED
|
@@ -25,6 +25,9 @@ export { PythonGenerator } from './python-generator';
|
|
|
25
25
|
export { DotnetGenerator } from './dotnet-generator';
|
|
26
26
|
export { GolangGenerator } from './golang-generator';
|
|
27
27
|
export { RustGenerator } from './rust-generator';
|
|
28
|
+
export { JavaGenerator } from './java-generator';
|
|
29
|
+
export { KotlinGenerator } from './kotlin-generator';
|
|
30
|
+
export { SwiftGenerator } from './swift-generator';
|
|
28
31
|
export { detectProvider, providerFromConfig, providerForProject } from './config';
|
|
29
32
|
export { dialectFor, defaultClause, sqlLiteral } from './dialect';
|
|
30
33
|
export { fieldKind } from './type-kinds';
|
|
@@ -2,6 +2,13 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { Model, Field, Relation } from './types';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Types `resolveFieldType` can return that the SQL keyword table in
|
|
7
|
+
* `normalizeType` does not cover. Kept explicit so a new resolver output is a
|
|
8
|
+
* deliberate addition rather than silently becoming `any`.
|
|
9
|
+
*/
|
|
10
|
+
const RESOLVED_TS_TYPES = new Set(['Buffer', 'number | bigint', 'number[] | string']);
|
|
11
|
+
|
|
5
12
|
export class CodeGenerator {
|
|
6
13
|
constructor(private outputDir: string) {
|
|
7
14
|
if (!fs.existsSync(this.outputDir)) {
|
|
@@ -249,7 +256,12 @@ export interface TableClient<T, WhereInput = any, _Select = any, Include = any,
|
|
|
249
256
|
if (['bytes', 'binary', 'varbinary', 'image'].includes(lowerType)) {
|
|
250
257
|
return 'string';
|
|
251
258
|
}
|
|
252
|
-
|
|
259
|
+
// `Field.type` already holds the TypeScript type the parser resolved for the
|
|
260
|
+
// provider, and the resolver returns a few types this SQL table does not list:
|
|
261
|
+
// `Buffer` (BLOB), `number | bigint` (BIGINT) and `number[] | string` (VECTOR).
|
|
262
|
+
// Those used to fall through to `any`, so the generated client silently lost the
|
|
263
|
+
// type of every byte and vector column.
|
|
264
|
+
return RESOLVED_TS_TYPES.has(type) ? type : 'any';
|
|
253
265
|
}
|
|
254
266
|
|
|
255
267
|
private toCamelCase(str: string): string {
|
|
@@ -8,9 +8,12 @@ import { PythonGenerator } from './python-generator';
|
|
|
8
8
|
import { DotnetGenerator } from './dotnet-generator';
|
|
9
9
|
import { GolangGenerator } from './golang-generator';
|
|
10
10
|
import { RustGenerator } from './rust-generator';
|
|
11
|
+
import { JavaGenerator } from './java-generator';
|
|
12
|
+
import { KotlinGenerator } from './kotlin-generator';
|
|
13
|
+
import { SwiftGenerator } from './swift-generator';
|
|
11
14
|
import type { Provider } from './field-types';
|
|
12
15
|
|
|
13
|
-
export const CODE_LANGUAGES = ['typescript', 'python', 'dotnet', 'golang', 'rust'] as const;
|
|
16
|
+
export const CODE_LANGUAGES = ['typescript', 'python', 'dotnet', 'golang', 'rust', 'java', 'kotlin', 'swift'] as const;
|
|
14
17
|
export type CodeLanguage = typeof CODE_LANGUAGES[number];
|
|
15
18
|
export interface CodeRequest {
|
|
16
19
|
request: string;
|
|
@@ -31,6 +34,14 @@ export function detectCodeLanguage(root: string): CodeLanguage {
|
|
|
31
34
|
if (names.some(name => /\.(csproj|sln|slnx)$/.test(name))) candidates.push('dotnet');
|
|
32
35
|
if (has('go.mod')) candidates.push('golang');
|
|
33
36
|
if (has('Cargo.toml')) candidates.push('rust');
|
|
37
|
+
// Java and Kotlin are told apart by their build file, not by a directory name: a Gradle
|
|
38
|
+
// project has both a build.gradle and a pom.xml, and only one says which language it is.
|
|
39
|
+
const hasJavaBuild = has('pom.xml') || has('build.gradle') || has('build.gradle.kts');
|
|
40
|
+
if (hasJavaBuild) {
|
|
41
|
+
const kotlinMarker = names.some((name) => /\.(kt|kts)$/.test(name)) || has('settings.gradle.kts');
|
|
42
|
+
candidates.push(kotlinMarker ? 'kotlin' : 'java');
|
|
43
|
+
}
|
|
44
|
+
if (has('Package.swift')) candidates.push('swift');
|
|
34
45
|
if (candidates.length !== 1) throw new Error(`Cannot select one project language (${candidates.join(', ') || 'none'}). Specify language explicitly or select the application directory.`);
|
|
35
46
|
return candidates[0]!;
|
|
36
47
|
}
|
|
@@ -42,7 +53,7 @@ export async function prepareCodeRequest(input: CodeRequest) {
|
|
|
42
53
|
if (!CODE_LANGUAGES.includes(language)) throw new Error(`Unsupported language: ${language}`);
|
|
43
54
|
const loaded = loadConfig(input.projectRoot);
|
|
44
55
|
const output = loaded.outputs;
|
|
45
|
-
const clientOutput = { typescript: output.typescriptDir, python: path.dirname(output.pythonMetadataFile), dotnet: output.dotnetDir, golang: output.golangDir, rust: output.rustDir }[language];
|
|
56
|
+
const clientOutput = { typescript: output.typescriptDir, python: path.dirname(output.pythonMetadataFile), dotnet: output.dotnetDir, golang: output.golangDir, rust: output.rustDir, java: output.javaDir, kotlin: output.kotlinDir, swift: output.swiftDir }[language];
|
|
46
57
|
const schemaPath = path.resolve(input.projectRoot, input.schemaPath);
|
|
47
58
|
const schemaDir = fs.statSync(schemaPath).isDirectory() ? schemaPath : path.dirname(schemaPath);
|
|
48
59
|
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'an5-code-request-'));
|
|
@@ -62,13 +73,16 @@ export async function prepareCodeRequest(input: CodeRequest) {
|
|
|
62
73
|
case 'dotnet': new DotnetGenerator(scratch).generate(models); break;
|
|
63
74
|
case 'golang': new GolangGenerator(scratch).generate(models); break;
|
|
64
75
|
case 'rust': new RustGenerator(scratch).generate(models); break;
|
|
76
|
+
case 'java': new JavaGenerator(scratch).generate(models); break;
|
|
77
|
+
case 'kotlin': new KotlinGenerator(scratch).generate(models); break;
|
|
78
|
+
case 'swift': new SwiftGenerator(scratch).generate(models); break;
|
|
65
79
|
}
|
|
66
80
|
const files: { path: string; content: string }[] = [];
|
|
67
81
|
function collect(dir: string) {
|
|
68
82
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
69
83
|
const full = path.join(dir, entry.name);
|
|
70
84
|
if (entry.isDirectory()) collect(full);
|
|
71
|
-
else if (/\.(ts|py|cs|go|rs|mod|toml)$/.test(entry.name)) files.push({ path: path.relative(scratch, full), content: fs.readFileSync(full, 'utf8') });
|
|
85
|
+
else if (/\.(ts|py|cs|go|rs|mod|toml|java|kt|swift)$/.test(entry.name)) files.push({ path: path.relative(scratch, full), content: fs.readFileSync(full, 'utf8') });
|
|
72
86
|
}
|
|
73
87
|
}
|
|
74
88
|
collect(scratch);
|
package/generator/src/config.ts
CHANGED
|
@@ -41,6 +41,21 @@ export interface RustOutput {
|
|
|
41
41
|
outputDir: string;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** Java client output. */
|
|
45
|
+
export interface JavaOutput {
|
|
46
|
+
outputDir: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Kotlin client output. */
|
|
50
|
+
export interface KotlinOutput {
|
|
51
|
+
outputDir: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Swift client output. */
|
|
55
|
+
export interface SwiftOutput {
|
|
56
|
+
outputDir: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
44
59
|
export interface An5OrmConfig {
|
|
45
60
|
/**
|
|
46
61
|
* Database connection for the commands that need one — db:push, db:pull,
|
|
@@ -62,6 +77,9 @@ export interface An5OrmConfig {
|
|
|
62
77
|
dotnet: DotnetOutput;
|
|
63
78
|
golang: GolangOutput;
|
|
64
79
|
rust: RustOutput;
|
|
80
|
+
java: JavaOutput;
|
|
81
|
+
kotlin: KotlinOutput;
|
|
82
|
+
swift: SwiftOutput;
|
|
65
83
|
};
|
|
66
84
|
pull: {
|
|
67
85
|
/** Regex patterns; tables matching any of them are skipped by db:pull. */
|
|
@@ -86,6 +104,9 @@ export const DEFAULT_CONFIG: An5OrmConfig = {
|
|
|
86
104
|
dotnet: { outputDir: 'an5Client/dotnet' },
|
|
87
105
|
golang: { outputDir: 'an5Client/golang' },
|
|
88
106
|
rust: { outputDir: 'an5Client/rust' },
|
|
107
|
+
java: { outputDir: 'an5Client/java' },
|
|
108
|
+
kotlin: { outputDir: 'an5Client/kotlin' },
|
|
109
|
+
swift: { outputDir: 'an5Client/swift' },
|
|
89
110
|
},
|
|
90
111
|
pull: {
|
|
91
112
|
exclude: ['^__', '^sys\\.', '^igrations'],
|
|
@@ -105,6 +126,9 @@ export interface ResolvedOutputs {
|
|
|
105
126
|
dotnetDir: string;
|
|
106
127
|
golangDir: string;
|
|
107
128
|
rustDir: string;
|
|
129
|
+
javaDir: string;
|
|
130
|
+
kotlinDir: string;
|
|
131
|
+
swiftDir: string;
|
|
108
132
|
}
|
|
109
133
|
|
|
110
134
|
export function resolveOutputs(config: An5OrmConfig, rootDir: string): ResolvedOutputs {
|
|
@@ -117,6 +141,9 @@ export function resolveOutputs(config: An5OrmConfig, rootDir: string): ResolvedO
|
|
|
117
141
|
dotnetDir: resolve(config.outputs.dotnet.outputDir),
|
|
118
142
|
golangDir: resolve(config.outputs.golang.outputDir),
|
|
119
143
|
rustDir: resolve(config.outputs.rust.outputDir),
|
|
144
|
+
javaDir: resolve(config.outputs.java.outputDir),
|
|
145
|
+
kotlinDir: resolve(config.outputs.kotlin.outputDir),
|
|
146
|
+
swiftDir: resolve(config.outputs.swift.outputDir),
|
|
120
147
|
};
|
|
121
148
|
}
|
|
122
149
|
|
|
@@ -232,6 +259,9 @@ const OUTPUT_SPEC: Record<string, FieldSpec> = {
|
|
|
232
259
|
dotnet: 'object',
|
|
233
260
|
golang: 'object',
|
|
234
261
|
rust: 'object',
|
|
262
|
+
java: 'object',
|
|
263
|
+
kotlin: 'object',
|
|
264
|
+
swift: 'object',
|
|
235
265
|
};
|
|
236
266
|
|
|
237
267
|
const OUTPUT_SECTION_SPEC: Record<string, Record<string, FieldSpec>> = {
|
|
@@ -240,6 +270,9 @@ const OUTPUT_SECTION_SPEC: Record<string, Record<string, FieldSpec>> = {
|
|
|
240
270
|
dotnet: { outputDir: 'string' },
|
|
241
271
|
golang: { outputDir: 'string' },
|
|
242
272
|
rust: { outputDir: 'string' },
|
|
273
|
+
java: { outputDir: 'string' },
|
|
274
|
+
kotlin: { outputDir: 'string' },
|
|
275
|
+
swift: { outputDir: 'string' },
|
|
243
276
|
};
|
|
244
277
|
|
|
245
278
|
const TOP_LEVEL_SPEC: Record<string, FieldSpec> = {
|
|
@@ -317,6 +350,21 @@ export function validateConfig(raw: unknown): An5OrmConfig {
|
|
|
317
350
|
(outputs.rust as Record<string, unknown> | undefined)?.outputDir as string ??
|
|
318
351
|
DEFAULT_CONFIG.outputs.rust.outputDir,
|
|
319
352
|
},
|
|
353
|
+
java: {
|
|
354
|
+
outputDir:
|
|
355
|
+
(outputs.java as Record<string, unknown> | undefined)?.outputDir as string ??
|
|
356
|
+
DEFAULT_CONFIG.outputs.java.outputDir,
|
|
357
|
+
},
|
|
358
|
+
kotlin: {
|
|
359
|
+
outputDir:
|
|
360
|
+
(outputs.kotlin as Record<string, unknown> | undefined)?.outputDir as string ??
|
|
361
|
+
DEFAULT_CONFIG.outputs.kotlin.outputDir,
|
|
362
|
+
},
|
|
363
|
+
swift: {
|
|
364
|
+
outputDir:
|
|
365
|
+
(outputs.swift as Record<string, unknown> | undefined)?.outputDir as string ??
|
|
366
|
+
DEFAULT_CONFIG.outputs.swift.outputDir,
|
|
367
|
+
},
|
|
320
368
|
},
|
|
321
369
|
pull: {
|
|
322
370
|
exclude: (pull.exclude as string[] | undefined) ?? DEFAULT_CONFIG.pull.exclude,
|
package/generator/src/dialect.ts
CHANGED
|
@@ -293,13 +293,15 @@ function createDialect(provider: Provider, quote: (name: string) => string): Pus
|
|
|
293
293
|
// destructured by a caller.
|
|
294
294
|
const columnDefinition = (column: ColumnSpec): string => {
|
|
295
295
|
const identity = identityKeyword(column, provider);
|
|
296
|
+
const sqlType = provider === 'sqlite' && /^VECTOR\s*(\(|$)/i.test(column.sqlType)
|
|
297
|
+
? 'BLOB' : column.sqlType;
|
|
296
298
|
// Where the identity keyword goes is not a detail: SQL Server takes it as part
|
|
297
299
|
// of the column type (`INT IDENTITY(1,1)`), while SQLite only accepts it after
|
|
298
300
|
// `PRIMARY KEY` and only spells the documented form `INTEGER PRIMARY KEY
|
|
299
301
|
// AUTOINCREMENT`.
|
|
300
302
|
const type = identity && provider !== 'sqlite'
|
|
301
|
-
? `${quote(column.name)} ${
|
|
302
|
-
: `${quote(column.name)} ${
|
|
303
|
+
? `${quote(column.name)} ${sqlType} ${identity}`
|
|
304
|
+
: `${quote(column.name)} ${sqlType}`;
|
|
303
305
|
const nullable = column.isOptional ? '' : ' NOT NULL';
|
|
304
306
|
const unique = column.isUnique && !column.isId ? ' UNIQUE' : '';
|
|
305
307
|
const defaultSql = defaultClause(column.defaultExpr, provider);
|
|
@@ -412,4 +414,4 @@ export function defaultClause(expression: string | undefined, provider: Provider
|
|
|
412
414
|
if (/^".*"$/.test(value)) return `DEFAULT '${value.slice(1, -1).replace(/'/g, "''")}'`;
|
|
413
415
|
return `DEFAULT ${value}`;
|
|
414
416
|
}
|
|
415
|
-
}
|
|
417
|
+
}
|
|
@@ -305,6 +305,7 @@ using System;
|
|
|
305
305
|
using System.Collections.Generic;
|
|
306
306
|
using System.Data;
|
|
307
307
|
using System.Data.Common;
|
|
308
|
+
using System.Linq;
|
|
308
309
|
using System.Reflection;
|
|
309
310
|
using System.Text.Json;
|
|
310
311
|
using An5Orm.Entities;
|
|
@@ -393,11 +394,41 @@ namespace An5Orm
|
|
|
393
394
|
{
|
|
394
395
|
var p = cmd.CreateParameter();
|
|
395
396
|
p.ParameterName = (name ?? "").TrimStart('@');
|
|
396
|
-
p.Value = value ?? DBNull.Value;
|
|
397
|
+
p.Value = Bindable(value) ?? DBNull.Value;
|
|
397
398
|
cmd.Parameters.Add(p);
|
|
398
399
|
return p;
|
|
399
400
|
}
|
|
400
401
|
|
|
402
|
+
/// <summary>
|
|
403
|
+
/// Converts a value into something the provider can bind.
|
|
404
|
+
/// </summary>
|
|
405
|
+
/// <remarks>
|
|
406
|
+
/// A <c>VECTOR(n)</c> column is transmitted as a JSON array over TDS, so a float or
|
|
407
|
+
/// double array is written in that form. The array itself is not bindable: the
|
|
408
|
+
/// provider has no mapping for it, and the failure names the array rather than the
|
|
409
|
+
/// column it was meant for.
|
|
410
|
+
/// </remarks>
|
|
411
|
+
public static object Bindable(object value)
|
|
412
|
+
{
|
|
413
|
+
// \`float[]\` and \`double[]\` are listed separately rather than through a shared
|
|
414
|
+
// \`IEnumerable<double>\`: an array of value type is not covariant, so
|
|
415
|
+
// \`float[]\` does not convert to it.
|
|
416
|
+
if (value is float[] floats) return FormatVector(floats.Select(v => (double)v));
|
|
417
|
+
if (value is double[] doubles) return FormatVector(doubles);
|
|
418
|
+
return value;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/// <summary>Renders a vector as the JSON array SQL Server expects.</summary>
|
|
422
|
+
public static string FormatVector(IEnumerable<double> vector)
|
|
423
|
+
{
|
|
424
|
+
var parts = new List<string>();
|
|
425
|
+
foreach (var value in vector)
|
|
426
|
+
{
|
|
427
|
+
parts.Add(value.ToString("R", System.Globalization.CultureInfo.InvariantCulture));
|
|
428
|
+
}
|
|
429
|
+
return "[" + string.Join(", ", parts) + "]";
|
|
430
|
+
}
|
|
431
|
+
|
|
401
432
|
/// <summary>Quotes an identifier for the dialect.</summary>
|
|
402
433
|
public static string Quote(string name, An5Dialect dialect)
|
|
403
434
|
{
|
|
@@ -1024,6 +1055,60 @@ namespace An5Orm
|
|
|
1024
1055
|
/// property would otherwise fail to set. A value that cannot be
|
|
1025
1056
|
/// converted is skipped rather than aborting the whole read.
|
|
1026
1057
|
/// </summary>
|
|
1058
|
+
/// <summary>
|
|
1059
|
+
/// Reads a stored vector into the \`float[]\` an entity declares.
|
|
1060
|
+
/// </summary>
|
|
1061
|
+
/// <remarks>
|
|
1062
|
+
/// A \`VECTOR(n)\` column arrives as a JSON array over TDS, so the JSON form is
|
|
1063
|
+
/// what is parsed. A float32 BLOB is also accepted because that is what the SQLite
|
|
1064
|
+
/// provider stores in the same column. Returns <c>null</c> for anything else, which
|
|
1065
|
+
/// leaves the property at its default rather than aborting the whole read.
|
|
1066
|
+
/// </remarks>
|
|
1067
|
+
private static float[] DecodeVectorColumn(object value)
|
|
1068
|
+
{
|
|
1069
|
+
if (value is float[] already) return already;
|
|
1070
|
+
if (value is double[] asDouble)
|
|
1071
|
+
{
|
|
1072
|
+
var fromDouble = new float[asDouble.Length];
|
|
1073
|
+
for (int i = 0; i < asDouble.Length; i++) fromDouble[i] = (float)asDouble[i];
|
|
1074
|
+
return fromDouble;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
string text = value as string;
|
|
1078
|
+
byte[] bytes = value as byte[];
|
|
1079
|
+
if (bytes != null && bytes.Length > 0 && bytes[0] != (byte)'[' && bytes.Length % 4 == 0)
|
|
1080
|
+
{
|
|
1081
|
+
var fromBlob = new float[bytes.Length / 4];
|
|
1082
|
+
for (int i = 0; i < fromBlob.Length; i++)
|
|
1083
|
+
{
|
|
1084
|
+
int offset = i * 4;
|
|
1085
|
+
uint bits = (uint)(bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24));
|
|
1086
|
+
fromBlob[i] = BitConverter.ToSingle(BitConverter.GetBytes(bits), 0);
|
|
1087
|
+
}
|
|
1088
|
+
return fromBlob;
|
|
1089
|
+
}
|
|
1090
|
+
if (text == null && bytes != null)
|
|
1091
|
+
{
|
|
1092
|
+
text = System.Text.Encoding.UTF8.GetString(bytes);
|
|
1093
|
+
}
|
|
1094
|
+
if (text == null) return null;
|
|
1095
|
+
|
|
1096
|
+
var open = text.IndexOf('[');
|
|
1097
|
+
var close = text.LastIndexOf(']');
|
|
1098
|
+
if (open < 0 || close <= open) return null;
|
|
1099
|
+
var body = text.Substring(open + 1, close - open - 1).Trim();
|
|
1100
|
+
if (body.Length == 0) return null;
|
|
1101
|
+
|
|
1102
|
+
var parts = body.Split(',');
|
|
1103
|
+
var parsed = new float[parts.Length];
|
|
1104
|
+
for (int i = 0; i < parts.Length; i++)
|
|
1105
|
+
{
|
|
1106
|
+
if (!float.TryParse(parts[i].Trim(), System.Globalization.NumberStyles.Float,
|
|
1107
|
+
System.Globalization.CultureInfo.InvariantCulture, out parsed[i])) return null;
|
|
1108
|
+
}
|
|
1109
|
+
return parsed;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1027
1112
|
private static void SetValue(System.Reflection.PropertyInfo prop, object target, object value)
|
|
1028
1113
|
{
|
|
1029
1114
|
var wanted = prop.PropertyType;
|
|
@@ -1041,6 +1126,17 @@ namespace An5Orm
|
|
|
1041
1126
|
prop.SetValue(target, Convert.ChangeType(value, underlying));
|
|
1042
1127
|
return;
|
|
1043
1128
|
}
|
|
1129
|
+
// A \`VECTOR(n)\` column is transmitted as a JSON array over TDS, which no
|
|
1130
|
+
// converter bridges to the \`float[]\` the entity declares.
|
|
1131
|
+
if (wanted == typeof(float[]))
|
|
1132
|
+
{
|
|
1133
|
+
var vector = DecodeVectorColumn(value);
|
|
1134
|
+
if (vector != null)
|
|
1135
|
+
{
|
|
1136
|
+
prop.SetValue(target, vector);
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1044
1140
|
if (value is string text && wanted == typeof(Guid)) { prop.SetValue(target, Guid.Parse(text)); return; }
|
|
1045
1141
|
if (value is string when && wanted == typeof(DateTime)) { prop.SetValue(target, DateTime.Parse(when, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind)); return; }
|
|
1046
1142
|
prop.SetValue(target, Convert.ChangeType(value, wanted));
|
|
@@ -266,6 +266,9 @@ export const PROVIDER_FIELD_TYPES: Record<Provider, Readonly<Record<string, TsTy
|
|
|
266
266
|
// Everything else
|
|
267
267
|
JSON: 'any',
|
|
268
268
|
UUID: 'string',
|
|
269
|
+
// Stored as a BLOB of little-endian float32: a third of the JSON text it replaces,
|
|
270
|
+
// and what the adapters' in-database distance functions read. A column already
|
|
271
|
+
// holding JSON text keeps working, because the adapters decode both.
|
|
269
272
|
VECTOR: 'number[] | string',
|
|
270
273
|
},
|
|
271
274
|
|
|
@@ -189,6 +189,7 @@ func GetDefaultConnectionString() string {
|
|
|
189
189
|
content += this.generateWhereBuilder();
|
|
190
190
|
content += this.generateCrudMethods();
|
|
191
191
|
content += this.generateVectorSearch();
|
|
192
|
+
content += this.generateVectorHelpers();
|
|
192
193
|
content += this.generateHelpers();
|
|
193
194
|
|
|
194
195
|
// NOTE: per-model ORM types live in each `<Model>.go` file.
|
|
@@ -417,9 +418,18 @@ func (c *TableClient[T]) queryAndScan(ctx context.Context, query string, args ..
|
|
|
417
418
|
\t\tvar item T
|
|
418
419
|
\t\tval := reflect.ValueOf(&item).Elem()
|
|
419
420
|
\t\tscanArgs := make([]interface{}, len(cols))
|
|
421
|
+
\t\tvectorCols := make([]bool, len(cols))
|
|
420
422
|
\t\tfor i, colName := range cols {
|
|
421
423
|
\t\t\tfieldVal := findFieldByDbTag(val, colName)
|
|
422
424
|
\t\t\tif fieldVal.IsValid() && fieldVal.CanSet() {
|
|
425
|
+
\t\t\t\t// A \`VECTOR(n)\` column is stored as bytes, which database/sql cannot
|
|
426
|
+
\t\t\t\t// scan into a slice, so it is read as bytes and decoded afterwards.
|
|
427
|
+
\t\t\t\tif isVectorGoType(fieldVal.Type()) {
|
|
428
|
+
\t\t\t\t\tvar raw []byte
|
|
429
|
+
\t\t\t\t\tscanArgs[i] = &raw
|
|
430
|
+
\t\t\t\t\tvectorCols[i] = true
|
|
431
|
+
\t\t\t\t\tcontinue
|
|
432
|
+
\t\t\t\t}
|
|
423
433
|
\t\t\t\tscanArgs[i] = fieldVal.Addr().Interface()
|
|
424
434
|
\t\t\t} else {
|
|
425
435
|
\t\t\t\tvar unused interface{}
|
|
@@ -429,6 +439,20 @@ func (c *TableClient[T]) queryAndScan(ctx context.Context, query string, args ..
|
|
|
429
439
|
\t\tif err := rows.Scan(scanArgs...); err != nil {
|
|
430
440
|
\t\t\treturn nil, err
|
|
431
441
|
\t\t}
|
|
442
|
+
\t\tfor i, isVector := range vectorCols {
|
|
443
|
+
\t\t\tif !isVector {
|
|
444
|
+
\t\t\t\tcontinue
|
|
445
|
+
\t\t\t}
|
|
446
|
+
\t\t\tdecoded, ok := decodeVectorBytes(*(scanArgs[i].(*[]byte)))
|
|
447
|
+
\t\t\tif !ok {
|
|
448
|
+
\t\t\t\tcontinue
|
|
449
|
+
\t\t\t}
|
|
450
|
+
\t\t\tfieldVal := findFieldByDbTag(val, cols[i])
|
|
451
|
+
\t\t\tif !fieldVal.IsValid() || !fieldVal.CanSet() {
|
|
452
|
+
\t\t\t\tcontinue
|
|
453
|
+
\t\t\t}
|
|
454
|
+
\t\t\tsetVectorField(fieldVal, decoded)
|
|
455
|
+
\t\t}
|
|
432
456
|
\t\tresults = append(results, item)
|
|
433
457
|
\t}
|
|
434
458
|
\tif err := rows.Err(); err != nil {
|
|
@@ -437,6 +461,161 @@ func (c *TableClient[T]) queryAndScan(ctx context.Context, query string, args ..
|
|
|
437
461
|
\treturn results, nil
|
|
438
462
|
}
|
|
439
463
|
|
|
464
|
+
`;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* The vector codec the generated client needs on both sides of a query.
|
|
469
|
+
*
|
|
470
|
+
* A \`VECTOR(n)\` column stores float32 bytes, which is a third of the JSON text it
|
|
471
|
+
* replaces and is what the in-database distance functions read. A column written
|
|
472
|
+
* before that encoding holds JSON text, so both are still accepted on the way in.
|
|
473
|
+
*/
|
|
474
|
+
private generateVectorHelpers(): string {
|
|
475
|
+
return `// bytesPerVectorFloat is the width of one stored vector component.
|
|
476
|
+
const bytesPerVectorFloat = 4
|
|
477
|
+
|
|
478
|
+
// isVectorGoType reports whether a model field is a vector column, which the
|
|
479
|
+
// generator declares as a float32 slice, optionally behind a pointer because the
|
|
480
|
+
// column is nullable.
|
|
481
|
+
func isVectorGoType(t reflect.Type) bool {
|
|
482
|
+
\treturn vectorSliceType(t) != nil
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// vectorSliceType returns the slice type a vector field holds, or nil for any
|
|
486
|
+
// other field. A pointer to a slice is unwrapped so both the nullable and the
|
|
487
|
+
// required spelling are recognised.
|
|
488
|
+
func vectorSliceType(t reflect.Type) reflect.Type {
|
|
489
|
+
\tif t.Kind() == reflect.Ptr {
|
|
490
|
+
\t\tt = t.Elem()
|
|
491
|
+
\t}
|
|
492
|
+
\tif t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Float32 {
|
|
493
|
+
\t\treturn t
|
|
494
|
+
\t}
|
|
495
|
+
\treturn nil
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// vectorComponents reads a vector field as float64 components.
|
|
499
|
+
//
|
|
500
|
+
// A field can arrive as JSON text, as a slice, or behind a pointer to either, and a
|
|
501
|
+
// pointer is how the generator spells a nullable column. Anything else is not a vector.
|
|
502
|
+
func vectorComponents(field reflect.Value) []float64 {
|
|
503
|
+
\tfor field.Kind() == reflect.Ptr {
|
|
504
|
+
\t\tif field.IsNil() {
|
|
505
|
+
\t\t\treturn nil
|
|
506
|
+
\t\t}
|
|
507
|
+
\t\tfield = field.Elem()
|
|
508
|
+
\t}
|
|
509
|
+
\tswitch {
|
|
510
|
+
\tcase field.Kind() == reflect.String:
|
|
511
|
+
\t\tvar parsed []float64
|
|
512
|
+
\t\tif err := json.Unmarshal([]byte(field.String()), &parsed); err != nil {
|
|
513
|
+
\t\t\treturn nil
|
|
514
|
+
\t\t}
|
|
515
|
+
\t\treturn parsed
|
|
516
|
+
\tcase field.Kind() == reflect.Slice:
|
|
517
|
+
\t\tout := make([]float64, field.Len())
|
|
518
|
+
\t\tfor i := range out {
|
|
519
|
+
\t\t\tout[i] = field.Index(i).Float()
|
|
520
|
+
\t\t}
|
|
521
|
+
\t\treturn out
|
|
522
|
+
\tdefault:
|
|
523
|
+
\t\treturn nil
|
|
524
|
+
\t}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// setVectorField stores decoded components into a vector field, matching whether
|
|
528
|
+
// the field holds the slice directly or behind a pointer.
|
|
529
|
+
func setVectorField(field reflect.Value, values []float64) {
|
|
530
|
+
\tsliceType := vectorSliceType(field.Type())
|
|
531
|
+
\tif sliceType == nil {
|
|
532
|
+
\t\treturn
|
|
533
|
+
\t}
|
|
534
|
+
\tslice := reflect.MakeSlice(sliceType, len(values), len(values))
|
|
535
|
+
\tfor i, value := range values {
|
|
536
|
+
\t\tslice.Index(i).SetFloat(value)
|
|
537
|
+
\t}
|
|
538
|
+
\tif field.Kind() == reflect.Ptr {
|
|
539
|
+
\t\tptr := reflect.New(sliceType)
|
|
540
|
+
\t\tptr.Elem().Set(slice)
|
|
541
|
+
\t\tfield.Set(ptr)
|
|
542
|
+
\t\treturn
|
|
543
|
+
\t}
|
|
544
|
+
\tfield.Set(slice)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// encodeVectorArg converts a vector field into the bytes the column stores.
|
|
548
|
+
//
|
|
549
|
+
// database/sql accepts only a fixed set of types, so a slice otherwise reaches the
|
|
550
|
+
// driver as an error. Values that already arrived as stored form pass through.
|
|
551
|
+
func encodeVectorArg(value interface{}) interface{} {
|
|
552
|
+
\tswitch v := value.(type) {
|
|
553
|
+
\tcase *[]float32:
|
|
554
|
+
\t\tif v == nil {
|
|
555
|
+
\t\t\treturn nil
|
|
556
|
+
\t\t}
|
|
557
|
+
\t\treturn encodeVectorFloats(floatsFrom32(*v))
|
|
558
|
+
\tcase []float32:
|
|
559
|
+
\t\treturn encodeVectorFloats(floatsFrom32(v))
|
|
560
|
+
\tcase *[]float64:
|
|
561
|
+
\t\tif v == nil {
|
|
562
|
+
\t\t\treturn nil
|
|
563
|
+
\t\t}
|
|
564
|
+
\t\treturn encodeVectorFloats(*v)
|
|
565
|
+
\tcase []float64:
|
|
566
|
+
\t\treturn encodeVectorFloats(v)
|
|
567
|
+
\tdefault:
|
|
568
|
+
\t\treturn value
|
|
569
|
+
\t}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
func floatsFrom32(values []float32) []float64 {
|
|
573
|
+
\tout := make([]float64, len(values))
|
|
574
|
+
\tfor i, value := range values {
|
|
575
|
+
\t\tout[i] = float64(value)
|
|
576
|
+
\t}
|
|
577
|
+
\treturn out
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// encodeVectorFloats writes float32 values little-endian, which is the order the
|
|
581
|
+
// stored bytes are read back in.
|
|
582
|
+
func encodeVectorFloats(values []float64) []byte {
|
|
583
|
+
\tout := make([]byte, len(values)*bytesPerVectorFloat)
|
|
584
|
+
\tfor i, value := range values {
|
|
585
|
+
\t\toffset := i * bytesPerVectorFloat
|
|
586
|
+
\t\tbits := math.Float32bits(float32(value))
|
|
587
|
+
\t\tout[offset] = byte(bits)
|
|
588
|
+
\t\tout[offset+1] = byte(bits >> 8)
|
|
589
|
+
\t\tout[offset+2] = byte(bits >> 16)
|
|
590
|
+
\t\tout[offset+3] = byte(bits >> 24)
|
|
591
|
+
\t}
|
|
592
|
+
\treturn out
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// decodeVectorBytes reads a stored vector: float32 bytes, legacy JSON text, or
|
|
596
|
+
// nothing at all.
|
|
597
|
+
func decodeVectorBytes(raw []byte) ([]float64, bool) {
|
|
598
|
+
\tif len(raw) == 0 {
|
|
599
|
+
\t\treturn nil, false
|
|
600
|
+
\t}
|
|
601
|
+
\t// JSON text is not a multiple of four bytes once encoded and starts with '[',
|
|
602
|
+
\t// so the two are told apart by length and first byte.
|
|
603
|
+
\tif raw[0] == '[' || len(raw)%bytesPerVectorFloat != 0 {
|
|
604
|
+
\t\tvar parsed []float64
|
|
605
|
+
\t\tif err := json.Unmarshal(raw, &parsed); err != nil || len(parsed) == 0 {
|
|
606
|
+
\t\t\treturn nil, false
|
|
607
|
+
\t\t}
|
|
608
|
+
\t\treturn parsed, true
|
|
609
|
+
\t}
|
|
610
|
+
\tout := make([]float64, len(raw)/bytesPerVectorFloat)
|
|
611
|
+
\tfor i := range out {
|
|
612
|
+
\t\toffset := i * bytesPerVectorFloat
|
|
613
|
+
\t\tbits := uint32(raw[offset]) | uint32(raw[offset+1])<<8 | uint32(raw[offset+2])<<16 | uint32(raw[offset+3])<<24
|
|
614
|
+
\t\tout[i] = float64(math.Float32frombits(bits))
|
|
615
|
+
\t}
|
|
616
|
+
\treturn out, true
|
|
617
|
+
}
|
|
618
|
+
|
|
440
619
|
`;
|
|
441
620
|
}
|
|
442
621
|
|
|
@@ -657,7 +836,7 @@ func (c *TableClient[T]) Create(ctx context.Context, entity *T) (*T, error) {
|
|
|
657
836
|
\t\tfieldVal := val.Field(i)
|
|
658
837
|
\t\tif !fieldVal.IsZero() {
|
|
659
838
|
\t\t\tcols = append(cols, c.quoteName(dbTag))
|
|
660
|
-
\t\t\targs = append(args, fieldVal.Interface())
|
|
839
|
+
\t\t\targs = append(args, encodeVectorArg(fieldVal.Interface()))
|
|
661
840
|
\t\t\tphs = append(phs, c.ph(len(args)))
|
|
662
841
|
\t\t}
|
|
663
842
|
\t}
|
|
@@ -704,7 +883,7 @@ func (c *TableClient[T]) Update(ctx context.Context, entity *T) (*T, error) {
|
|
|
704
883
|
\t\t\tidVal = fieldVal.Interface()
|
|
705
884
|
\t\t\tidCol = dbTag
|
|
706
885
|
\t\t} else if !fieldVal.IsZero() {
|
|
707
|
-
\t\t\targs = append(args, fieldVal.Interface())
|
|
886
|
+
\t\t\targs = append(args, encodeVectorArg(fieldVal.Interface()))
|
|
708
887
|
\t\t\tsets = append(sets, c.quoteName(dbTag)+" = "+c.ph(len(args)))
|
|
709
888
|
\t\t}
|
|
710
889
|
\t}
|
|
@@ -1053,13 +1232,9 @@ func (c *TableClient[T]) VectorSearch(ctx context.Context, vector []float64, opt
|
|
|
1053
1232
|
\t\t\tcontinue
|
|
1054
1233
|
\t\t}
|
|
1055
1234
|
|
|
1056
|
-
\t\
|
|
1057
|
-
\t\tif
|
|
1058
|
-
\t\t\
|
|
1059
|
-
\t\t} else if fieldVal.Kind() == reflect.Slice {
|
|
1060
|
-
\t\t\tfor i := 0; i < fieldVal.Len(); i++ {
|
|
1061
|
-
\t\t\t\trowVec = append(rowVec, fieldVal.Index(i).Float())
|
|
1062
|
-
\t\t\t}
|
|
1235
|
+
\t\trowVec := vectorComponents(fieldVal)
|
|
1236
|
+
\t\tif len(rowVec) == 0 {
|
|
1237
|
+
\t\t\tcontinue
|
|
1063
1238
|
\t\t}
|
|
1064
1239
|
\t\tif len(rowVec) != len(vector) {
|
|
1065
1240
|
\t\t\tcontinue
|
package/generator/src/index.ts
CHANGED
|
@@ -8,6 +8,9 @@ import { PythonGenerator } from './python-generator';
|
|
|
8
8
|
import { DotnetGenerator } from './dotnet-generator';
|
|
9
9
|
import { GolangGenerator } from './golang-generator';
|
|
10
10
|
import { RustGenerator } from './rust-generator';
|
|
11
|
+
import { JavaGenerator } from './java-generator';
|
|
12
|
+
import { KotlinGenerator } from './kotlin-generator';
|
|
13
|
+
import { SwiftGenerator } from './swift-generator';
|
|
11
14
|
|
|
12
15
|
import fs from 'fs';
|
|
13
16
|
|
|
@@ -69,6 +72,9 @@ async function main() {
|
|
|
69
72
|
const outputDotnetDir = config.outputs.dotnetDir;
|
|
70
73
|
const outputGolangDir = config.outputs.golangDir;
|
|
71
74
|
const outputRustDir = config.outputs.rustDir;
|
|
75
|
+
const outputJavaDir = config.outputs.javaDir;
|
|
76
|
+
const outputKotlinDir = config.outputs.kotlinDir;
|
|
77
|
+
const outputSwiftDir = config.outputs.swiftDir;
|
|
72
78
|
const generateMetadata = config.config.generation.generateMetadata;
|
|
73
79
|
|
|
74
80
|
console.log('🚀 Starting ORM generation...');
|
|
@@ -83,6 +89,9 @@ async function main() {
|
|
|
83
89
|
clearGeneratedFiles(outputDotnetDir, '.cs');
|
|
84
90
|
clearGeneratedFiles(outputGolangDir, '.go');
|
|
85
91
|
clearGeneratedFiles(path.join(outputRustDir, 'src'), '.rs');
|
|
92
|
+
clearGeneratedFiles(outputJavaDir, '.java');
|
|
93
|
+
clearGeneratedFiles(outputKotlinDir, '.kt');
|
|
94
|
+
clearGeneratedFiles(path.join(outputSwiftDir, 'Sources'), '.swift');
|
|
86
95
|
const pythonDirEarly = path.dirname(outputPythonMetadataPath);
|
|
87
96
|
clearGeneratedPythonFiles(pythonDirEarly);
|
|
88
97
|
if (config.config.generation.generateMetadata && fs.existsSync(outputMetadataPath)) {
|
|
@@ -129,6 +138,18 @@ async function main() {
|
|
|
129
138
|
rustGen.generate(models);
|
|
130
139
|
console.log(`✨ Generated Rust client in ${outputRustDir}`);
|
|
131
140
|
|
|
141
|
+
const javaGen = new JavaGenerator(outputJavaDir);
|
|
142
|
+
javaGen.generate(models);
|
|
143
|
+
console.log(`✨ Generated Java client in ${outputJavaDir}`);
|
|
144
|
+
|
|
145
|
+
const kotlinGen = new KotlinGenerator(outputKotlinDir);
|
|
146
|
+
kotlinGen.generate(models);
|
|
147
|
+
console.log(`✨ Generated Kotlin client in ${outputKotlinDir}`);
|
|
148
|
+
|
|
149
|
+
const swiftGen = new SwiftGenerator(outputSwiftDir);
|
|
150
|
+
swiftGen.generate(models);
|
|
151
|
+
console.log(`✨ Generated Swift client in ${outputSwiftDir}`);
|
|
152
|
+
|
|
132
153
|
console.log('✅ ORM generation completed successfully.');
|
|
133
154
|
} catch (error) {
|
|
134
155
|
// Print every bad field type with its provider, instead of the parser's
|