@an5/adapters 0.2.6 → 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.
- package/README.md +96 -2
- package/dist/an5Adapter.d.ts +57 -0
- package/dist/an5Adapter.js +263 -4
- package/dist/base/sql.js +16 -15
- package/dist/base/types.d.ts +6 -0
- package/dist/googlesheets/helpers.js +13 -8
- package/dist/index.d.ts +2 -0
- package/dist/index.js +9 -1
- package/dist/nbase/client.d.ts +92 -0
- package/dist/nbase/client.js +147 -0
- package/dist/nbase/index.d.ts +47 -0
- package/dist/nbase/index.js +80 -0
- package/dist/sqlite/engine.js +2 -1
- package/dotnet/An5Adapter.cs +41 -18
- package/dotnet/Base/Core.cs +56 -26
- package/dotnet/Postgres/PostgresEngine.cs +58 -30
- package/dotnet/Sqlite/SqliteEngine.cs +214 -0
- package/golang/base/where.go +32 -49
- package/golang/base/where_test.go +57 -0
- package/package.json +26 -11
- package/python/an5_adapter.py +74 -9
- package/python/base/__init__.py +1 -1
- package/python/base/metadata.py +25 -1
- package/python/base/sql.py +50 -21
- package/python/sqlite/__init__.py +3 -0
- package/python/sqlite/provider.py +83 -0
- package/python/table_client.py +53 -36
- package/rust/Cargo.toml +31 -0
- package/rust/README.md +71 -0
- package/rust/src/adapter.rs +1314 -0
- package/rust/src/base/dialect.rs +201 -0
- package/rust/src/base/metadata.rs +138 -0
- package/rust/src/base/mod.rs +17 -0
- package/rust/src/base/where_builder.rs +386 -0
- package/rust/src/lib.rs +75 -0
- package/typescript/src/an5Adapter.ts +300 -4
- package/typescript/src/base/sql.ts +15 -14
- package/typescript/src/base/types.ts +6 -0
- package/typescript/src/googlesheets/helpers.ts +11 -8
- package/typescript/src/index.ts +17 -0
- package/typescript/src/nbase/client.ts +214 -0
- package/typescript/src/nbase/index.ts +104 -0
- package/typescript/src/sqlite/engine.ts +2 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@an5/adapters",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Standalone language adapters for AN5 ORM - TypeScript, Python, .NET, Go, and Google Sheets query execution.",
|
|
3
|
+
"version": "0.2.10",
|
|
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",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
"./typescript/*": "./dist/*",
|
|
22
22
|
"./python": "./python/an5_adapter.py",
|
|
23
23
|
"./dotnet": "./dotnet/An5Adapter.cs",
|
|
24
|
-
"./golang": "./golang/an5_adapter.go"
|
|
24
|
+
"./golang": "./golang/an5_adapter.go",
|
|
25
|
+
"./rust": "./rust/Cargo.toml",
|
|
26
|
+
"./nbase": "./dist/nbase/index.js"
|
|
25
27
|
},
|
|
26
28
|
"author": "an5ORM",
|
|
27
29
|
"homepage": "https://github.com/an5ORM/an5",
|
|
@@ -37,7 +39,8 @@
|
|
|
37
39
|
"python",
|
|
38
40
|
"dotnet",
|
|
39
41
|
"go",
|
|
40
|
-
"golang"
|
|
42
|
+
"golang",
|
|
43
|
+
"rust"
|
|
41
44
|
],
|
|
42
45
|
"files": [
|
|
43
46
|
"dist/**/*.{js,d.ts}",
|
|
@@ -45,22 +48,26 @@
|
|
|
45
48
|
"python/**/*.py",
|
|
46
49
|
"dotnet/**/*",
|
|
47
50
|
"golang/**/*",
|
|
48
|
-
"
|
|
51
|
+
"rust/Cargo.toml",
|
|
52
|
+
"rust/src/**/*",
|
|
53
|
+
"README.md",
|
|
54
|
+
"typescript/src/nbase/**/*.ts"
|
|
49
55
|
],
|
|
50
56
|
"scripts": {
|
|
51
57
|
"build": "tsc -p tsconfig.json",
|
|
52
58
|
"pretest": "npm run build",
|
|
53
59
|
"prepack": "npm run build",
|
|
54
60
|
"build:python": "(python -m build --outdir dist-py || python3 -m build --outdir dist-py)",
|
|
55
|
-
"test": "node test/unit.test.js && node test/package.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",
|
|
56
62
|
"test:package:smoke": "node scripts/package-smoke.js",
|
|
57
|
-
"test:integration:live": "node test/live-db.integration.test.js",
|
|
58
|
-
"test:python": "
|
|
59
|
-
"test:dotnet": "node scripts/dotnet-compile-check.js",
|
|
60
|
-
"test:go": "cd golang && go test ./..."
|
|
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
|
+
"test:dotnet": "node scripts/dotnet-compile-check.js && node scripts/dotnet-sqlite-smoke.js",
|
|
66
|
+
"test:go": "cd golang && go test ./...",
|
|
67
|
+
"test:rust": "node scripts/rust-compile-check.js"
|
|
61
68
|
},
|
|
62
69
|
"dependencies": {
|
|
63
|
-
"googleapis": "^
|
|
70
|
+
"googleapis": "^183.0.0",
|
|
64
71
|
"mssql": "^12.5.5",
|
|
65
72
|
"pg": "^8.13.0"
|
|
66
73
|
},
|
|
@@ -72,5 +79,13 @@
|
|
|
72
79
|
},
|
|
73
80
|
"publishConfig": {
|
|
74
81
|
"access": "public"
|
|
82
|
+
},
|
|
83
|
+
"peerDependencies": {
|
|
84
|
+
"mysql2": "^3.0.0"
|
|
85
|
+
},
|
|
86
|
+
"peerDependenciesMeta": {
|
|
87
|
+
"mysql2": {
|
|
88
|
+
"optional": true
|
|
89
|
+
}
|
|
75
90
|
}
|
|
76
91
|
}
|
package/python/an5_adapter.py
CHANGED
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
from typing import Dict, List, Optional, Any
|
|
4
4
|
|
|
5
5
|
try:
|
|
6
|
-
from .base import DIALECT_MSSQL, DIALECT_POSTGRES, detect_dialect, set_adapter_metadata
|
|
6
|
+
from .base import DIALECT_MSSQL, DIALECT_POSTGRES, DIALECT_SQLITE, detect_dialect, set_adapter_metadata
|
|
7
7
|
from .mssql import connect as connect_mssql
|
|
8
8
|
from .postgres import connect as connect_postgres
|
|
9
|
+
from .sqlite import connect as connect_sqlite, is_memory as is_sqlite_memory
|
|
9
10
|
from .table_client import AdapterTableClient, ViewClient
|
|
10
11
|
except ImportError:
|
|
11
|
-
from base import DIALECT_MSSQL, DIALECT_POSTGRES, detect_dialect, set_adapter_metadata
|
|
12
|
+
from base import DIALECT_MSSQL, DIALECT_POSTGRES, DIALECT_SQLITE, detect_dialect, set_adapter_metadata
|
|
12
13
|
from mssql import connect as connect_mssql
|
|
13
14
|
from postgres import connect as connect_postgres
|
|
15
|
+
from sqlite import connect as connect_sqlite, is_memory as is_sqlite_memory
|
|
14
16
|
from table_client import AdapterTableClient, ViewClient
|
|
15
17
|
|
|
16
18
|
# Backward-compatible aliases used by tests and older imports.
|
|
@@ -24,12 +26,38 @@ class An5Adapter:
|
|
|
24
26
|
def __init__(self, connection_string: str):
|
|
25
27
|
self._dialect = detect_dialect(connection_string)
|
|
26
28
|
self._conn_str = connection_string
|
|
29
|
+
# The open connection of `transaction()`, when there is one. Every
|
|
30
|
+
# statement in the callback has to use this very connection, otherwise a
|
|
31
|
+
# rollback returns to a different, already committed connection — that
|
|
32
|
+
# is "the error was raised but the data is still there".
|
|
33
|
+
self._txn_conn = None
|
|
34
|
+
# Long-lived connection for in-memory databases, because `:memory:` is
|
|
35
|
+
# gone as soon as every connection is closed.
|
|
36
|
+
self._conn = None
|
|
27
37
|
|
|
28
38
|
def _connect(self):
|
|
29
39
|
if self._dialect == DIALECT_POSTGRES:
|
|
30
40
|
return connect_postgres(self._conn_str)
|
|
41
|
+
if self._dialect == DIALECT_SQLITE:
|
|
42
|
+
return connect_sqlite(self._conn_str)
|
|
31
43
|
return connect_mssql(self._conn_str)
|
|
32
44
|
|
|
45
|
+
def _acquire(self):
|
|
46
|
+
"""Returns (connection, whether we opened it and must close it)."""
|
|
47
|
+
if self._txn_conn is not None:
|
|
48
|
+
return self._txn_conn, False
|
|
49
|
+
if self._dialect == DIALECT_SQLITE and is_sqlite_memory(self._conn_str):
|
|
50
|
+
if self._conn is None:
|
|
51
|
+
self._conn = self._connect()
|
|
52
|
+
return self._conn, False
|
|
53
|
+
return self._connect(), True
|
|
54
|
+
|
|
55
|
+
def close(self):
|
|
56
|
+
"""Closes the long-lived connection (in-memory databases only)."""
|
|
57
|
+
if self._conn is not None:
|
|
58
|
+
self._conn.close()
|
|
59
|
+
self._conn = None
|
|
60
|
+
|
|
33
61
|
def _to_dicts(self, cursor, query: str) -> List[Dict]:
|
|
34
62
|
if cursor.description:
|
|
35
63
|
cols = [col[0] for col in cursor.description]
|
|
@@ -38,22 +66,24 @@ class An5Adapter:
|
|
|
38
66
|
return []
|
|
39
67
|
|
|
40
68
|
def exec(self, query: str, params: Optional[List] = None) -> List[Dict]:
|
|
41
|
-
conn = self.
|
|
69
|
+
conn, owned = self._acquire()
|
|
42
70
|
try:
|
|
43
71
|
cursor = conn.cursor()
|
|
44
72
|
cursor.execute(query, params or [])
|
|
45
73
|
return self._to_dicts(cursor, query)
|
|
46
74
|
finally:
|
|
47
|
-
|
|
75
|
+
if owned:
|
|
76
|
+
conn.close()
|
|
48
77
|
|
|
49
78
|
def execute(self, query: str, params: Optional[List] = None) -> int:
|
|
50
|
-
conn = self.
|
|
79
|
+
conn, owned = self._acquire()
|
|
51
80
|
try:
|
|
52
81
|
cursor = conn.cursor()
|
|
53
82
|
cursor.execute(query, params or [])
|
|
54
83
|
return cursor.rowcount
|
|
55
84
|
finally:
|
|
56
|
-
|
|
85
|
+
if owned:
|
|
86
|
+
conn.close()
|
|
57
87
|
|
|
58
88
|
def query_raw(self, query: str, *values) -> List[Dict]:
|
|
59
89
|
return self.exec(query, list(values))
|
|
@@ -68,6 +98,15 @@ class An5Adapter:
|
|
|
68
98
|
return ViewClient(self, view_name)
|
|
69
99
|
|
|
70
100
|
def query_proc(self, proc_name: str, params: Optional[List] = None) -> List[Dict]:
|
|
101
|
+
if self._dialect == DIALECT_SQLITE:
|
|
102
|
+
# SQLite has no stored procedures. This used to fall through to the
|
|
103
|
+
# MSSQL branch and generate `EXEC ...`, which is not valid SQL, and
|
|
104
|
+
# the error only surfaced at the call. Saying so beats sending wrong
|
|
105
|
+
# SQL.
|
|
106
|
+
raise NotImplementedError(
|
|
107
|
+
"SQLite has no stored procedures. Use the generated table client "
|
|
108
|
+
"(db.<model>.*) or db.exec() with a parameterised query."
|
|
109
|
+
)
|
|
71
110
|
if self._dialect == DIALECT_POSTGRES:
|
|
72
111
|
placeholders = ", ".join(["%s"] * len(params or []))
|
|
73
112
|
sql = f"CALL {proc_name}({placeholders})" if placeholders else f"CALL {proc_name}()"
|
|
@@ -77,6 +116,11 @@ class An5Adapter:
|
|
|
77
116
|
return self.exec(sql, params or [])
|
|
78
117
|
|
|
79
118
|
def execute_proc(self, proc_name: str, params: Optional[List] = None) -> int:
|
|
119
|
+
if self._dialect == DIALECT_SQLITE:
|
|
120
|
+
raise NotImplementedError(
|
|
121
|
+
"SQLite has no stored procedures. Use the generated table client "
|
|
122
|
+
"(db.<model>.*) or db.execute() with a parameterised query."
|
|
123
|
+
)
|
|
80
124
|
if self._dialect == DIALECT_POSTGRES:
|
|
81
125
|
placeholders = ", ".join(["%s"] * len(params or []))
|
|
82
126
|
sql = f"CALL {proc_name}({placeholders})" if placeholders else f"CALL {proc_name}()"
|
|
@@ -89,8 +133,24 @@ class An5Adapter:
|
|
|
89
133
|
return self.table(model_name)
|
|
90
134
|
|
|
91
135
|
def transaction(self, fn):
|
|
92
|
-
|
|
93
|
-
|
|
136
|
+
if self._txn_conn is not None:
|
|
137
|
+
raise RuntimeError("Nested transaction() is not supported")
|
|
138
|
+
# In-memory database: the connection holding the data has to be reused.
|
|
139
|
+
# Opening a new one here points at a different, empty database — the
|
|
140
|
+
# table just created disappears and every statement in the transaction
|
|
141
|
+
# fails.
|
|
142
|
+
conn = self._conn if self._conn is not None else self._connect()
|
|
143
|
+
owned = self._conn is None
|
|
144
|
+
if self._dialect == DIALECT_SQLITE:
|
|
145
|
+
# `sqlite3.Connection` has no `autocommit` attribute; transactions
|
|
146
|
+
# are driven by `isolation_level`. The provider opens at `None`
|
|
147
|
+
# (autocommit), so to get a transaction it has to be set back to `""`
|
|
148
|
+
# and sqlite3 opens an implicit BEGIN before each DML statement —
|
|
149
|
+
# only then do `commit()`/`rollback()` do anything.
|
|
150
|
+
conn.isolation_level = ""
|
|
151
|
+
else:
|
|
152
|
+
conn.autocommit = False
|
|
153
|
+
self._txn_conn = conn
|
|
94
154
|
try:
|
|
95
155
|
result = fn(self)
|
|
96
156
|
conn.commit()
|
|
@@ -99,9 +159,13 @@ class An5Adapter:
|
|
|
99
159
|
conn.rollback()
|
|
100
160
|
raise
|
|
101
161
|
finally:
|
|
162
|
+
self._txn_conn = None
|
|
102
163
|
if self._dialect == DIALECT_POSTGRES:
|
|
103
164
|
conn.autocommit = True
|
|
104
|
-
|
|
165
|
+
elif self._dialect == DIALECT_SQLITE:
|
|
166
|
+
conn.isolation_level = None
|
|
167
|
+
if owned:
|
|
168
|
+
conn.close()
|
|
105
169
|
|
|
106
170
|
def create_an5_adapter(connection_string: str) -> An5Adapter:
|
|
107
171
|
return An5Adapter(connection_string)
|
|
@@ -113,6 +177,7 @@ __all__ = [
|
|
|
113
177
|
"create_an5_adapter",
|
|
114
178
|
"DIALECT_MSSQL",
|
|
115
179
|
"DIALECT_POSTGRES",
|
|
180
|
+
"DIALECT_SQLITE",
|
|
116
181
|
"_detect_dialect",
|
|
117
182
|
"_parse_connection_string",
|
|
118
183
|
"set_adapter_metadata",
|
package/python/base/__init__.py
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
from .dialects import DIALECT_MSSQL, DIALECT_POSTGRES, detect_dialect
|
|
1
|
+
from .dialects import DIALECT_MSSQL, DIALECT_POSTGRES, DIALECT_SQLITE, detect_dialect
|
|
2
2
|
from .metadata import model_to_table, model_fields, relation_map, set_adapter_metadata, get_relations_for_model, get_model_to_table, get_fields_for_model
|
|
3
3
|
from .sql import _quote, _parse_where, _build_order_by, _resolve_table, _quote_table
|
package/python/base/metadata.py
CHANGED
|
@@ -25,11 +25,35 @@ def set_adapter_metadata(metadata: Dict[str, Any]) -> None:
|
|
|
25
25
|
"localKey": defn.get("localKey") or defn.get("local_key") or "",
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
def resolve_model_key(model_name: str) -> str:
|
|
29
|
+
"""The model name in the metadata, accepting a different capitalisation.
|
|
30
|
+
|
|
31
|
+
The generated client registers tables under their PascalCase name
|
|
32
|
+
(`CatalogType`) while the metadata keys them camelCase/snake_case
|
|
33
|
+
(`catalogType`, `catalog_type`). A plain lookup would find no columns at all
|
|
34
|
+
and silently skip `isId` — that is, never generate the primary key.
|
|
35
|
+
"""
|
|
36
|
+
if not model_name:
|
|
37
|
+
return model_name
|
|
38
|
+
if model_name in model_to_table or model_name in model_fields:
|
|
39
|
+
return model_name
|
|
40
|
+
camel = model_name[0].lower() + model_name[1:]
|
|
41
|
+
if camel in model_to_table or camel in model_fields:
|
|
42
|
+
return camel
|
|
43
|
+
lower = model_name.lower()
|
|
44
|
+
if lower in model_to_table or lower in model_fields:
|
|
45
|
+
return lower
|
|
46
|
+
return model_name
|
|
47
|
+
|
|
48
|
+
|
|
28
49
|
def get_relations_for_model(model_name: str) -> Dict[str, Dict[str, str]]:
|
|
29
|
-
return relation_map.get(model_name) or {}
|
|
50
|
+
return relation_map.get(model_name) or relation_map.get(resolve_model_key(model_name)) or {}
|
|
30
51
|
|
|
31
52
|
def get_model_to_table() -> Dict[str, str]:
|
|
32
53
|
return dict(model_to_table)
|
|
33
54
|
|
|
34
55
|
def get_fields_for_model(model_name: str) -> Any:
|
|
56
|
+
key = resolve_model_key(model_name)
|
|
57
|
+
if key in model_fields:
|
|
58
|
+
return model_fields[key]
|
|
35
59
|
return model_fields.get(model_name)
|
package/python/base/sql.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from typing import Any, Dict, List, Optional
|
|
2
2
|
from .dialects import DIALECT_MSSQL, DIALECT_POSTGRES, DIALECT_SQLITE
|
|
3
|
-
from .metadata import model_to_table, model_fields
|
|
3
|
+
from .metadata import model_to_table, model_fields, resolve_model_key
|
|
4
4
|
|
|
5
5
|
# ─── Quoting ───────────────────────────────────────────────────────────────────────
|
|
6
6
|
|
|
@@ -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}")
|
|
@@ -152,21 +149,53 @@ def _build_order_by(order_by: Any, dialect: str) -> str:
|
|
|
152
149
|
# ─── Table name resolution ─────────────────────────────────────────────────────────
|
|
153
150
|
|
|
154
151
|
def _resolve_table(model_name: str) -> str:
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if camel in model_to_table:
|
|
159
|
-
return model_to_table[camel]
|
|
160
|
-
lower = model_name.lower()
|
|
161
|
-
if lower in model_to_table:
|
|
162
|
-
return model_to_table[lower]
|
|
152
|
+
key = resolve_model_key(model_name)
|
|
153
|
+
if key in model_to_table:
|
|
154
|
+
return model_to_table[key]
|
|
163
155
|
return model_name
|
|
164
156
|
|
|
165
157
|
|
|
158
|
+
def _split_qualified(t: str) -> List[str]:
|
|
159
|
+
"""Splits `schema.table` without cutting a dot that sits inside brackets.
|
|
160
|
+
|
|
161
|
+
The generated metadata is always MSSQL-shaped: `[dbo].[users]`. A plain
|
|
162
|
+
`split(".")` yields `['[dbo]', '[users]']` — right for MSSQL, but moving to
|
|
163
|
+
PostgreSQL/SQLite means stripping the old brackets and re-quoting for the
|
|
164
|
+
target dialect, otherwise `[dbo].[users]` stays as-is and the table name
|
|
165
|
+
comes out wrong.
|
|
166
|
+
"""
|
|
167
|
+
parts: List[str] = []
|
|
168
|
+
buf: List[str] = []
|
|
169
|
+
open_ch = ""
|
|
170
|
+
close_ch = ""
|
|
171
|
+
for ch in t:
|
|
172
|
+
if open_ch:
|
|
173
|
+
buf.append(ch)
|
|
174
|
+
if ch == close_ch:
|
|
175
|
+
open_ch = ""
|
|
176
|
+
close_ch = ""
|
|
177
|
+
continue
|
|
178
|
+
if ch in ("[", '"'):
|
|
179
|
+
open_ch, close_ch = ch, ("]" if ch == "[" else '"')
|
|
180
|
+
buf.append(ch)
|
|
181
|
+
continue
|
|
182
|
+
if ch == ".":
|
|
183
|
+
parts.append("".join(buf))
|
|
184
|
+
buf = []
|
|
185
|
+
continue
|
|
186
|
+
buf.append(ch)
|
|
187
|
+
parts.append("".join(buf))
|
|
188
|
+
return [p for p in parts if p]
|
|
189
|
+
|
|
190
|
+
|
|
166
191
|
def _quote_table(t: str, dialect: str) -> str:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
192
|
+
# Never return an already bracketed `[...]` name as-is: that quoting is only
|
|
193
|
+
# correct on MSSQL. Other dialects have to strip it and re-quote, or the
|
|
194
|
+
# previous dialect's brackets follow the name into the new dialect.
|
|
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:]
|
|
198
|
+
if not parts:
|
|
199
|
+
parts = [str(t)]
|
|
200
|
+
return ".".join(_quote(p, dialect) for p in parts)
|
|
172
201
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""SQLite provider — uses the standard library `sqlite3`, no extra driver needed.
|
|
2
|
+
|
|
3
|
+
Why it exists: `base/dialects.py` already had `DIALECT_SQLITE` and the SQL layer
|
|
4
|
+
already used the `?` placeholder for it, but with no provider `An5Adapter._connect`
|
|
5
|
+
fell back to MSSQL — every statement then carried `WITH (NOLOCK)` and
|
|
6
|
+
`OFFSET ... FETCH NEXT`, SQL SQLite does not understand, failing on the first one.
|
|
7
|
+
|
|
8
|
+
Why `isolation_level=None`: `sqlite3` opens an implicit transaction before each DML
|
|
9
|
+
statement by default, so `An5Adapter.exec` (which opens/closes a connection per
|
|
10
|
+
statement) would leave transactions dangling. `None` means autocommit, matching the
|
|
11
|
+
`pyodbc.connect(..., autocommit=True)` of the MSSQL provider.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import sqlite3
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
|
|
17
|
+
# Strip the `sqlite:`/`sqlite://`/`sqlite:///` wrapper first, only then look for
|
|
18
|
+
# `file:`. Two steps are required because `sqlite:file::memory:?cache=shared` only
|
|
19
|
+
# becomes a URI after `sqlite:` is removed; checking once would miss it and sqlite3
|
|
20
|
+
# would treat the whole thing as a file name.
|
|
21
|
+
_SQLITE_PREFIXES = ("sqlite:///", "sqlite://", "sqlite:")
|
|
22
|
+
_FILE_PREFIX = "file:"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_connection_string(url: str) -> Tuple[str, bool]:
|
|
26
|
+
"""Returns (path/URI, whether sqlite3 should treat it as a URI).
|
|
27
|
+
|
|
28
|
+
Accepts `sqlite:///path`, `sqlite:path`, `file:path` and a bare path.
|
|
29
|
+
`file:` has to return URI=True because sqlite3 only understands URIs with the
|
|
30
|
+
`uri` flag on.
|
|
31
|
+
"""
|
|
32
|
+
value = (url or "").strip()
|
|
33
|
+
for prefix in _SQLITE_PREFIXES:
|
|
34
|
+
if value.lower().startswith(prefix):
|
|
35
|
+
value = value[len(prefix):]
|
|
36
|
+
break
|
|
37
|
+
is_uri = value.lower().startswith(_FILE_PREFIX)
|
|
38
|
+
if is_uri:
|
|
39
|
+
# Keep the `file:` prefix — sqlite3 needs the full URI form
|
|
40
|
+
# (`file::memory:?cache=shared`); without it `:memory:?cache=shared` is
|
|
41
|
+
# no longer a valid URI.
|
|
42
|
+
return value, True
|
|
43
|
+
# On Windows `sqlite:///C:/path/x.db` is left with `/C:/path/x.db`; the
|
|
44
|
+
# leading slash belongs to the URL, not to the path.
|
|
45
|
+
if len(value) > 2 and value[0] == "/" and value[2] == ":":
|
|
46
|
+
value = value[1:]
|
|
47
|
+
return value or ":memory:", is_uri
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_memory(url: str) -> bool:
|
|
51
|
+
"""An in-memory database does not survive every connection being closed.
|
|
52
|
+
|
|
53
|
+
The adapter opens/closes a connection per statement, so with a plain
|
|
54
|
+
`:memory:` the data disappears after the first statement (the table just
|
|
55
|
+
CREATEd is gone). The adapter therefore has to hold a long-lived connection
|
|
56
|
+
for this case; see `An5Adapter._acquire`.
|
|
57
|
+
"""
|
|
58
|
+
target, is_uri = parse_connection_string(url)
|
|
59
|
+
if is_uri:
|
|
60
|
+
# `parse_connection_string` returns the whole URI (with `file:`).
|
|
61
|
+
inner = target[len(_FILE_PREFIX):] if target.lower().startswith(_FILE_PREFIX) else target
|
|
62
|
+
return inner.startswith(":memory:") or "mode=memory" in inner
|
|
63
|
+
return target == ":memory:"
|
|
64
|
+
|
|
65
|
+
def connect(connection_string: str):
|
|
66
|
+
target, is_uri = parse_connection_string(connection_string)
|
|
67
|
+
conn = sqlite3.connect(
|
|
68
|
+
target,
|
|
69
|
+
isolation_level=None,
|
|
70
|
+
check_same_thread=False,
|
|
71
|
+
uri=is_uri,
|
|
72
|
+
)
|
|
73
|
+
# There is no `NOLOCK`; WAL lets concurrent readers and writers proceed
|
|
74
|
+
# without blocking each other, and foreign_keys is enabled because SQLite
|
|
75
|
+
# defaults it OFF (unlike every other dialect).
|
|
76
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
77
|
+
if not is_memory(connection_string) and not is_uri:
|
|
78
|
+
conn.execute("PRAGMA journal_mode = WAL")
|
|
79
|
+
return conn
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def placeholder() -> str:
|
|
83
|
+
return "?"
|
package/python/table_client.py
CHANGED
|
@@ -4,9 +4,9 @@ import json
|
|
|
4
4
|
import uuid
|
|
5
5
|
from typing import Any, Dict, Generic, List, Optional, TypeVar
|
|
6
6
|
try:
|
|
7
|
-
from .base import DIALECT_MSSQL, DIALECT_POSTGRES,
|
|
7
|
+
from .base import DIALECT_MSSQL, DIALECT_POSTGRES, DIALECT_SQLITE, get_fields_for_model, _build_order_by, _parse_where, _quote, _quote_table, _resolve_table, get_relations_for_model
|
|
8
8
|
except ImportError:
|
|
9
|
-
from base import DIALECT_MSSQL, DIALECT_POSTGRES,
|
|
9
|
+
from base import DIALECT_MSSQL, DIALECT_POSTGRES, DIALECT_SQLITE, get_fields_for_model, _build_order_by, _parse_where, _quote, _quote_table, _resolve_table, get_relations_for_model
|
|
10
10
|
|
|
11
11
|
# ─── Select / Include helpers ────────────────────────────────────────────────────────
|
|
12
12
|
|
|
@@ -179,7 +179,11 @@ def _to_non_negative_int(value: Any, fallback: int = 0) -> int:
|
|
|
179
179
|
|
|
180
180
|
def _append_update_set(set_parts: List[str], values: List[Any], col: str, val: Any, dialect: str) -> None:
|
|
181
181
|
quoted = _quote(col, dialect)
|
|
182
|
-
|
|
182
|
+
# Only PostgreSQL uses `%s`; MSSQL and SQLite both use `?`. The condition is
|
|
183
|
+
# written as "is it Postgres" rather than "is it MSSQL" so a new dialect cannot
|
|
184
|
+
# fall into the `%s` branch by default — which once made SQLite generate SQL it
|
|
185
|
+
# could not parse.
|
|
186
|
+
placeholder = "%s" if dialect == DIALECT_POSTGRES else "?"
|
|
183
187
|
if isinstance(val, dict):
|
|
184
188
|
if "increment" in val:
|
|
185
189
|
set_parts.append(f"{quoted} = {quoted} + {placeholder}")
|
|
@@ -232,22 +236,31 @@ class AdapterTableClient(Generic[T]):
|
|
|
232
236
|
|
|
233
237
|
@property
|
|
234
238
|
def _nolock(self) -> str:
|
|
235
|
-
|
|
239
|
+
# `WITH (NOLOCK)` is an MSSQL table hint. PostgreSQL has none and SQLite
|
|
240
|
+
# has no equivalent concept at all — leaving it in makes every generated
|
|
241
|
+
# SELECT invalid SQL, failing on the first one.
|
|
242
|
+
return "" if self._dialect in (DIALECT_POSTGRES, DIALECT_SQLITE) else " WITH (NOLOCK)"
|
|
236
243
|
|
|
237
244
|
@property
|
|
238
245
|
def _fields(self) -> List[Dict]:
|
|
239
|
-
|
|
246
|
+
# Uses the metadata resolver instead of a plain lookup: the generated
|
|
247
|
+
# client names tables in PascalCase while the metadata keys them in
|
|
248
|
+
# camelCase, so a plain lookup yields an empty list and silently skips
|
|
249
|
+
# `isId` (never generating the primary key).
|
|
250
|
+
fields = get_fields_for_model(self._model)
|
|
240
251
|
if isinstance(fields, dict):
|
|
241
252
|
raise TypeError(
|
|
242
253
|
"AN5 Python metadata is out of date. Regenerate with @an5/orm >= 1.0.4 "
|
|
243
254
|
"so MODEL_FIELDS uses a list of field objects."
|
|
244
255
|
)
|
|
245
|
-
return fields
|
|
256
|
+
return fields or []
|
|
246
257
|
|
|
247
258
|
def _pagination(self, take: Optional[int], skip: int, order_sql: str) -> str:
|
|
248
259
|
if take is None:
|
|
249
260
|
return ""
|
|
250
|
-
if self._dialect
|
|
261
|
+
if self._dialect in (DIALECT_POSTGRES, DIALECT_SQLITE):
|
|
262
|
+
# SQLite has `LIMIT ... OFFSET` like PostgreSQL. Taking the
|
|
263
|
+
# `OFFSET ... FETCH NEXT` branch produces SQL SQLite does not understand.
|
|
251
264
|
return f" LIMIT {take} OFFSET {skip}"
|
|
252
265
|
order_prefix = "" if order_sql else " ORDER BY (SELECT NULL)"
|
|
253
266
|
return f"{order_prefix} OFFSET {skip} ROWS FETCH NEXT {take} ROWS ONLY"
|
|
@@ -304,7 +317,7 @@ class AdapterTableClient(Generic[T]):
|
|
|
304
317
|
|
|
305
318
|
cols = [k for k, v in data.items() if v is not None]
|
|
306
319
|
values = [data[c] for c in cols]
|
|
307
|
-
placeholders = ", ".join(["
|
|
320
|
+
placeholders = ", ".join(["%s" if self._dialect == DIALECT_POSTGRES else "?"] * len(cols))
|
|
308
321
|
col_list = ", ".join(_quote(c, self._dialect) for c in cols)
|
|
309
322
|
query = f"INSERT INTO {self._table_sql} ({col_list}) VALUES ({placeholders})"
|
|
310
323
|
self._adapter.execute(query, values)
|
|
@@ -490,7 +503,7 @@ class AdapterTableClient(Generic[T]):
|
|
|
490
503
|
query += f" ORDER BY {by_cols}"
|
|
491
504
|
if has_take or has_skip:
|
|
492
505
|
final_skip = _to_non_negative_int(skip)
|
|
493
|
-
if self._dialect
|
|
506
|
+
if self._dialect in (DIALECT_POSTGRES, DIALECT_SQLITE):
|
|
494
507
|
if has_take:
|
|
495
508
|
query += f" LIMIT {_to_non_negative_int(take, 1)}"
|
|
496
509
|
query += f" OFFSET {final_skip}"
|
|
@@ -508,34 +521,38 @@ class AdapterTableClient(Generic[T]):
|
|
|
508
521
|
where_sql = _parse_where(self._model, where, params, self._dialect)
|
|
509
522
|
|
|
510
523
|
# 1. Primary path: Native database SQL vector query execution (MSSQL VECTOR_DISTANCE / Postgres pgvector)
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
query
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
query += f" WHERE {field_sql} IS NOT NULL AND ({where_sql})"
|
|
524
|
+
# SQLite has no vector operators at all, so skip it entirely: building
|
|
525
|
+
# the SQL and catching the error would cost work and still end up on the
|
|
526
|
+
# in-memory path in step 2.
|
|
527
|
+
native_rows = None
|
|
528
|
+
if self._dialect != DIALECT_SQLITE:
|
|
529
|
+
try:
|
|
530
|
+
field_sql = _quote(vector_field, self._dialect)
|
|
531
|
+
if self._dialect == DIALECT_POSTGRES:
|
|
532
|
+
op = "<=>" if distance_metric == "cosine" else ("<->" if distance_metric == "euclidean" else "<#>")
|
|
533
|
+
query = f"SELECT *, ({field_sql} {op} %s::vector) AS distance FROM {self._table_sql}"
|
|
534
|
+
query_params = [vec_json] + list(params.values())
|
|
535
|
+
if where_sql:
|
|
536
|
+
query += f" WHERE {field_sql} IS NOT NULL AND ({where_sql})"
|
|
537
|
+
else:
|
|
538
|
+
query += f" WHERE {field_sql} IS NOT NULL"
|
|
539
|
+
query += f" ORDER BY distance ASC LIMIT {take}"
|
|
528
540
|
else:
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
541
|
+
placeholder = "?"
|
|
542
|
+
query = f"SELECT TOP ({take}) *, VECTOR_DISTANCE('{distance_metric}', CAST({field_sql} AS VECTOR({dim}, float32)), CAST({placeholder} AS VECTOR({dim}, float32))) AS distance FROM {self._table_sql}{self._nolock}"
|
|
543
|
+
query_params = [vec_json] + list(params.values())
|
|
544
|
+
if where_sql:
|
|
545
|
+
query += f" WHERE {field_sql} IS NOT NULL AND ({where_sql})"
|
|
546
|
+
else:
|
|
547
|
+
query += f" WHERE {field_sql} IS NOT NULL"
|
|
548
|
+
query += " ORDER BY distance ASC"
|
|
549
|
+
|
|
550
|
+
native_rows = self._adapter.exec(query, query_params)
|
|
551
|
+
|
|
552
|
+
if native_rows is not None:
|
|
553
|
+
return native_rows
|
|
554
|
+
except Exception:
|
|
555
|
+
pass
|
|
539
556
|
|
|
540
557
|
# 2. Secondary fallback: In-memory similarity computation if DB engine lacks native vector extension
|
|
541
558
|
rows = self.find_many(where=where)
|
package/rust/Cargo.toml
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "an5-adapters"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
description = "Standalone database adapter runtime for AN5 ORM (Rust) — connection handling, dialect-aware CRUD, aggregates and vector search."
|
|
6
|
+
license = "MIT"
|
|
7
|
+
repository = "https://github.com/an5ORM/an5Adapters"
|
|
8
|
+
homepage = "https://github.com/an5ORM/an5Adapters/tree/main/rust"
|
|
9
|
+
documentation = "https://docs.rs/an5-adapters"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
keywords = ["orm", "sql", "database", "postgres", "sqlx"]
|
|
12
|
+
categories = ["database", "database-implementations"]
|
|
13
|
+
|
|
14
|
+
[dependencies]
|
|
15
|
+
serde = { version = "1", features = ["derive"] }
|
|
16
|
+
serde_json = "1"
|
|
17
|
+
sqlx = { version = "0.8", default-features = false, features = [
|
|
18
|
+
"any",
|
|
19
|
+
"runtime-tokio",
|
|
20
|
+
"json",
|
|
21
|
+
] }
|
|
22
|
+
tokio = { version = "1", features = ["rt"] }
|
|
23
|
+
|
|
24
|
+
[dev-dependencies]
|
|
25
|
+
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
|
26
|
+
sqlx = { version = "0.8", default-features = false, features = [
|
|
27
|
+
"any",
|
|
28
|
+
"runtime-tokio",
|
|
29
|
+
"json",
|
|
30
|
+
"sqlite",
|
|
31
|
+
] }
|