@an5/orm 1.0.9 → 1.1.1

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 (57) hide show
  1. package/README.md +10 -3
  2. package/dist/config.d.ts +9 -1
  3. package/dist/config.js +13 -18
  4. package/dist/generator/src/api.d.ts +35 -0
  5. package/dist/generator/src/api.js +58 -0
  6. package/dist/generator/src/config.d.ts +162 -0
  7. package/dist/generator/src/config.js +354 -0
  8. package/dist/generator/src/dialect.d.ts +88 -0
  9. package/dist/generator/src/dialect.js +318 -0
  10. package/dist/generator/src/dotnet-generator.d.ts +8 -0
  11. package/dist/generator/src/dotnet-generator.js +282 -101
  12. package/dist/generator/src/field-types.d.ts +96 -0
  13. package/dist/generator/src/field-types.js +433 -0
  14. package/dist/generator/src/golang-generator.d.ts +36 -0
  15. package/dist/generator/src/golang-generator.js +1230 -0
  16. package/dist/generator/src/index.js +69 -36
  17. package/dist/generator/src/metadata-generator.js +2 -1
  18. package/dist/generator/src/parser.d.ts +28 -2
  19. package/dist/generator/src/parser.js +92 -90
  20. package/dist/generator/src/push-apply.d.ts +37 -0
  21. package/dist/generator/src/push-apply.js +109 -0
  22. package/dist/generator/src/push-schema.d.ts +93 -0
  23. package/dist/generator/src/push-schema.js +189 -0
  24. package/dist/generator/src/python-generator.d.ts +19 -1
  25. package/dist/generator/src/python-generator.js +147 -64
  26. package/dist/generator/src/rust-generator.d.ts +38 -0
  27. package/dist/generator/src/rust-generator.js +971 -0
  28. package/dist/generator/src/suggest.d.ts +17 -0
  29. package/dist/generator/src/suggest.js +45 -0
  30. package/dist/generator/src/type-kinds.d.ts +27 -0
  31. package/dist/generator/src/type-kinds.js +72 -0
  32. package/dist/generator/src/types.d.ts +33 -0
  33. package/dist/generator/src/types.js +39 -0
  34. package/dist/migration-core.d.ts +16 -1
  35. package/dist/migration-core.js +60 -21
  36. package/dist/orm.d.ts +147 -0
  37. package/dist/orm.js +1654 -0
  38. package/dist/provider-support.d.ts +27 -0
  39. package/dist/provider-support.js +50 -0
  40. package/dist/sql-utils.js +8 -3
  41. package/generator/src/api.ts +45 -0
  42. package/generator/src/config.ts +496 -0
  43. package/generator/src/dialect.ts +415 -0
  44. package/generator/src/dotnet-generator.ts +277 -88
  45. package/generator/src/field-types.ts +473 -0
  46. package/generator/src/golang-generator.ts +94 -68
  47. package/generator/src/index.ts +71 -36
  48. package/generator/src/metadata-generator.ts +2 -2
  49. package/generator/src/parser.ts +122 -90
  50. package/generator/src/push-apply.ts +148 -0
  51. package/generator/src/push-schema.ts +250 -0
  52. package/generator/src/python-generator.ts +118 -62
  53. package/generator/src/rust-generator.ts +974 -0
  54. package/generator/src/suggest.ts +41 -0
  55. package/generator/src/type-kinds.ts +78 -0
  56. package/generator/src/types.ts +48 -0
  57. package/package.json +26 -6
@@ -6,6 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.DotnetGenerator = void 0;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
+ const types_1 = require("./types");
10
+ const type_kinds_1 = require("./type-kinds");
9
11
  class DotnetGenerator {
10
12
  constructor(outputDir) {
11
13
  this.outputDir = outputDir;
@@ -14,6 +16,7 @@ class DotnetGenerator {
14
16
  }
15
17
  }
16
18
  generate(models) {
19
+ this.provider = models[0]?.provider;
17
20
  // 1. Generate individual C# entity class files
18
21
  for (const model of models) {
19
22
  this.generateEntityClass(model);
@@ -25,17 +28,17 @@ class DotnetGenerator {
25
28
  // 4. Generate configuration helper class
26
29
  this.generateConfigClass();
27
30
  }
28
- getCsFilterType(fieldType, isOptional) {
29
- const lower = fieldType.toLowerCase();
30
- if (['datetime', 'datetime2', 'smalldatetime', 'date', 'datetimeoffset'].includes(lower))
31
- return 'DateTimeFilter';
32
- if (['bool', 'boolean', 'bit'].includes(lower))
33
- return 'BoolFilter';
34
- if (['int', 'integer', 'smallint', 'tinyint', 'number'].includes(lower))
35
- return 'IntFilter';
36
- if (['bigint', 'long', 'float', 'real', 'double', 'decimal', 'numeric', 'money', 'smallmoney'].includes(lower))
37
- return 'NumberFilter';
38
- return 'StringFilter';
31
+ getCsFilterType(field, isOptional) {
32
+ // From the declared type: a DECIMAL column took the integer filter because it
33
+ // arrived as `number`.
34
+ switch ((0, type_kinds_1.fieldKind)(field, this.provider)) {
35
+ case 'date': return 'DateTimeFilter';
36
+ case 'bool': return 'BoolFilter';
37
+ case 'int':
38
+ case 'bigint': return 'IntFilter';
39
+ case 'float': return 'NumberFilter';
40
+ default: return 'StringFilter';
41
+ }
39
42
  }
40
43
  generateOrmTypes(models) {
41
44
  let content = `// This file is auto-generated. Do not edit directly.
@@ -110,7 +113,7 @@ namespace An5Orm
110
113
  `;
111
114
  for (const model of models) {
112
115
  const name = this.capitalize(model.name);
113
- const csType = (f) => this.mapType(f.type, f.isOptional);
116
+ const csType = (f) => this.mapType(f, f.isOptional);
114
117
  content += ` // ── ${name} ORM Types ──────────────────────────────────────────────────────\n\n`;
115
118
  // WhereInput
116
119
  content += ` /// <summary>Type-safe WHERE filter for ${name} queries.</summary>\n`;
@@ -119,7 +122,7 @@ namespace An5Orm
119
122
  content += ` public List<${name}WhereInput> OR { get; set; }\n`;
120
123
  content += ` public ${name}WhereInput NOT { get; set; }\n`;
121
124
  for (const f of model.fields) {
122
- const ft = this.getCsFilterType(f.type, f.isOptional);
125
+ const ft = this.getCsFilterType(f, f.isOptional);
123
126
  content += ` public ${ft} ${this.capitalize(f.name)} { get; set; }\n`;
124
127
  }
125
128
  content += ` }\n\n`;
@@ -135,7 +138,7 @@ namespace An5Orm
135
138
  content += ` public class ${name}CreateInput\n {\n`;
136
139
  for (const f of model.fields) {
137
140
  if (!f.isId) {
138
- const ct = this.mapType(f.type, f.isOptional || f.hasDefault);
141
+ const ct = this.mapType(f, f.isOptional || f.hasDefault);
139
142
  content += ` public ${ct} ${this.capitalize(f.name)} { get; set; }\n`;
140
143
  }
141
144
  }
@@ -145,7 +148,7 @@ namespace An5Orm
145
148
  content += ` public class ${name}UpdateInput\n {\n`;
146
149
  for (const f of model.fields) {
147
150
  if (!f.isId) {
148
- const ut = this.mapType(f.type, true); // always nullable for update
151
+ const ut = this.mapType(f, true); // always nullable for update
149
152
  content += ` public ${ut} ${this.capitalize(f.name)} { get; set; }\n`;
150
153
  }
151
154
  }
@@ -199,44 +202,49 @@ namespace An5Orm
199
202
  `;
200
203
  fs_1.default.writeFileSync(path_1.default.join(this.outputDir, 'An5Config.cs'), content);
201
204
  }
202
- mapType(fieldType, isOptional) {
203
- let csType = 'string';
204
- const lowerType = fieldType.toLowerCase();
205
- if (['int', 'integer', 'smallint', 'tinyint', 'number'].includes(lowerType)) {
206
- csType = 'int';
207
- }
208
- else if (['bigint', 'long'].includes(lowerType)) {
209
- csType = 'long';
210
- }
211
- else if (['float', 'real', 'double'].includes(lowerType)) {
212
- csType = 'double';
213
- }
214
- else if (['decimal', 'numeric', 'money', 'smallmoney'].includes(lowerType)) {
215
- csType = 'decimal';
216
- }
217
- else if (['bool', 'boolean', 'bit'].includes(lowerType)) {
218
- csType = 'bool';
219
- }
220
- else if (['datetime', 'datetime2', 'smalldatetime', 'date'].includes(lowerType)) {
221
- csType = 'DateTime';
222
- }
223
- else if (lowerType === 'datetimeoffset') {
224
- csType = 'DateTimeOffset';
225
- }
226
- else if (lowerType === 'time') {
227
- csType = 'TimeSpan';
228
- }
229
- else if (['guid', 'uuid', 'uniqueidentifier'].includes(lowerType)) {
230
- csType = 'Guid';
231
- }
232
- else if (['bytes', 'binary', 'varbinary', 'image'].includes(lowerType)) {
233
- csType = 'byte[]';
234
- }
235
- else if (['xml', 'hierarchyid', 'geography', 'geometry', 'sysname', 'sql_variant', 'sqlvariant', 'rowversion', 'variant'].includes(lowerType)) {
236
- csType = 'string';
237
- }
238
- else if (fieldType.endsWith('[]')) {
239
- csType = 'string';
205
+ /**
206
+ * The C# type for a field.
207
+ *
208
+ * Driven by the declared type, so a `DECIMAL` column is a `decimal` and a `FLOAT`
209
+ * one a `double`: both arrived as `number` and took the `int` branch.
210
+ */
211
+ mapType(field, isOptional) {
212
+ let csType;
213
+ switch ((0, type_kinds_1.fieldKind)(field, this.provider)) {
214
+ case 'int':
215
+ csType = 'int';
216
+ break;
217
+ case 'bigint':
218
+ csType = 'long';
219
+ break;
220
+ case 'float': {
221
+ // `DECIMAL`/`NUMERIC`/`MONEY` are exact and stay `decimal`; a floating
222
+ // column is a `double`. Both used to arrive as `number` and became `int`.
223
+ const base = (field.sqlType ?? field.type).replace(/\([^)]*\)/g, '').trim().toUpperCase();
224
+ csType = ['DECIMAL', 'NUMERIC', 'MONEY', 'SMALLMONEY'].includes(base) ? 'decimal' : 'double';
225
+ break;
226
+ }
227
+ case 'bool':
228
+ csType = 'bool';
229
+ break;
230
+ case 'date': {
231
+ const base = (field.sqlType ?? field.type).replace(/\([^)]*\)/g, '').trim().toUpperCase();
232
+ csType = base === 'DATETIMEOFFSET' ? 'DateTimeOffset' : base === 'TIME' ? 'TimeSpan' : 'DateTime';
233
+ break;
234
+ }
235
+ case 'bytes':
236
+ csType = 'byte[]';
237
+ break;
238
+ case 'vector':
239
+ csType = 'float[]';
240
+ break;
241
+ // JSON, XML, geometry and the rest stay strings: they are read as text and a
242
+ // Guid column is covered by the string case below via its declared name.
243
+ default: {
244
+ const base = (field.sqlType ?? field.type).replace(/\([^)]*\)/g, '').trim().toUpperCase();
245
+ csType = ['GUID', 'UUID', 'UNIQUEIDENTIFIER'].includes(base) ? 'Guid' : 'string';
246
+ break;
247
+ }
240
248
  }
241
249
  if (isOptional && csType !== 'string' && !csType.endsWith('[]')) {
242
250
  csType += '?';
@@ -255,7 +263,7 @@ namespace An5Orm.Entities
255
263
  `;
256
264
  // Generate fields
257
265
  for (const field of model.fields) {
258
- const csType = this.mapType(field.type, field.isOptional);
266
+ const csType = this.mapType(field, field.isOptional);
259
267
  content += ` public ${csType} ${this.capitalize(field.name)} { get; set; }\n`;
260
268
  }
261
269
  // Generate relations
@@ -280,31 +288,138 @@ namespace An5Orm.Entities
280
288
  using System;
281
289
  using System.Collections.Generic;
282
290
  using System.Data;
291
+ using System.Data.Common;
283
292
  using System.Reflection;
284
293
  using System.Text.Json;
285
- using Microsoft.Data.SqlClient;
286
294
  using An5Orm.Entities;
287
295
 
288
296
  namespace An5Orm
289
297
  {
298
+ /// <summary>SQL dialects this client can talk to.</summary>
299
+ public enum An5Dialect
300
+ {
301
+ Mssql,
302
+ Postgres,
303
+ Sqlite
304
+ }
305
+
306
+ /// <summary>
307
+ /// Picks the ADO.NET provider and rewrites the connection string.
308
+ ///
309
+ /// Everything below works against DbConnection/DbCommand rather than the
310
+ /// SQL Server types, because SqlClient, Npgsql and Sqlite each derive from
311
+ /// those but share no other members. Only the SQL that differs per dialect
312
+ /// is branched on.
313
+ /// </summary>
314
+ internal static class An5Provider
315
+ {
316
+ public static An5Dialect Detect(string connectionString)
317
+ {
318
+ var cs = (connectionString ?? "").Trim().ToLowerInvariant();
319
+ // SQLite first: a bare path or Data Source= would otherwise fall
320
+ // through to SQL Server.
321
+ if (cs.StartsWith("sqlite:") || cs.StartsWith("file:") || cs == ":memory:"
322
+ || cs.Contains("data source=") || cs.Contains("datasource=")
323
+ || cs.EndsWith(".db") || cs.EndsWith(".sqlite") || cs.EndsWith(".sqlite3"))
324
+ return An5Dialect.Sqlite;
325
+ if (cs.StartsWith("postgres://") || cs.StartsWith("postgresql://") || cs.Contains("host="))
326
+ return An5Dialect.Postgres;
327
+ return An5Dialect.Mssql;
328
+ }
329
+
330
+ /// <summary>
331
+ /// Rewrites the accepted SQLite spellings into the one
332
+ /// Microsoft.Data.Sqlite reads, which is Data Source=.
333
+ /// </summary>
334
+ public static string NormalizeSqlite(string connectionString)
335
+ {
336
+ var cs = (connectionString ?? "").Trim();
337
+ if (cs.Length == 0) return "Data Source=:memory:";
338
+ if (cs.IndexOf("Data Source", StringComparison.OrdinalIgnoreCase) >= 0
339
+ || cs.IndexOf("DataSource", StringComparison.OrdinalIgnoreCase) >= 0
340
+ || cs.IndexOf("Mode", StringComparison.OrdinalIgnoreCase) >= 0)
341
+ return cs;
342
+ if (cs == ":memory:") return "Data Source=:memory:";
343
+ if (cs.StartsWith("sqlite://", StringComparison.OrdinalIgnoreCase))
344
+ return "Data Source=" + cs.Substring("sqlite://".Length);
345
+ if (cs.StartsWith("sqlite:", StringComparison.OrdinalIgnoreCase))
346
+ return "Data Source=" + cs.Substring("sqlite:".Length);
347
+ if (cs.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) return cs;
348
+ return "Data Source=" + cs;
349
+ }
350
+
351
+ public static DbConnection Open(string connectionString, An5Dialect dialect)
352
+ {
353
+ DbConnection conn = dialect switch
354
+ {
355
+ An5Dialect.Postgres => new Npgsql.NpgsqlConnection(connectionString),
356
+ An5Dialect.Sqlite => new Microsoft.Data.Sqlite.SqliteConnection(NormalizeSqlite(connectionString)),
357
+ _ => new Microsoft.Data.SqlClient.SqlConnection(connectionString)
358
+ };
359
+ conn.Open();
360
+ if (dialect == An5Dialect.Sqlite)
361
+ {
362
+ // WAL keeps readers off the writer's back, and SQLite leaves
363
+ // foreign keys off even when the schema declares them.
364
+ using var pragma = conn.CreateCommand();
365
+ pragma.CommandText = "PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;";
366
+ pragma.ExecuteNonQuery();
367
+ }
368
+ return conn;
369
+ }
370
+
371
+ /// <summary>
372
+ /// Adds a parameter. The name is stored without the @ prefix: Npgsql and
373
+ /// Sqlite treat a leading @ as part of the name, while SqlClient adds it
374
+ /// back, so stripping it is the only spelling all three agree on.
375
+ /// </summary>
376
+ public static DbParameter Bind(DbCommand cmd, string name, object value)
377
+ {
378
+ var p = cmd.CreateParameter();
379
+ p.ParameterName = (name ?? "").TrimStart('@');
380
+ p.Value = value ?? DBNull.Value;
381
+ cmd.Parameters.Add(p);
382
+ return p;
383
+ }
384
+
385
+ /// <summary>Quotes an identifier for the dialect.</summary>
386
+ public static string Quote(string name, An5Dialect dialect)
387
+ {
388
+ if (name.StartsWith("[") || name.StartsWith("\\"")) return name;
389
+ if (dialect == An5Dialect.Mssql)
390
+ return "[" + name.Replace("]", "]]") + "]";
391
+ return "\\"" + name.Replace("\\"", "\\"\\"") + "\\"";
392
+ }
393
+
394
+ /// <summary>Schema-less engines have no dbo prefix on the generated table names.</summary>
395
+ public static string TableName(string tableName, An5Dialect dialect)
396
+ {
397
+ if (dialect != An5Dialect.Sqlite) return tableName;
398
+ if (tableName.StartsWith("dbo.", StringComparison.OrdinalIgnoreCase))
399
+ return tableName.Substring(4);
400
+ return tableName;
401
+ }
402
+ }
403
+
290
404
  public class An5DbContext
291
405
  {
292
406
  public string ConnectionString { get; }
293
-
407
+ public An5Dialect Dialect { get; }
408
+
294
409
  [ThreadStatic]
295
- private static SqlConnection _txConn;
410
+ private static DbConnection _txConn;
296
411
  [ThreadStatic]
297
- private static SqlTransaction _tx;
412
+ private static DbTransaction _tx;
298
413
 
299
414
  public An5DbContext(string connectionString = null)
300
415
  {
301
416
  ConnectionString = connectionString ?? An5Config.ConnectionString;
417
+ Dialect = An5Provider.Detect(ConnectionString);
302
418
  }
303
419
 
304
420
  public An5Transaction BeginTransaction()
305
421
  {
306
- var conn = new SqlConnection(ConnectionString);
307
- conn.Open();
422
+ var conn = An5Provider.Open(ConnectionString, Dialect);
308
423
  var tx = conn.BeginTransaction();
309
424
  _txConn = conn;
310
425
  _tx = tx;
@@ -313,8 +428,8 @@ namespace An5Orm
313
428
  _tx = null;
314
429
  });
315
430
  }
316
-
317
- public static SqlConnection GetActiveConnection(string connectionString, out bool isTx)
431
+
432
+ public static DbConnection GetActiveConnection(string connectionString, out bool isTx)
318
433
  {
319
434
  if (_txConn != null)
320
435
  {
@@ -322,30 +437,28 @@ namespace An5Orm
322
437
  return _txConn;
323
438
  }
324
439
  isTx = false;
325
- var conn = new SqlConnection(connectionString);
326
- conn.Open();
327
- return conn;
440
+ return An5Provider.Open(connectionString, An5Provider.Detect(connectionString));
328
441
  }
329
-
330
- public static SqlTransaction GetActiveTransaction() => _tx;
442
+
443
+ public static DbTransaction GetActiveTransaction() => _tx;
331
444
 
332
445
  // ── Tables / Repositories ──────────────────────────────────────────────
333
446
  `;
334
447
  for (const model of models) {
335
448
  const name = this.capitalize(model.name);
336
- content += ` public TableClient<${model.name}> ${name}s => new TableClient<${model.name}>(ConnectionString, "${model.schemaName}.${model.tableName}");\n`;
449
+ content += ` public TableClient<${model.name}> ${name}s => new TableClient<${model.name}>(ConnectionString, "${(0, types_1.dottedTableName)(model)}");\n`;
337
450
  content += ` public TableClient<${model.name}> ${name} => ${name}s;\n`;
338
451
  }
339
452
  content += ` }
340
453
 
341
454
  public class An5Transaction : IDisposable
342
455
  {
343
- private readonly SqlConnection _conn;
344
- private readonly SqlTransaction _tx;
456
+ private readonly DbConnection _conn;
457
+ private readonly DbTransaction _tx;
345
458
  private readonly Action _cleanup;
346
459
  private bool _completed;
347
460
 
348
- public An5Transaction(SqlConnection conn, SqlTransaction tx, Action cleanup)
461
+ public An5Transaction(DbConnection conn, DbTransaction tx, Action cleanup)
349
462
  {
350
463
  _conn = conn;
351
464
  _tx = tx;
@@ -380,16 +493,21 @@ namespace An5Orm
380
493
  {
381
494
  public string ConnectionString { get; }
382
495
  public string TableName { get; }
496
+ public An5Dialect Dialect { get; }
383
497
 
384
498
  public TableClient(string connectionString, string tableName)
385
499
  {
386
500
  ConnectionString = connectionString;
387
- TableName = tableName;
501
+ Dialect = An5Provider.Detect(connectionString);
502
+ // SQLite has no schemas, so the generated dbo. prefix would be part
503
+ // of the table name and match nothing.
504
+ TableName = An5Provider.TableName(tableName, Dialect);
388
505
  }
389
506
 
390
- private SqlCommand CreateCommand(SqlConnection conn, string query)
507
+ private DbCommand CreateCommand(DbConnection conn, string query)
391
508
  {
392
- var cmd = new SqlCommand(query, conn);
509
+ var cmd = conn.CreateCommand();
510
+ cmd.CommandText = query;
393
511
  var activeTx = An5DbContext.GetActiveTransaction();
394
512
  if (activeTx != null)
395
513
  {
@@ -410,7 +528,7 @@ namespace An5Orm
410
528
  {
411
529
  foreach (var kvp in parameters)
412
530
  {
413
- cmd.Parameters.AddWithValue(kvp.Key.StartsWith("@") ? kvp.Key : "@" + kvp.Key, kvp.Value ?? DBNull.Value);
531
+ An5Provider.Bind(cmd, kvp.Key, kvp.Value);
414
532
  }
415
533
  }
416
534
 
@@ -427,7 +545,7 @@ namespace An5Orm
427
545
  var val = reader[prop.Name];
428
546
  if (val != DBNull.Value)
429
547
  {
430
- prop.SetValue(item, val);
548
+ SetValue(prop, item, val);
431
549
  }
432
550
  }
433
551
  }
@@ -461,7 +579,7 @@ namespace An5Orm
461
579
  {
462
580
  foreach (var kvp in parameters)
463
581
  {
464
- cmd.Parameters.AddWithValue(kvp.Key.StartsWith("@") ? kvp.Key : "@" + kvp.Key, kvp.Value ?? DBNull.Value);
582
+ An5Provider.Bind(cmd, kvp.Key, kvp.Value);
465
583
  }
466
584
  }
467
585
 
@@ -478,7 +596,7 @@ namespace An5Orm
478
596
  var val = reader[prop.Name];
479
597
  if (val != DBNull.Value)
480
598
  {
481
- prop.SetValue(item, val);
599
+ SetValue(prop, item, val);
482
600
  }
483
601
  }
484
602
  }
@@ -496,11 +614,19 @@ namespace An5Orm
496
614
 
497
615
  public T FindFirst(string whereClause = null, Dictionary<string, object> parameters = null)
498
616
  {
499
- string query = $"SELECT TOP 1 * FROM {TableName}";
617
+ // TOP goes before the table, LIMIT after the WHERE — appending LIMIT
618
+ // to the SELECT would put it ahead of the predicate.
619
+ string query = Dialect == An5Dialect.Mssql
620
+ ? $"SELECT TOP 1 * FROM {TableName}"
621
+ : $"SELECT * FROM {TableName}";
500
622
  if (!string.IsNullOrEmpty(whereClause))
501
623
  {
502
624
  query += $" WHERE {whereClause}";
503
625
  }
626
+ if (Dialect != An5Dialect.Mssql)
627
+ {
628
+ query += " LIMIT 1";
629
+ }
504
630
 
505
631
  var conn = An5DbContext.GetActiveConnection(ConnectionString, out bool isTx);
506
632
  try
@@ -511,7 +637,7 @@ namespace An5Orm
511
637
  {
512
638
  foreach (var kvp in parameters)
513
639
  {
514
- cmd.Parameters.AddWithValue(kvp.Key.StartsWith("@") ? kvp.Key : "@" + kvp.Key, kvp.Value ?? DBNull.Value);
640
+ An5Provider.Bind(cmd, kvp.Key, kvp.Value);
515
641
  }
516
642
  }
517
643
 
@@ -528,7 +654,7 @@ namespace An5Orm
528
654
  var val = reader[prop.Name];
529
655
  if (val != DBNull.Value)
530
656
  {
531
- prop.SetValue(item, val);
657
+ SetValue(prop, item, val);
532
658
  }
533
659
  }
534
660
  }
@@ -554,7 +680,9 @@ namespace An5Orm
554
680
  var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
555
681
  var columns = new List<string>();
556
682
  var values = new List<string>();
557
- var sqlParams = new List<SqlParameter>();
683
+ // Collected before the command exists; the provider creates the
684
+ // concrete parameter type, so binding has to wait for the command.
685
+ var sqlParams = new List<(string Name, object Value)>();
558
686
 
559
687
  foreach (var prop in properties)
560
688
  {
@@ -563,7 +691,7 @@ namespace An5Orm
563
691
  {
564
692
  columns.Add(prop.Name);
565
693
  values.Add("@" + prop.Name);
566
- sqlParams.Add(new SqlParameter("@" + prop.Name, val));
694
+ sqlParams.Add((prop.Name, val));
567
695
  }
568
696
  }
569
697
 
@@ -573,7 +701,7 @@ namespace An5Orm
573
701
  {
574
702
  using (var cmd = CreateCommand(conn, query))
575
703
  {
576
- cmd.Parameters.AddRange(sqlParams.ToArray());
704
+ foreach (var (name, value) in sqlParams) An5Provider.Bind(cmd, name, value);
577
705
  cmd.ExecuteNonQuery();
578
706
  if (!isTx)
579
707
  {
@@ -592,7 +720,9 @@ namespace An5Orm
592
720
  {
593
721
  var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
594
722
  var sets = new List<string>();
595
- var sqlParams = new List<SqlParameter>();
723
+ // Collected before the command exists; the provider creates the
724
+ // concrete parameter type, so binding has to wait for the command.
725
+ var sqlParams = new List<(string Name, object Value)>();
596
726
  object idVal = null;
597
727
 
598
728
  foreach (var prop in properties)
@@ -605,7 +735,7 @@ namespace An5Orm
605
735
  else if (val != null)
606
736
  {
607
737
  sets.Add($"{prop.Name} = @{prop.Name}");
608
- sqlParams.Add(new SqlParameter("@" + prop.Name, val));
738
+ sqlParams.Add((prop.Name, val));
609
739
  }
610
740
  }
611
741
 
@@ -614,14 +744,14 @@ namespace An5Orm
614
744
  throw new InvalidOperationException("Cannot update entity without Id");
615
745
  }
616
746
 
617
- sqlParams.Add(new SqlParameter("@id", idVal));
747
+ sqlParams.Add(("id", idVal));
618
748
  string query = $"UPDATE {TableName} SET {string.Join(", ", sets)} WHERE Id = @id";
619
749
  var conn = An5DbContext.GetActiveConnection(ConnectionString, out bool isTx);
620
750
  try
621
751
  {
622
752
  using (var cmd = CreateCommand(conn, query))
623
753
  {
624
- cmd.Parameters.AddRange(sqlParams.ToArray());
754
+ foreach (var (name, value) in sqlParams) An5Provider.Bind(cmd, name, value);
625
755
  cmd.ExecuteNonQuery();
626
756
  }
627
757
  }
@@ -640,7 +770,7 @@ namespace An5Orm
640
770
  {
641
771
  using (var cmd = CreateCommand(conn, query))
642
772
  {
643
- cmd.Parameters.AddWithValue("@id", id);
773
+ An5Provider.Bind(cmd, "id", id);
644
774
  int affected = cmd.ExecuteNonQuery();
645
775
  return affected > 0;
646
776
  }
@@ -663,7 +793,7 @@ namespace An5Orm
663
793
  if (parameters != null)
664
794
  {
665
795
  foreach (var kvp in parameters)
666
- cmd.Parameters.AddWithValue(kvp.Key.StartsWith("@") ? kvp.Key : "@" + kvp.Key, kvp.Value ?? DBNull.Value);
796
+ An5Provider.Bind(cmd, kvp.Key, kvp.Value);
667
797
  }
668
798
  var res = cmd.ExecuteScalar();
669
799
  return res != null && res != DBNull.Value ? Convert.ToInt32(res) : 0;
@@ -687,13 +817,15 @@ namespace An5Orm
687
817
  {
688
818
  if (updateData == null || updateData.Count == 0) return 0;
689
819
  var sets = new List<string>();
690
- var sqlParams = new List<SqlParameter>();
820
+ // Bound after the command exists, so the provider can create the
821
+ // right parameter type; a value tuple carries (name, value) until then.
822
+ var sqlParams = new List<(string Name, object Value)>();
691
823
  int pIndex = 0;
692
824
  foreach (var kvp in updateData)
693
825
  {
694
826
  string paramName = "@u_" + pIndex++;
695
827
  sets.Add($"{kvp.Key} = {paramName}");
696
- sqlParams.Add(new SqlParameter(paramName, kvp.Value ?? DBNull.Value));
828
+ sqlParams.Add((paramName, kvp.Value));
697
829
  }
698
830
  string query = $"UPDATE {TableName} SET {string.Join(", ", sets)}";
699
831
  if (!string.IsNullOrEmpty(whereClause)) query += $" WHERE {whereClause}";
@@ -702,11 +834,11 @@ namespace An5Orm
702
834
  {
703
835
  using (var cmd = CreateCommand(conn, query))
704
836
  {
705
- cmd.Parameters.AddRange(sqlParams.ToArray());
837
+ foreach (var (name, value) in sqlParams) An5Provider.Bind(cmd, name, value);
706
838
  if (parameters != null)
707
839
  {
708
840
  foreach (var kvp in parameters)
709
- cmd.Parameters.AddWithValue(kvp.Key.StartsWith("@") ? kvp.Key : "@" + kvp.Key, kvp.Value ?? DBNull.Value);
841
+ An5Provider.Bind(cmd, kvp.Key, kvp.Value);
710
842
  }
711
843
  return cmd.ExecuteNonQuery();
712
844
  }
@@ -726,7 +858,7 @@ namespace An5Orm
726
858
  if (parameters != null)
727
859
  {
728
860
  foreach (var kvp in parameters)
729
- cmd.Parameters.AddWithValue(kvp.Key.StartsWith("@") ? kvp.Key : "@" + kvp.Key, kvp.Value ?? DBNull.Value);
861
+ An5Provider.Bind(cmd, kvp.Key, kvp.Value);
730
862
  }
731
863
  return cmd.ExecuteNonQuery();
732
864
  }
@@ -746,28 +878,44 @@ namespace An5Orm
746
878
 
747
879
  public List<T> VectorSearch(List<double> vector, int take = 10, string whereClause = null, Dictionary<string, object> parameters = null, string vectorField = "Embedding", string distanceMetric = "cosine")
748
880
  {
749
- // 1. Primary path: Native database SQL vector query execution (VECTOR_DISTANCE)
881
+ // 1. Primary path: native vector query. SQL Server has VECTOR_DISTANCE
882
+ // and Postgres has pgvector; SQLite has no vector operator, so it is
883
+ // skipped outright rather than issuing SQL that can only fail and
884
+ // cost a round trip before falling through.
885
+ if (Dialect != An5Dialect.Sqlite)
750
886
  try
751
887
  {
752
888
  var dim = vector.Count;
753
889
  var vecJson = JsonSerializer.Serialize(vector);
754
- var sql = $"SELECT TOP ({take}) *, VECTOR_DISTANCE('{distanceMetric}', CAST([{vectorField}] AS VECTOR({dim}, float32)), CAST(@query_vector AS VECTOR({dim}, float32))) AS distance FROM {TableName} WITH (NOLOCK)";
890
+ var field = An5Provider.Quote(vectorField, Dialect);
891
+ string sql;
892
+ if (Dialect == An5Dialect.Postgres)
893
+ {
894
+ var op = distanceMetric.Equals("cosine", StringComparison.OrdinalIgnoreCase) ? "<=>"
895
+ : (distanceMetric.Equals("euclidean", StringComparison.OrdinalIgnoreCase) ? "<->" : "<#>");
896
+ sql = $"SELECT *, ({field} {op} @query_vector::vector) AS distance FROM {TableName}";
897
+ }
898
+ else
899
+ {
900
+ sql = $"SELECT TOP ({take}) *, VECTOR_DISTANCE('{distanceMetric}', CAST({field} AS VECTOR({dim}, float32)), CAST(@query_vector AS VECTOR({dim}, float32))) AS distance FROM {TableName} WITH (NOLOCK)";
901
+ }
755
902
 
756
903
  var p = parameters != null ? new Dictionary<string, object>(parameters) : new Dictionary<string, object>();
757
904
  p["query_vector"] = vecJson;
758
905
 
759
906
  if (!string.IsNullOrWhiteSpace(whereClause))
760
- sql += $" WHERE [{vectorField}] IS NOT NULL AND ({whereClause})";
907
+ sql += $" WHERE {field} IS NOT NULL AND ({whereClause})";
761
908
  else
762
- sql += $" WHERE [{vectorField}] IS NOT NULL";
909
+ sql += $" WHERE {field} IS NOT NULL";
763
910
  sql += " ORDER BY distance ASC";
911
+ if (Dialect == An5Dialect.Postgres) sql += $" LIMIT {take}";
764
912
 
765
913
  var nativeRows = QueryRaw(sql, p);
766
914
  if (nativeRows != null) return nativeRows;
767
915
  }
768
916
  catch
769
917
  {
770
- // Fallback to in-memory similarity computation if DB instance lacks native VECTOR_DISTANCE
918
+ // Fallback to in-memory similarity computation if the database has no native vector support
771
919
  }
772
920
 
773
921
  // 2. Secondary fallback: In-memory similarity computation
@@ -849,7 +997,40 @@ namespace An5Orm
849
997
  return dot / (Math.Sqrt(m1) * Math.Sqrt(m2));
850
998
  }
851
999
 
852
- private bool HasColumn(SqlDataReader reader, string columnName)
1000
+ /// <summary>
1001
+ /// Assigns a column to a property, converting when the provider's CLR
1002
+ /// type does not match the property.
1003
+ ///
1004
+ /// Required across dialects, not just for SQLite: SQLite hands back
1005
+ /// dates as TEXT and every integer as Int64, so a DateTime or int
1006
+ /// property would otherwise fail to set. A value that cannot be
1007
+ /// converted is skipped rather than aborting the whole read.
1008
+ /// </summary>
1009
+ private static void SetValue(System.Reflection.PropertyInfo prop, object target, object value)
1010
+ {
1011
+ var wanted = prop.PropertyType;
1012
+ if (wanted.IsInstanceOfType(value))
1013
+ {
1014
+ prop.SetValue(target, value);
1015
+ return;
1016
+ }
1017
+ try
1018
+ {
1019
+ var underlying = Nullable.GetUnderlyingType(wanted);
1020
+ if (underlying != null && value == null) { prop.SetValue(target, null); return; }
1021
+ if (underlying != null)
1022
+ {
1023
+ prop.SetValue(target, Convert.ChangeType(value, underlying));
1024
+ return;
1025
+ }
1026
+ if (value is string text && wanted == typeof(Guid)) { prop.SetValue(target, Guid.Parse(text)); return; }
1027
+ if (value is string when && wanted == typeof(DateTime)) { prop.SetValue(target, DateTime.Parse(when, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind)); return; }
1028
+ prop.SetValue(target, Convert.ChangeType(value, wanted));
1029
+ }
1030
+ catch { }
1031
+ }
1032
+
1033
+ private bool HasColumn(DbDataReader reader, string columnName)
853
1034
  {
854
1035
  for (int i = 0; i < reader.FieldCount; i++)
855
1036
  {