@an5/adapters 0.2.9 → 0.2.10

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.
@@ -275,7 +275,8 @@ class An5Adapter {
275
275
  else if (forDialect.startsWith('mysql://') || forDialect.startsWith('mariadb://')) {
276
276
  this._engineType = 'mysql';
277
277
  }
278
- else if (forDialect.startsWith('sqlite://')
278
+ else if (forDialect === ':memory:'
279
+ || forDialect.startsWith('sqlite:')
279
280
  || SQLITE_FILE_SUFFIXES.some((suffix) => forDialect.endsWith(suffix))) {
280
281
  this._engineType = 'sqlite';
281
282
  }
package/dist/base/sql.js CHANGED
@@ -68,9 +68,10 @@ function parseWhere(modelName, where, params, dialect, prefix = '', ctx) {
68
68
  if (!where)
69
69
  return '';
70
70
  const conditions = [];
71
+ const relations = ctx?.relationMap?.[modelName] || {};
71
72
  const cleanWhere = {};
72
73
  for (const [key, value] of Object.entries(where)) {
73
- if (key.includes('_') &&
74
+ if (key.includes('_') && !relations[key] &&
74
75
  value && typeof value === 'object' &&
75
76
  !(value instanceof Date) &&
76
77
  !isOperatorValue(value)) {
@@ -80,13 +81,13 @@ function parseWhere(modelName, where, params, dialect, prefix = '', ctx) {
80
81
  cleanWhere[key] = value;
81
82
  }
82
83
  }
83
- const relations = ctx?.relationMap?.[modelName] || {};
84
84
  const colPrefix = ctx?.colPrefix || '';
85
85
  for (const [key, value] of Object.entries(cleanWhere)) {
86
+ if (value === undefined)
87
+ continue;
86
88
  if (key === 'OR' && Array.isArray(value)) {
87
- const sub = value.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}or_${i}_`, ctx)).filter(Boolean);
88
- if (sub.length > 0)
89
- conditions.push(`(${sub.join(' OR ')})`);
89
+ const sub = value.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}or_${i}_`, ctx) || '1=1');
90
+ conditions.push(sub.length > 0 ? `(${sub.join(' OR ')})` : '1=0');
90
91
  }
91
92
  else if (key === 'AND') {
92
93
  const items = Array.isArray(value) ? value : [value];
@@ -96,12 +97,12 @@ function parseWhere(modelName, where, params, dialect, prefix = '', ctx) {
96
97
  }
97
98
  else if (key === 'NOT') {
98
99
  const items = Array.isArray(value) ? value : [value];
99
- const sub = items.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}not_${i}_`, ctx)).filter(Boolean);
100
+ const sub = items.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}not_${i}_`, ctx) || '1=1');
100
101
  if (sub.length > 0)
101
- conditions.push(`NOT (${sub.join(' AND ')})`);
102
+ conditions.push(`NOT (${sub.join(' OR ')})`);
102
103
  }
103
104
  else if (relations[key] && value && typeof value === 'object') {
104
- const clause = buildRelationClause(relations[key], value, modelName, params, dialect, prefix, ctx);
105
+ const clause = buildRelationClause(relations[key], value, modelName, params, dialect, `${prefix}${key}_`, ctx);
105
106
  if (clause)
106
107
  conditions.push(clause);
107
108
  }
@@ -220,16 +221,16 @@ function buildRelationClause(relation, value, modelName, params, dialect, prefix
220
221
  const parts = [];
221
222
  if (isMany) {
222
223
  if (value.some !== undefined) {
223
- const inner = buildInnerWhere(relation.modelName, value.some === null ? {} : value.some, params, dialect, prefix, ctx, childAlias);
224
+ const inner = buildInnerWhere(relation.modelName, value.some === null ? {} : value.some, params, dialect, `${prefix}some_`, ctx, childAlias);
224
225
  parts.push(buildExists(inner));
225
226
  }
226
227
  if (value.none !== undefined) {
227
- const inner = buildInnerWhere(relation.modelName, value.none === null ? {} : value.none, params, dialect, prefix, ctx, childAlias);
228
+ const inner = buildInnerWhere(relation.modelName, value.none === null ? {} : value.none, params, dialect, `${prefix}none_`, ctx, childAlias);
228
229
  parts.push(`NOT ${buildExists(inner)}`);
229
230
  }
230
231
  if (value.every !== undefined) {
231
232
  // "every" = no related row violates the condition.
232
- const inner = buildInnerWhere(relation.modelName, value.every === null ? {} : value.every, params, dialect, prefix, ctx, childAlias);
233
+ const inner = buildInnerWhere(relation.modelName, value.every === null ? {} : value.every, params, dialect, `${prefix}every_`, ctx, childAlias);
233
234
  const negated = inner ? `NOT (${inner})` : '';
234
235
  const violated = `EXISTS (SELECT 1 FROM ${childTable} AS ${childAlias} WHERE ${correlation}${negated ? ` AND ${negated}` : ' AND 1=0'})`;
235
236
  parts.push(`NOT ${violated}`);
@@ -237,12 +238,12 @@ function buildRelationClause(relation, value, modelName, params, dialect, prefix
237
238
  }
238
239
  else {
239
240
  if (value.is !== undefined) {
240
- const inner = buildInnerWhere(relation.modelName, value.is === null ? {} : value.is, params, dialect, prefix, ctx, childAlias);
241
- parts.push(buildExists(inner));
241
+ const inner = buildInnerWhere(relation.modelName, value.is === null ? {} : value.is, params, dialect, `${prefix}is_`, ctx, childAlias);
242
+ parts.push(value.is === null ? `NOT ${buildExists(inner)}` : buildExists(inner));
242
243
  }
243
244
  if (value.isNot !== undefined) {
244
- const inner = buildInnerWhere(relation.modelName, value.isNot === null ? {} : value.isNot, params, dialect, prefix, ctx, childAlias);
245
- parts.push(`NOT ${buildExists(inner)}`);
245
+ const inner = buildInnerWhere(relation.modelName, value.isNot === null ? {} : value.isNot, params, dialect, `${prefix}isNot_`, ctx, childAlias);
246
+ parts.push(value.isNot === null ? buildExists(inner) : `NOT ${buildExists(inner)}`);
246
247
  }
247
248
  }
248
249
  return parts.length > 0 ? parts.join(' AND ') : null;
@@ -25,9 +25,7 @@ function matchWhere(row, where) {
25
25
  if (key.includes('_') &&
26
26
  value && typeof value === 'object' &&
27
27
  !(value instanceof Date) &&
28
- !value.in && !value.contains &&
29
- !value.not && !value.gte &&
30
- !value.lte && !value.gt && !value.lt) {
28
+ !['equals', 'in', 'notIn', 'contains', 'startsWith', 'endsWith', 'not', 'gte', 'lte', 'gt', 'lt'].some(op => op in value)) {
31
29
  Object.assign(cleanWhere, value);
32
30
  }
33
31
  else {
@@ -35,13 +33,16 @@ function matchWhere(row, where) {
35
33
  }
36
34
  }
37
35
  for (const [key, value] of Object.entries(cleanWhere)) {
36
+ if (value === undefined)
37
+ continue;
38
38
  if (key === 'OR' && Array.isArray(value)) {
39
39
  if (!value.some((v) => matchWhere(row, v)))
40
40
  return false;
41
41
  continue;
42
42
  }
43
- if (key === 'AND' && Array.isArray(value)) {
44
- if (!value.every((v) => matchWhere(row, v)))
43
+ if (key === 'AND') {
44
+ const clauses = Array.isArray(value) ? value : [value];
45
+ if (!clauses.every((v) => matchWhere(row, v)))
45
46
  return false;
46
47
  continue;
47
48
  }
@@ -65,21 +66,25 @@ function matchWhere(row, where) {
65
66
  if (cellVal === null || cellVal === undefined)
66
67
  return false;
67
68
  }
69
+ else if (v.not && typeof v.not === 'object' && !(v.not instanceof Date) && !Array.isArray(v.not)) {
70
+ if (matchWhere(row, { [key]: v.not }))
71
+ return false;
72
+ }
68
73
  else if (normalize(cellVal) == normalize(v.not))
69
74
  return false;
70
75
  }
71
76
  if (v.equals !== undefined && normalize(cellVal) != normalize(v.equals))
72
77
  return false;
73
78
  if (v.contains !== undefined) {
74
- if (!cellVal || !normalize(String(cellVal)).includes(normalize(v.contains)))
79
+ if (cellVal === null || cellVal === undefined || !normalize(String(cellVal)).includes(normalize(v.contains)))
75
80
  return false;
76
81
  }
77
82
  if (v.startsWith !== undefined) {
78
- if (!cellVal || !normalize(String(cellVal)).startsWith(normalize(v.startsWith)))
83
+ if (cellVal === null || cellVal === undefined || !normalize(String(cellVal)).startsWith(normalize(v.startsWith)))
79
84
  return false;
80
85
  }
81
86
  if (v.endsWith !== undefined) {
82
- if (!cellVal || !normalize(String(cellVal)).endsWith(normalize(v.endsWith)))
87
+ if (cellVal === null || cellVal === undefined || !normalize(String(cellVal)).endsWith(normalize(v.endsWith)))
83
88
  return false;
84
89
  }
85
90
  if (v.gte !== undefined && !(Number(cellVal) >= Number(v.gte)))
@@ -15,7 +15,8 @@ class SqliteEngine {
15
15
  throw new Error('better-sqlite3 package is required for SQLite support. Run: npm install better-sqlite3');
16
16
  this.filePath = (adapterConfig.connectionString ?? '')
17
17
  .replace(/^sqlite:\/\/\//i, '/')
18
- .replace(/^sqlite:\/\//i, '');
18
+ .replace(/^sqlite:\/\//i, '')
19
+ .replace(/^sqlite:/i, '');
19
20
  }
20
21
  getDb() {
21
22
  if (!this.db)
@@ -259,40 +259,30 @@ public enum Dialect
259
259
  {
260
260
  var key = kv.Key;
261
261
  var value = kv.Value;
262
- if (key == "OR" && value is System.Collections.IList orList && orList.Count > 0)
262
+ if (key == "OR" || key == "AND" || key == "NOT")
263
263
  {
264
+ var items = value is System.Collections.IList list
265
+ ? list : new object[] { value };
264
266
  var subs = new List<string>();
265
267
  var i = 0;
266
- foreach (var item in orList)
268
+ foreach (var item in items)
267
269
  {
268
270
  if (item is Dictionary<string, object> subMap)
269
271
  {
270
- var s = ParseWhere(subMap, parameters, dialect, $"{prefix}or{i++}_");
271
- if (!string.IsNullOrEmpty(s)) subs.Add(s);
272
+ var s = ParseWhere(subMap, parameters, dialect, $"{prefix}{key}{i++}_");
273
+ subs.Add(string.IsNullOrEmpty(s) ? "1=1" : s);
272
274
  }
273
275
  }
274
- if (subs.Count > 0) conditions.Add("(" + string.Join(" OR ", subs) + ")");
275
- continue;
276
- }
277
- if (key == "AND" && value is System.Collections.IList andList && andList.Count > 0)
278
- {
279
- var subs = new List<string>();
280
- var i = 0;
281
- foreach (var item in andList)
276
+ if (subs.Count == 0)
282
277
  {
283
- if (item is Dictionary<string, object> subMap)
284
- {
285
- var s = ParseWhere(subMap, parameters, dialect, $"{prefix}and{i++}_");
286
- if (!string.IsNullOrEmpty(s)) subs.Add(s);
287
- }
278
+ if (key == "OR") conditions.Add("1=0");
279
+ }
280
+ else
281
+ {
282
+ var joiner = key == "AND" ? " AND " : " OR ";
283
+ var negation = key == "NOT" ? "NOT " : "";
284
+ conditions.Add(negation + "(" + string.Join(joiner, subs) + ")");
288
285
  }
289
- if (subs.Count > 0) conditions.Add("(" + string.Join(" AND ", subs) + ")");
290
- continue;
291
- }
292
- if (key == "NOT" && value is Dictionary<string, object> notMap)
293
- {
294
- var s = ParseWhere(notMap, parameters, dialect, $"{prefix}not_");
295
- if (!string.IsNullOrEmpty(s)) conditions.Add("NOT (" + s + ")");
296
286
  continue;
297
287
  }
298
288
 
@@ -80,55 +80,41 @@ func buildWhereRec(where map[string]interface{}, dialect Dialect, args *[]interf
80
80
  value := where[key]
81
81
 
82
82
  switch key {
83
- case "OR":
84
- if list, ok := toSlice(value); ok && len(list) > 0 {
85
- var subs []string
86
- for i, item := range list {
87
- if m, ok := item.(map[string]interface{}); ok && m != nil {
88
- if s := buildWhereRec(m, dialect, args, ph, fmt.Sprintf("%sor%d_", prefix, i)); s != "" {
89
- subs = append(subs, s)
90
- }
91
- }
92
- }
93
- if len(subs) > 0 {
94
- conditions = append(conditions, "("+strings.Join(subs, " OR ")+")")
95
- }
96
- continue
97
- }
98
- case "AND":
99
- if list, ok := toSlice(value); ok && len(list) > 0 {
100
- var subs []string
101
- for i, item := range list {
102
- if m, ok := item.(map[string]interface{}); ok && m != nil {
103
- if s := buildWhereRec(m, dialect, args, ph, fmt.Sprintf("%sand%d_", prefix, i)); s != "" {
104
- subs = append(subs, s)
105
- }
106
- }
107
- }
108
- if len(subs) > 0 {
109
- conditions = append(conditions, "("+strings.Join(subs, " AND ")+")")
110
- }
111
- continue
112
- }
113
- case "NOT":
83
+ case "OR", "AND", "NOT":
114
84
  items := value
115
85
  if m, ok := value.(map[string]interface{}); ok {
116
86
  items = []interface{}{m}
117
87
  }
118
- if list, ok := toSlice(items); ok && len(list) > 0 {
119
- var subs []string
120
- for i, item := range list {
121
- if m, ok := item.(map[string]interface{}); ok && m != nil {
122
- if s := buildWhereRec(m, dialect, args, ph, fmt.Sprintf("%snot%d_", prefix, i)); s != "" {
123
- subs = append(subs, s)
124
- }
88
+ list, ok := toSlice(items)
89
+ if !ok {
90
+ continue
91
+ }
92
+ var subs []string
93
+ for i, item := range list {
94
+ if m, ok := item.(map[string]interface{}); ok {
95
+ s := buildWhereRec(cleanWhere(m), dialect, args, ph, fmt.Sprintf("%s%s%d_", prefix, key, i))
96
+ if s == "" {
97
+ s = "1=1"
125
98
  }
99
+ subs = append(subs, s)
126
100
  }
127
- if len(subs) > 0 {
128
- conditions = append(conditions, "NOT ("+strings.Join(subs, " AND ")+")")
101
+ }
102
+ if len(subs) == 0 {
103
+ if key == "OR" {
104
+ conditions = append(conditions, "1=0")
129
105
  }
130
- continue
106
+ } else {
107
+ joiner := " OR "
108
+ if key == "AND" {
109
+ joiner = " AND "
110
+ }
111
+ clause := "(" + strings.Join(subs, joiner) + ")"
112
+ if key == "NOT" {
113
+ clause = "NOT " + clause
114
+ }
115
+ conditions = append(conditions, clause)
131
116
  }
117
+ continue
132
118
  }
133
119
 
134
120
  conditions = append(conditions, buildFieldCondition(key, value, dialect, args, ph, prefix)...)
@@ -171,13 +157,10 @@ func buildFieldCondition(key string, value interface{}, dialect Dialect, args *[
171
157
  case "not":
172
158
  if val == nil {
173
159
  parts = append(parts, col+" IS NOT NULL")
174
- } else if nested, ok := val.(map[string]interface{}); ok && !isOperatorMap(val) {
175
- if nestedArgs := len(*args); true {
176
- nestedCond := buildWhereRec(map[string]interface{}{key: nested}, dialect, args, ph, key+"_not_")
177
- _ = nestedArgs
178
- if nestedCond != "" {
179
- parts = append(parts, "NOT ("+nestedCond+")")
180
- }
160
+ } else if nested, ok := val.(map[string]interface{}); ok {
161
+ nestedCond := buildWhereRec(map[string]interface{}{key: nested}, dialect, args, ph, key+"_not_")
162
+ if nestedCond != "" {
163
+ parts = append(parts, "NOT ("+nestedCond+")")
181
164
  }
182
165
  } else {
183
166
  parts = append(parts, fmt.Sprintf("%s <> %s", col, addArg(val)))
@@ -308,4 +291,4 @@ func BuildOrderBy(orderBy interface{}, dialect Dialect) string {
308
291
  return ""
309
292
  }
310
293
  return "ORDER BY " + strings.Join(parts, ", ")
311
- }
294
+ }
@@ -0,0 +1,57 @@
1
+ package base
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "os/exec"
7
+ "path/filepath"
8
+ "reflect"
9
+ "testing"
10
+ )
11
+
12
+ // SQLite is provided by Python's standard library to keep the adapter driver-neutral.
13
+ func TestSharedQueryContract(t *testing.T) {
14
+ fixtures, err := os.ReadFile(filepath.Join("..", "..", "test", "fixtures", "query-semantics.json"))
15
+ if err != nil {
16
+ t.Fatal(err)
17
+ }
18
+ var contract struct {
19
+ Rows []map[string]interface{} `json:"rows"`
20
+ Cases []struct {
21
+ Name string `json:"name"`
22
+ Where map[string]interface{} `json:"where"`
23
+ IDs []int `json:"ids"`
24
+ } `json:"cases"`
25
+ }
26
+ if err := json.Unmarshal(fixtures, &contract); err != nil {
27
+ t.Fatal(err)
28
+ }
29
+ python, err := exec.LookPath("python3")
30
+ if err != nil {
31
+ t.Fatal("python3 is required for the SQLite query contract")
32
+ }
33
+ for _, c := range contract.Cases {
34
+ t.Run(c.Name, func(t *testing.T) {
35
+ clause, args := BuildWhere(c.Where, DialectSqlite, func(int) string { return "?" })
36
+ payload, _ := json.Marshal(map[string]interface{}{"rows": contract.Rows, "clause": clause, "args": args})
37
+ cmd := exec.Command(python, "-c", `import json,sqlite3,sys
38
+ p=json.loads(sys.argv[1])
39
+ db=sqlite3.connect(':memory:')
40
+ db.execute('CREATE TABLE users (id INTEGER, score INTEGER)')
41
+ db.executemany('INSERT INTO users VALUES (?,?)',[(r['id'],r['score']) for r in p['rows']])
42
+ sql='SELECT id FROM users'+(' WHERE '+p['clause'] if p['clause'] else '')+' ORDER BY id'
43
+ print(json.dumps([r[0] for r in db.execute(sql,p['args'])]))`, string(payload))
44
+ output, err := cmd.CombinedOutput()
45
+ if err != nil {
46
+ t.Fatalf("query failed: %v\n%s", err, output)
47
+ }
48
+ var got []int
49
+ if err := json.Unmarshal(output, &got); err != nil {
50
+ t.Fatal(err)
51
+ }
52
+ if !reflect.DeepEqual(got, c.IDs) {
53
+ t.Fatalf("got %v, want %v", got, c.IDs)
54
+ }
55
+ })
56
+ }
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@an5/adapters",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "Standalone language adapters for AN5 ORM - TypeScript, Python, .NET, Go, Rust, and Google Sheets query execution.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -58,16 +58,16 @@
58
58
  "pretest": "npm run build",
59
59
  "prepack": "npm run build",
60
60
  "build:python": "(python -m build --outdir dist-py || python3 -m build --outdir dist-py)",
61
- "test": "node test/unit.test.js && node test/package.test.js && node test/nbase.test.js && node --test test/version-sync.test.js",
61
+ "test": "node test/unit.test.js && node test/package.test.js && node test/nbase.test.js && node --test test/version-sync.test.js && node --test test/query-semantics.test.js && node test/query-relations.integration.test.js",
62
62
  "test:package:smoke": "node scripts/package-smoke.js",
63
- "test:integration:live": "node test/live-db.integration.test.js",
64
- "test:python": "(python -m compileall python 2>&1 || python3 -m compileall python 2>&1)",
63
+ "test:integration:live": "node test/live-db.integration.test.js && node test/query-relations.integration.test.js",
64
+ "test:python": "node scripts/python-tests.js",
65
65
  "test:dotnet": "node scripts/dotnet-compile-check.js && node scripts/dotnet-sqlite-smoke.js",
66
66
  "test:go": "cd golang && go test ./...",
67
67
  "test:rust": "node scripts/rust-compile-check.js"
68
68
  },
69
69
  "dependencies": {
70
- "googleapis": "^134.0.0",
70
+ "googleapis": "^183.0.0",
71
71
  "mssql": "^12.5.5",
72
72
  "pg": "^8.13.0"
73
73
  },
@@ -79,5 +79,13 @@
79
79
  },
80
80
  "publishConfig": {
81
81
  "access": "public"
82
+ },
83
+ "peerDependencies": {
84
+ "mysql2": "^3.0.0"
85
+ },
86
+ "peerDependenciesMeta": {
87
+ "mysql2": {
88
+ "optional": true
89
+ }
82
90
  }
83
91
  }
@@ -51,10 +51,8 @@ def _parse_where(model_name: str, where: Optional[Dict], params: Dict, dialect:
51
51
 
52
52
  for key, value in clean_where.items():
53
53
  if key == "OR" and isinstance(value, list):
54
- sub = [_parse_where(model_name, v, params, dialect, f"{prefix}or{i}_") for i, v in enumerate(value)]
55
- sub = [s for s in sub if s]
56
- if sub:
57
- conditions.append(f"({' OR '.join(sub)})")
54
+ sub = [_parse_where(model_name, v, params, dialect, f"{prefix}or{i}_") or "1=1" for i, v in enumerate(value)]
55
+ conditions.append(f"({' OR '.join(sub)})" if sub else "1=0")
58
56
  elif key == "AND":
59
57
  items = value if isinstance(value, list) else [value]
60
58
  sub = [_parse_where(model_name, v, params, dialect, f"{prefix}and{i}_") for i, v in enumerate(items)]
@@ -63,10 +61,9 @@ def _parse_where(model_name: str, where: Optional[Dict], params: Dict, dialect:
63
61
  conditions.append(f"({' AND '.join(sub)})")
64
62
  elif key == "NOT":
65
63
  items = value if isinstance(value, list) else [value]
66
- sub = [_parse_where(model_name, v, params, dialect, f"{prefix}not{i}_") for i, v in enumerate(items)]
67
- sub = [s for s in sub if s]
64
+ sub = [_parse_where(model_name, v, params, dialect, f"{prefix}not{i}_") or "1=1" for i, v in enumerate(items)]
68
65
  if sub:
69
- conditions.append(f"NOT ({' AND '.join(sub)})")
66
+ conditions.append(f"NOT ({' OR '.join(sub)})")
70
67
  else:
71
68
  col = _quote(key, dialect)
72
69
  pname = _sanitize_param_name(f"{prefix}{key}")
@@ -196,6 +193,8 @@ def _quote_table(t: str, dialect: str) -> str:
196
193
  # correct on MSSQL. Other dialects have to strip it and re-quote, or the
197
194
  # previous dialect's brackets follow the name into the new dialect.
198
195
  parts = _split_qualified(str(t))
196
+ if dialect == DIALECT_SQLITE and len(parts) == 2 and _strip_wrapping(_strip_wrapping(parts[0], "[", "]"), '"', '"').lower() == "dbo":
197
+ parts = parts[1:]
199
198
  if not parts:
200
199
  parts = [str(t)]
201
200
  return ".".join(_quote(p, dialect) for p in parts)
@@ -1238,6 +1238,7 @@ mod tests {
1238
1238
 
1239
1239
  #[tokio::test]
1240
1240
  async fn resolves_physical_table() {
1241
+ let _guard = crate::base::metadata::TEST_METADATA_LOCK.lock().unwrap();
1241
1242
  crate::base::set_adapter_metadata(AdapterMetadata {
1242
1243
  model_to_table: HashMap::from([("User".to_string(), "[dbo].[Users]".to_string())]),
1243
1244
  ..Default::default()
@@ -1285,6 +1286,7 @@ mod tests {
1285
1286
 
1286
1287
  #[test]
1287
1288
  fn split_relations_uses_metadata() {
1289
+ let _guard = crate::base::metadata::TEST_METADATA_LOCK.lock().unwrap();
1288
1290
  crate::base::set_adapter_metadata(AdapterMetadata {
1289
1291
  relation_map: HashMap::from([(
1290
1292
  "Order".to_string(),
@@ -35,7 +35,11 @@ impl Dialect {
35
35
 
36
36
  /// Quote a single identifier (column or table part).
37
37
  pub fn quote_identifier(&self, name: &str) -> String {
38
- let raw = strip_wrapping(strip_wrapping(name, "[", "]"), "\"", "\"");
38
+ let raw = if name.starts_with('[') && name.ends_with(']') {
39
+ strip_wrapping(name, "[", "]").replace("]]", "]")
40
+ } else if name.starts_with('"') && name.ends_with('"') {
41
+ strip_wrapping(name, "\"", "\"").replace("\"\"", "\"")
42
+ } else { name.to_string() };
39
43
  match self {
40
44
  Dialect::Postgres | Dialect::Sqlite => {
41
45
  format!("\"{}\"", raw.replace('"', "\"\""))
@@ -46,15 +50,31 @@ impl Dialect {
46
50
 
47
51
  /// Quote a possibly schema-qualified table name (`dbo.users`).
48
52
  pub fn quote_table(&self, table: &str) -> String {
49
- let t = table.trim();
50
- // Already quoted by the generator: `[dbo].[User]`.
51
- if t.starts_with('[') || t.starts_with('"') {
52
- return t.to_string();
53
+ let mut parts = Vec::new();
54
+ let mut part = String::new();
55
+ let mut closing = None;
56
+ let mut chars = table.trim().chars().peekable();
57
+ while let Some(ch) = chars.next() {
58
+ if let Some(close) = closing {
59
+ part.push(ch);
60
+ if ch == close {
61
+ if chars.peek() == Some(&close) {
62
+ part.push(chars.next().unwrap());
63
+ } else { closing = None; }
64
+ }
65
+ } else if ch == '[' || ch == '"' {
66
+ closing = Some(if ch == '[' { ']' } else { '"' });
67
+ part.push(ch);
68
+ } else if ch == '.' {
69
+ parts.push(std::mem::take(&mut part));
70
+ } else { part.push(ch); }
53
71
  }
54
- t.split('.')
55
- .map(|p| self.quote_identifier(p))
56
- .collect::<Vec<_>>()
57
- .join(".")
72
+ parts.push(part);
73
+ if *self == Dialect::Sqlite && parts.len() == 2 {
74
+ let schema = strip_wrapping(strip_wrapping(parts[0].trim(), "[", "]"), "\"", "\"");
75
+ if schema.eq_ignore_ascii_case("dbo") { parts.remove(0); }
76
+ }
77
+ parts.iter().map(|p| self.quote_identifier(p.trim())).collect::<Vec<_>>().join(".")
58
78
  }
59
79
 
60
80
  /// Positional placeholder: `$N` for Postgres, `?` otherwise.
@@ -133,6 +153,15 @@ pub fn vector_distance(a: &[f64], b: &[f64], metric: &str) -> f64 {
133
153
  mod tests {
134
154
  use super::*;
135
155
 
156
+ #[test]
157
+ fn converts_generated_table_names_for_sqlite() {
158
+ assert_eq!(Dialect::Sqlite.quote_table("[dbo].[users]"), "\"users\"");
159
+ assert_eq!(Dialect::Sqlite.quote_table("[main].[users]"), "\"main\".\"users\"");
160
+ assert_eq!(Dialect::Sqlite.quote_table("[dot.name]"), "\"dot.name\"");
161
+ assert_eq!(Dialect::Postgres.quote_table("[dbo].[a]]b]"), "\"dbo\".\"a]b\"");
162
+ assert_eq!(Dialect::Mssql.quote_table("[dbo].[a]]b]"), "[dbo].[a]]b]");
163
+ }
164
+
136
165
  #[test]
137
166
  fn detects_dialects() {
138
167
  assert_eq!(
@@ -2,6 +2,9 @@
2
2
  //!
3
3
  //! Mirrors the Go `an5adapters/base` metadata store.
4
4
 
5
+ #[cfg(test)]
6
+ pub(crate) static TEST_METADATA_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
7
+
5
8
  use std::collections::HashMap;
6
9
  use std::sync::{OnceLock, RwLock};
7
10
 
@@ -123,6 +126,7 @@ mod tests {
123
126
  fn resolves_table_with_case_fallback() {
124
127
  let mut map = HashMap::new();
125
128
  map.insert("User".to_string(), "[dbo].[Users]".to_string());
129
+ let _guard = TEST_METADATA_LOCK.lock().unwrap();
126
130
  set_adapter_metadata(AdapterMetadata {
127
131
  model_to_table: map,
128
132
  ..Default::default()
@@ -44,10 +44,6 @@ fn as_object(v: &Value) -> Option<&Map<String, Value>> {
44
44
  }
45
45
  }
46
46
 
47
- fn is_list(v: &Value) -> bool {
48
- v.is_array()
49
- }
50
-
51
47
  fn to_list(v: &Value) -> Vec<Value> {
52
48
  match v {
53
49
  Value::Array(items) => items.clone(),
@@ -113,38 +109,23 @@ fn build_where_rec(
113
109
  let value = &where_obj[key];
114
110
 
115
111
  match key.as_str() {
116
- "AND" | "OR" if is_list(value) => {
117
- let joiner = if key == "AND" { " AND " } else { " OR " };
112
+ "AND" | "OR" | "NOT" => {
118
113
  let mut subs: Vec<String> = Vec::new();
119
114
  for item in to_list(value) {
120
115
  if let Some(m) = as_object(&item) {
121
- let s = build_where_rec(m, dialect, args, ph);
122
- if !s.is_empty() {
123
- subs.push(s);
124
- }
116
+ let s = build_where_rec(&clean_where(m), dialect, args, ph);
117
+ subs.push(if s.is_empty() { "1=1".to_string() } else { s });
125
118
  }
126
119
  }
127
- if !subs.is_empty() {
128
- conditions.push(format!("({})", subs.join(joiner)));
129
- }
130
- continue;
131
- }
132
- "NOT" => {
133
- let mut subs: Vec<String> = Vec::new();
134
- for item in to_list(value) {
135
- if let Some(m) = as_object(&item) {
136
- let s = build_where_rec(m, dialect, args, ph);
137
- if !s.is_empty() {
138
- subs.push(s);
139
- }
140
- }
141
- }
142
- if !subs.is_empty() {
143
- conditions.push(format!("NOT ({})", subs.join(" AND ")));
120
+ if subs.is_empty() {
121
+ if key == "OR" { conditions.push("1=0".to_string()); }
122
+ } else {
123
+ let joiner = if key == "AND" { " AND " } else { " OR " };
124
+ let negation = if key == "NOT" { "NOT " } else { "" };
125
+ conditions.push(format!("{}({})", negation, subs.join(joiner)));
144
126
  }
145
127
  continue;
146
128
  }
147
- "AND" | "OR" => continue,
148
129
  _ => {}
149
130
  }
150
131
 
@@ -317,7 +317,8 @@ export class An5Adapter {
317
317
  } else if (forDialect.startsWith('mysql://') || forDialect.startsWith('mariadb://')) {
318
318
  this._engineType = 'mysql';
319
319
  } else if (
320
- forDialect.startsWith('sqlite://')
320
+ forDialect === ':memory:'
321
+ || forDialect.startsWith('sqlite:')
321
322
  || SQLITE_FILE_SUFFIXES.some((suffix) => forDialect.endsWith(suffix))
322
323
  ) {
323
324
  this._engineType = 'sqlite';
@@ -88,10 +88,11 @@ export function parseWhere(
88
88
  if (!where) return '';
89
89
  const conditions: string[] = [];
90
90
 
91
+ const relations = ctx?.relationMap?.[modelName] || {};
91
92
  const cleanWhere: Record<string, any> = {};
92
93
  for (const [key, value] of Object.entries(where)) {
93
94
  if (
94
- key.includes('_') &&
95
+ key.includes('_') && !relations[key] &&
95
96
  value && typeof value === 'object' &&
96
97
  !(value instanceof Date) &&
97
98
  !isOperatorValue(value)
@@ -102,23 +103,23 @@ export function parseWhere(
102
103
  }
103
104
  }
104
105
 
105
- const relations = ctx?.relationMap?.[modelName] || {};
106
106
  const colPrefix = ctx?.colPrefix || '';
107
107
 
108
108
  for (const [key, value] of Object.entries(cleanWhere)) {
109
+ if (value === undefined) continue;
109
110
  if (key === 'OR' && Array.isArray(value)) {
110
- const sub = value.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}or_${i}_`, ctx)).filter(Boolean);
111
- if (sub.length > 0) conditions.push(`(${sub.join(' OR ')})`);
111
+ const sub = value.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}or_${i}_`, ctx) || '1=1');
112
+ conditions.push(sub.length > 0 ? `(${sub.join(' OR ')})` : '1=0');
112
113
  } else if (key === 'AND') {
113
114
  const items = Array.isArray(value) ? value : [value];
114
115
  const sub = items.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}and_${i}_`, ctx)).filter(Boolean);
115
116
  if (sub.length > 0) conditions.push(`(${sub.join(' AND ')})`);
116
117
  } else if (key === 'NOT') {
117
118
  const items = Array.isArray(value) ? value : [value];
118
- const sub = items.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}not_${i}_`, ctx)).filter(Boolean);
119
- if (sub.length > 0) conditions.push(`NOT (${sub.join(' AND ')})`);
119
+ const sub = items.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}not_${i}_`, ctx) || '1=1');
120
+ if (sub.length > 0) conditions.push(`NOT (${sub.join(' OR ')})`);
120
121
  } else if (relations[key] && value && typeof value === 'object') {
121
- const clause = buildRelationClause(relations[key], value, modelName, params, dialect, prefix, ctx);
122
+ const clause = buildRelationClause(relations[key], value, modelName, params, dialect, `${prefix}${key}_`, ctx);
122
123
  if (clause) conditions.push(clause);
123
124
  } else {
124
125
  const pname = sanitizeParamName(`${prefix}${key}`);
@@ -203,28 +204,28 @@ function buildRelationClause(
203
204
 
204
205
  if (isMany) {
205
206
  if (value.some !== undefined) {
206
- const inner = buildInnerWhere(relation.modelName, value.some === null ? {} : value.some, params, dialect, prefix, ctx, childAlias);
207
+ const inner = buildInnerWhere(relation.modelName, value.some === null ? {} : value.some, params, dialect, `${prefix}some_`, ctx, childAlias);
207
208
  parts.push(buildExists(inner));
208
209
  }
209
210
  if (value.none !== undefined) {
210
- const inner = buildInnerWhere(relation.modelName, value.none === null ? {} : value.none, params, dialect, prefix, ctx, childAlias);
211
+ const inner = buildInnerWhere(relation.modelName, value.none === null ? {} : value.none, params, dialect, `${prefix}none_`, ctx, childAlias);
211
212
  parts.push(`NOT ${buildExists(inner)}`);
212
213
  }
213
214
  if (value.every !== undefined) {
214
215
  // "every" = no related row violates the condition.
215
- const inner = buildInnerWhere(relation.modelName, value.every === null ? {} : value.every, params, dialect, prefix, ctx, childAlias);
216
+ const inner = buildInnerWhere(relation.modelName, value.every === null ? {} : value.every, params, dialect, `${prefix}every_`, ctx, childAlias);
216
217
  const negated = inner ? `NOT (${inner})` : '';
217
218
  const violated = `EXISTS (SELECT 1 FROM ${childTable} AS ${childAlias} WHERE ${correlation}${negated ? ` AND ${negated}` : ' AND 1=0'})`;
218
219
  parts.push(`NOT ${violated}`);
219
220
  }
220
221
  } else {
221
222
  if (value.is !== undefined) {
222
- const inner = buildInnerWhere(relation.modelName, value.is === null ? {} : value.is, params, dialect, prefix, ctx, childAlias);
223
- parts.push(buildExists(inner));
223
+ const inner = buildInnerWhere(relation.modelName, value.is === null ? {} : value.is, params, dialect, `${prefix}is_`, ctx, childAlias);
224
+ parts.push(value.is === null ? `NOT ${buildExists(inner)}` : buildExists(inner));
224
225
  }
225
226
  if (value.isNot !== undefined) {
226
- const inner = buildInnerWhere(relation.modelName, value.isNot === null ? {} : value.isNot, params, dialect, prefix, ctx, childAlias);
227
- parts.push(`NOT ${buildExists(inner)}`);
227
+ const inner = buildInnerWhere(relation.modelName, value.isNot === null ? {} : value.isNot, params, dialect, `${prefix}isNot_`, ctx, childAlias);
228
+ parts.push(value.isNot === null ? buildExists(inner) : `NOT ${buildExists(inner)}`);
228
229
  }
229
230
  }
230
231
 
@@ -21,9 +21,7 @@ export function matchWhere(row: Record<string, any>, where: any): boolean {
21
21
  key.includes('_') &&
22
22
  value && typeof value === 'object' &&
23
23
  !(value instanceof Date) &&
24
- !(value as any).in && !(value as any).contains &&
25
- !(value as any).not && !(value as any).gte &&
26
- !(value as any).lte && !(value as any).gt && !(value as any).lt
24
+ !['equals', 'in', 'notIn', 'contains', 'startsWith', 'endsWith', 'not', 'gte', 'lte', 'gt', 'lt'].some(op => op in value)
27
25
  ) {
28
26
  Object.assign(cleanWhere, value);
29
27
  } else {
@@ -32,12 +30,14 @@ export function matchWhere(row: Record<string, any>, where: any): boolean {
32
30
  }
33
31
 
34
32
  for (const [key, value] of Object.entries(cleanWhere)) {
33
+ if (value === undefined) continue;
35
34
  if (key === 'OR' && Array.isArray(value)) {
36
35
  if (!value.some((v: any) => matchWhere(row, v))) return false;
37
36
  continue;
38
37
  }
39
- if (key === 'AND' && Array.isArray(value)) {
40
- if (!value.every((v: any) => matchWhere(row, v))) return false;
38
+ if (key === 'AND') {
39
+ const clauses = Array.isArray(value) ? value : [value];
40
+ if (!clauses.every((v: any) => matchWhere(row, v))) return false;
41
41
  continue;
42
42
  }
43
43
  if (key === 'NOT') {
@@ -57,17 +57,20 @@ export function matchWhere(row: Record<string, any>, where: any): boolean {
57
57
 
58
58
  if (v.not !== undefined) {
59
59
  if (v.not === null) { if (cellVal === null || cellVal === undefined) return false; }
60
+ else if (v.not && typeof v.not === 'object' && !(v.not instanceof Date) && !Array.isArray(v.not)) {
61
+ if (matchWhere(row, { [key]: v.not })) return false;
62
+ }
60
63
  else if (normalize(cellVal) == normalize(v.not)) return false;
61
64
  }
62
65
  if (v.equals !== undefined && normalize(cellVal) != normalize(v.equals)) return false;
63
66
  if (v.contains !== undefined) {
64
- if (!cellVal || !normalize(String(cellVal)).includes(normalize(v.contains))) return false;
67
+ if (cellVal === null || cellVal === undefined || !normalize(String(cellVal)).includes(normalize(v.contains))) return false;
65
68
  }
66
69
  if (v.startsWith !== undefined) {
67
- if (!cellVal || !normalize(String(cellVal)).startsWith(normalize(v.startsWith))) return false;
70
+ if (cellVal === null || cellVal === undefined || !normalize(String(cellVal)).startsWith(normalize(v.startsWith))) return false;
68
71
  }
69
72
  if (v.endsWith !== undefined) {
70
- if (!cellVal || !normalize(String(cellVal)).endsWith(normalize(v.endsWith))) return false;
73
+ if (cellVal === null || cellVal === undefined || !normalize(String(cellVal)).endsWith(normalize(v.endsWith))) return false;
71
74
  }
72
75
  if (v.gte !== undefined && !(Number(cellVal) >= Number(v.gte))) return false;
73
76
  if (v.lte !== undefined && !(Number(cellVal) <= Number(v.lte))) return false;
@@ -14,7 +14,8 @@ export class SqliteEngine implements QueryEngine {
14
14
  if (!BetterSqlite3) throw new Error('better-sqlite3 package is required for SQLite support. Run: npm install better-sqlite3');
15
15
  this.filePath = (adapterConfig.connectionString ?? '')
16
16
  .replace(/^sqlite:\/\/\//i, '/')
17
- .replace(/^sqlite:\/\//i, '');
17
+ .replace(/^sqlite:\/\//i, '')
18
+ .replace(/^sqlite:/i, '');
18
19
  }
19
20
 
20
21
  private getDb(): any {