@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/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Runtime database connections, query building, transactions, and multi-database d
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
13
13
|
- **Schema Definition:** Define models and relationships using concise `.an5` syntax with native database types.
|
|
14
|
-
- **Multi-Language Generator:** Generate typed clients and entity models for TypeScript, Python, .NET (C#), Golang, and
|
|
14
|
+
- **Multi-Language Generator:** Generate typed clients and entity models for TypeScript, Python, .NET (C#), Golang, Rust, Java, Kotlin, and Swift.
|
|
15
15
|
- **Database Introspection (`db:pull`):** Inspect an existing database schema and generate matching `.an5` models.
|
|
16
16
|
- **Direct Schema Push (`db:push`):** Synchronize `.an5` schema definitions directly to the database.
|
|
17
17
|
- **Automated Migrations (`db:migrate`):** Diff schema against database, generate up/down migration SQL scripts, apply, rollback, and check migration status.
|
|
@@ -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';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.unknownFieldTypeMessage = exports.resolveFieldType = exports.fieldTypesFor = exports.defaultSqlTypeForTs = exports.PROVIDER_LABELS = exports.PROVIDER_FIELD_TYPES = exports.PROVIDERS = exports.FieldTypeError = exports.DEFAULT_PROVIDER = exports.prepareCodeRequest = exports.detectCodeLanguage = exports.CODE_LANGUAGES = exports.fieldKind = exports.sqlLiteral = exports.defaultClause = exports.dialectFor = exports.providerForProject = exports.providerFromConfig = exports.detectProvider = exports.RustGenerator = exports.GolangGenerator = exports.DotnetGenerator = exports.PythonGenerator = exports.MetadataGenerator = exports.CodeGenerator = exports.sqlTypeToTs = exports.SchemaParser = void 0;
|
|
3
|
+
exports.unknownFieldTypeMessage = exports.resolveFieldType = exports.fieldTypesFor = exports.defaultSqlTypeForTs = exports.PROVIDER_LABELS = exports.PROVIDER_FIELD_TYPES = exports.PROVIDERS = exports.FieldTypeError = exports.DEFAULT_PROVIDER = exports.prepareCodeRequest = exports.detectCodeLanguage = exports.CODE_LANGUAGES = exports.fieldKind = exports.sqlLiteral = exports.defaultClause = exports.dialectFor = exports.providerForProject = exports.providerFromConfig = exports.detectProvider = exports.SwiftGenerator = exports.KotlinGenerator = exports.JavaGenerator = exports.RustGenerator = exports.GolangGenerator = exports.DotnetGenerator = exports.PythonGenerator = exports.MetadataGenerator = exports.CodeGenerator = exports.sqlTypeToTs = exports.SchemaParser = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* Side-effect-free entrypoint for programmatic use of the code generator.
|
|
6
6
|
*
|
|
@@ -36,6 +36,12 @@ var golang_generator_1 = require("./golang-generator");
|
|
|
36
36
|
Object.defineProperty(exports, "GolangGenerator", { enumerable: true, get: function () { return golang_generator_1.GolangGenerator; } });
|
|
37
37
|
var rust_generator_1 = require("./rust-generator");
|
|
38
38
|
Object.defineProperty(exports, "RustGenerator", { enumerable: true, get: function () { return rust_generator_1.RustGenerator; } });
|
|
39
|
+
var java_generator_1 = require("./java-generator");
|
|
40
|
+
Object.defineProperty(exports, "JavaGenerator", { enumerable: true, get: function () { return java_generator_1.JavaGenerator; } });
|
|
41
|
+
var kotlin_generator_1 = require("./kotlin-generator");
|
|
42
|
+
Object.defineProperty(exports, "KotlinGenerator", { enumerable: true, get: function () { return kotlin_generator_1.KotlinGenerator; } });
|
|
43
|
+
var swift_generator_1 = require("./swift-generator");
|
|
44
|
+
Object.defineProperty(exports, "SwiftGenerator", { enumerable: true, get: function () { return swift_generator_1.SwiftGenerator; } });
|
|
39
45
|
var config_1 = require("./config");
|
|
40
46
|
Object.defineProperty(exports, "detectProvider", { enumerable: true, get: function () { return config_1.detectProvider; } });
|
|
41
47
|
Object.defineProperty(exports, "providerFromConfig", { enumerable: true, get: function () { return config_1.providerFromConfig; } });
|
|
@@ -6,6 +6,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.CodeGenerator = void 0;
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
|
+
/**
|
|
10
|
+
* Types `resolveFieldType` can return that the SQL keyword table in
|
|
11
|
+
* `normalizeType` does not cover. Kept explicit so a new resolver output is a
|
|
12
|
+
* deliberate addition rather than silently becoming `any`.
|
|
13
|
+
*/
|
|
14
|
+
const RESOLVED_TS_TYPES = new Set(['Buffer', 'number | bigint', 'number[] | string']);
|
|
9
15
|
class CodeGenerator {
|
|
10
16
|
constructor(outputDir) {
|
|
11
17
|
this.outputDir = outputDir;
|
|
@@ -238,7 +244,12 @@ export interface TableClient<T, WhereInput = any, _Select = any, Include = any,
|
|
|
238
244
|
if (['bytes', 'binary', 'varbinary', 'image'].includes(lowerType)) {
|
|
239
245
|
return 'string';
|
|
240
246
|
}
|
|
241
|
-
|
|
247
|
+
// `Field.type` already holds the TypeScript type the parser resolved for the
|
|
248
|
+
// provider, and the resolver returns a few types this SQL table does not list:
|
|
249
|
+
// `Buffer` (BLOB), `number | bigint` (BIGINT) and `number[] | string` (VECTOR).
|
|
250
|
+
// Those used to fall through to `any`, so the generated client silently lost the
|
|
251
|
+
// type of every byte and vector column.
|
|
252
|
+
return RESOLVED_TS_TYPES.has(type) ? type : 'any';
|
|
242
253
|
}
|
|
243
254
|
toCamelCase(str) {
|
|
244
255
|
if (!str)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Provider } from './field-types';
|
|
2
|
-
export declare const CODE_LANGUAGES: readonly ['typescript', 'python', 'dotnet', 'golang', 'rust'];
|
|
2
|
+
export declare const CODE_LANGUAGES: readonly ['typescript', 'python', 'dotnet', 'golang', 'rust', 'java', 'kotlin', 'swift'];
|
|
3
3
|
export type CodeLanguage = typeof CODE_LANGUAGES[number];
|
|
4
4
|
export interface CodeRequest {
|
|
5
5
|
request: string;
|
|
@@ -14,7 +14,7 @@ export declare function detectCodeLanguage(root: string): CodeLanguage;
|
|
|
14
14
|
/** Ground the caller's model in real generated APIs; never execute user code. */
|
|
15
15
|
export declare function prepareCodeRequest(input: CodeRequest): Promise<{
|
|
16
16
|
status: 'context_ready';
|
|
17
|
-
language: "dotnet" | "golang" | "python" | "rust" | "typescript";
|
|
17
|
+
language: "dotnet" | "golang" | "java" | "kotlin" | "python" | "rust" | "swift" | "typescript";
|
|
18
18
|
request: string;
|
|
19
19
|
clientOutput: string;
|
|
20
20
|
models: import("./types").Model[];
|
|
@@ -16,7 +16,10 @@ const python_generator_1 = require("./python-generator");
|
|
|
16
16
|
const dotnet_generator_1 = require("./dotnet-generator");
|
|
17
17
|
const golang_generator_1 = require("./golang-generator");
|
|
18
18
|
const rust_generator_1 = require("./rust-generator");
|
|
19
|
-
|
|
19
|
+
const java_generator_1 = require("./java-generator");
|
|
20
|
+
const kotlin_generator_1 = require("./kotlin-generator");
|
|
21
|
+
const swift_generator_1 = require("./swift-generator");
|
|
22
|
+
exports.CODE_LANGUAGES = ['typescript', 'python', 'dotnet', 'golang', 'rust', 'java', 'kotlin', 'swift'];
|
|
20
23
|
/** Detect only the selected project, never unrelated sibling packages. */
|
|
21
24
|
function detectCodeLanguage(root) {
|
|
22
25
|
const names = fs_1.default.readdirSync(root);
|
|
@@ -32,6 +35,15 @@ function detectCodeLanguage(root) {
|
|
|
32
35
|
candidates.push('golang');
|
|
33
36
|
if (has('Cargo.toml'))
|
|
34
37
|
candidates.push('rust');
|
|
38
|
+
// Java and Kotlin are told apart by their build file, not by a directory name: a Gradle
|
|
39
|
+
// project has both a build.gradle and a pom.xml, and only one says which language it is.
|
|
40
|
+
const hasJavaBuild = has('pom.xml') || has('build.gradle') || has('build.gradle.kts');
|
|
41
|
+
if (hasJavaBuild) {
|
|
42
|
+
const kotlinMarker = names.some((name) => /\.(kt|kts)$/.test(name)) || has('settings.gradle.kts');
|
|
43
|
+
candidates.push(kotlinMarker ? 'kotlin' : 'java');
|
|
44
|
+
}
|
|
45
|
+
if (has('Package.swift'))
|
|
46
|
+
candidates.push('swift');
|
|
35
47
|
if (candidates.length !== 1)
|
|
36
48
|
throw new Error(`Cannot select one project language (${candidates.join(', ') || 'none'}). Specify language explicitly or select the application directory.`);
|
|
37
49
|
return candidates[0];
|
|
@@ -45,7 +57,7 @@ async function prepareCodeRequest(input) {
|
|
|
45
57
|
throw new Error(`Unsupported language: ${language}`);
|
|
46
58
|
const loaded = (0, config_1.loadConfig)(input.projectRoot);
|
|
47
59
|
const output = loaded.outputs;
|
|
48
|
-
const clientOutput = { typescript: output.typescriptDir, python: path_1.default.dirname(output.pythonMetadataFile), dotnet: output.dotnetDir, golang: output.golangDir, rust: output.rustDir }[language];
|
|
60
|
+
const clientOutput = { typescript: output.typescriptDir, python: path_1.default.dirname(output.pythonMetadataFile), dotnet: output.dotnetDir, golang: output.golangDir, rust: output.rustDir, java: output.javaDir, kotlin: output.kotlinDir, swift: output.swiftDir }[language];
|
|
49
61
|
const schemaPath = path_1.default.resolve(input.projectRoot, input.schemaPath);
|
|
50
62
|
const schemaDir = fs_1.default.statSync(schemaPath).isDirectory() ? schemaPath : path_1.default.dirname(schemaPath);
|
|
51
63
|
const scratch = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'an5-code-request-'));
|
|
@@ -76,6 +88,15 @@ async function prepareCodeRequest(input) {
|
|
|
76
88
|
case 'rust':
|
|
77
89
|
new rust_generator_1.RustGenerator(scratch).generate(models);
|
|
78
90
|
break;
|
|
91
|
+
case 'java':
|
|
92
|
+
new java_generator_1.JavaGenerator(scratch).generate(models);
|
|
93
|
+
break;
|
|
94
|
+
case 'kotlin':
|
|
95
|
+
new kotlin_generator_1.KotlinGenerator(scratch).generate(models);
|
|
96
|
+
break;
|
|
97
|
+
case 'swift':
|
|
98
|
+
new swift_generator_1.SwiftGenerator(scratch).generate(models);
|
|
99
|
+
break;
|
|
79
100
|
}
|
|
80
101
|
const files = [];
|
|
81
102
|
function collect(dir) {
|
|
@@ -83,7 +104,7 @@ async function prepareCodeRequest(input) {
|
|
|
83
104
|
const full = path_1.default.join(dir, entry.name);
|
|
84
105
|
if (entry.isDirectory())
|
|
85
106
|
collect(full);
|
|
86
|
-
else if (/\.(ts|py|cs|go|rs|mod|toml)$/.test(entry.name))
|
|
107
|
+
else if (/\.(ts|py|cs|go|rs|mod|toml|java|kt|swift)$/.test(entry.name))
|
|
87
108
|
files.push({ path: path_1.default.relative(scratch, full), content: fs_1.default.readFileSync(full, 'utf8') });
|
|
88
109
|
}
|
|
89
110
|
}
|
|
@@ -19,6 +19,18 @@ export interface GolangOutput {
|
|
|
19
19
|
export interface RustOutput {
|
|
20
20
|
outputDir: string;
|
|
21
21
|
}
|
|
22
|
+
/** Java client output. */
|
|
23
|
+
export interface JavaOutput {
|
|
24
|
+
outputDir: string;
|
|
25
|
+
}
|
|
26
|
+
/** Kotlin client output. */
|
|
27
|
+
export interface KotlinOutput {
|
|
28
|
+
outputDir: string;
|
|
29
|
+
}
|
|
30
|
+
/** Swift client output. */
|
|
31
|
+
export interface SwiftOutput {
|
|
32
|
+
outputDir: string;
|
|
33
|
+
}
|
|
22
34
|
export interface An5OrmConfig {
|
|
23
35
|
/**
|
|
24
36
|
* Database connection for the commands that need one — db:push, db:pull,
|
|
@@ -40,6 +52,9 @@ export interface An5OrmConfig {
|
|
|
40
52
|
dotnet: DotnetOutput;
|
|
41
53
|
golang: GolangOutput;
|
|
42
54
|
rust: RustOutput;
|
|
55
|
+
java: JavaOutput;
|
|
56
|
+
kotlin: KotlinOutput;
|
|
57
|
+
swift: SwiftOutput;
|
|
43
58
|
};
|
|
44
59
|
pull: {
|
|
45
60
|
/** Regex patterns; tables matching any of them are skipped by db:pull. */
|
|
@@ -62,6 +77,9 @@ export interface ResolvedOutputs {
|
|
|
62
77
|
dotnetDir: string;
|
|
63
78
|
golangDir: string;
|
|
64
79
|
rustDir: string;
|
|
80
|
+
javaDir: string;
|
|
81
|
+
kotlinDir: string;
|
|
82
|
+
swiftDir: string;
|
|
65
83
|
}
|
|
66
84
|
export declare function resolveOutputs(config: An5OrmConfig, rootDir: string): ResolvedOutputs;
|
|
67
85
|
/**
|
|
@@ -40,6 +40,9 @@ exports.DEFAULT_CONFIG = {
|
|
|
40
40
|
dotnet: { outputDir: 'an5Client/dotnet' },
|
|
41
41
|
golang: { outputDir: 'an5Client/golang' },
|
|
42
42
|
rust: { outputDir: 'an5Client/rust' },
|
|
43
|
+
java: { outputDir: 'an5Client/java' },
|
|
44
|
+
kotlin: { outputDir: 'an5Client/kotlin' },
|
|
45
|
+
swift: { outputDir: 'an5Client/swift' },
|
|
43
46
|
},
|
|
44
47
|
pull: {
|
|
45
48
|
exclude: ['^__', '^sys\\.', '^igrations'],
|
|
@@ -59,6 +62,9 @@ function resolveOutputs(config, rootDir) {
|
|
|
59
62
|
dotnetDir: resolve(config.outputs.dotnet.outputDir),
|
|
60
63
|
golangDir: resolve(config.outputs.golang.outputDir),
|
|
61
64
|
rustDir: resolve(config.outputs.rust.outputDir),
|
|
65
|
+
javaDir: resolve(config.outputs.java.outputDir),
|
|
66
|
+
kotlinDir: resolve(config.outputs.kotlin.outputDir),
|
|
67
|
+
swiftDir: resolve(config.outputs.swift.outputDir),
|
|
62
68
|
};
|
|
63
69
|
}
|
|
64
70
|
class ConfigError extends Error {
|
|
@@ -136,6 +142,9 @@ const OUTPUT_SPEC = {
|
|
|
136
142
|
dotnet: 'object',
|
|
137
143
|
golang: 'object',
|
|
138
144
|
rust: 'object',
|
|
145
|
+
java: 'object',
|
|
146
|
+
kotlin: 'object',
|
|
147
|
+
swift: 'object',
|
|
139
148
|
};
|
|
140
149
|
const OUTPUT_SECTION_SPEC = {
|
|
141
150
|
typescript: { outputDir: 'string', metadataFile: 'string' },
|
|
@@ -143,6 +152,9 @@ const OUTPUT_SECTION_SPEC = {
|
|
|
143
152
|
dotnet: { outputDir: 'string' },
|
|
144
153
|
golang: { outputDir: 'string' },
|
|
145
154
|
rust: { outputDir: 'string' },
|
|
155
|
+
java: { outputDir: 'string' },
|
|
156
|
+
kotlin: { outputDir: 'string' },
|
|
157
|
+
swift: { outputDir: 'string' },
|
|
146
158
|
};
|
|
147
159
|
const TOP_LEVEL_SPEC = {
|
|
148
160
|
connectionString: 'string',
|
|
@@ -203,6 +215,18 @@ function validateConfig(raw) {
|
|
|
203
215
|
outputDir: outputs.rust?.outputDir ??
|
|
204
216
|
exports.DEFAULT_CONFIG.outputs.rust.outputDir,
|
|
205
217
|
},
|
|
218
|
+
java: {
|
|
219
|
+
outputDir: outputs.java?.outputDir ??
|
|
220
|
+
exports.DEFAULT_CONFIG.outputs.java.outputDir,
|
|
221
|
+
},
|
|
222
|
+
kotlin: {
|
|
223
|
+
outputDir: outputs.kotlin?.outputDir ??
|
|
224
|
+
exports.DEFAULT_CONFIG.outputs.kotlin.outputDir,
|
|
225
|
+
},
|
|
226
|
+
swift: {
|
|
227
|
+
outputDir: outputs.swift?.outputDir ??
|
|
228
|
+
exports.DEFAULT_CONFIG.outputs.swift.outputDir,
|
|
229
|
+
},
|
|
206
230
|
},
|
|
207
231
|
pull: {
|
|
208
232
|
exclude: pull.exclude ?? exports.DEFAULT_CONFIG.pull.exclude,
|
|
@@ -197,13 +197,15 @@ function createDialect(provider, quote) {
|
|
|
197
197
|
// destructured by a caller.
|
|
198
198
|
const columnDefinition = (column) => {
|
|
199
199
|
const identity = identityKeyword(column, provider);
|
|
200
|
+
const sqlType = provider === 'sqlite' && /^VECTOR\s*(\(|$)/i.test(column.sqlType)
|
|
201
|
+
? 'BLOB' : column.sqlType;
|
|
200
202
|
// Where the identity keyword goes is not a detail: SQL Server takes it as part
|
|
201
203
|
// of the column type (`INT IDENTITY(1,1)`), while SQLite only accepts it after
|
|
202
204
|
// `PRIMARY KEY` and only spells the documented form `INTEGER PRIMARY KEY
|
|
203
205
|
// AUTOINCREMENT`.
|
|
204
206
|
const type = identity && provider !== 'sqlite'
|
|
205
|
-
? `${quote(column.name)} ${
|
|
206
|
-
: `${quote(column.name)} ${
|
|
207
|
+
? `${quote(column.name)} ${sqlType} ${identity}`
|
|
208
|
+
: `${quote(column.name)} ${sqlType}`;
|
|
207
209
|
const nullable = column.isOptional ? '' : ' NOT NULL';
|
|
208
210
|
const unique = column.isUnique && !column.isId ? ' UNIQUE' : '';
|
|
209
211
|
const defaultSql = defaultClause(column.defaultExpr, provider);
|
|
@@ -289,6 +289,7 @@ using System;
|
|
|
289
289
|
using System.Collections.Generic;
|
|
290
290
|
using System.Data;
|
|
291
291
|
using System.Data.Common;
|
|
292
|
+
using System.Linq;
|
|
292
293
|
using System.Reflection;
|
|
293
294
|
using System.Text.Json;
|
|
294
295
|
using An5Orm.Entities;
|
|
@@ -377,11 +378,41 @@ namespace An5Orm
|
|
|
377
378
|
{
|
|
378
379
|
var p = cmd.CreateParameter();
|
|
379
380
|
p.ParameterName = (name ?? "").TrimStart('@');
|
|
380
|
-
p.Value = value ?? DBNull.Value;
|
|
381
|
+
p.Value = Bindable(value) ?? DBNull.Value;
|
|
381
382
|
cmd.Parameters.Add(p);
|
|
382
383
|
return p;
|
|
383
384
|
}
|
|
384
385
|
|
|
386
|
+
/// <summary>
|
|
387
|
+
/// Converts a value into something the provider can bind.
|
|
388
|
+
/// </summary>
|
|
389
|
+
/// <remarks>
|
|
390
|
+
/// A <c>VECTOR(n)</c> column is transmitted as a JSON array over TDS, so a float or
|
|
391
|
+
/// double array is written in that form. The array itself is not bindable: the
|
|
392
|
+
/// provider has no mapping for it, and the failure names the array rather than the
|
|
393
|
+
/// column it was meant for.
|
|
394
|
+
/// </remarks>
|
|
395
|
+
public static object Bindable(object value)
|
|
396
|
+
{
|
|
397
|
+
// \`float[]\` and \`double[]\` are listed separately rather than through a shared
|
|
398
|
+
// \`IEnumerable<double>\`: an array of value type is not covariant, so
|
|
399
|
+
// \`float[]\` does not convert to it.
|
|
400
|
+
if (value is float[] floats) return FormatVector(floats.Select(v => (double)v));
|
|
401
|
+
if (value is double[] doubles) return FormatVector(doubles);
|
|
402
|
+
return value;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/// <summary>Renders a vector as the JSON array SQL Server expects.</summary>
|
|
406
|
+
public static string FormatVector(IEnumerable<double> vector)
|
|
407
|
+
{
|
|
408
|
+
var parts = new List<string>();
|
|
409
|
+
foreach (var value in vector)
|
|
410
|
+
{
|
|
411
|
+
parts.Add(value.ToString("R", System.Globalization.CultureInfo.InvariantCulture));
|
|
412
|
+
}
|
|
413
|
+
return "[" + string.Join(", ", parts) + "]";
|
|
414
|
+
}
|
|
415
|
+
|
|
385
416
|
/// <summary>Quotes an identifier for the dialect.</summary>
|
|
386
417
|
public static string Quote(string name, An5Dialect dialect)
|
|
387
418
|
{
|
|
@@ -1006,6 +1037,60 @@ namespace An5Orm
|
|
|
1006
1037
|
/// property would otherwise fail to set. A value that cannot be
|
|
1007
1038
|
/// converted is skipped rather than aborting the whole read.
|
|
1008
1039
|
/// </summary>
|
|
1040
|
+
/// <summary>
|
|
1041
|
+
/// Reads a stored vector into the \`float[]\` an entity declares.
|
|
1042
|
+
/// </summary>
|
|
1043
|
+
/// <remarks>
|
|
1044
|
+
/// A \`VECTOR(n)\` column arrives as a JSON array over TDS, so the JSON form is
|
|
1045
|
+
/// what is parsed. A float32 BLOB is also accepted because that is what the SQLite
|
|
1046
|
+
/// provider stores in the same column. Returns <c>null</c> for anything else, which
|
|
1047
|
+
/// leaves the property at its default rather than aborting the whole read.
|
|
1048
|
+
/// </remarks>
|
|
1049
|
+
private static float[] DecodeVectorColumn(object value)
|
|
1050
|
+
{
|
|
1051
|
+
if (value is float[] already) return already;
|
|
1052
|
+
if (value is double[] asDouble)
|
|
1053
|
+
{
|
|
1054
|
+
var fromDouble = new float[asDouble.Length];
|
|
1055
|
+
for (int i = 0; i < asDouble.Length; i++) fromDouble[i] = (float)asDouble[i];
|
|
1056
|
+
return fromDouble;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
string text = value as string;
|
|
1060
|
+
byte[] bytes = value as byte[];
|
|
1061
|
+
if (bytes != null && bytes.Length > 0 && bytes[0] != (byte)'[' && bytes.Length % 4 == 0)
|
|
1062
|
+
{
|
|
1063
|
+
var fromBlob = new float[bytes.Length / 4];
|
|
1064
|
+
for (int i = 0; i < fromBlob.Length; i++)
|
|
1065
|
+
{
|
|
1066
|
+
int offset = i * 4;
|
|
1067
|
+
uint bits = (uint)(bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24));
|
|
1068
|
+
fromBlob[i] = BitConverter.ToSingle(BitConverter.GetBytes(bits), 0);
|
|
1069
|
+
}
|
|
1070
|
+
return fromBlob;
|
|
1071
|
+
}
|
|
1072
|
+
if (text == null && bytes != null)
|
|
1073
|
+
{
|
|
1074
|
+
text = System.Text.Encoding.UTF8.GetString(bytes);
|
|
1075
|
+
}
|
|
1076
|
+
if (text == null) return null;
|
|
1077
|
+
|
|
1078
|
+
var open = text.IndexOf('[');
|
|
1079
|
+
var close = text.LastIndexOf(']');
|
|
1080
|
+
if (open < 0 || close <= open) return null;
|
|
1081
|
+
var body = text.Substring(open + 1, close - open - 1).Trim();
|
|
1082
|
+
if (body.Length == 0) return null;
|
|
1083
|
+
|
|
1084
|
+
var parts = body.Split(',');
|
|
1085
|
+
var parsed = new float[parts.Length];
|
|
1086
|
+
for (int i = 0; i < parts.Length; i++)
|
|
1087
|
+
{
|
|
1088
|
+
if (!float.TryParse(parts[i].Trim(), System.Globalization.NumberStyles.Float,
|
|
1089
|
+
System.Globalization.CultureInfo.InvariantCulture, out parsed[i])) return null;
|
|
1090
|
+
}
|
|
1091
|
+
return parsed;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1009
1094
|
private static void SetValue(System.Reflection.PropertyInfo prop, object target, object value)
|
|
1010
1095
|
{
|
|
1011
1096
|
var wanted = prop.PropertyType;
|
|
@@ -1023,6 +1108,17 @@ namespace An5Orm
|
|
|
1023
1108
|
prop.SetValue(target, Convert.ChangeType(value, underlying));
|
|
1024
1109
|
return;
|
|
1025
1110
|
}
|
|
1111
|
+
// A \`VECTOR(n)\` column is transmitted as a JSON array over TDS, which no
|
|
1112
|
+
// converter bridges to the \`float[]\` the entity declares.
|
|
1113
|
+
if (wanted == typeof(float[]))
|
|
1114
|
+
{
|
|
1115
|
+
var vector = DecodeVectorColumn(value);
|
|
1116
|
+
if (vector != null)
|
|
1117
|
+
{
|
|
1118
|
+
prop.SetValue(target, vector);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1026
1122
|
if (value is string text && wanted == typeof(Guid)) { prop.SetValue(target, Guid.Parse(text)); return; }
|
|
1027
1123
|
if (value is string when && wanted == typeof(DateTime)) { prop.SetValue(target, DateTime.Parse(when, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind)); return; }
|
|
1028
1124
|
prop.SetValue(target, Convert.ChangeType(value, wanted));
|
|
@@ -255,6 +255,9 @@ exports.PROVIDER_FIELD_TYPES = {
|
|
|
255
255
|
// Everything else
|
|
256
256
|
JSON: 'any',
|
|
257
257
|
UUID: 'string',
|
|
258
|
+
// Stored as a BLOB of little-endian float32: a third of the JSON text it replaces,
|
|
259
|
+
// and what the adapters' in-database distance functions read. A column already
|
|
260
|
+
// holding JSON text keeps working, because the adapters decode both.
|
|
258
261
|
VECTOR: 'number[] | string',
|
|
259
262
|
},
|
|
260
263
|
// ─── Google Sheets ─────────────────────────────────────────────────────────
|
|
@@ -23,6 +23,14 @@ export declare class GolangGenerator {
|
|
|
23
23
|
private generateDbContext;
|
|
24
24
|
private generateTableClientStruct;
|
|
25
25
|
private generateQueryAndScan;
|
|
26
|
+
/**
|
|
27
|
+
* The vector codec the generated client needs on both sides of a query.
|
|
28
|
+
*
|
|
29
|
+
* A \`VECTOR(n)\` column stores float32 bytes, which is a third of the JSON text it
|
|
30
|
+
* replaces and is what the in-database distance functions read. A column written
|
|
31
|
+
* before that encoding holds JSON text, so both are still accepted on the way in.
|
|
32
|
+
*/
|
|
33
|
+
private generateVectorHelpers;
|
|
26
34
|
private generateWhereBuilder;
|
|
27
35
|
private generateCrudMethods;
|
|
28
36
|
private generateVectorSearch;
|
|
@@ -186,6 +186,7 @@ func GetDefaultConnectionString() string {
|
|
|
186
186
|
content += this.generateWhereBuilder();
|
|
187
187
|
content += this.generateCrudMethods();
|
|
188
188
|
content += this.generateVectorSearch();
|
|
189
|
+
content += this.generateVectorHelpers();
|
|
189
190
|
content += this.generateHelpers();
|
|
190
191
|
// NOTE: per-model ORM types live in each `<Model>.go` file.
|
|
191
192
|
fs_1.default.writeFileSync(path_1.default.join(this.outputDir, 'client.go'), content);
|
|
@@ -401,9 +402,18 @@ func (c *TableClient[T]) queryAndScan(ctx context.Context, query string, args ..
|
|
|
401
402
|
\t\tvar item T
|
|
402
403
|
\t\tval := reflect.ValueOf(&item).Elem()
|
|
403
404
|
\t\tscanArgs := make([]interface{}, len(cols))
|
|
405
|
+
\t\tvectorCols := make([]bool, len(cols))
|
|
404
406
|
\t\tfor i, colName := range cols {
|
|
405
407
|
\t\t\tfieldVal := findFieldByDbTag(val, colName)
|
|
406
408
|
\t\t\tif fieldVal.IsValid() && fieldVal.CanSet() {
|
|
409
|
+
\t\t\t\t// A \`VECTOR(n)\` column is stored as bytes, which database/sql cannot
|
|
410
|
+
\t\t\t\t// scan into a slice, so it is read as bytes and decoded afterwards.
|
|
411
|
+
\t\t\t\tif isVectorGoType(fieldVal.Type()) {
|
|
412
|
+
\t\t\t\t\tvar raw []byte
|
|
413
|
+
\t\t\t\t\tscanArgs[i] = &raw
|
|
414
|
+
\t\t\t\t\tvectorCols[i] = true
|
|
415
|
+
\t\t\t\t\tcontinue
|
|
416
|
+
\t\t\t\t}
|
|
407
417
|
\t\t\t\tscanArgs[i] = fieldVal.Addr().Interface()
|
|
408
418
|
\t\t\t} else {
|
|
409
419
|
\t\t\t\tvar unused interface{}
|
|
@@ -413,6 +423,20 @@ func (c *TableClient[T]) queryAndScan(ctx context.Context, query string, args ..
|
|
|
413
423
|
\t\tif err := rows.Scan(scanArgs...); err != nil {
|
|
414
424
|
\t\t\treturn nil, err
|
|
415
425
|
\t\t}
|
|
426
|
+
\t\tfor i, isVector := range vectorCols {
|
|
427
|
+
\t\t\tif !isVector {
|
|
428
|
+
\t\t\t\tcontinue
|
|
429
|
+
\t\t\t}
|
|
430
|
+
\t\t\tdecoded, ok := decodeVectorBytes(*(scanArgs[i].(*[]byte)))
|
|
431
|
+
\t\t\tif !ok {
|
|
432
|
+
\t\t\t\tcontinue
|
|
433
|
+
\t\t\t}
|
|
434
|
+
\t\t\tfieldVal := findFieldByDbTag(val, cols[i])
|
|
435
|
+
\t\t\tif !fieldVal.IsValid() || !fieldVal.CanSet() {
|
|
436
|
+
\t\t\t\tcontinue
|
|
437
|
+
\t\t\t}
|
|
438
|
+
\t\t\tsetVectorField(fieldVal, decoded)
|
|
439
|
+
\t\t}
|
|
416
440
|
\t\tresults = append(results, item)
|
|
417
441
|
\t}
|
|
418
442
|
\tif err := rows.Err(); err != nil {
|
|
@@ -421,6 +445,160 @@ func (c *TableClient[T]) queryAndScan(ctx context.Context, query string, args ..
|
|
|
421
445
|
\treturn results, nil
|
|
422
446
|
}
|
|
423
447
|
|
|
448
|
+
`;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* The vector codec the generated client needs on both sides of a query.
|
|
452
|
+
*
|
|
453
|
+
* A \`VECTOR(n)\` column stores float32 bytes, which is a third of the JSON text it
|
|
454
|
+
* replaces and is what the in-database distance functions read. A column written
|
|
455
|
+
* before that encoding holds JSON text, so both are still accepted on the way in.
|
|
456
|
+
*/
|
|
457
|
+
generateVectorHelpers() {
|
|
458
|
+
return `// bytesPerVectorFloat is the width of one stored vector component.
|
|
459
|
+
const bytesPerVectorFloat = 4
|
|
460
|
+
|
|
461
|
+
// isVectorGoType reports whether a model field is a vector column, which the
|
|
462
|
+
// generator declares as a float32 slice, optionally behind a pointer because the
|
|
463
|
+
// column is nullable.
|
|
464
|
+
func isVectorGoType(t reflect.Type) bool {
|
|
465
|
+
\treturn vectorSliceType(t) != nil
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// vectorSliceType returns the slice type a vector field holds, or nil for any
|
|
469
|
+
// other field. A pointer to a slice is unwrapped so both the nullable and the
|
|
470
|
+
// required spelling are recognised.
|
|
471
|
+
func vectorSliceType(t reflect.Type) reflect.Type {
|
|
472
|
+
\tif t.Kind() == reflect.Ptr {
|
|
473
|
+
\t\tt = t.Elem()
|
|
474
|
+
\t}
|
|
475
|
+
\tif t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Float32 {
|
|
476
|
+
\t\treturn t
|
|
477
|
+
\t}
|
|
478
|
+
\treturn nil
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// vectorComponents reads a vector field as float64 components.
|
|
482
|
+
//
|
|
483
|
+
// A field can arrive as JSON text, as a slice, or behind a pointer to either, and a
|
|
484
|
+
// pointer is how the generator spells a nullable column. Anything else is not a vector.
|
|
485
|
+
func vectorComponents(field reflect.Value) []float64 {
|
|
486
|
+
\tfor field.Kind() == reflect.Ptr {
|
|
487
|
+
\t\tif field.IsNil() {
|
|
488
|
+
\t\t\treturn nil
|
|
489
|
+
\t\t}
|
|
490
|
+
\t\tfield = field.Elem()
|
|
491
|
+
\t}
|
|
492
|
+
\tswitch {
|
|
493
|
+
\tcase field.Kind() == reflect.String:
|
|
494
|
+
\t\tvar parsed []float64
|
|
495
|
+
\t\tif err := json.Unmarshal([]byte(field.String()), &parsed); err != nil {
|
|
496
|
+
\t\t\treturn nil
|
|
497
|
+
\t\t}
|
|
498
|
+
\t\treturn parsed
|
|
499
|
+
\tcase field.Kind() == reflect.Slice:
|
|
500
|
+
\t\tout := make([]float64, field.Len())
|
|
501
|
+
\t\tfor i := range out {
|
|
502
|
+
\t\t\tout[i] = field.Index(i).Float()
|
|
503
|
+
\t\t}
|
|
504
|
+
\t\treturn out
|
|
505
|
+
\tdefault:
|
|
506
|
+
\t\treturn nil
|
|
507
|
+
\t}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// setVectorField stores decoded components into a vector field, matching whether
|
|
511
|
+
// the field holds the slice directly or behind a pointer.
|
|
512
|
+
func setVectorField(field reflect.Value, values []float64) {
|
|
513
|
+
\tsliceType := vectorSliceType(field.Type())
|
|
514
|
+
\tif sliceType == nil {
|
|
515
|
+
\t\treturn
|
|
516
|
+
\t}
|
|
517
|
+
\tslice := reflect.MakeSlice(sliceType, len(values), len(values))
|
|
518
|
+
\tfor i, value := range values {
|
|
519
|
+
\t\tslice.Index(i).SetFloat(value)
|
|
520
|
+
\t}
|
|
521
|
+
\tif field.Kind() == reflect.Ptr {
|
|
522
|
+
\t\tptr := reflect.New(sliceType)
|
|
523
|
+
\t\tptr.Elem().Set(slice)
|
|
524
|
+
\t\tfield.Set(ptr)
|
|
525
|
+
\t\treturn
|
|
526
|
+
\t}
|
|
527
|
+
\tfield.Set(slice)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// encodeVectorArg converts a vector field into the bytes the column stores.
|
|
531
|
+
//
|
|
532
|
+
// database/sql accepts only a fixed set of types, so a slice otherwise reaches the
|
|
533
|
+
// driver as an error. Values that already arrived as stored form pass through.
|
|
534
|
+
func encodeVectorArg(value interface{}) interface{} {
|
|
535
|
+
\tswitch v := value.(type) {
|
|
536
|
+
\tcase *[]float32:
|
|
537
|
+
\t\tif v == nil {
|
|
538
|
+
\t\t\treturn nil
|
|
539
|
+
\t\t}
|
|
540
|
+
\t\treturn encodeVectorFloats(floatsFrom32(*v))
|
|
541
|
+
\tcase []float32:
|
|
542
|
+
\t\treturn encodeVectorFloats(floatsFrom32(v))
|
|
543
|
+
\tcase *[]float64:
|
|
544
|
+
\t\tif v == nil {
|
|
545
|
+
\t\t\treturn nil
|
|
546
|
+
\t\t}
|
|
547
|
+
\t\treturn encodeVectorFloats(*v)
|
|
548
|
+
\tcase []float64:
|
|
549
|
+
\t\treturn encodeVectorFloats(v)
|
|
550
|
+
\tdefault:
|
|
551
|
+
\t\treturn value
|
|
552
|
+
\t}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
func floatsFrom32(values []float32) []float64 {
|
|
556
|
+
\tout := make([]float64, len(values))
|
|
557
|
+
\tfor i, value := range values {
|
|
558
|
+
\t\tout[i] = float64(value)
|
|
559
|
+
\t}
|
|
560
|
+
\treturn out
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// encodeVectorFloats writes float32 values little-endian, which is the order the
|
|
564
|
+
// stored bytes are read back in.
|
|
565
|
+
func encodeVectorFloats(values []float64) []byte {
|
|
566
|
+
\tout := make([]byte, len(values)*bytesPerVectorFloat)
|
|
567
|
+
\tfor i, value := range values {
|
|
568
|
+
\t\toffset := i * bytesPerVectorFloat
|
|
569
|
+
\t\tbits := math.Float32bits(float32(value))
|
|
570
|
+
\t\tout[offset] = byte(bits)
|
|
571
|
+
\t\tout[offset+1] = byte(bits >> 8)
|
|
572
|
+
\t\tout[offset+2] = byte(bits >> 16)
|
|
573
|
+
\t\tout[offset+3] = byte(bits >> 24)
|
|
574
|
+
\t}
|
|
575
|
+
\treturn out
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// decodeVectorBytes reads a stored vector: float32 bytes, legacy JSON text, or
|
|
579
|
+
// nothing at all.
|
|
580
|
+
func decodeVectorBytes(raw []byte) ([]float64, bool) {
|
|
581
|
+
\tif len(raw) == 0 {
|
|
582
|
+
\t\treturn nil, false
|
|
583
|
+
\t}
|
|
584
|
+
\t// JSON text is not a multiple of four bytes once encoded and starts with '[',
|
|
585
|
+
\t// so the two are told apart by length and first byte.
|
|
586
|
+
\tif raw[0] == '[' || len(raw)%bytesPerVectorFloat != 0 {
|
|
587
|
+
\t\tvar parsed []float64
|
|
588
|
+
\t\tif err := json.Unmarshal(raw, &parsed); err != nil || len(parsed) == 0 {
|
|
589
|
+
\t\t\treturn nil, false
|
|
590
|
+
\t\t}
|
|
591
|
+
\t\treturn parsed, true
|
|
592
|
+
\t}
|
|
593
|
+
\tout := make([]float64, len(raw)/bytesPerVectorFloat)
|
|
594
|
+
\tfor i := range out {
|
|
595
|
+
\t\toffset := i * bytesPerVectorFloat
|
|
596
|
+
\t\tbits := uint32(raw[offset]) | uint32(raw[offset+1])<<8 | uint32(raw[offset+2])<<16 | uint32(raw[offset+3])<<24
|
|
597
|
+
\t\tout[i] = float64(math.Float32frombits(bits))
|
|
598
|
+
\t}
|
|
599
|
+
\treturn out, true
|
|
600
|
+
}
|
|
601
|
+
|
|
424
602
|
`;
|
|
425
603
|
}
|
|
426
604
|
// ─── WHERE builder ─────────────────────────────────────────────────────────
|
|
@@ -637,7 +815,7 @@ func (c *TableClient[T]) Create(ctx context.Context, entity *T) (*T, error) {
|
|
|
637
815
|
\t\tfieldVal := val.Field(i)
|
|
638
816
|
\t\tif !fieldVal.IsZero() {
|
|
639
817
|
\t\t\tcols = append(cols, c.quoteName(dbTag))
|
|
640
|
-
\t\t\targs = append(args, fieldVal.Interface())
|
|
818
|
+
\t\t\targs = append(args, encodeVectorArg(fieldVal.Interface()))
|
|
641
819
|
\t\t\tphs = append(phs, c.ph(len(args)))
|
|
642
820
|
\t\t}
|
|
643
821
|
\t}
|
|
@@ -684,7 +862,7 @@ func (c *TableClient[T]) Update(ctx context.Context, entity *T) (*T, error) {
|
|
|
684
862
|
\t\t\tidVal = fieldVal.Interface()
|
|
685
863
|
\t\t\tidCol = dbTag
|
|
686
864
|
\t\t} else if !fieldVal.IsZero() {
|
|
687
|
-
\t\t\targs = append(args, fieldVal.Interface())
|
|
865
|
+
\t\t\targs = append(args, encodeVectorArg(fieldVal.Interface()))
|
|
688
866
|
\t\t\tsets = append(sets, c.quoteName(dbTag)+" = "+c.ph(len(args)))
|
|
689
867
|
\t\t}
|
|
690
868
|
\t}
|
|
@@ -1031,13 +1209,9 @@ func (c *TableClient[T]) VectorSearch(ctx context.Context, vector []float64, opt
|
|
|
1031
1209
|
\t\t\tcontinue
|
|
1032
1210
|
\t\t}
|
|
1033
1211
|
|
|
1034
|
-
\t\
|
|
1035
|
-
\t\tif
|
|
1036
|
-
\t\t\
|
|
1037
|
-
\t\t} else if fieldVal.Kind() == reflect.Slice {
|
|
1038
|
-
\t\t\tfor i := 0; i < fieldVal.Len(); i++ {
|
|
1039
|
-
\t\t\t\trowVec = append(rowVec, fieldVal.Index(i).Float())
|
|
1040
|
-
\t\t\t}
|
|
1212
|
+
\t\trowVec := vectorComponents(fieldVal)
|
|
1213
|
+
\t\tif len(rowVec) == 0 {
|
|
1214
|
+
\t\t\tcontinue
|
|
1041
1215
|
\t\t}
|
|
1042
1216
|
\t\tif len(rowVec) != len(vector) {
|
|
1043
1217
|
\t\t\tcontinue
|