@an5/adapters 0.2.5 → 0.2.9

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 (99) hide show
  1. package/README.md +283 -305
  2. package/dist/an5Adapter.d.ts +142 -7
  3. package/dist/an5Adapter.js +998 -68
  4. package/dist/base/metadata.d.ts +10 -0
  5. package/dist/base/metadata.js +107 -0
  6. package/dist/base/sql.d.ts +19 -1
  7. package/dist/base/sql.js +152 -24
  8. package/dist/base/types.d.ts +10 -1
  9. package/dist/browser.d.ts +4 -1
  10. package/dist/browser.js +9 -2
  11. package/dist/googlesheets/adapter.d.ts +1 -0
  12. package/dist/googlesheets/adapter.js +9 -2
  13. package/dist/googlesheets/config.d.ts +4 -4
  14. package/dist/googlesheets/helpers.js +2 -0
  15. package/dist/googlesheets/parseConnectionString.js +5 -2
  16. package/dist/googlesheets/sqlExecutor.js +29 -24
  17. package/dist/googlesheets/tableClient.js +14 -5
  18. package/dist/index.d.ts +5 -1
  19. package/dist/index.js +15 -1
  20. package/dist/mssql/engine.js +8 -4
  21. package/dist/mysql/engine.js +1 -1
  22. package/dist/nbase/client.d.ts +92 -0
  23. package/dist/nbase/client.js +147 -0
  24. package/dist/nbase/index.d.ts +47 -0
  25. package/dist/nbase/index.js +80 -0
  26. package/dist/postgres/engine.js +1 -1
  27. package/dist/sqlite/browserEngine.d.ts +27 -0
  28. package/dist/sqlite/browserEngine.js +161 -0
  29. package/dist/sqlite/engine.js +15 -3
  30. package/dist/sqlite/index.d.ts +1 -0
  31. package/dist/sqlite/index.js +1 -0
  32. package/dist/typed.d.ts +5 -0
  33. package/dist/typed.js +2 -0
  34. package/dotnet/An5Adapter.cs +1112 -275
  35. package/dotnet/Base/Core.cs +380 -82
  36. package/dotnet/Mssql/MssqlEngine.cs +145 -144
  37. package/dotnet/Postgres/PostgresEngine.cs +149 -121
  38. package/dotnet/Sqlite/SqliteEngine.cs +214 -0
  39. package/golang/an5_adapter.go +314 -0
  40. package/golang/base/base.go +100 -0
  41. package/golang/base/metadata.go +116 -0
  42. package/golang/base/where.go +311 -0
  43. package/golang/go.mod +3 -0
  44. package/golang/mssql/mssql.go +32 -0
  45. package/golang/postgres/postgres.go +37 -0
  46. package/golang/sqlite/sqlite.go +14 -0
  47. package/golang/sqlite_test.go +42 -0
  48. package/golang/table_client.go +1329 -0
  49. package/package.json +26 -9
  50. package/python/an5_adapter.py +184 -97
  51. package/python/base/__init__.py +3 -3
  52. package/python/base/dialects.py +11 -8
  53. package/python/base/metadata.py +59 -10
  54. package/python/base/sql.py +202 -121
  55. package/python/mssql/__init__.py +1 -1
  56. package/python/mssql/provider.py +62 -62
  57. package/python/postgres/__init__.py +1 -1
  58. package/python/postgres/provider.py +15 -15
  59. package/python/sqlite/__init__.py +3 -0
  60. package/python/sqlite/provider.py +83 -0
  61. package/python/table_client.py +636 -216
  62. package/rust/Cargo.toml +31 -0
  63. package/rust/README.md +71 -0
  64. package/rust/src/adapter.rs +1312 -0
  65. package/rust/src/base/dialect.rs +172 -0
  66. package/rust/src/base/metadata.rs +134 -0
  67. package/rust/src/base/mod.rs +17 -0
  68. package/rust/src/base/where_builder.rs +405 -0
  69. package/rust/src/lib.rs +75 -0
  70. package/typescript/src/an5Adapter.ts +1515 -505
  71. package/typescript/src/base/index.ts +3 -3
  72. package/typescript/src/base/metadata.ts +148 -28
  73. package/typescript/src/base/sql.ts +264 -98
  74. package/typescript/src/base/types.ts +33 -24
  75. package/typescript/src/browser.ts +10 -6
  76. package/typescript/src/config.ts +95 -95
  77. package/typescript/src/googlesheets/adapter.ts +269 -259
  78. package/typescript/src/googlesheets/an5SheetsAdapter.ts +3 -3
  79. package/typescript/src/googlesheets/config.ts +47 -47
  80. package/typescript/src/googlesheets/helpers.ts +144 -143
  81. package/typescript/src/googlesheets/index.ts +3 -3
  82. package/typescript/src/googlesheets/parseConnectionString.ts +48 -46
  83. package/typescript/src/googlesheets/retry.ts +126 -126
  84. package/typescript/src/googlesheets/sqlExecutor.ts +304 -301
  85. package/typescript/src/googlesheets/tableClient.ts +461 -455
  86. package/typescript/src/googlesheets/types.ts +6 -6
  87. package/typescript/src/index.ts +48 -27
  88. package/typescript/src/mssql/engine.ts +134 -132
  89. package/typescript/src/mssql/index.ts +1 -1
  90. package/typescript/src/mysql/engine.ts +80 -80
  91. package/typescript/src/mysql/index.ts +1 -1
  92. package/typescript/src/nbase/client.ts +214 -0
  93. package/typescript/src/nbase/index.ts +104 -0
  94. package/typescript/src/postgres/engine.ts +87 -87
  95. package/typescript/src/postgres/index.ts +1 -1
  96. package/typescript/src/sqlite/browserEngine.ts +192 -0
  97. package/typescript/src/sqlite/engine.ts +66 -55
  98. package/typescript/src/sqlite/index.ts +3 -1
  99. package/typescript/src/typed.ts +52 -0
@@ -1,275 +1,1112 @@
1
- // An5Adapter.cs
2
- // Standalone .NET runtime adapter for AN5 ORM.
3
-
4
- using System;
5
- using System.Collections.Generic;
6
- using System.Reflection;
7
- using System.Text;
8
- using System.Text.Json;
9
-
10
- namespace An5Orm
11
- {
12
- // ─── Adapter ────────────────────────────────────────────────────────────────
13
-
14
- public class An5Adapter : IDisposable
15
- {
16
- public string ConnectionString { get; }
17
- public Dialect Dialect => _engine.Dialect;
18
- private readonly IQueryEngine _engine;
19
- private readonly int _commandTimeout;
20
-
21
- public An5Adapter(string connectionString, int commandTimeout = 60)
22
- {
23
- ConnectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
24
- _commandTimeout = commandTimeout;
25
- _engine = DialectDetector.Detect(connectionString) switch
26
- {
27
- Dialect.Postgres => new PostgresEngine(connectionString, commandTimeout),
28
- _ => new MssqlEngine(connectionString, commandTimeout)
29
- };
30
- }
31
-
32
- public An5Adapter(An5AdapterOptions options) : this(options.ConnectionString, options.CommandTimeout) { }
33
-
34
- // ── Raw query execution ────────────────────────────────────────────────
35
-
36
- public List<Dictionary<string, object>> QueryRaw(string sql, Dictionary<string, object> parameters = null)
37
- => _engine.QueryRaw(sql, parameters);
38
-
39
- public List<T> QueryRaw<T>(string sql, Dictionary<string, object> parameters = null) where T : new()
40
- => _engine.QueryRaw<T>(sql, parameters);
41
-
42
- public int ExecuteRaw(string sql, Dictionary<string, object> parameters = null)
43
- => _engine.ExecuteRaw(sql, parameters);
44
-
45
- // ── Transaction ────────────────────────────────────────────────────────
46
-
47
- public An5Transaction BeginTransaction()
48
- {
49
- return new An5Transaction(_engine.BeginTransaction());
50
- }
51
-
52
- public TResult Transaction<TResult>(Func<An5Adapter, TResult> fn)
53
- {
54
- using var txScope = _engine.BeginTransaction();
55
- try
56
- {
57
- var result = fn(this);
58
- txScope.Commit();
59
- return result;
60
- }
61
- catch
62
- {
63
- txScope.Rollback();
64
- throw;
65
- }
66
- }
67
-
68
- // ── Table client factory ───────────────────────────────────────────────
69
-
70
- public AdapterTableClient<T> Table<T>(string tableName) where T : new()
71
- => new AdapterTableClient<T>(this, tableName, _engine.Dialect);
72
-
73
- public void Dispose() { }
74
- }
75
-
76
- // ─── Transaction wrapper ───────────────────────────────────────────────────
77
-
78
- public class An5Transaction : IDisposable
79
- {
80
- private readonly An5TransactionBase _inner;
81
-
82
- internal An5Transaction(An5TransactionBase inner) => _inner = inner;
83
-
84
- public void Commit() => _inner.Commit();
85
- public void Rollback() => _inner.Rollback();
86
- public void Dispose() => _inner.Dispose();
87
- }
88
-
89
- // ─── Typed Table Client ────────────────────────────────────────────────────
90
-
91
- public class AdapterTableClient<T> where T : new()
92
- {
93
- private readonly An5Adapter _adapter;
94
- private readonly string _tableName;
95
- private readonly Dialect _dialect;
96
-
97
- public AdapterTableClient(An5Adapter adapter, string tableName, Dialect dialect)
98
- {
99
- _adapter = adapter;
100
- _tableName = SqlQuote.QuoteTable(tableName, dialect);
101
- _dialect = dialect;
102
- }
103
-
104
- private string Nolock => _dialect == Dialect.Mssql ? " WITH (NOLOCK)" : "";
105
-
106
- private string BuildPagination(string orderBy, int? skip, int? take)
107
- {
108
- if (take == null) return "";
109
- if (_dialect == Dialect.Postgres)
110
- return $" LIMIT {take.Value} OFFSET {skip ?? 0}";
111
- var o = !string.IsNullOrEmpty(orderBy) ? $" ORDER BY {orderBy}" : " ORDER BY (SELECT NULL)";
112
- return $"{o} OFFSET {skip ?? 0} ROWS FETCH NEXT {take.Value} ROWS ONLY";
113
- }
114
-
115
- public List<T> FindMany(string whereClause = null, Dictionary<string, object> parameters = null,
116
- string orderBy = null, int? skip = null, int? take = null)
117
- {
118
- var sb = new StringBuilder($"SELECT * FROM {_tableName}{Nolock}");
119
- if (!string.IsNullOrEmpty(whereClause)) sb.Append($" WHERE {whereClause}");
120
- if (take != null && _dialect == Dialect.Postgres)
121
- {
122
- if (!string.IsNullOrEmpty(orderBy)) sb.Append($" ORDER BY {orderBy}");
123
- sb.Append(BuildPagination(orderBy, skip, take));
124
- }
125
- else
126
- {
127
- if (!string.IsNullOrEmpty(orderBy)) sb.Append($" ORDER BY {orderBy}");
128
- sb.Append(BuildPagination(orderBy, skip, take));
129
- }
130
- return _adapter.QueryRaw<T>(sb.ToString(), parameters);
131
- }
132
-
133
- public T FindFirst(string whereClause = null, Dictionary<string, object> parameters = null, string orderBy = null)
134
- {
135
- var rows = FindMany(whereClause, parameters, orderBy, skip: null, take: 1);
136
- return rows.Count > 0 ? rows[0] : default;
137
- }
138
-
139
- public T FindUnique(object id, string idColumnName = "Id")
140
- {
141
- return FindFirst($"{SqlQuote.QuoteName(idColumnName, _dialect)} = @id",
142
- new Dictionary<string, object> { { "id", id } });
143
- }
144
-
145
- public int Count(string whereClause = null, Dictionary<string, object> parameters = null)
146
- {
147
- var sql = $"SELECT COUNT(*) FROM {_tableName}";
148
- if (!string.IsNullOrEmpty(whereClause)) sql += $" WHERE {whereClause}";
149
- var rows = _adapter.QueryRaw(sql, parameters);
150
- if (rows.Count > 0)
151
- {
152
- var val = rows[0].Values.GetEnumerator();
153
- val.MoveNext();
154
- return Convert.ToInt32(val.Current);
155
- }
156
- return 0;
157
- }
158
-
159
- public T Create(T entity)
160
- {
161
- var props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
162
- var cols = new List<string>();
163
- var vals = new List<string>();
164
- var parameters = new Dictionary<string, object>();
165
-
166
- foreach (var prop in props)
167
- {
168
- var val = prop.GetValue(entity);
169
- if (val == null) continue;
170
- cols.Add(SqlQuote.QuoteName(prop.Name, _dialect));
171
- vals.Add("@" + prop.Name);
172
- parameters["@" + prop.Name] = val;
173
- }
174
-
175
- var sql = $"INSERT INTO {_tableName} ({string.Join(", ", cols)}) VALUES ({string.Join(", ", vals)})";
176
- _adapter.ExecuteRaw(sql, parameters);
177
- return entity;
178
- }
179
-
180
- public T Update(T entity, string idColumnName = "Id")
181
- {
182
- var props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
183
- var sets = new List<string>();
184
- var parameters = new Dictionary<string, object>();
185
- object idVal = null;
186
-
187
- foreach (var prop in props)
188
- {
189
- var val = prop.GetValue(entity);
190
- if (prop.Name.Equals(idColumnName, StringComparison.OrdinalIgnoreCase))
191
- {
192
- idVal = val;
193
- }
194
- else if (val != null)
195
- {
196
- sets.Add($"{SqlQuote.QuoteName(prop.Name, _dialect)} = @{prop.Name}");
197
- parameters["@" + prop.Name] = val;
198
- }
199
- }
200
-
201
- if (idVal == null) throw new InvalidOperationException($"Cannot update entity without {idColumnName}");
202
- parameters["@_id"] = idVal;
203
- var quotedIdCol = SqlQuote.QuoteName(idColumnName, _dialect);
204
- var sql = $"UPDATE {_tableName} SET {string.Join(", ", sets)} WHERE {quotedIdCol} = @_id";
205
- _adapter.ExecuteRaw(sql, parameters);
206
- return entity;
207
- }
208
-
209
- public bool Delete(object id, string idColumnName = "Id")
210
- {
211
- var quotedIdCol = SqlQuote.QuoteName(idColumnName, _dialect);
212
- var sql = $"DELETE FROM {_tableName} WHERE {quotedIdCol} = @id";
213
- var n = _adapter.ExecuteRaw(sql, new Dictionary<string, object> { { "@id", id } });
214
- return n > 0;
215
- }
216
-
217
- public int DeleteMany(string whereClause = null, Dictionary<string, object> parameters = null)
218
- {
219
- var sql = $"DELETE FROM {_tableName}";
220
- if (!string.IsNullOrEmpty(whereClause)) sql += $" WHERE {whereClause}";
221
- return _adapter.ExecuteRaw(sql, parameters);
222
- }
223
-
224
- public T Upsert(T entity, string idColumnName = "Id")
225
- {
226
- var idProp = typeof(T).GetProperty(idColumnName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
227
- if (idProp == null) throw new InvalidOperationException($"Property {idColumnName} not found on {typeof(T).Name}");
228
- var idVal = idProp.GetValue(entity);
229
- var existing = idVal != null ? FindUnique(idVal, idColumnName) : default;
230
- if (existing != null) return Update(entity, idColumnName);
231
- return Create(entity);
232
- }
233
-
234
- // ── Vector Search ──────────────────────────────────────────────────────
235
-
236
- public List<(T Item, double Distance)> VectorSearch(
237
- List<double> vector, int take = 10,
238
- string whereClause = null, Dictionary<string, object> parameters = null,
239
- string vectorField = "Embedding", string distanceMetric = "cosine")
240
- {
241
- var rows = FindMany(whereClause, parameters);
242
- var results = new List<(T, double)>();
243
- var vectorProp = typeof(T).GetProperty(vectorField, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
244
- if (vectorProp == null) return results;
245
-
246
- foreach (var row in rows)
247
- {
248
- var rawVal = vectorProp.GetValue(row);
249
- if (rawVal == null) continue;
250
- List<double> vec = null;
251
- try
252
- {
253
- if (rawVal is string s) vec = JsonSerializer.Deserialize<List<double>>(s);
254
- }
255
- catch { continue; }
256
- if (vec == null || vec.Count != vector.Count) continue;
257
-
258
- double dot = 0, m1 = 0, m2 = 0;
259
- for (int i = 0; i < vector.Count; i++)
260
- {
261
- dot += vector[i] * vec[i];
262
- m1 += vector[i] * vector[i];
263
- m2 += vec[i] * vec[i];
264
- }
265
- double cosine = (m1 > 0 && m2 > 0) ? dot / (Math.Sqrt(m1) * Math.Sqrt(m2)) : 0;
266
- double dist = distanceMetric == "cosine" ? 1.0 - cosine :
267
- distanceMetric == "dot" ? -dot : Math.Sqrt(vector.Count);
268
- results.Add((row, dist));
269
- }
270
-
271
- results.Sort((a, b) => a.Item2.CompareTo(b.Item2));
272
- return results.GetRange(0, Math.Min(take, results.Count));
273
- }
274
- }
275
- }
1
+ // An5Adapter.cs
2
+ // Standalone .NET runtime adapter for AN5 ORM.
3
+
4
+ using System;
5
+ using System.Collections.Generic;
6
+ using System.Reflection;
7
+ using System.Text;
8
+ using System.Text.Json;
9
+
10
+ namespace An5Orm
11
+ {
12
+ // ─── Adapter ────────────────────────────────────────────────────────────────
13
+
14
+ public class An5Adapter : IDisposable
15
+ {
16
+ public string ConnectionString { get; }
17
+ public Dialect Dialect => _engine.Dialect;
18
+ private readonly IQueryEngine _engine;
19
+ private readonly int _commandTimeout;
20
+
21
+ public An5Adapter(string connectionString, int commandTimeout = 60)
22
+ {
23
+ ConnectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
24
+ _commandTimeout = commandTimeout;
25
+ _engine = DialectDetector.Detect(connectionString) switch
26
+ {
27
+ Dialect.Postgres => new PostgresEngine(connectionString, commandTimeout),
28
+ Dialect.Sqlite => new SqliteEngine(connectionString, commandTimeout),
29
+ _ => new MssqlEngine(connectionString, commandTimeout)
30
+ };
31
+ }
32
+
33
+ public An5Adapter(An5AdapterOptions options) : this(options.ConnectionString, options.CommandTimeout) { }
34
+
35
+ // ── Raw query execution ────────────────────────────────────────────────
36
+
37
+ public List<Dictionary<string, object>> QueryRaw(string sql, Dictionary<string, object> parameters = null)
38
+ => _engine.QueryRaw(sql, parameters);
39
+
40
+ public List<T> QueryRaw<T>(string sql, Dictionary<string, object> parameters = null) where T : new()
41
+ => _engine.QueryRaw<T>(sql, parameters);
42
+
43
+ public int ExecuteRaw(string sql, Dictionary<string, object> parameters = null)
44
+ => _engine.ExecuteRaw(sql, parameters);
45
+
46
+ // ── Stored Procedure Execution ────────────────────────────────────────
47
+
48
+ private void ThrowIfNoStoredProcedures()
49
+ {
50
+ if (Dialect == Dialect.Sqlite)
51
+ throw new NotSupportedException(
52
+ "SQLite has no stored procedures. Run the statements directly with QueryRaw or ExecuteRaw.");
53
+ }
54
+
55
+ public List<T> QueryProc<T>(string procName, Dictionary<string, object> parameters = null) where T : new()
56
+ {
57
+ ThrowIfNoStoredProcedures();
58
+ var paramList = new List<string>();
59
+ if (parameters != null)
60
+ {
61
+ foreach (var k in parameters.Keys)
62
+ {
63
+ paramList.Add(Dialect == Dialect.Postgres ? "@" + k : "@" + k + " = @" + k);
64
+ }
65
+ }
66
+ var sql = Dialect == Dialect.Postgres
67
+ ? $"CALL {procName}({string.Join(", ", paramList)})"
68
+ : (paramList.Count > 0 ? $"EXEC {procName} {string.Join(", ", paramList)}" : $"EXEC {procName}");
69
+ return QueryRaw<T>(sql, parameters);
70
+ }
71
+
72
+ public int ExecuteProc(string procName, Dictionary<string, object> parameters = null)
73
+ {
74
+ ThrowIfNoStoredProcedures();
75
+ var paramList = new List<string>();
76
+ if (parameters != null)
77
+ {
78
+ foreach (var k in parameters.Keys)
79
+ {
80
+ paramList.Add(Dialect == Dialect.Postgres ? "@" + k : "@" + k + " = @" + k);
81
+ }
82
+ }
83
+ var sql = Dialect == Dialect.Postgres
84
+ ? $"CALL {procName}({string.Join(", ", paramList)})"
85
+ : (paramList.Count > 0 ? $"EXEC {procName} {string.Join(", ", paramList)}" : $"EXEC {procName}");
86
+ return ExecuteRaw(sql, parameters);
87
+ }
88
+
89
+ // ── Transaction ────────────────────────────────────────────────────────
90
+
91
+ public An5Transaction BeginTransaction()
92
+ {
93
+ return new An5Transaction(_engine.BeginTransaction());
94
+ }
95
+
96
+ public TResult Transaction<TResult>(Func<An5Adapter, TResult> fn)
97
+ {
98
+ using var txScope = _engine.BeginTransaction();
99
+ try
100
+ {
101
+ var result = fn(this);
102
+ txScope.Commit();
103
+ return result;
104
+ }
105
+ catch
106
+ {
107
+ txScope.Rollback();
108
+ throw;
109
+ }
110
+ }
111
+
112
+ // ── Table & View client factory ────────────────────────────────────────
113
+
114
+ public AdapterTableClient<T> Table<T>(string tableName) where T : new()
115
+ => new AdapterTableClient<T>(this, tableName, _engine.Dialect);
116
+
117
+ public ViewClient<T> View<T>(string viewName) where T : new()
118
+ => new ViewClient<T>(this, viewName, _engine.Dialect);
119
+
120
+ public ViewClient<T> View<T>(string viewName, string modelName) where T : new()
121
+ => new ViewClient<T>(this, viewName, modelName, _engine.Dialect);
122
+
123
+ public void Dispose() { }
124
+ }
125
+
126
+ // ─── Adapter metadata injection ─────────────────────────────────────────────
127
+
128
+ public static class An5AdapterMetadata
129
+ {
130
+ public static void Set(AdapterMetadata metadata)
131
+ => An5Metadata.SetAdapterMetadata(metadata);
132
+
133
+ public static Dictionary<string, string> GetModelToTable()
134
+ => An5Metadata.GetModelToTable();
135
+
136
+ public static Dictionary<string, RelationDef> GetRelationsForModel(string modelName)
137
+ => An5Metadata.GetRelationsForModel(modelName);
138
+
139
+ public static object GetFieldsForModel(string modelName)
140
+ => An5Metadata.GetFieldsForModel(modelName);
141
+ }
142
+
143
+ // ─── Transaction wrapper ───────────────────────────────────────────────────
144
+
145
+ public class An5Transaction : IDisposable
146
+ {
147
+ private readonly An5TransactionBase _inner;
148
+
149
+ internal An5Transaction(An5TransactionBase inner) => _inner = inner;
150
+
151
+ public void Commit() => _inner.Commit();
152
+ public void Rollback() => _inner.Rollback();
153
+ public void Dispose() => _inner.Dispose();
154
+ }
155
+
156
+ // ─── Read-Only View Client ──────────────────────────────────────────────────
157
+
158
+ public class ViewClient<T> where T : new()
159
+ {
160
+ private readonly AdapterTableClient<T> _tableClient;
161
+ public string ViewName { get; }
162
+
163
+ public ViewClient(An5Adapter adapter, string viewName, Dialect dialect)
164
+ {
165
+ ViewName = viewName;
166
+ _tableClient = new AdapterTableClient<T>(adapter, viewName, dialect);
167
+ }
168
+
169
+ public ViewClient(An5Adapter adapter, string viewName, string modelName, Dialect dialect)
170
+ {
171
+ ViewName = viewName;
172
+ _tableClient = new AdapterTableClient<T>(adapter, viewName, modelName, dialect);
173
+ }
174
+
175
+ public List<T> FindMany(string whereClause = null, Dictionary<string, object> parameters = null, string orderBy = null, int? skip = null, int? take = null)
176
+ => _tableClient.FindMany(whereClause, parameters, orderBy, skip, take);
177
+
178
+ public List<T> FindMany(FindManyArgs args)
179
+ => _tableClient.FindMany(args);
180
+
181
+ public T FindFirst(string whereClause = null, Dictionary<string, object> parameters = null, string orderBy = null)
182
+ => _tableClient.FindFirst(whereClause, parameters, orderBy);
183
+
184
+ public T FindFirst(FindManyArgs args)
185
+ => _tableClient.FindFirst(args);
186
+
187
+ public T FindUnique(object id, string idColumnName = "Id")
188
+ => _tableClient.FindUnique(id, idColumnName);
189
+
190
+ public T FindUnique(FindManyArgs args)
191
+ => _tableClient.FindUnique(args);
192
+
193
+ public int Count(string whereClause = null, Dictionary<string, object> parameters = null)
194
+ => _tableClient.Count(whereClause, parameters);
195
+
196
+ public int Count(CountArgs args)
197
+ => _tableClient.Count(args);
198
+
199
+ public List<(T Item, double Distance)> VectorSearch(List<double> vector, int take = 10, string whereClause = null, Dictionary<string, object> parameters = null, string vectorField = "Embedding", string distanceMetric = "cosine")
200
+ => _tableClient.VectorSearch(vector, take, whereClause, parameters, vectorField, distanceMetric);
201
+
202
+ public List<(T Item, double Distance)> VectorSearch(VectorSearchArgs args)
203
+ => _tableClient.VectorSearch(args);
204
+
205
+ public Dictionary<string, object> Aggregate(AggregateArgs args)
206
+ => _tableClient.Aggregate(args);
207
+
208
+ public List<T> GroupBy(GroupByArgs args)
209
+ => _tableClient.GroupBy(args);
210
+
211
+ public T Create(T entity) => throw new NotSupportedException($"View '{ViewName}' is read-only. Create is not allowed.");
212
+ public T Update(T entity, string idColumnName = "Id") => throw new NotSupportedException($"View '{ViewName}' is read-only. Update is not allowed.");
213
+ public bool Delete(object id, string idColumnName = "Id") => throw new NotSupportedException($"View '{ViewName}' is read-only. Delete is not allowed.");
214
+ public int DeleteMany(string whereClause = null, Dictionary<string, object> parameters = null) => throw new NotSupportedException($"View '{ViewName}' is read-only. DeleteMany is not allowed.");
215
+ public T Upsert(T entity, string idColumnName = "Id") => throw new NotSupportedException($"View '{ViewName}' is read-only. Upsert is not allowed.");
216
+ }
217
+
218
+ // ─── ORM-style Args types ────────────────────────────────────────────────────
219
+
220
+ public class FindManyArgs
221
+ {
222
+ public object Where { get; set; }
223
+ public object OrderBy { get; set; }
224
+ public int? Skip { get; set; }
225
+ public int? Take { get; set; }
226
+ public object Select { get; set; }
227
+ public object Include { get; set; }
228
+ }
229
+
230
+ public class CountArgs
231
+ {
232
+ public object Where { get; set; }
233
+ }
234
+
235
+ public class CreateArgs
236
+ {
237
+ public Dictionary<string, object> Data { get; set; }
238
+ public object Include { get; set; }
239
+ public object Select { get; set; }
240
+ }
241
+
242
+ public class CreateManyArgs
243
+ {
244
+ public List<Dictionary<string, object>> Data { get; set; }
245
+ public bool SkipDuplicates { get; set; }
246
+ }
247
+
248
+ public class UpdateArgs
249
+ {
250
+ public object Where { get; set; }
251
+ public Dictionary<string, object> Data { get; set; }
252
+ public object Include { get; set; }
253
+ public object Select { get; set; }
254
+ }
255
+
256
+ public class UpdateManyArgs
257
+ {
258
+ public object Where { get; set; }
259
+ public Dictionary<string, object> Data { get; set; }
260
+ }
261
+
262
+ public class DeleteManyArgs
263
+ {
264
+ public object Where { get; set; }
265
+ }
266
+
267
+ public class UpsertArgs
268
+ {
269
+ public object Where { get; set; }
270
+ public Dictionary<string, object> Create { get; set; }
271
+ public Dictionary<string, object> Update { get; set; }
272
+ }
273
+
274
+ public class AggregateArgs
275
+ {
276
+ public object Where { get; set; }
277
+ public object Count { get; set; }
278
+ public object Sum { get; set; }
279
+ public object Avg { get; set; }
280
+ public object Min { get; set; }
281
+ public object Max { get; set; }
282
+ }
283
+
284
+ public class GroupByArgs
285
+ {
286
+ public object By { get; set; }
287
+ public object Where { get; set; }
288
+ public object OrderBy { get; set; }
289
+ public int? Skip { get; set; }
290
+ public int? Take { get; set; }
291
+ public object Sum { get; set; }
292
+ public object Avg { get; set; }
293
+ public object Min { get; set; }
294
+ public object Max { get; set; }
295
+ }
296
+
297
+ public class VectorSearchArgs
298
+ {
299
+ public List<double> Vector { get; set; }
300
+ public int Take { get; set; } = 10;
301
+ public object Where { get; set; }
302
+ public string VectorField { get; set; } = "Embedding";
303
+ public string DistanceMetric { get; set; } = "cosine";
304
+ }
305
+
306
+ internal class DictionaryModel
307
+ {
308
+ }
309
+
310
+ internal static class ArgsHelper
311
+ {
312
+ public static List<string> SelectedFields(object select)
313
+ {
314
+ var keys = new List<string>();
315
+ switch (select)
316
+ {
317
+ case null:
318
+ return keys;
319
+ case string s when !string.IsNullOrEmpty(s):
320
+ keys.Add(s);
321
+ return keys;
322
+ case System.Collections.IList list:
323
+ foreach (var item in list)
324
+ if (item is string key) keys.Add(key);
325
+ return keys;
326
+ case Dictionary<string, object> map:
327
+ foreach (var kv in map)
328
+ if (kv.Value is bool b && b) keys.Add(kv.Key);
329
+ return keys;
330
+ case Dictionary<string, bool> bmap:
331
+ foreach (var kv in bmap)
332
+ if (kv.Value) keys.Add(kv.Key);
333
+ return keys;
334
+ }
335
+ return keys;
336
+ }
337
+
338
+ public static bool HasRelationSelect(object select, Dictionary<string, RelationDef> relations)
339
+ {
340
+ foreach (var key in SelectedFields(select))
341
+ if (key == "_count" || relations.ContainsKey(key)) return true;
342
+ return false;
343
+ }
344
+
345
+ public static List<string> SelectedAggregateFields(object fields)
346
+ {
347
+ var outList = new List<string>();
348
+ switch (fields)
349
+ {
350
+ case null:
351
+ break;
352
+ case string s when !string.IsNullOrEmpty(s):
353
+ outList.Add(s);
354
+ break;
355
+ case System.Collections.IList list:
356
+ foreach (var item in list)
357
+ if (item is string key && !string.IsNullOrEmpty(key)) outList.Add(key);
358
+ break;
359
+ case Dictionary<string, object> map:
360
+ foreach (var kv in map)
361
+ if (kv.Value is bool b && b) outList.Add(kv.Key);
362
+ break;
363
+ case Dictionary<string, bool> bmap:
364
+ foreach (var kv in bmap)
365
+ if (kv.Value) outList.Add(kv.Key);
366
+ break;
367
+ }
368
+ outList.Sort();
369
+ return outList;
370
+ }
371
+
372
+ public static List<string> NormalizeByFields(object by)
373
+ {
374
+ var outList = new List<string>();
375
+ switch (by)
376
+ {
377
+ case string s when !string.IsNullOrEmpty(s):
378
+ outList.Add(s);
379
+ break;
380
+ case System.Collections.IList list:
381
+ foreach (var item in list)
382
+ if (item is string key && !string.IsNullOrEmpty(key)) outList.Add(key);
383
+ break;
384
+ }
385
+ return outList;
386
+ }
387
+
388
+ public static Dictionary<string, object> ProjectDict(Dictionary<string, object> row, object select)
389
+ {
390
+ var projected = new Dictionary<string, object>();
391
+ foreach (var key in SelectedFields(select))
392
+ if (row.TryGetValue(key, out var v)) projected[key] = v;
393
+ if (row.TryGetValue("_count", out var count)) projected["_count"] = count;
394
+ return projected;
395
+ }
396
+ }
397
+
398
+ // ─── Typed Table Client ────────────────────────────────────────────────────
399
+
400
+ public class AdapterTableClient<T> where T : new()
401
+ {
402
+ private readonly An5Adapter _adapter;
403
+ private readonly string _tableName;
404
+ private readonly string _rawTable;
405
+ private readonly string _modelName;
406
+ private readonly Dialect _dialect;
407
+
408
+ public AdapterTableClient(An5Adapter adapter, string tableName, Dialect dialect)
409
+ {
410
+ _adapter = adapter;
411
+ _rawTable = tableName;
412
+ _tableName = SqlQuote.QuoteTable(tableName, dialect);
413
+ _modelName = tableName;
414
+ _dialect = dialect;
415
+ }
416
+
417
+ public AdapterTableClient(An5Adapter adapter, string tableName, string modelName, Dialect dialect)
418
+ {
419
+ _adapter = adapter;
420
+ _rawTable = tableName;
421
+ _tableName = SqlQuote.QuoteTable(tableName, dialect);
422
+ _modelName = modelName;
423
+ _dialect = dialect;
424
+ }
425
+
426
+ public string TableName => _rawTable;
427
+ public string ModelName => _modelName;
428
+
429
+ private string Nolock => _dialect == Dialect.Mssql ? " WITH (NOLOCK)" : "";
430
+
431
+ /// <summary>
432
+ /// True for the dialects that paginate with LIMIT/OFFSET rather than
433
+ /// OFFSET ... FETCH NEXT, and have no SELECT TOP.
434
+ /// </summary>
435
+ private bool UsesLimitOffset => _dialect == Dialect.Postgres || _dialect == Dialect.Sqlite;
436
+
437
+ private string BuildPagination(string orderBy, int? skip, int? take)
438
+ {
439
+ // A skip on its own still has to skip. OFFSET without FETCH NEXT is
440
+ // valid on MSSQL, and on the LIMIT/OFFSET dialects an unbounded page
441
+ // is LIMIT -1; without this, `skip` was silently discarded whenever
442
+ // `take` was null.
443
+ if (take == null && skip == null) return "";
444
+ if (UsesLimitOffset)
445
+ {
446
+ if (take == null) return $" LIMIT -1 OFFSET {skip.Value}";
447
+ if (skip == null) return $" LIMIT {take.Value}";
448
+ return $" LIMIT {take.Value} OFFSET {skip.Value}";
449
+ }
450
+ var o = !string.IsNullOrEmpty(orderBy) ? $" ORDER BY {orderBy}" : " ORDER BY (SELECT NULL)";
451
+ if (take == null) return $"{o} OFFSET {skip.Value} ROWS";
452
+ return $"{o} OFFSET {skip ?? 0} ROWS FETCH NEXT {take.Value} ROWS ONLY";
453
+ }
454
+
455
+ public List<T> FindMany(string whereClause = null, Dictionary<string, object> parameters = null,
456
+ string orderBy = null, int? skip = null, int? take = null)
457
+ {
458
+ var sb = new StringBuilder($"SELECT * FROM {_tableName}{Nolock}");
459
+ if (!string.IsNullOrEmpty(whereClause)) sb.Append($" WHERE {whereClause}");
460
+ if (!string.IsNullOrEmpty(orderBy)) sb.Append($" ORDER BY {orderBy}");
461
+ // LIMIT precedes OFFSET, and neither carries MSSQL's `ROWS` keyword.
462
+ // Postgres tolerates either order, SQLite does not: `OFFSET n LIMIT m`
463
+ // is a syntax error there.
464
+ sb.Append(BuildPagination(orderBy, skip, take));
465
+ return _adapter.QueryRaw<T>(sb.ToString(), parameters);
466
+ }
467
+
468
+ public List<T> FindMany(FindManyArgs args)
469
+ {
470
+ var parameters = new Dictionary<string, object>();
471
+ var whereSql = SqlBuilder.ParseWhere(args?.Where, parameters, _dialect);
472
+ var orderSql = SqlBuilder.BuildOrderBy(args?.OrderBy, _dialect);
473
+ var skip = args?.Skip;
474
+ var take = args?.Take;
475
+ var hasSkip = skip != null;
476
+
477
+ var cols = "*";
478
+ var keys = ArgsHelper.SelectedFields(args?.Select);
479
+ var usesLimitOffset = UsesLimitOffset;
480
+
481
+ if (keys.Count > 0 && !ArgsHelper.HasRelationSelect(args?.Select, An5Metadata.GetRelationsForModel(_modelName)))
482
+ {
483
+ var quoted = keys.ConvertAll(k => SqlQuote.QuoteName(k, _dialect));
484
+ cols = string.Join(", ", quoted);
485
+ }
486
+
487
+ string query;
488
+ if (take != null && !hasSkip)
489
+ {
490
+ if (!usesLimitOffset)
491
+ {
492
+ query = $"SELECT TOP ({take.Value}) {cols} FROM {_tableName}{Nolock}";
493
+ if (!string.IsNullOrEmpty(whereSql)) query += $" WHERE {whereSql}";
494
+ if (!string.IsNullOrEmpty(orderSql)) query += $" {orderSql}";
495
+ }
496
+ else
497
+ {
498
+ query = $"SELECT {cols} FROM {_tableName}";
499
+ if (!string.IsNullOrEmpty(whereSql)) query += $" WHERE {whereSql}";
500
+ if (!string.IsNullOrEmpty(orderSql)) query += $" {orderSql}";
501
+ query += $" LIMIT {take.Value}";
502
+ }
503
+ }
504
+ else if (hasSkip)
505
+ {
506
+ if (!usesLimitOffset)
507
+ {
508
+ query = $"SELECT {cols} FROM {_tableName}{Nolock}";
509
+ if (!string.IsNullOrEmpty(whereSql)) query += $" WHERE {whereSql}";
510
+ query += $" {(!string.IsNullOrEmpty(orderSql) ? orderSql : "ORDER BY (SELECT NULL)")}";
511
+ query += $" OFFSET {skip.Value} ROWS";
512
+ if (take != null) query += $" FETCH NEXT {take.Value} ROWS ONLY";
513
+ }
514
+ else
515
+ {
516
+ query = $"SELECT {cols} FROM {_tableName}";
517
+ if (!string.IsNullOrEmpty(whereSql)) query += $" WHERE {whereSql}";
518
+ if (!string.IsNullOrEmpty(orderSql)) query += $" {orderSql}";
519
+ if (take != null) query += $" LIMIT {take.Value}";
520
+ else query += _dialect == Dialect.Sqlite ? " LIMIT -1" : " LIMIT ALL";
521
+ query += $" OFFSET {skip.Value}";
522
+ }
523
+ }
524
+ else
525
+ {
526
+ query = $"SELECT {cols} FROM {_tableName}{Nolock}";
527
+ if (!string.IsNullOrEmpty(whereSql)) query += $" WHERE {whereSql}";
528
+ if (!string.IsNullOrEmpty(orderSql)) query += $" {orderSql}";
529
+ }
530
+
531
+ var rows = _adapter.QueryRaw<T>(query, parameters);
532
+
533
+ var include = args?.Include != null && args.Include is Dictionary<string, object> incDict && incDict.Count > 0
534
+ ? incDict
535
+ : null;
536
+ if (include != null)
537
+ ResolveIncludes(rows, include);
538
+
539
+ var select = args?.Select;
540
+ if (select != null)
541
+ {
542
+ if (ArgsHelper.HasRelationSelect(select, An5Metadata.GetRelationsForModel(_modelName)))
543
+ ResolveIncludes(rows, select as Dictionary<string, object>);
544
+ return ApplyProjection(rows, select);
545
+ }
546
+
547
+ return rows;
548
+ }
549
+
550
+ // ── ORM-standard FindFirst / FindUnique / Count ────────────────────────
551
+
552
+ public T FindFirst(FindManyArgs args)
553
+ {
554
+ var list = FindMany(new FindManyArgs
555
+ {
556
+ Where = args?.Where,
557
+ OrderBy = args?.OrderBy,
558
+ Take = 1,
559
+ Select = args?.Select,
560
+ Include = args?.Include,
561
+ });
562
+ return list.Count > 0 ? list[0] : default;
563
+ }
564
+
565
+ public T FindUnique(FindManyArgs args)
566
+ => FindFirst(args);
567
+
568
+ public int Count(CountArgs args)
569
+ {
570
+ var parameters = new Dictionary<string, object>();
571
+ var whereSql = args != null ? SqlBuilder.ParseWhere(args.Where, parameters, _dialect) : "";
572
+ var sql = $"SELECT COUNT(*) FROM {_tableName}{Nolock}";
573
+ if (!string.IsNullOrEmpty(whereSql)) sql += $" WHERE {whereSql}";
574
+ var rows = _adapter.QueryRaw(sql, parameters);
575
+ if (rows.Count > 0)
576
+ {
577
+ using var e = rows[0].Values.GetEnumerator();
578
+ if (e.MoveNext() && e.Current != null) return Convert.ToInt32(e.Current);
579
+ }
580
+ return 0;
581
+ }
582
+
583
+ // ── Projection / Include helpers ──────────────────────────────────────
584
+
585
+ private List<T> ApplyProjection(List<T> rows, object select)
586
+ {
587
+ var result = new List<T>();
588
+ var relations = An5Metadata.GetRelationsForModel(_modelName);
589
+ var isDictSelect = select is Dictionary<string, object> || select is Dictionary<string, bool>;
590
+ if (!isDictSelect)
591
+ {
592
+ var props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
593
+ var selectedKeys = new HashSet<string>(ArgsHelper.SelectedFields(select), StringComparer.OrdinalIgnoreCase);
594
+ foreach (var row in rows)
595
+ {
596
+ var item = new T();
597
+ foreach (var prop in props)
598
+ if (selectedKeys.Contains(prop.Name))
599
+ prop.SetValue(item, prop.GetValue(row));
600
+ result.Add(item);
601
+ }
602
+ return result;
603
+ }
604
+
605
+ // Relation select keys may reference a nested dict; keep it simple and
606
+ // copy only selected top-level properties.
607
+ var dictSelect = select as Dictionary<string, object>;
608
+ var boolSet = select as Dictionary<string, bool>;
609
+ foreach (var row in rows)
610
+ {
611
+ var item = new T();
612
+ var props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
613
+ foreach (var prop in props)
614
+ {
615
+ if (dictSelect != null && dictSelect.TryGetValue(prop.Name, out var sv) && sv is bool b && b)
616
+ prop.SetValue(item, prop.GetValue(row));
617
+ else if (boolSet != null && boolSet.TryGetValue(prop.Name, out var b2) && b2)
618
+ prop.SetValue(item, prop.GetValue(row));
619
+ }
620
+ result.Add(item);
621
+ }
622
+ _ = relations;
623
+ return result;
624
+ }
625
+
626
+ private void ResolveIncludes(List<T> rows, Dictionary<string, object> include)
627
+ {
628
+ if (rows.Count == 0 || include == null || include.Count == 0) return;
629
+ var modelRelations = An5Metadata.GetRelationsForModel(_modelName);
630
+ if (modelRelations.Count == 0) return;
631
+
632
+ foreach (var kv in include)
633
+ {
634
+ var key = kv.Key;
635
+ if (kv.Value is bool b && !b) continue;
636
+ if (key == "_count")
637
+ {
638
+ foreach (var rel in modelRelations)
639
+ {
640
+ if (rel.Value.RelationType != "many") continue;
641
+ var relKeys = new HashSet<object>();
642
+ foreach (var r in rows)
643
+ {
644
+ var lk = GetPropValue(r, rel.Value.LocalKey);
645
+ if (lk != null) relKeys.Add(lk);
646
+ }
647
+ var childClient = new AdapterTableClient<DictionaryModel>(_adapter, rel.Value.ModelName, _dialect);
648
+ var matches = childClient.FindManyDynamic(new FindManyArgs
649
+ {
650
+ Where = new Dictionary<string, object> { { rel.Value.ForeignKey, new Dictionary<string, object> { { "in", ToList(relKeys) } } } },
651
+ });
652
+ var cmap = new Dictionary<object, int>();
653
+ foreach (var m in matches)
654
+ if (m.ContainsKey(rel.Value.ForeignKey) && m[rel.Value.ForeignKey] != null)
655
+ cmap[m[rel.Value.ForeignKey]] = cmap.GetValueOrDefault(m[rel.Value.ForeignKey]) + 1;
656
+ foreach (var r in rows)
657
+ {
658
+ var lk = GetPropValue(r, rel.Value.LocalKey);
659
+ var countProp = typeof(T).GetProperty("_count", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
660
+ var countBox = countProp != null ? countProp.GetValue(r) as Dictionary<string, object> : null;
661
+ countBox ??= new Dictionary<string, object>();
662
+ countBox[rel.Key] = lk != null && cmap.TryGetValue(lk, out var n) ? n : 0;
663
+ if (countProp != null) TrySetProp(r, "_count", countBox);
664
+ }
665
+ }
666
+ continue;
667
+ }
668
+
669
+ var relation = modelRelations.TryGetValue(key, out var rdef) ? rdef : null;
670
+ if (relation == null) continue;
671
+ var isMany = relation.RelationType == "many";
672
+ var joinKey = isMany ? relation.LocalKey : relation.ForeignKey;
673
+ var matchKey = isMany ? relation.ForeignKey : relation.LocalKey;
674
+
675
+ var uniqueKeys = new HashSet<object>();
676
+ foreach (var r in rows)
677
+ {
678
+ var jv = GetPropValue(r, joinKey);
679
+ if (jv != null) uniqueKeys.Add(jv);
680
+ }
681
+ foreach (var r in rows)
682
+ {
683
+ if (!isMany) { r.GetType(); }
684
+ }
685
+ if (uniqueKeys.Count == 0)
686
+ {
687
+ foreach (var r in rows)
688
+ TrySetProp(r, key, isMany ? new List<object>() : null);
689
+ continue;
690
+ }
691
+
692
+ var childIncludeClient = new AdapterTableClient<DictionaryModel>(_adapter, relation.ModelName, _dialect);
693
+ var subArgs = kv.Value as Dictionary<string, object> ?? new Dictionary<string, object>();
694
+ var subWhere = new Dictionary<string, object>
695
+ {
696
+ { matchKey, new Dictionary<string, object> { { "in", ToList(uniqueKeys) } } },
697
+ };
698
+ if (subArgs.TryGetValue("where", out var subWhereVal) && subWhereVal is Dictionary<string, object> sw)
699
+ foreach (var swKv in sw) subWhere[swKv.Key] = swKv.Value;
700
+
701
+ var orderBy = subArgs.TryGetValue("orderBy", out var oB) ? oB : null;
702
+ var children = childIncludeClient.FindManyDynamic(new FindManyArgs
703
+ {
704
+ Where = subWhere,
705
+ OrderBy = orderBy,
706
+ });
707
+
708
+ Dictionary<object, List<Dictionary<string, object>>> groupMap = new();
709
+ foreach (var c in children)
710
+ {
711
+ var gk = isMany ? (c.ContainsKey(relation.ForeignKey) ? c[relation.ForeignKey] : null)
712
+ : (c.ContainsKey(relation.LocalKey) ? c[relation.LocalKey] : null);
713
+ if (gk == null) continue;
714
+ if (!groupMap.TryGetValue(gk, out var list)) { list = new List<Dictionary<string, object>>(); groupMap[gk] = list; }
715
+ list.Add(c);
716
+ }
717
+
718
+ foreach (var r in rows)
719
+ {
720
+ var gk = isMany ? GetPropValue(r, relation.LocalKey) : GetPropValue(r, relation.ForeignKey);
721
+ var matches = gk != null && groupMap.TryGetValue(gk, out var ml) ? ml : new List<Dictionary<string, object>>();
722
+ if (isMany)
723
+ TrySetProp(r, key, matches);
724
+ else
725
+ TrySetProp(r, key, matches.Count > 0 ? matches[0] : null);
726
+ }
727
+ }
728
+ }
729
+
730
+ private List<Dictionary<string, object>> FindManyDynamic(FindManyArgs args)
731
+ {
732
+ var parameters = new Dictionary<string, object>();
733
+ var whereSql = SqlBuilder.ParseWhere(args?.Where, parameters, _dialect);
734
+ var orderSql = SqlBuilder.BuildOrderBy(args?.OrderBy, _dialect);
735
+ var query = $"SELECT * FROM {_tableName}{Nolock}";
736
+ if (!string.IsNullOrEmpty(whereSql)) query += $" WHERE {whereSql}";
737
+ if (!string.IsNullOrEmpty(orderSql)) query += $" {orderSql}";
738
+ return _adapter.QueryRaw(query, parameters);
739
+ }
740
+
741
+ private static object GetPropValue(object obj, string propName)
742
+ {
743
+ var prop = obj.GetType().GetProperty(propName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
744
+ return prop?.GetValue(obj);
745
+ }
746
+
747
+ private static void TrySetProp(object obj, string propName, object value)
748
+ {
749
+ var prop = obj.GetType().GetProperty(propName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
750
+ if (prop == null || !prop.CanWrite) return;
751
+ if (value == null) { prop.SetValue(obj, null); return; }
752
+ try
753
+ {
754
+ if (prop.PropertyType.IsAssignableFrom(value.GetType()))
755
+ prop.SetValue(obj, value);
756
+ else if (prop.PropertyType == typeof(string))
757
+ prop.SetValue(obj, System.Text.Json.JsonSerializer.Serialize(value));
758
+ }
759
+ catch { }
760
+ }
761
+
762
+ private static List<object> ToList(ICollection<object> src)
763
+ {
764
+ var list = new List<object>(src.Count);
765
+ foreach (var item in src) list.Add(item);
766
+ return list;
767
+ }
768
+
769
+ // ── ORM-standard Aggregate / GroupBy ───────────────────────────────────
770
+
771
+ public Dictionary<string, object> Aggregate(AggregateArgs args)
772
+ {
773
+ args ??= new AggregateArgs();
774
+ var parameters = new Dictionary<string, object>();
775
+ var whereSql = SqlBuilder.ParseWhere(args.Where, parameters, _dialect);
776
+ var aggs = new List<string>();
777
+ var countFields = ArgsHelper.SelectedAggregateFields(args.Count);
778
+ if (countFields.Count == 0 && args.Count != null && args.Count is not bool)
779
+ {
780
+ // noop
781
+ }
782
+ if (args.Count != null)
783
+ {
784
+ if (countFields.Count == 0)
785
+ aggs.Add("COUNT(*) AS cnt_all");
786
+ else
787
+ foreach (var f in countFields)
788
+ aggs.Add($"COUNT({SqlQuote.QuoteName(f, _dialect)}) AS cnt_{ArgsHelper_Sanitize(f)}");
789
+ }
790
+ AddAggregates(aggs, "SUM", args.Sum);
791
+ AddAggregates(aggs, "AVG", args.Avg);
792
+ AddAggregates(aggs, "MIN", args.Min);
793
+ AddAggregates(aggs, "MAX", args.Max);
794
+ if (aggs.Count == 0)
795
+ throw new InvalidOperationException("Aggregate requires at least one aggregator field");
796
+
797
+ var sql = $"SELECT {string.Join(", ", aggs)} FROM {_tableName}{Nolock}";
798
+ if (!string.IsNullOrEmpty(whereSql)) sql += $" WHERE {whereSql}";
799
+ var rows = _adapter.QueryRaw(sql, parameters);
800
+ var row = rows.Count > 0 ? rows[0] : new Dictionary<string, object>();
801
+ var result = new Dictionary<string, object>();
802
+
803
+ if (args.Count != null)
804
+ {
805
+ var all = ToNumber(row.TryGetValue("cnt_all", out var ca) ? ca : row.TryGetValue("_count", out var cc) ? cc : 0);
806
+ var countRes = new Dictionary<string, object> { { "_all", all } };
807
+ foreach (var f in countFields)
808
+ {
809
+ if (f == "_all") continue;
810
+ var v = row.TryGetValue("cnt_" + ArgsHelper_Sanitize(f), out var cv) ? cv : all;
811
+ countRes[f] = ToNumber(v);
812
+ }
813
+ result["_count"] = countRes;
814
+ }
815
+ AddAggregateResult(result, "sum", "SUM", args.Sum, row);
816
+ AddAggregateResult(result, "avg", "AVG", args.Avg, row);
817
+ AddAggregateResult(result, "min", "MIN", args.Min, row);
818
+ AddAggregateResult(result, "max", "MAX", args.Max, row);
819
+ return result;
820
+ }
821
+
822
+ private void AddAggregates(List<string> aggs, string op, object fields)
823
+ {
824
+ foreach (var f in ArgsHelper.SelectedAggregateFields(fields))
825
+ aggs.Add($"{op}({SqlQuote.QuoteName(f, _dialect)}) AS {op.ToLowerInvariant()}_{ArgsHelper_Sanitize(f)}");
826
+ }
827
+
828
+ private void AddAggregateResult(Dictionary<string, object> result, string key, string op, object fields, Dictionary<string, object> row)
829
+ {
830
+ var list = ArgsHelper.SelectedAggregateFields(fields);
831
+ if (list.Count == 0) return;
832
+ var sub = new Dictionary<string, object>();
833
+ foreach (var f in list)
834
+ {
835
+ var kk = key + "_" + ArgsHelper_Sanitize(f);
836
+ var v = row.TryGetValue(kk, out var rv) ? rv : null;
837
+ sub[f] = v == null ? null : ToNumber(v);
838
+ }
839
+ result["_" + key] = sub;
840
+ }
841
+
842
+ private static string ArgsHelper_Sanitize(string name) => SqlBuilder.SanitizeParamName(name);
843
+
844
+ private static double ToNumber(object v)
845
+ {
846
+ if (v == null) return 0;
847
+ try { return Convert.ToDouble(v); } catch { return 0; }
848
+ }
849
+
850
+ public List<T> GroupBy(GroupByArgs args)
851
+ {
852
+ args ??= new GroupByArgs();
853
+ var byFields = ArgsHelper.NormalizeByFields(args.By);
854
+ if (byFields.Count == 0)
855
+ throw new InvalidOperationException("groupBy requires 'by' fields");
856
+
857
+ var parameters = new Dictionary<string, object>();
858
+ var whereSql = SqlBuilder.ParseWhere(args.Where, parameters, _dialect);
859
+ var byCols = string.Join(", ", byFields.ConvertAll(f => SqlQuote.QuoteName(f, _dialect)));
860
+ var aggs = new List<string> { "COUNT(*) AS _count" };
861
+ AddAggregates(aggs, "SUM", args.Sum);
862
+ AddAggregates(aggs, "AVG", args.Avg);
863
+ AddAggregates(aggs, "MIN", args.Min);
864
+ AddAggregates(aggs, "MAX", args.Max);
865
+
866
+ var query = $"SELECT {byCols}, {string.Join(", ", aggs)} FROM {_tableName}";
867
+ if (!string.IsNullOrEmpty(whereSql)) query += $" WHERE {whereSql}";
868
+ query += $" GROUP BY {byCols}";
869
+
870
+ var orderSql = SqlBuilder.BuildOrderBy(args.OrderBy, _dialect);
871
+ var hasSkip = args.Skip != null;
872
+ var hasTake = args.Take != null;
873
+ if (!string.IsNullOrEmpty(orderSql)) query += $" {orderSql}";
874
+ else if (hasSkip || hasTake) query += $" ORDER BY {byCols}";
875
+ if (hasSkip || hasTake)
876
+ {
877
+ var skip = args.Skip ?? 0;
878
+ var take = args.Take ?? 1;
879
+ if (_dialect == Dialect.Postgres)
880
+ {
881
+ if (hasTake) query += $" LIMIT {take}";
882
+ query += $" OFFSET {skip}";
883
+ }
884
+ else
885
+ {
886
+ query += $" OFFSET {skip} ROWS";
887
+ if (hasTake) query += $" FETCH NEXT {take} ROWS ONLY";
888
+ }
889
+ }
890
+ return _adapter.QueryRaw<T>(query, parameters);
891
+ }
892
+
893
+ // ── ORM-standard VectorSearch ──────────────────────────────────────────
894
+
895
+ public List<(T Item, double Distance)> VectorSearch(VectorSearchArgs args)
896
+ {
897
+ args ??= new VectorSearchArgs();
898
+ return VectorSearch(args.Vector, args.Take, null, null, args.VectorField, args.DistanceMetric);
899
+ }
900
+
901
+ public T FindFirst(string whereClause = null, Dictionary<string, object> parameters = null, string orderBy = null)
902
+ {
903
+ var rows = FindMany(whereClause, parameters, orderBy, skip: null, take: 1);
904
+ return rows.Count > 0 ? rows[0] : default;
905
+ }
906
+
907
+ public T FindUnique(object id, string idColumnName = "Id")
908
+ {
909
+ return FindFirst($"{SqlQuote.QuoteName(idColumnName, _dialect)} = @id",
910
+ new Dictionary<string, object> { { "id", id } });
911
+ }
912
+
913
+ public int Count(string whereClause = null, Dictionary<string, object> parameters = null)
914
+ {
915
+ var sql = $"SELECT COUNT(*) FROM {_tableName}";
916
+ if (!string.IsNullOrEmpty(whereClause)) sql += $" WHERE {whereClause}";
917
+ var rows = _adapter.QueryRaw(sql, parameters);
918
+ if (rows.Count > 0)
919
+ {
920
+ var val = rows[0].Values.GetEnumerator();
921
+ val.MoveNext();
922
+ return Convert.ToInt32(val.Current);
923
+ }
924
+ return 0;
925
+ }
926
+
927
+ public T Create(T entity)
928
+ {
929
+ var props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
930
+ var cols = new List<string>();
931
+ var vals = new List<string>();
932
+ var parameters = new Dictionary<string, object>();
933
+
934
+ foreach (var prop in props)
935
+ {
936
+ var val = prop.GetValue(entity);
937
+ if (val == null) continue;
938
+ cols.Add(SqlQuote.QuoteName(prop.Name, _dialect));
939
+ vals.Add("@" + prop.Name);
940
+ parameters["@" + prop.Name] = val;
941
+ }
942
+
943
+ var sql = $"INSERT INTO {_tableName} ({string.Join(", ", cols)}) VALUES ({string.Join(", ", vals)})";
944
+ _adapter.ExecuteRaw(sql, parameters);
945
+ return entity;
946
+ }
947
+
948
+ public T Update(T entity, string idColumnName = "Id")
949
+ {
950
+ var props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
951
+ var sets = new List<string>();
952
+ var parameters = new Dictionary<string, object>();
953
+ object idVal = null;
954
+
955
+ foreach (var prop in props)
956
+ {
957
+ var val = prop.GetValue(entity);
958
+ if (prop.Name.Equals(idColumnName, StringComparison.OrdinalIgnoreCase))
959
+ {
960
+ idVal = val;
961
+ }
962
+ else if (val != null)
963
+ {
964
+ sets.Add($"{SqlQuote.QuoteName(prop.Name, _dialect)} = @{prop.Name}");
965
+ parameters["@" + prop.Name] = val;
966
+ }
967
+ }
968
+
969
+ if (idVal == null) throw new InvalidOperationException($"Cannot update entity without {idColumnName}");
970
+ parameters["@_id"] = idVal;
971
+ var quotedIdCol = SqlQuote.QuoteName(idColumnName, _dialect);
972
+ var sql = $"UPDATE {_tableName} SET {string.Join(", ", sets)} WHERE {quotedIdCol} = @_id";
973
+ _adapter.ExecuteRaw(sql, parameters);
974
+ return entity;
975
+ }
976
+
977
+ public bool Delete(object id, string idColumnName = "Id")
978
+ {
979
+ var quotedIdCol = SqlQuote.QuoteName(idColumnName, _dialect);
980
+ var sql = $"DELETE FROM {_tableName} WHERE {quotedIdCol} = @id";
981
+ var n = _adapter.ExecuteRaw(sql, new Dictionary<string, object> { { "@id", id } });
982
+ return n > 0;
983
+ }
984
+
985
+ public int DeleteMany(string whereClause = null, Dictionary<string, object> parameters = null)
986
+ {
987
+ var sql = $"DELETE FROM {_tableName}";
988
+ if (!string.IsNullOrEmpty(whereClause)) sql += $" WHERE {whereClause}";
989
+ return _adapter.ExecuteRaw(sql, parameters);
990
+ }
991
+
992
+ public T Upsert(T entity, string idColumnName = "Id")
993
+ {
994
+ var idProp = typeof(T).GetProperty(idColumnName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
995
+ if (idProp == null) throw new InvalidOperationException($"Property {idColumnName} not found on {typeof(T).Name}");
996
+ var idVal = idProp.GetValue(entity);
997
+ var existing = idVal != null ? FindUnique(idVal, idColumnName) : default;
998
+ if (existing != null) return Update(entity, idColumnName);
999
+ return Create(entity);
1000
+ }
1001
+
1002
+ // ── Vector Search ──────────────────────────────────────────────────────
1003
+
1004
+ public List<(T Item, double Distance)> VectorSearch(
1005
+ List<double> vector, int take = 10,
1006
+ string whereClause = null, Dictionary<string, object> parameters = null,
1007
+ string vectorField = "Embedding", string distanceMetric = "cosine")
1008
+ {
1009
+ // 1. Primary path: Native database SQL vector query execution (MSSQL VECTOR_DISTANCE / Postgres pgvector)
1010
+ // SQLite has no vector operator, so it is skipped outright: building the
1011
+ // statement only to have it rejected costs a round trip and lands on the
1012
+ // in-memory path below anyway.
1013
+ if (_dialect != Dialect.Sqlite)
1014
+ try
1015
+ {
1016
+ var dim = vector.Count;
1017
+ var vecJson = JsonSerializer.Serialize(vector);
1018
+ var p = parameters != null ? new Dictionary<string, object>(parameters) : new Dictionary<string, object>();
1019
+ p["query_vector"] = vecJson;
1020
+ var quotedVectorField = SqlQuote.QuoteName(vectorField, _dialect);
1021
+
1022
+ string sql;
1023
+ if (_dialect == Dialect.Postgres)
1024
+ {
1025
+ string op = distanceMetric.Equals("cosine", StringComparison.OrdinalIgnoreCase) ? "<=>" :
1026
+ (distanceMetric.Equals("euclidean", StringComparison.OrdinalIgnoreCase) ? "<->" : "<#>");
1027
+ sql = $"SELECT *, ({quotedVectorField} {op} @query_vector::vector) AS distance FROM {_tableName}";
1028
+ if (!string.IsNullOrWhiteSpace(whereClause))
1029
+ sql += $" WHERE {quotedVectorField} IS NOT NULL AND ({whereClause})";
1030
+ else
1031
+ sql += $" WHERE {quotedVectorField} IS NOT NULL";
1032
+ sql += $" ORDER BY distance ASC LIMIT {take}";
1033
+ }
1034
+ else
1035
+ {
1036
+ sql = $"SELECT TOP ({take}) *, VECTOR_DISTANCE('{distanceMetric}', CAST({quotedVectorField} AS VECTOR({dim}, float32)), CAST(@query_vector AS VECTOR({dim}, float32))) AS distance FROM {_tableName}{Nolock}";
1037
+ if (!string.IsNullOrWhiteSpace(whereClause))
1038
+ sql += $" WHERE {quotedVectorField} IS NOT NULL AND ({whereClause})";
1039
+ else
1040
+ sql += $" WHERE {quotedVectorField} IS NOT NULL";
1041
+ sql += " ORDER BY distance ASC";
1042
+ }
1043
+
1044
+ var nativeRows = _adapter.QueryRaw<T>(sql, p);
1045
+ if (nativeRows != null && nativeRows.Count > 0)
1046
+ {
1047
+ var nativeResults = new List<(T, double)>();
1048
+ foreach (var row in nativeRows)
1049
+ {
1050
+ var distanceProp = typeof(T).GetProperty("Distance", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
1051
+ double dist = 0;
1052
+ if (distanceProp != null && distanceProp.GetValue(row) is double dVal)
1053
+ {
1054
+ dist = dVal;
1055
+ }
1056
+ nativeResults.Add((row, dist));
1057
+ }
1058
+ return nativeResults;
1059
+ }
1060
+ }
1061
+ catch
1062
+ {
1063
+ // Secondary fallback: In-memory similarity computation if DB instance lacks native vector extension
1064
+ }
1065
+
1066
+ // 2. Secondary fallback: In-memory similarity computation
1067
+ var rows = FindMany(whereClause, parameters);
1068
+ var results = new List<(T, double)>();
1069
+ var vectorProp = typeof(T).GetProperty(vectorField, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
1070
+ if (vectorProp == null) return results;
1071
+
1072
+ foreach (var row in rows)
1073
+ {
1074
+ var rawVal = vectorProp.GetValue(row);
1075
+ if (rawVal == null) continue;
1076
+ List<double> vec = null;
1077
+ try
1078
+ {
1079
+ if (rawVal is string s) vec = JsonSerializer.Deserialize<List<double>>(s);
1080
+ }
1081
+ catch { continue; }
1082
+ if (vec == null || vec.Count != vector.Count) continue;
1083
+
1084
+ double dot = 0, m1 = 0, m2 = 0;
1085
+ for (int i = 0; i < vector.Count; i++)
1086
+ {
1087
+ dot += vector[i] * vec[i];
1088
+ m1 += vector[i] * vector[i];
1089
+ m2 += vec[i] * vec[i];
1090
+ }
1091
+ double cosine = (m1 > 0 && m2 > 0) ? dot / (Math.Sqrt(m1) * Math.Sqrt(m2)) : 0;
1092
+ double dist = distanceMetric.Equals("cosine", StringComparison.OrdinalIgnoreCase) ? 1.0 - cosine :
1093
+ distanceMetric.Equals("dot", StringComparison.OrdinalIgnoreCase) ? -dot : EuclideanDistance(vector, vec);
1094
+ results.Add((row, dist));
1095
+ }
1096
+
1097
+ results.Sort((a, b) => a.Item2.CompareTo(b.Item2));
1098
+ return results.GetRange(0, Math.Min(take, results.Count));
1099
+ }
1100
+
1101
+ private static double EuclideanDistance(List<double> left, List<double> right)
1102
+ {
1103
+ double sum = 0;
1104
+ for (int i = 0; i < left.Count; i++)
1105
+ {
1106
+ var diff = left[i] - right[i];
1107
+ sum += diff * diff;
1108
+ }
1109
+ return Math.Sqrt(sum);
1110
+ }
1111
+ }
1112
+ }