@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.
Files changed (35) hide show
  1. package/README.md +1 -1
  2. package/dist/generator/src/api.d.ts +3 -0
  3. package/dist/generator/src/api.js +7 -1
  4. package/dist/generator/src/code-generator.js +12 -1
  5. package/dist/generator/src/code-request.d.ts +2 -2
  6. package/dist/generator/src/code-request.js +24 -3
  7. package/dist/generator/src/config.d.ts +18 -0
  8. package/dist/generator/src/config.js +24 -0
  9. package/dist/generator/src/dialect.js +4 -2
  10. package/dist/generator/src/dotnet-generator.js +97 -1
  11. package/dist/generator/src/field-types.js +3 -0
  12. package/dist/generator/src/golang-generator.d.ts +8 -0
  13. package/dist/generator/src/golang-generator.js +183 -9
  14. package/dist/generator/src/index.js +18 -0
  15. package/dist/generator/src/java-generator.d.ts +56 -0
  16. package/dist/generator/src/java-generator.js +1406 -0
  17. package/dist/generator/src/kotlin-generator.d.ts +34 -0
  18. package/dist/generator/src/kotlin-generator.js +753 -0
  19. package/dist/generator/src/rust-generator.js +36 -1
  20. package/dist/generator/src/swift-generator.d.ts +42 -0
  21. package/dist/generator/src/swift-generator.js +768 -0
  22. package/generator/src/api.ts +3 -0
  23. package/generator/src/code-generator.ts +13 -1
  24. package/generator/src/code-request.ts +17 -3
  25. package/generator/src/config.ts +48 -0
  26. package/generator/src/dialect.ts +5 -3
  27. package/generator/src/dotnet-generator.ts +97 -1
  28. package/generator/src/field-types.ts +3 -0
  29. package/generator/src/golang-generator.ts +184 -9
  30. package/generator/src/index.ts +21 -0
  31. package/generator/src/java-generator.ts +1457 -0
  32. package/generator/src/kotlin-generator.ts +798 -0
  33. package/generator/src/rust-generator.ts +37 -1
  34. package/generator/src/swift-generator.ts +830 -0
  35. 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 Rust.
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
- return 'any';
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
- exports.CODE_LANGUAGES = ['typescript', 'python', 'dotnet', 'golang', 'rust'];
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)} ${column.sqlType} ${identity}`
206
- : `${quote(column.name)} ${column.sqlType}`;
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\tvar rowVec []float64
1035
- \t\tif fieldVal.Kind() == reflect.String {
1036
- \t\t\t_ = json.Unmarshal([]byte(fieldVal.String()), &rowVec)
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