@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.
- package/README.md +30 -0
- package/dist/an5Adapter.js +54 -31
- package/dist/base/metadata.d.ts +2 -0
- package/dist/base/metadata.js +10 -0
- package/dist/base/sql.d.ts +2 -0
- package/dist/base/sql.js +40 -40
- package/dist/googlesheets/adapter.js +16 -8
- package/dist/googlesheets/config.d.ts +13 -0
- package/dist/googlesheets/config.js +5 -1
- package/dist/googlesheets/helpers.js +13 -8
- package/dist/googlesheets/oauth.d.ts +3 -0
- package/dist/googlesheets/oauth.js +43 -0
- package/dist/googlesheets/parseConnectionString.js +12 -0
- package/dist/googlesheets/tableClient.js +5 -23
- package/dist/sqlite/engine.js +2 -1
- package/dotnet/Base/Core.cs +14 -24
- package/golang/base/where.go +32 -49
- package/golang/base/where_test.go +57 -0
- package/package.json +19 -6
- package/python/base/sql.py +6 -7
- package/rust/src/adapter.rs +2 -0
- package/rust/src/base/dialect.rs +38 -9
- package/rust/src/base/metadata.rs +4 -0
- package/rust/src/base/where_builder.rs +9 -28
- package/typescript/src/an5Adapter.ts +56 -30
- package/typescript/src/base/metadata.ts +8 -0
- package/typescript/src/base/sql.ts +38 -30
- package/typescript/src/googlesheets/adapter.ts +17 -8
- package/typescript/src/googlesheets/config.ts +10 -1
- package/typescript/src/googlesheets/helpers.ts +11 -8
- package/typescript/src/googlesheets/oauth.ts +31 -0
- package/typescript/src/googlesheets/parseConnectionString.ts +8 -0
- package/typescript/src/googlesheets/tableClient.ts +5 -19
- package/typescript/src/sqlite/engine.ts +2 -1
|
@@ -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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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();
|
package/dist/sqlite/engine.js
CHANGED
|
@@ -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)
|
package/dotnet/Base/Core.cs
CHANGED
|
@@ -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"
|
|
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
|
|
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}
|
|
271
|
-
|
|
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
|
|
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 (
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
package/golang/base/where.go
CHANGED
|
@@ -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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
128
|
-
|
|
101
|
+
}
|
|
102
|
+
if len(subs) == 0 {
|
|
103
|
+
if key == "OR" {
|
|
104
|
+
conditions = append(conditions, "1=0")
|
|
129
105
|
}
|
|
130
|
-
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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.
|
|
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": "
|
|
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": "^
|
|
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
|
}
|
package/python/base/sql.py
CHANGED
|
@@ -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
|
-
|
|
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 ({'
|
|
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)
|
package/rust/src/adapter.rs
CHANGED
|
@@ -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(),
|
package/rust/src/base/dialect.rs
CHANGED
|
@@ -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 =
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
.
|
|
57
|
-
.
|
|
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"
|
|
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
|
|
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
|
|
128
|
-
conditions.push(
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|