@an5/adapters 0.2.9 → 0.2.11

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.
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sheetsTokenProvider = sheetsTokenProvider;
4
+ /** Deduplicate refresh requests within an adapter; do not expose token responses. */
5
+ function sheetsTokenProvider(config) {
6
+ let token = config.accessToken;
7
+ let expiresAt = config.tokenExpiresAt;
8
+ let pending;
9
+ return async (force = false) => {
10
+ if (token && !force && (expiresAt === undefined || expiresAt > Date.now() + 60000))
11
+ return token;
12
+ if (!config.refreshToken || !config.oauthClientId) {
13
+ if (force || expiresAt !== undefined)
14
+ throw new Error('Google access expired. Reconnect your Google account.');
15
+ return token;
16
+ }
17
+ if (!pending)
18
+ pending = (async () => {
19
+ try {
20
+ const response = await fetch('https://oauth2.googleapis.com/token', {
21
+ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
22
+ body: new URLSearchParams({ grant_type: 'refresh_token', client_id: config.oauthClientId, refresh_token: config.refreshToken, ...(config.oauthClientSecret ? { client_secret: config.oauthClientSecret } : {}) }),
23
+ signal: AbortSignal.timeout(20000),
24
+ });
25
+ if (!response.ok)
26
+ throw new Error();
27
+ const result = await response.json();
28
+ if (!result.access_token || typeof result.access_token !== 'string' || typeof result.expires_in !== 'number' || !Number.isFinite(result.expires_in) || result.expires_in <= 0)
29
+ throw new Error();
30
+ token = result.access_token;
31
+ expiresAt = Date.now() + result.expires_in * 1000;
32
+ return token;
33
+ }
34
+ catch {
35
+ throw new Error('Google access expired or was revoked. Reconnect your Google account.');
36
+ }
37
+ finally {
38
+ pending = undefined;
39
+ }
40
+ })();
41
+ return pending;
42
+ };
43
+ }
@@ -36,6 +36,18 @@ function parseSheetsConnectionString(url) {
36
36
  result.privateKey = config.privatekey;
37
37
  if (config.accesstoken)
38
38
  result.accessToken = config.accesstoken;
39
+ if (config.refreshtoken)
40
+ result.refreshToken = config.refreshtoken;
41
+ if (config.oauthclientid)
42
+ result.oauthClientId = config.oauthclientid;
43
+ if (config.oauthclientsecret)
44
+ result.oauthClientSecret = config.oauthclientsecret;
45
+ if (config.tokenexpiresat) {
46
+ const expiry = Number(config.tokenexpiresat);
47
+ if (!Number.isFinite(expiry) || expiry < 0)
48
+ throw new Error('Invalid OAuth token expiry.');
49
+ result.tokenExpiresAt = expiry;
50
+ }
39
51
  if (config.apikey)
40
52
  result.apiKey = config.apikey;
41
53
  if (config.sheetmapping) {
@@ -6,11 +6,6 @@ const metadata_1 = require("../base/metadata");
6
6
  const helpers_1 = require("./helpers");
7
7
  const retry_1 = require("./retry");
8
8
  // ─── Utility ──────────────────────────────────────────────────────────────────
9
- function resolveIdField(fields) {
10
- if (Object.prototype.hasOwnProperty.call(fields, 'id'))
11
- return 'id';
12
- return Object.keys(fields).find(name => name.endsWith('_id') || name.endsWith('Id') || name.toLowerCase() === 'id');
13
- }
14
9
  // ─── Table Client ─────────────────────────────────────────────────────────────
15
10
  class SheetsTableClient {
16
11
  constructor(adapter, modelName, sheetMapping) {
@@ -156,7 +151,7 @@ class SheetsTableClient {
156
151
  return rows.length;
157
152
  }
158
153
  assignId(data, fields) {
159
- const idFieldName = resolveIdField(fields);
154
+ const idFieldName = (0, metadata_1.resolveIdField)(fields);
160
155
  if (!idFieldName)
161
156
  return;
162
157
  const fieldDef = fields[idFieldName];
@@ -201,23 +196,10 @@ class SheetsTableClient {
201
196
  }
202
197
  return this.rowToValues(data, headers);
203
198
  });
204
- try {
205
- await this.adapter.appendRange(`${this.escSheetName}!A:A`, batchValues);
206
- return { count: batchValues.length };
207
- }
208
- catch (e) {
209
- if (!args.skipDuplicates)
210
- throw e;
211
- let count = 0;
212
- for (const row of args.data) {
213
- try {
214
- await this.create({ data: row });
215
- count++;
216
- }
217
- catch { /* skip */ }
218
- }
219
- return { count };
220
- }
199
+ // Sheets has no unique constraints. An append failure must propagate;
200
+ // retrying individual rows could duplicate a batch the service accepted.
201
+ await this.adapter.appendRange(`${this.escSheetName}!A:A`, batchValues);
202
+ return { count: batchValues.length };
221
203
  }
222
204
  async update(args) {
223
205
  const { headers, rows } = await this.readAllRows();
@@ -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.11",
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 && node --test test/sheets-oauth.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
  },
@@ -75,9 +75,22 @@
75
75
  "@types/mssql": "^9.1.8",
76
76
  "@types/node": "^20.11.0",
77
77
  "@types/pg": "^8.11.0",
78
- "typescript": "^7.0.2"
78
+ "typescript": "^7.0.2",
79
+ "better-sqlite3": "^11.5.0"
79
80
  },
80
81
  "publishConfig": {
81
82
  "access": "public"
83
+ },
84
+ "peerDependencies": {
85
+ "mysql2": "^3.0.0",
86
+ "better-sqlite3": "^11.0.0"
87
+ },
88
+ "peerDependenciesMeta": {
89
+ "mysql2": {
90
+ "optional": true
91
+ },
92
+ "better-sqlite3": {
93
+ "optional": true
94
+ }
82
95
  }
83
96
  }
@@ -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