@an5/orm 1.0.9 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -3
- package/dist/config.d.ts +9 -1
- package/dist/config.js +13 -18
- package/dist/generator/src/api.d.ts +35 -0
- package/dist/generator/src/api.js +58 -0
- package/dist/generator/src/config.d.ts +162 -0
- package/dist/generator/src/config.js +354 -0
- package/dist/generator/src/dialect.d.ts +88 -0
- package/dist/generator/src/dialect.js +318 -0
- package/dist/generator/src/dotnet-generator.d.ts +8 -0
- package/dist/generator/src/dotnet-generator.js +282 -101
- package/dist/generator/src/field-types.d.ts +96 -0
- package/dist/generator/src/field-types.js +433 -0
- package/dist/generator/src/golang-generator.d.ts +36 -0
- package/dist/generator/src/golang-generator.js +1229 -0
- package/dist/generator/src/index.js +69 -36
- package/dist/generator/src/metadata-generator.js +2 -1
- package/dist/generator/src/parser.d.ts +28 -2
- package/dist/generator/src/parser.js +92 -90
- package/dist/generator/src/push-apply.d.ts +37 -0
- package/dist/generator/src/push-apply.js +109 -0
- package/dist/generator/src/push-schema.d.ts +93 -0
- package/dist/generator/src/push-schema.js +189 -0
- package/dist/generator/src/python-generator.d.ts +19 -1
- package/dist/generator/src/python-generator.js +147 -64
- package/dist/generator/src/rust-generator.d.ts +38 -0
- package/dist/generator/src/rust-generator.js +971 -0
- package/dist/generator/src/suggest.d.ts +17 -0
- package/dist/generator/src/suggest.js +45 -0
- package/dist/generator/src/type-kinds.d.ts +27 -0
- package/dist/generator/src/type-kinds.js +72 -0
- package/dist/generator/src/types.d.ts +33 -0
- package/dist/generator/src/types.js +39 -0
- package/dist/migration-core.d.ts +16 -1
- package/dist/migration-core.js +60 -21
- package/dist/orm.d.ts +147 -0
- package/dist/orm.js +1654 -0
- package/dist/provider-support.d.ts +27 -0
- package/dist/provider-support.js +50 -0
- package/generator/src/api.ts +45 -0
- package/generator/src/config.ts +496 -0
- package/generator/src/dialect.ts +415 -0
- package/generator/src/dotnet-generator.ts +277 -88
- package/generator/src/field-types.ts +473 -0
- package/generator/src/golang-generator.ts +89 -64
- package/generator/src/index.ts +71 -36
- package/generator/src/metadata-generator.ts +2 -2
- package/generator/src/parser.ts +122 -90
- package/generator/src/push-apply.ts +148 -0
- package/generator/src/push-schema.ts +250 -0
- package/generator/src/python-generator.ts +118 -62
- package/generator/src/rust-generator.ts +974 -0
- package/generator/src/suggest.ts +41 -0
- package/generator/src/type-kinds.ts +78 -0
- package/generator/src/types.ts +48 -0
- 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(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
csType = '
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
csType = '
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
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
|
|
426
|
+
private static DbConnection _txConn;
|
|
304
427
|
[ThreadStatic]
|
|
305
|
-
private static
|
|
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 =
|
|
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
|
|
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
|
-
|
|
334
|
-
conn.Open();
|
|
335
|
-
return conn;
|
|
456
|
+
return An5Provider.Open(connectionString, An5Provider.Detect(connectionString));
|
|
336
457
|
}
|
|
337
|
-
|
|
338
|
-
public static
|
|
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
|
|
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
|
|
354
|
-
private readonly
|
|
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(
|
|
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
|
-
|
|
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
|
|
525
|
+
private DbCommand CreateCommand(DbConnection conn, string query)
|
|
401
526
|
{
|
|
402
|
-
var cmd =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
925
|
+
sql += $" WHERE {field} IS NOT NULL AND ({whereClause})";
|
|
771
926
|
else
|
|
772
|
-
sql += $" WHERE
|
|
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
|
|
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
|
-
|
|
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
|
{
|