@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
@@ -1,8 +1,13 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { Model, Field, Relation } from './types';
3
+ import { Model, Field, Relation, dottedTableName } from './types';
4
+ import { fieldKind } from './type-kinds';
5
+ import { Provider } from './field-types';
4
6
 
5
7
  export class DotnetGenerator {
8
+ /** The database being generated for; decides types like `TIMESTAMP`. */
9
+ private provider: Provider | undefined;
10
+
6
11
  constructor(private outputDir: string) {
7
12
  if (!fs.existsSync(this.outputDir)) {
8
13
  fs.mkdirSync(this.outputDir, { recursive: true });
@@ -10,6 +15,8 @@ export class DotnetGenerator {
10
15
  }
11
16
 
12
17
  public generate(models: Model[]) {
18
+ this.provider = models[0]?.provider;
19
+
13
20
  // 1. Generate individual C# entity class files
14
21
  for (const model of models) {
15
22
  this.generateEntityClass(model);
@@ -25,13 +32,17 @@ export class DotnetGenerator {
25
32
  this.generateConfigClass();
26
33
  }
27
34
 
28
- private getCsFilterType(fieldType: string, isOptional: boolean): string {
29
- const lower = fieldType.toLowerCase();
30
- if (['datetime', 'datetime2', 'smalldatetime', 'date', 'datetimeoffset'].includes(lower)) return 'DateTimeFilter';
31
- if (['bool', 'boolean', 'bit'].includes(lower)) return 'BoolFilter';
32
- if (['int', 'integer', 'smallint', 'tinyint', 'number'].includes(lower)) return 'IntFilter';
33
- if (['bigint', 'long', 'float', 'real', 'double', 'decimal', 'numeric', 'money', 'smallmoney'].includes(lower)) return 'NumberFilter';
34
- return 'StringFilter';
35
+ private getCsFilterType(field: Field, isOptional: boolean): string {
36
+ // From the declared type: a DECIMAL column took the integer filter because it
37
+ // arrived as `number`.
38
+ switch (fieldKind(field, this.provider)) {
39
+ case 'date': return 'DateTimeFilter';
40
+ case 'bool': return 'BoolFilter';
41
+ case 'int':
42
+ case 'bigint': return 'IntFilter';
43
+ case 'float': return 'NumberFilter';
44
+ default: return 'StringFilter';
45
+ }
35
46
  }
36
47
 
37
48
  private generateOrmTypes(models: Model[]) {
@@ -108,7 +119,7 @@ namespace An5Orm
108
119
 
109
120
  for (const model of models) {
110
121
  const name = this.capitalize(model.name);
111
- const csType = (f: { type: string; isOptional: boolean }) => this.mapType(f.type, f.isOptional);
122
+ const csType = (f: Field) => this.mapType(f, f.isOptional);
112
123
 
113
124
  content += ` // ── ${name} ORM Types ──────────────────────────────────────────────────────\n\n`;
114
125
 
@@ -119,7 +130,7 @@ namespace An5Orm
119
130
  content += ` public List<${name}WhereInput> OR { get; set; }\n`;
120
131
  content += ` public ${name}WhereInput NOT { get; set; }\n`;
121
132
  for (const f of model.fields) {
122
- const ft = this.getCsFilterType(f.type, f.isOptional);
133
+ const ft = this.getCsFilterType(f, f.isOptional);
123
134
  content += ` public ${ft} ${this.capitalize(f.name)} { get; set; }\n`;
124
135
  }
125
136
  content += ` }\n\n`;
@@ -137,7 +148,7 @@ namespace An5Orm
137
148
  content += ` public class ${name}CreateInput\n {\n`;
138
149
  for (const f of model.fields) {
139
150
  if (!f.isId) {
140
- const ct = this.mapType(f.type, f.isOptional || f.hasDefault);
151
+ const ct = this.mapType(f, f.isOptional || f.hasDefault);
141
152
  content += ` public ${ct} ${this.capitalize(f.name)} { get; set; }\n`;
142
153
  }
143
154
  }
@@ -148,7 +159,7 @@ namespace An5Orm
148
159
  content += ` public class ${name}UpdateInput\n {\n`;
149
160
  for (const f of model.fields) {
150
161
  if (!f.isId) {
151
- const ut = this.mapType(f.type, true); // always nullable for update
162
+ const ut = this.mapType(f, true); // always nullable for update
152
163
  content += ` public ${ut} ${this.capitalize(f.name)} { get; set; }\n`;
153
164
  }
154
165
  }
@@ -211,34 +222,39 @@ namespace An5Orm
211
222
  fs.writeFileSync(path.join(this.outputDir, 'An5Config.cs'), content);
212
223
  }
213
224
 
214
- private mapType(fieldType: string, isOptional: boolean): string {
215
- let csType = 'string';
216
- const lowerType = fieldType.toLowerCase();
217
-
218
- if (['int', 'integer', 'smallint', 'tinyint', 'number'].includes(lowerType)) {
219
- csType = 'int';
220
- } else if (['bigint', 'long'].includes(lowerType)) {
221
- csType = 'long';
222
- } else if (['float', 'real', 'double'].includes(lowerType)) {
223
- csType = 'double';
224
- } else if (['decimal', 'numeric', 'money', 'smallmoney'].includes(lowerType)) {
225
- csType = 'decimal';
226
- } else if (['bool', 'boolean', 'bit'].includes(lowerType)) {
227
- csType = 'bool';
228
- } else if (['datetime', 'datetime2', 'smalldatetime', 'date'].includes(lowerType)) {
229
- csType = 'DateTime';
230
- } else if (lowerType === 'datetimeoffset') {
231
- csType = 'DateTimeOffset';
232
- } else if (lowerType === 'time') {
233
- csType = 'TimeSpan';
234
- } else if (['guid', 'uuid', 'uniqueidentifier'].includes(lowerType)) {
235
- csType = 'Guid';
236
- } else if (['bytes', 'binary', 'varbinary', 'image'].includes(lowerType)) {
237
- csType = 'byte[]';
238
- } else if (['xml', 'hierarchyid', 'geography', 'geometry', 'sysname', 'sql_variant', 'sqlvariant', 'rowversion', 'variant'].includes(lowerType)) {
239
- csType = 'string';
240
- } else if (fieldType.endsWith('[]')) {
241
- csType = 'string';
225
+ /**
226
+ * The C# type for a field.
227
+ *
228
+ * Driven by the declared type, so a `DECIMAL` column is a `decimal` and a `FLOAT`
229
+ * one a `double`: both arrived as `number` and took the `int` branch.
230
+ */
231
+ private mapType(field: Field, isOptional: boolean): string {
232
+ let csType: string;
233
+ switch (fieldKind(field, this.provider)) {
234
+ case 'int': csType = 'int'; break;
235
+ case 'bigint': csType = 'long'; break;
236
+ case 'float': {
237
+ // `DECIMAL`/`NUMERIC`/`MONEY` are exact and stay `decimal`; a floating
238
+ // column is a `double`. Both used to arrive as `number` and became `int`.
239
+ const base = (field.sqlType ?? field.type).replace(/\([^)]*\)/g, '').trim().toUpperCase();
240
+ csType = ['DECIMAL', 'NUMERIC', 'MONEY', 'SMALLMONEY'].includes(base) ? 'decimal' : 'double';
241
+ break;
242
+ }
243
+ case 'bool': csType = 'bool'; break;
244
+ case 'date': {
245
+ const base = (field.sqlType ?? field.type).replace(/\([^)]*\)/g, '').trim().toUpperCase();
246
+ csType = base === 'DATETIMEOFFSET' ? 'DateTimeOffset' : base === 'TIME' ? 'TimeSpan' : 'DateTime';
247
+ break;
248
+ }
249
+ case 'bytes': csType = 'byte[]'; break;
250
+ case 'vector': csType = 'float[]'; break;
251
+ // JSON, XML, geometry and the rest stay strings: they are read as text and a
252
+ // Guid column is covered by the string case below via its declared name.
253
+ default: {
254
+ const base = (field.sqlType ?? field.type).replace(/\([^)]*\)/g, '').trim().toUpperCase();
255
+ csType = ['GUID', 'UUID', 'UNIQUEIDENTIFIER'].includes(base) ? 'Guid' : 'string';
256
+ break;
257
+ }
242
258
  }
243
259
 
244
260
  if (isOptional && csType !== 'string' && !csType.endsWith('[]')) {
@@ -260,7 +276,7 @@ namespace An5Orm.Entities
260
276
 
261
277
  // Generate fields
262
278
  for (const field of model.fields) {
263
- const csType = this.mapType(field.type, field.isOptional);
279
+ const csType = this.mapType(field, field.isOptional);
264
280
  content += ` public ${csType} ${this.capitalize(field.name)} { get; set; }\n`;
265
281
  }
266
282
 
@@ -288,31 +304,138 @@ namespace An5Orm.Entities
288
304
  using System;
289
305
  using System.Collections.Generic;
290
306
  using System.Data;
307
+ using System.Data.Common;
291
308
  using System.Reflection;
292
309
  using System.Text.Json;
293
- using Microsoft.Data.SqlClient;
294
310
  using An5Orm.Entities;
295
311
 
296
312
  namespace An5Orm
297
313
  {
314
+ /// <summary>SQL dialects this client can talk to.</summary>
315
+ public enum An5Dialect
316
+ {
317
+ Mssql,
318
+ Postgres,
319
+ Sqlite
320
+ }
321
+
322
+ /// <summary>
323
+ /// Picks the ADO.NET provider and rewrites the connection string.
324
+ ///
325
+ /// Everything below works against DbConnection/DbCommand rather than the
326
+ /// SQL Server types, because SqlClient, Npgsql and Sqlite each derive from
327
+ /// those but share no other members. Only the SQL that differs per dialect
328
+ /// is branched on.
329
+ /// </summary>
330
+ internal static class An5Provider
331
+ {
332
+ public static An5Dialect Detect(string connectionString)
333
+ {
334
+ var cs = (connectionString ?? "").Trim().ToLowerInvariant();
335
+ // SQLite first: a bare path or Data Source= would otherwise fall
336
+ // through to SQL Server.
337
+ if (cs.StartsWith("sqlite:") || cs.StartsWith("file:") || cs == ":memory:"
338
+ || cs.Contains("data source=") || cs.Contains("datasource=")
339
+ || cs.EndsWith(".db") || cs.EndsWith(".sqlite") || cs.EndsWith(".sqlite3"))
340
+ return An5Dialect.Sqlite;
341
+ if (cs.StartsWith("postgres://") || cs.StartsWith("postgresql://") || cs.Contains("host="))
342
+ return An5Dialect.Postgres;
343
+ return An5Dialect.Mssql;
344
+ }
345
+
346
+ /// <summary>
347
+ /// Rewrites the accepted SQLite spellings into the one
348
+ /// Microsoft.Data.Sqlite reads, which is Data Source=.
349
+ /// </summary>
350
+ public static string NormalizeSqlite(string connectionString)
351
+ {
352
+ var cs = (connectionString ?? "").Trim();
353
+ if (cs.Length == 0) return "Data Source=:memory:";
354
+ if (cs.IndexOf("Data Source", StringComparison.OrdinalIgnoreCase) >= 0
355
+ || cs.IndexOf("DataSource", StringComparison.OrdinalIgnoreCase) >= 0
356
+ || cs.IndexOf("Mode", StringComparison.OrdinalIgnoreCase) >= 0)
357
+ return cs;
358
+ if (cs == ":memory:") return "Data Source=:memory:";
359
+ if (cs.StartsWith("sqlite://", StringComparison.OrdinalIgnoreCase))
360
+ return "Data Source=" + cs.Substring("sqlite://".Length);
361
+ if (cs.StartsWith("sqlite:", StringComparison.OrdinalIgnoreCase))
362
+ return "Data Source=" + cs.Substring("sqlite:".Length);
363
+ if (cs.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) return cs;
364
+ return "Data Source=" + cs;
365
+ }
366
+
367
+ public static DbConnection Open(string connectionString, An5Dialect dialect)
368
+ {
369
+ DbConnection conn = dialect switch
370
+ {
371
+ An5Dialect.Postgres => new Npgsql.NpgsqlConnection(connectionString),
372
+ An5Dialect.Sqlite => new Microsoft.Data.Sqlite.SqliteConnection(NormalizeSqlite(connectionString)),
373
+ _ => new Microsoft.Data.SqlClient.SqlConnection(connectionString)
374
+ };
375
+ conn.Open();
376
+ if (dialect == An5Dialect.Sqlite)
377
+ {
378
+ // WAL keeps readers off the writer's back, and SQLite leaves
379
+ // foreign keys off even when the schema declares them.
380
+ using var pragma = conn.CreateCommand();
381
+ pragma.CommandText = "PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;";
382
+ pragma.ExecuteNonQuery();
383
+ }
384
+ return conn;
385
+ }
386
+
387
+ /// <summary>
388
+ /// Adds a parameter. The name is stored without the @ prefix: Npgsql and
389
+ /// Sqlite treat a leading @ as part of the name, while SqlClient adds it
390
+ /// back, so stripping it is the only spelling all three agree on.
391
+ /// </summary>
392
+ public static DbParameter Bind(DbCommand cmd, string name, object value)
393
+ {
394
+ var p = cmd.CreateParameter();
395
+ p.ParameterName = (name ?? "").TrimStart('@');
396
+ p.Value = value ?? DBNull.Value;
397
+ cmd.Parameters.Add(p);
398
+ return p;
399
+ }
400
+
401
+ /// <summary>Quotes an identifier for the dialect.</summary>
402
+ public static string Quote(string name, An5Dialect dialect)
403
+ {
404
+ if (name.StartsWith("[") || name.StartsWith("\\"")) return name;
405
+ if (dialect == An5Dialect.Mssql)
406
+ return "[" + name.Replace("]", "]]") + "]";
407
+ return "\\"" + name.Replace("\\"", "\\"\\"") + "\\"";
408
+ }
409
+
410
+ /// <summary>Schema-less engines have no dbo prefix on the generated table names.</summary>
411
+ public static string TableName(string tableName, An5Dialect dialect)
412
+ {
413
+ if (dialect != An5Dialect.Sqlite) return tableName;
414
+ if (tableName.StartsWith("dbo.", StringComparison.OrdinalIgnoreCase))
415
+ return tableName.Substring(4);
416
+ return tableName;
417
+ }
418
+ }
419
+
298
420
  public class An5DbContext
299
421
  {
300
422
  public string ConnectionString { get; }
301
-
423
+ public An5Dialect Dialect { get; }
424
+
302
425
  [ThreadStatic]
303
- private static SqlConnection _txConn;
426
+ private static DbConnection _txConn;
304
427
  [ThreadStatic]
305
- private static SqlTransaction _tx;
428
+ private static DbTransaction _tx;
306
429
 
307
430
  public An5DbContext(string connectionString = null)
308
431
  {
309
432
  ConnectionString = connectionString ?? An5Config.ConnectionString;
433
+ Dialect = An5Provider.Detect(ConnectionString);
310
434
  }
311
435
 
312
436
  public An5Transaction BeginTransaction()
313
437
  {
314
- var conn = new SqlConnection(ConnectionString);
315
- conn.Open();
438
+ var conn = An5Provider.Open(ConnectionString, Dialect);
316
439
  var tx = conn.BeginTransaction();
317
440
  _txConn = conn;
318
441
  _tx = tx;
@@ -321,8 +444,8 @@ namespace An5Orm
321
444
  _tx = null;
322
445
  });
323
446
  }
324
-
325
- public static SqlConnection GetActiveConnection(string connectionString, out bool isTx)
447
+
448
+ public static DbConnection GetActiveConnection(string connectionString, out bool isTx)
326
449
  {
327
450
  if (_txConn != null)
328
451
  {
@@ -330,19 +453,17 @@ namespace An5Orm
330
453
  return _txConn;
331
454
  }
332
455
  isTx = false;
333
- var conn = new SqlConnection(connectionString);
334
- conn.Open();
335
- return conn;
456
+ return An5Provider.Open(connectionString, An5Provider.Detect(connectionString));
336
457
  }
337
-
338
- public static SqlTransaction GetActiveTransaction() => _tx;
458
+
459
+ public static DbTransaction GetActiveTransaction() => _tx;
339
460
 
340
461
  // ── Tables / Repositories ──────────────────────────────────────────────
341
462
  `;
342
463
 
343
464
  for (const model of models) {
344
465
  const name = this.capitalize(model.name);
345
- content += ` public TableClient<${model.name}> ${name}s => new TableClient<${model.name}>(ConnectionString, "${model.schemaName}.${model.tableName}");\n`;
466
+ content += ` public TableClient<${model.name}> ${name}s => new TableClient<${model.name}>(ConnectionString, "${dottedTableName(model)}");\n`;
346
467
  content += ` public TableClient<${model.name}> ${name} => ${name}s;\n`;
347
468
  }
348
469
 
@@ -350,12 +471,12 @@ namespace An5Orm
350
471
 
351
472
  public class An5Transaction : IDisposable
352
473
  {
353
- private readonly SqlConnection _conn;
354
- private readonly SqlTransaction _tx;
474
+ private readonly DbConnection _conn;
475
+ private readonly DbTransaction _tx;
355
476
  private readonly Action _cleanup;
356
477
  private bool _completed;
357
478
 
358
- public An5Transaction(SqlConnection conn, SqlTransaction tx, Action cleanup)
479
+ public An5Transaction(DbConnection conn, DbTransaction tx, Action cleanup)
359
480
  {
360
481
  _conn = conn;
361
482
  _tx = tx;
@@ -390,16 +511,21 @@ namespace An5Orm
390
511
  {
391
512
  public string ConnectionString { get; }
392
513
  public string TableName { get; }
514
+ public An5Dialect Dialect { get; }
393
515
 
394
516
  public TableClient(string connectionString, string tableName)
395
517
  {
396
518
  ConnectionString = connectionString;
397
- TableName = tableName;
519
+ Dialect = An5Provider.Detect(connectionString);
520
+ // SQLite has no schemas, so the generated dbo. prefix would be part
521
+ // of the table name and match nothing.
522
+ TableName = An5Provider.TableName(tableName, Dialect);
398
523
  }
399
524
 
400
- private SqlCommand CreateCommand(SqlConnection conn, string query)
525
+ private DbCommand CreateCommand(DbConnection conn, string query)
401
526
  {
402
- var cmd = new SqlCommand(query, conn);
527
+ var cmd = conn.CreateCommand();
528
+ cmd.CommandText = query;
403
529
  var activeTx = An5DbContext.GetActiveTransaction();
404
530
  if (activeTx != null)
405
531
  {
@@ -420,7 +546,7 @@ namespace An5Orm
420
546
  {
421
547
  foreach (var kvp in parameters)
422
548
  {
423
- cmd.Parameters.AddWithValue(kvp.Key.StartsWith("@") ? kvp.Key : "@" + kvp.Key, kvp.Value ?? DBNull.Value);
549
+ An5Provider.Bind(cmd, kvp.Key, kvp.Value);
424
550
  }
425
551
  }
426
552
 
@@ -437,7 +563,7 @@ namespace An5Orm
437
563
  var val = reader[prop.Name];
438
564
  if (val != DBNull.Value)
439
565
  {
440
- prop.SetValue(item, val);
566
+ SetValue(prop, item, val);
441
567
  }
442
568
  }
443
569
  }
@@ -471,7 +597,7 @@ namespace An5Orm
471
597
  {
472
598
  foreach (var kvp in parameters)
473
599
  {
474
- cmd.Parameters.AddWithValue(kvp.Key.StartsWith("@") ? kvp.Key : "@" + kvp.Key, kvp.Value ?? DBNull.Value);
600
+ An5Provider.Bind(cmd, kvp.Key, kvp.Value);
475
601
  }
476
602
  }
477
603
 
@@ -488,7 +614,7 @@ namespace An5Orm
488
614
  var val = reader[prop.Name];
489
615
  if (val != DBNull.Value)
490
616
  {
491
- prop.SetValue(item, val);
617
+ SetValue(prop, item, val);
492
618
  }
493
619
  }
494
620
  }
@@ -506,11 +632,19 @@ namespace An5Orm
506
632
 
507
633
  public T FindFirst(string whereClause = null, Dictionary<string, object> parameters = null)
508
634
  {
509
- string query = $"SELECT TOP 1 * FROM {TableName}";
635
+ // TOP goes before the table, LIMIT after the WHERE — appending LIMIT
636
+ // to the SELECT would put it ahead of the predicate.
637
+ string query = Dialect == An5Dialect.Mssql
638
+ ? $"SELECT TOP 1 * FROM {TableName}"
639
+ : $"SELECT * FROM {TableName}";
510
640
  if (!string.IsNullOrEmpty(whereClause))
511
641
  {
512
642
  query += $" WHERE {whereClause}";
513
643
  }
644
+ if (Dialect != An5Dialect.Mssql)
645
+ {
646
+ query += " LIMIT 1";
647
+ }
514
648
 
515
649
  var conn = An5DbContext.GetActiveConnection(ConnectionString, out bool isTx);
516
650
  try
@@ -521,7 +655,7 @@ namespace An5Orm
521
655
  {
522
656
  foreach (var kvp in parameters)
523
657
  {
524
- cmd.Parameters.AddWithValue(kvp.Key.StartsWith("@") ? kvp.Key : "@" + kvp.Key, kvp.Value ?? DBNull.Value);
658
+ An5Provider.Bind(cmd, kvp.Key, kvp.Value);
525
659
  }
526
660
  }
527
661
 
@@ -538,7 +672,7 @@ namespace An5Orm
538
672
  var val = reader[prop.Name];
539
673
  if (val != DBNull.Value)
540
674
  {
541
- prop.SetValue(item, val);
675
+ SetValue(prop, item, val);
542
676
  }
543
677
  }
544
678
  }
@@ -564,7 +698,9 @@ namespace An5Orm
564
698
  var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
565
699
  var columns = new List<string>();
566
700
  var values = new List<string>();
567
- var sqlParams = new List<SqlParameter>();
701
+ // Collected before the command exists; the provider creates the
702
+ // concrete parameter type, so binding has to wait for the command.
703
+ var sqlParams = new List<(string Name, object Value)>();
568
704
 
569
705
  foreach (var prop in properties)
570
706
  {
@@ -573,7 +709,7 @@ namespace An5Orm
573
709
  {
574
710
  columns.Add(prop.Name);
575
711
  values.Add("@" + prop.Name);
576
- sqlParams.Add(new SqlParameter("@" + prop.Name, val));
712
+ sqlParams.Add((prop.Name, val));
577
713
  }
578
714
  }
579
715
 
@@ -583,7 +719,7 @@ namespace An5Orm
583
719
  {
584
720
  using (var cmd = CreateCommand(conn, query))
585
721
  {
586
- cmd.Parameters.AddRange(sqlParams.ToArray());
722
+ foreach (var (name, value) in sqlParams) An5Provider.Bind(cmd, name, value);
587
723
  cmd.ExecuteNonQuery();
588
724
  if (!isTx)
589
725
  {
@@ -602,7 +738,9 @@ namespace An5Orm
602
738
  {
603
739
  var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
604
740
  var sets = new List<string>();
605
- var sqlParams = new List<SqlParameter>();
741
+ // Collected before the command exists; the provider creates the
742
+ // concrete parameter type, so binding has to wait for the command.
743
+ var sqlParams = new List<(string Name, object Value)>();
606
744
  object idVal = null;
607
745
 
608
746
  foreach (var prop in properties)
@@ -615,7 +753,7 @@ namespace An5Orm
615
753
  else if (val != null)
616
754
  {
617
755
  sets.Add($"{prop.Name} = @{prop.Name}");
618
- sqlParams.Add(new SqlParameter("@" + prop.Name, val));
756
+ sqlParams.Add((prop.Name, val));
619
757
  }
620
758
  }
621
759
 
@@ -624,14 +762,14 @@ namespace An5Orm
624
762
  throw new InvalidOperationException("Cannot update entity without Id");
625
763
  }
626
764
 
627
- sqlParams.Add(new SqlParameter("@id", idVal));
765
+ sqlParams.Add(("id", idVal));
628
766
  string query = $"UPDATE {TableName} SET {string.Join(", ", sets)} WHERE Id = @id";
629
767
  var conn = An5DbContext.GetActiveConnection(ConnectionString, out bool isTx);
630
768
  try
631
769
  {
632
770
  using (var cmd = CreateCommand(conn, query))
633
771
  {
634
- cmd.Parameters.AddRange(sqlParams.ToArray());
772
+ foreach (var (name, value) in sqlParams) An5Provider.Bind(cmd, name, value);
635
773
  cmd.ExecuteNonQuery();
636
774
  }
637
775
  }
@@ -650,7 +788,7 @@ namespace An5Orm
650
788
  {
651
789
  using (var cmd = CreateCommand(conn, query))
652
790
  {
653
- cmd.Parameters.AddWithValue("@id", id);
791
+ An5Provider.Bind(cmd, "id", id);
654
792
  int affected = cmd.ExecuteNonQuery();
655
793
  return affected > 0;
656
794
  }
@@ -673,7 +811,7 @@ namespace An5Orm
673
811
  if (parameters != null)
674
812
  {
675
813
  foreach (var kvp in parameters)
676
- cmd.Parameters.AddWithValue(kvp.Key.StartsWith("@") ? kvp.Key : "@" + kvp.Key, kvp.Value ?? DBNull.Value);
814
+ An5Provider.Bind(cmd, kvp.Key, kvp.Value);
677
815
  }
678
816
  var res = cmd.ExecuteScalar();
679
817
  return res != null && res != DBNull.Value ? Convert.ToInt32(res) : 0;
@@ -697,13 +835,15 @@ namespace An5Orm
697
835
  {
698
836
  if (updateData == null || updateData.Count == 0) return 0;
699
837
  var sets = new List<string>();
700
- var sqlParams = new List<SqlParameter>();
838
+ // Bound after the command exists, so the provider can create the
839
+ // right parameter type; a value tuple carries (name, value) until then.
840
+ var sqlParams = new List<(string Name, object Value)>();
701
841
  int pIndex = 0;
702
842
  foreach (var kvp in updateData)
703
843
  {
704
844
  string paramName = "@u_" + pIndex++;
705
845
  sets.Add($"{kvp.Key} = {paramName}");
706
- sqlParams.Add(new SqlParameter(paramName, kvp.Value ?? DBNull.Value));
846
+ sqlParams.Add((paramName, kvp.Value));
707
847
  }
708
848
  string query = $"UPDATE {TableName} SET {string.Join(", ", sets)}";
709
849
  if (!string.IsNullOrEmpty(whereClause)) query += $" WHERE {whereClause}";
@@ -712,11 +852,11 @@ namespace An5Orm
712
852
  {
713
853
  using (var cmd = CreateCommand(conn, query))
714
854
  {
715
- cmd.Parameters.AddRange(sqlParams.ToArray());
855
+ foreach (var (name, value) in sqlParams) An5Provider.Bind(cmd, name, value);
716
856
  if (parameters != null)
717
857
  {
718
858
  foreach (var kvp in parameters)
719
- cmd.Parameters.AddWithValue(kvp.Key.StartsWith("@") ? kvp.Key : "@" + kvp.Key, kvp.Value ?? DBNull.Value);
859
+ An5Provider.Bind(cmd, kvp.Key, kvp.Value);
720
860
  }
721
861
  return cmd.ExecuteNonQuery();
722
862
  }
@@ -736,7 +876,7 @@ namespace An5Orm
736
876
  if (parameters != null)
737
877
  {
738
878
  foreach (var kvp in parameters)
739
- cmd.Parameters.AddWithValue(kvp.Key.StartsWith("@") ? kvp.Key : "@" + kvp.Key, kvp.Value ?? DBNull.Value);
879
+ An5Provider.Bind(cmd, kvp.Key, kvp.Value);
740
880
  }
741
881
  return cmd.ExecuteNonQuery();
742
882
  }
@@ -756,28 +896,44 @@ namespace An5Orm
756
896
 
757
897
  public List<T> VectorSearch(List<double> vector, int take = 10, string whereClause = null, Dictionary<string, object> parameters = null, string vectorField = "Embedding", string distanceMetric = "cosine")
758
898
  {
759
- // 1. Primary path: Native database SQL vector query execution (VECTOR_DISTANCE)
899
+ // 1. Primary path: native vector query. SQL Server has VECTOR_DISTANCE
900
+ // and Postgres has pgvector; SQLite has no vector operator, so it is
901
+ // skipped outright rather than issuing SQL that can only fail and
902
+ // cost a round trip before falling through.
903
+ if (Dialect != An5Dialect.Sqlite)
760
904
  try
761
905
  {
762
906
  var dim = vector.Count;
763
907
  var vecJson = JsonSerializer.Serialize(vector);
764
- 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)";
908
+ var field = An5Provider.Quote(vectorField, Dialect);
909
+ string sql;
910
+ if (Dialect == An5Dialect.Postgres)
911
+ {
912
+ var op = distanceMetric.Equals("cosine", StringComparison.OrdinalIgnoreCase) ? "<=>"
913
+ : (distanceMetric.Equals("euclidean", StringComparison.OrdinalIgnoreCase) ? "<->" : "<#>");
914
+ sql = $"SELECT *, ({field} {op} @query_vector::vector) AS distance FROM {TableName}";
915
+ }
916
+ else
917
+ {
918
+ 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)";
919
+ }
765
920
 
766
921
  var p = parameters != null ? new Dictionary<string, object>(parameters) : new Dictionary<string, object>();
767
922
  p["query_vector"] = vecJson;
768
923
 
769
924
  if (!string.IsNullOrWhiteSpace(whereClause))
770
- sql += $" WHERE [{vectorField}] IS NOT NULL AND ({whereClause})";
925
+ sql += $" WHERE {field} IS NOT NULL AND ({whereClause})";
771
926
  else
772
- sql += $" WHERE [{vectorField}] IS NOT NULL";
927
+ sql += $" WHERE {field} IS NOT NULL";
773
928
  sql += " ORDER BY distance ASC";
929
+ if (Dialect == An5Dialect.Postgres) sql += $" LIMIT {take}";
774
930
 
775
931
  var nativeRows = QueryRaw(sql, p);
776
932
  if (nativeRows != null) return nativeRows;
777
933
  }
778
934
  catch
779
935
  {
780
- // Fallback to in-memory similarity computation if DB instance lacks native VECTOR_DISTANCE
936
+ // Fallback to in-memory similarity computation if the database has no native vector support
781
937
  }
782
938
 
783
939
  // 2. Secondary fallback: In-memory similarity computation
@@ -859,7 +1015,40 @@ namespace An5Orm
859
1015
  return dot / (Math.Sqrt(m1) * Math.Sqrt(m2));
860
1016
  }
861
1017
 
862
- private bool HasColumn(SqlDataReader reader, string columnName)
1018
+ /// <summary>
1019
+ /// Assigns a column to a property, converting when the provider's CLR
1020
+ /// type does not match the property.
1021
+ ///
1022
+ /// Required across dialects, not just for SQLite: SQLite hands back
1023
+ /// dates as TEXT and every integer as Int64, so a DateTime or int
1024
+ /// property would otherwise fail to set. A value that cannot be
1025
+ /// converted is skipped rather than aborting the whole read.
1026
+ /// </summary>
1027
+ private static void SetValue(System.Reflection.PropertyInfo prop, object target, object value)
1028
+ {
1029
+ var wanted = prop.PropertyType;
1030
+ if (wanted.IsInstanceOfType(value))
1031
+ {
1032
+ prop.SetValue(target, value);
1033
+ return;
1034
+ }
1035
+ try
1036
+ {
1037
+ var underlying = Nullable.GetUnderlyingType(wanted);
1038
+ if (underlying != null && value == null) { prop.SetValue(target, null); return; }
1039
+ if (underlying != null)
1040
+ {
1041
+ prop.SetValue(target, Convert.ChangeType(value, underlying));
1042
+ return;
1043
+ }
1044
+ if (value is string text && wanted == typeof(Guid)) { prop.SetValue(target, Guid.Parse(text)); return; }
1045
+ if (value is string when && wanted == typeof(DateTime)) { prop.SetValue(target, DateTime.Parse(when, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind)); return; }
1046
+ prop.SetValue(target, Convert.ChangeType(value, wanted));
1047
+ }
1048
+ catch { }
1049
+ }
1050
+
1051
+ private bool HasColumn(DbDataReader reader, string columnName)
863
1052
  {
864
1053
  for (int i = 0; i < reader.FieldCount; i++)
865
1054
  {