@an5/adapters 0.2.5 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +283 -305
- package/dist/an5Adapter.d.ts +142 -7
- package/dist/an5Adapter.js +998 -68
- package/dist/base/metadata.d.ts +10 -0
- package/dist/base/metadata.js +107 -0
- package/dist/base/sql.d.ts +19 -1
- package/dist/base/sql.js +152 -24
- package/dist/base/types.d.ts +10 -1
- package/dist/browser.d.ts +4 -1
- package/dist/browser.js +9 -2
- package/dist/googlesheets/adapter.d.ts +1 -0
- package/dist/googlesheets/adapter.js +9 -2
- package/dist/googlesheets/config.d.ts +4 -4
- package/dist/googlesheets/helpers.js +2 -0
- package/dist/googlesheets/parseConnectionString.js +5 -2
- package/dist/googlesheets/sqlExecutor.js +29 -24
- package/dist/googlesheets/tableClient.js +14 -5
- package/dist/index.d.ts +5 -1
- package/dist/index.js +15 -1
- package/dist/mssql/engine.js +8 -4
- package/dist/mysql/engine.js +1 -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/postgres/engine.js +1 -1
- package/dist/sqlite/browserEngine.d.ts +27 -0
- package/dist/sqlite/browserEngine.js +161 -0
- package/dist/sqlite/engine.js +15 -3
- package/dist/sqlite/index.d.ts +1 -0
- package/dist/sqlite/index.js +1 -0
- package/dist/typed.d.ts +5 -0
- package/dist/typed.js +2 -0
- package/dotnet/An5Adapter.cs +1112 -275
- package/dotnet/Base/Core.cs +380 -82
- package/dotnet/Mssql/MssqlEngine.cs +145 -144
- package/dotnet/Postgres/PostgresEngine.cs +149 -121
- package/dotnet/Sqlite/SqliteEngine.cs +214 -0
- package/golang/an5_adapter.go +314 -0
- package/golang/base/base.go +100 -0
- package/golang/base/metadata.go +116 -0
- package/golang/base/where.go +311 -0
- package/golang/go.mod +3 -0
- package/golang/mssql/mssql.go +32 -0
- package/golang/postgres/postgres.go +37 -0
- package/golang/sqlite/sqlite.go +14 -0
- package/golang/sqlite_test.go +42 -0
- package/golang/table_client.go +1329 -0
- package/package.json +26 -9
- package/python/an5_adapter.py +184 -97
- package/python/base/__init__.py +3 -3
- package/python/base/dialects.py +11 -8
- package/python/base/metadata.py +59 -10
- package/python/base/sql.py +202 -121
- package/python/mssql/__init__.py +1 -1
- package/python/mssql/provider.py +62 -62
- package/python/postgres/__init__.py +1 -1
- package/python/postgres/provider.py +15 -15
- package/python/sqlite/__init__.py +3 -0
- package/python/sqlite/provider.py +83 -0
- package/python/table_client.py +636 -216
- package/rust/Cargo.toml +31 -0
- package/rust/README.md +71 -0
- package/rust/src/adapter.rs +1312 -0
- package/rust/src/base/dialect.rs +172 -0
- package/rust/src/base/metadata.rs +134 -0
- package/rust/src/base/mod.rs +17 -0
- package/rust/src/base/where_builder.rs +405 -0
- package/rust/src/lib.rs +75 -0
- package/typescript/src/an5Adapter.ts +1515 -505
- package/typescript/src/base/index.ts +3 -3
- package/typescript/src/base/metadata.ts +148 -28
- package/typescript/src/base/sql.ts +264 -98
- package/typescript/src/base/types.ts +33 -24
- package/typescript/src/browser.ts +10 -6
- package/typescript/src/config.ts +95 -95
- package/typescript/src/googlesheets/adapter.ts +269 -259
- package/typescript/src/googlesheets/an5SheetsAdapter.ts +3 -3
- package/typescript/src/googlesheets/config.ts +47 -47
- package/typescript/src/googlesheets/helpers.ts +144 -143
- package/typescript/src/googlesheets/index.ts +3 -3
- package/typescript/src/googlesheets/parseConnectionString.ts +48 -46
- package/typescript/src/googlesheets/retry.ts +126 -126
- package/typescript/src/googlesheets/sqlExecutor.ts +304 -301
- package/typescript/src/googlesheets/tableClient.ts +461 -455
- package/typescript/src/googlesheets/types.ts +6 -6
- package/typescript/src/index.ts +48 -27
- package/typescript/src/mssql/engine.ts +134 -132
- package/typescript/src/mssql/index.ts +1 -1
- package/typescript/src/mysql/engine.ts +80 -80
- package/typescript/src/mysql/index.ts +1 -1
- package/typescript/src/nbase/client.ts +214 -0
- package/typescript/src/nbase/index.ts +104 -0
- package/typescript/src/postgres/engine.ts +87 -87
- package/typescript/src/postgres/index.ts +1 -1
- package/typescript/src/sqlite/browserEngine.ts +192 -0
- package/typescript/src/sqlite/engine.ts +66 -55
- package/typescript/src/sqlite/index.ts +3 -1
- package/typescript/src/typed.ts +52 -0
package/python/base/sql.py
CHANGED
|
@@ -1,121 +1,202 @@
|
|
|
1
|
-
from typing import Any, Dict, List, Optional
|
|
2
|
-
from .dialects import DIALECT_MSSQL, DIALECT_POSTGRES
|
|
3
|
-
from .metadata import model_to_table, model_fields
|
|
4
|
-
|
|
5
|
-
# ─── Quoting ───────────────────────────────────────────────────────────────────────
|
|
6
|
-
|
|
7
|
-
def
|
|
8
|
-
if
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
from .dialects import DIALECT_MSSQL, DIALECT_POSTGRES, DIALECT_SQLITE
|
|
3
|
+
from .metadata import model_to_table, model_fields, resolve_model_key
|
|
4
|
+
|
|
5
|
+
# ─── Quoting ───────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
def _strip_wrapping(name: str, left: str, right: str) -> str:
|
|
8
|
+
return name[len(left):-len(right)] if name.startswith(left) and name.endswith(right) else name
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _quote(name: str, dialect: str) -> str:
|
|
12
|
+
raw = str(name)
|
|
13
|
+
if dialect in (DIALECT_POSTGRES, DIALECT_SQLITE):
|
|
14
|
+
unwrapped = _strip_wrapping(_strip_wrapping(raw, "[", "]"), '"', '"')
|
|
15
|
+
return f'"{unwrapped.replace(chr(34), chr(34) * 2)}"'
|
|
16
|
+
unwrapped = _strip_wrapping(raw, "[", "]")
|
|
17
|
+
return f"[{unwrapped.replace(']', ']]')}]"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _sanitize_param_name(name: str) -> str:
|
|
21
|
+
cleaned = "".join(ch if ch.isalnum() or ch == "_" else "_" for ch in str(name))
|
|
22
|
+
return cleaned if cleaned[:1].isalpha() else f"p_{cleaned}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _normalize_sort_direction(direction: Any) -> str:
|
|
26
|
+
return "DESC" if isinstance(direction, str) and direction.upper() == "DESC" else "ASC"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ─── Connection helpers live in provider modules ────────────────────────────────
|
|
30
|
+
|
|
31
|
+
# ─── Where clause builder ──────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
_OPERATOR_KEYS = {"equals", "in", "notIn", "contains", "startsWith", "endsWith", "not", "gte", "lte", "gt", "lt"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_operator_value(value: Any) -> bool:
|
|
37
|
+
return isinstance(value, dict) and any(op in value for op in _OPERATOR_KEYS)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_where(model_name: str, where: Optional[Dict], params: Dict, dialect: str, prefix: str = "") -> str:
|
|
41
|
+
if not where:
|
|
42
|
+
return ""
|
|
43
|
+
conditions: List[str] = []
|
|
44
|
+
|
|
45
|
+
clean_where: Dict[str, Any] = {}
|
|
46
|
+
for key, value in where.items():
|
|
47
|
+
if "_" in key and isinstance(value, dict) and not _is_operator_value(value):
|
|
48
|
+
clean_where.update(value)
|
|
49
|
+
else:
|
|
50
|
+
clean_where[key] = value
|
|
51
|
+
|
|
52
|
+
for key, value in clean_where.items():
|
|
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)})")
|
|
58
|
+
elif key == "AND":
|
|
59
|
+
items = value if isinstance(value, list) else [value]
|
|
60
|
+
sub = [_parse_where(model_name, v, params, dialect, f"{prefix}and{i}_") for i, v in enumerate(items)]
|
|
61
|
+
sub = [s for s in sub if s]
|
|
62
|
+
if sub:
|
|
63
|
+
conditions.append(f"({' AND '.join(sub)})")
|
|
64
|
+
elif key == "NOT":
|
|
65
|
+
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]
|
|
68
|
+
if sub:
|
|
69
|
+
conditions.append(f"NOT ({' AND '.join(sub)})")
|
|
70
|
+
else:
|
|
71
|
+
col = _quote(key, dialect)
|
|
72
|
+
pname = _sanitize_param_name(f"{prefix}{key}")
|
|
73
|
+
placeholder = "%s" if dialect == DIALECT_POSTGRES else "?"
|
|
74
|
+
if value is None:
|
|
75
|
+
conditions.append(f"{col} IS NULL")
|
|
76
|
+
elif isinstance(value, dict):
|
|
77
|
+
if "not" in value:
|
|
78
|
+
if value["not"] is None:
|
|
79
|
+
conditions.append(f"{col} IS NOT NULL")
|
|
80
|
+
elif isinstance(value["not"], dict):
|
|
81
|
+
nested_params: Dict[str, Any] = {}
|
|
82
|
+
nested_sql = _parse_where(model_name, {key: value["not"]}, nested_params, dialect, f"{prefix}{key}_not_")
|
|
83
|
+
params.update(nested_params)
|
|
84
|
+
if nested_sql:
|
|
85
|
+
conditions.append(f"NOT ({nested_sql})")
|
|
86
|
+
else:
|
|
87
|
+
params[f"{pname}_not"] = value["not"]
|
|
88
|
+
conditions.append(f"{col} <> {placeholder}")
|
|
89
|
+
if "equals" in value:
|
|
90
|
+
if value["equals"] is None:
|
|
91
|
+
conditions.append(f"{col} IS NULL")
|
|
92
|
+
else:
|
|
93
|
+
params[f"{pname}_eq"] = value["equals"]
|
|
94
|
+
conditions.append(f"{col} = {placeholder}")
|
|
95
|
+
if "contains" in value:
|
|
96
|
+
params[f"{pname}_co"] = f"%{value['contains']}%"
|
|
97
|
+
conditions.append(f"{col} LIKE {placeholder}")
|
|
98
|
+
if "startsWith" in value:
|
|
99
|
+
params[f"{pname}_sw"] = f"{value['startsWith']}%"
|
|
100
|
+
conditions.append(f"{col} LIKE {placeholder}")
|
|
101
|
+
if "endsWith" in value:
|
|
102
|
+
params[f"{pname}_ew"] = f"%{value['endsWith']}"
|
|
103
|
+
conditions.append(f"{col} LIKE {placeholder}")
|
|
104
|
+
if "gte" in value:
|
|
105
|
+
params[f"{pname}_gte"] = value["gte"]
|
|
106
|
+
conditions.append(f"{col} >= {placeholder}")
|
|
107
|
+
if "lte" in value:
|
|
108
|
+
params[f"{pname}_lte"] = value["lte"]
|
|
109
|
+
conditions.append(f"{col} <= {placeholder}")
|
|
110
|
+
if "gt" in value:
|
|
111
|
+
params[f"{pname}_gt"] = value["gt"]
|
|
112
|
+
conditions.append(f"{col} > {placeholder}")
|
|
113
|
+
if "lt" in value:
|
|
114
|
+
params[f"{pname}_lt"] = value["lt"]
|
|
115
|
+
conditions.append(f"{col} < {placeholder}")
|
|
116
|
+
if "in" in value:
|
|
117
|
+
vals = value["in"]
|
|
118
|
+
if vals:
|
|
119
|
+
placeholders = ", ".join([placeholder] * len(vals))
|
|
120
|
+
for i, v in enumerate(vals):
|
|
121
|
+
params[f"{pname}_in{i}"] = v
|
|
122
|
+
conditions.append(f"{col} IN ({placeholders})")
|
|
123
|
+
else:
|
|
124
|
+
conditions.append("1=0")
|
|
125
|
+
if "notIn" in value:
|
|
126
|
+
vals = value["notIn"]
|
|
127
|
+
if vals:
|
|
128
|
+
placeholders = ", ".join([placeholder] * len(vals))
|
|
129
|
+
for i, v in enumerate(vals):
|
|
130
|
+
params[f"{pname}_notin{i}"] = v
|
|
131
|
+
conditions.append(f"{col} NOT IN ({placeholders})")
|
|
132
|
+
else:
|
|
133
|
+
conditions.append("1=1")
|
|
134
|
+
else:
|
|
135
|
+
params[pname] = value
|
|
136
|
+
conditions.append(f"{col} = {placeholder}")
|
|
137
|
+
|
|
138
|
+
return " AND ".join(conditions)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _build_order_by(order_by: Any, dialect: str) -> str:
|
|
142
|
+
if not order_by:
|
|
143
|
+
return ""
|
|
144
|
+
entries = order_by if isinstance(order_by, list) else [order_by]
|
|
145
|
+
parts: List[str] = []
|
|
146
|
+
for entry in entries:
|
|
147
|
+
for col, direction in entry.items():
|
|
148
|
+
parts.append(f"{_quote(col, dialect)} {_normalize_sort_direction(direction)}")
|
|
149
|
+
return f"ORDER BY {', '.join(parts)}" if parts else ""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ─── Table name resolution ─────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
def _resolve_table(model_name: str) -> str:
|
|
155
|
+
key = resolve_model_key(model_name)
|
|
156
|
+
if key in model_to_table:
|
|
157
|
+
return model_to_table[key]
|
|
158
|
+
return model_name
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _split_qualified(t: str) -> List[str]:
|
|
162
|
+
"""Splits `schema.table` without cutting a dot that sits inside brackets.
|
|
163
|
+
|
|
164
|
+
The generated metadata is always MSSQL-shaped: `[dbo].[users]`. A plain
|
|
165
|
+
`split(".")` yields `['[dbo]', '[users]']` — right for MSSQL, but moving to
|
|
166
|
+
PostgreSQL/SQLite means stripping the old brackets and re-quoting for the
|
|
167
|
+
target dialect, otherwise `[dbo].[users]` stays as-is and the table name
|
|
168
|
+
comes out wrong.
|
|
169
|
+
"""
|
|
170
|
+
parts: List[str] = []
|
|
171
|
+
buf: List[str] = []
|
|
172
|
+
open_ch = ""
|
|
173
|
+
close_ch = ""
|
|
174
|
+
for ch in t:
|
|
175
|
+
if open_ch:
|
|
176
|
+
buf.append(ch)
|
|
177
|
+
if ch == close_ch:
|
|
178
|
+
open_ch = ""
|
|
179
|
+
close_ch = ""
|
|
180
|
+
continue
|
|
181
|
+
if ch in ("[", '"'):
|
|
182
|
+
open_ch, close_ch = ch, ("]" if ch == "[" else '"')
|
|
183
|
+
buf.append(ch)
|
|
184
|
+
continue
|
|
185
|
+
if ch == ".":
|
|
186
|
+
parts.append("".join(buf))
|
|
187
|
+
buf = []
|
|
188
|
+
continue
|
|
189
|
+
buf.append(ch)
|
|
190
|
+
parts.append("".join(buf))
|
|
191
|
+
return [p for p in parts if p]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _quote_table(t: str, dialect: str) -> str:
|
|
195
|
+
# Never return an already bracketed `[...]` name as-is: that quoting is only
|
|
196
|
+
# correct on MSSQL. Other dialects have to strip it and re-quote, or the
|
|
197
|
+
# previous dialect's brackets follow the name into the new dialect.
|
|
198
|
+
parts = _split_qualified(str(t))
|
|
199
|
+
if not parts:
|
|
200
|
+
parts = [str(t)]
|
|
201
|
+
return ".".join(_quote(p, dialect) for p in parts)
|
|
202
|
+
|
package/python/mssql/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
from .provider import PYODBC_AVAILABLE, connect, parse_connection_string, placeholder
|
|
1
|
+
from .provider import PYODBC_AVAILABLE, connect, parse_connection_string, placeholder
|
package/python/mssql/provider.py
CHANGED
|
@@ -1,62 +1,62 @@
|
|
|
1
|
-
try:
|
|
2
|
-
import pyodbc
|
|
3
|
-
PYODBC_AVAILABLE = True
|
|
4
|
-
except ImportError:
|
|
5
|
-
pyodbc = None
|
|
6
|
-
PYODBC_AVAILABLE = False
|
|
7
|
-
|
|
8
|
-
def parse_connection_string(url: str) -> str:
|
|
9
|
-
"""Convert sqlserver:// URL to pyodbc connection string."""
|
|
10
|
-
url = url.replace("sqlserver://", "")
|
|
11
|
-
parts = url.split(";")
|
|
12
|
-
host_part = parts[0]
|
|
13
|
-
host, _, port = host_part.partition(":")
|
|
14
|
-
port = port or "1433"
|
|
15
|
-
|
|
16
|
-
config: Dict[str, str] = {"server": host, "port": port}
|
|
17
|
-
for part in parts[1:]:
|
|
18
|
-
if "=" not in part:
|
|
19
|
-
continue
|
|
20
|
-
k, _, v = part.partition("=")
|
|
21
|
-
k = k.strip().lower()
|
|
22
|
-
v = v.strip()
|
|
23
|
-
if k in ("database",):
|
|
24
|
-
config["database"] = v
|
|
25
|
-
elif k in ("user", "uid"):
|
|
26
|
-
config["user"] = v
|
|
27
|
-
elif k in ("password", "pwd"):
|
|
28
|
-
config["password"] = v
|
|
29
|
-
elif k == "encrypt":
|
|
30
|
-
config["encrypt"] = "yes" if v.lower() == "true" else "no"
|
|
31
|
-
elif k == "trustservercertificate":
|
|
32
|
-
config["trust"] = "yes" if v.lower() == "true" else "no"
|
|
33
|
-
|
|
34
|
-
encrypt = config.get("encrypt", "yes")
|
|
35
|
-
trust = config.get("trust", "yes")
|
|
36
|
-
if "user" in config:
|
|
37
|
-
return (
|
|
38
|
-
f"DRIVER={{ODBC Driver 17 for SQL Server}};"
|
|
39
|
-
f"SERVER={config['server']},{config['port']};"
|
|
40
|
-
f"DATABASE={config.get('database', '')};"
|
|
41
|
-
f"UID={config['user']};"
|
|
42
|
-
f"PWD={config.get('password', '')};"
|
|
43
|
-
f"Encrypt={encrypt};TrustServerCertificate={trust};"
|
|
44
|
-
)
|
|
45
|
-
# Windows auth
|
|
46
|
-
return (
|
|
47
|
-
f"DRIVER={{ODBC Driver 17 for SQL Server}};"
|
|
48
|
-
f"SERVER={config['server']},{config['port']};"
|
|
49
|
-
f"DATABASE={config.get('database', '')};"
|
|
50
|
-
f"Trusted_Connection=yes;"
|
|
51
|
-
f"Encrypt={encrypt};TrustServerCertificate={trust};"
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def connect(connection_string: str):
|
|
57
|
-
if not PYODBC_AVAILABLE:
|
|
58
|
-
raise ImportError("pyodbc is required: pip install pyodbc")
|
|
59
|
-
return pyodbc.connect(parse_connection_string(connection_string), autocommit=True)
|
|
60
|
-
|
|
61
|
-
def placeholder() -> str:
|
|
62
|
-
return "?"
|
|
1
|
+
try:
|
|
2
|
+
import pyodbc
|
|
3
|
+
PYODBC_AVAILABLE = True
|
|
4
|
+
except ImportError:
|
|
5
|
+
pyodbc = None
|
|
6
|
+
PYODBC_AVAILABLE = False
|
|
7
|
+
|
|
8
|
+
def parse_connection_string(url: str) -> str:
|
|
9
|
+
"""Convert sqlserver:// URL to pyodbc connection string."""
|
|
10
|
+
url = url.replace("sqlserver://", "")
|
|
11
|
+
parts = url.split(";")
|
|
12
|
+
host_part = parts[0]
|
|
13
|
+
host, _, port = host_part.partition(":")
|
|
14
|
+
port = port or "1433"
|
|
15
|
+
|
|
16
|
+
config: Dict[str, str] = {"server": host, "port": port}
|
|
17
|
+
for part in parts[1:]:
|
|
18
|
+
if "=" not in part:
|
|
19
|
+
continue
|
|
20
|
+
k, _, v = part.partition("=")
|
|
21
|
+
k = k.strip().lower()
|
|
22
|
+
v = v.strip()
|
|
23
|
+
if k in ("database",):
|
|
24
|
+
config["database"] = v
|
|
25
|
+
elif k in ("user", "uid"):
|
|
26
|
+
config["user"] = v
|
|
27
|
+
elif k in ("password", "pwd"):
|
|
28
|
+
config["password"] = v
|
|
29
|
+
elif k == "encrypt":
|
|
30
|
+
config["encrypt"] = "yes" if v.lower() == "true" else "no"
|
|
31
|
+
elif k == "trustservercertificate":
|
|
32
|
+
config["trust"] = "yes" if v.lower() == "true" else "no"
|
|
33
|
+
|
|
34
|
+
encrypt = config.get("encrypt", "yes")
|
|
35
|
+
trust = config.get("trust", "yes")
|
|
36
|
+
if "user" in config:
|
|
37
|
+
return (
|
|
38
|
+
f"DRIVER={{ODBC Driver 17 for SQL Server}};"
|
|
39
|
+
f"SERVER={config['server']},{config['port']};"
|
|
40
|
+
f"DATABASE={config.get('database', '')};"
|
|
41
|
+
f"UID={config['user']};"
|
|
42
|
+
f"PWD={config.get('password', '')};"
|
|
43
|
+
f"Encrypt={encrypt};TrustServerCertificate={trust};"
|
|
44
|
+
)
|
|
45
|
+
# Windows auth
|
|
46
|
+
return (
|
|
47
|
+
f"DRIVER={{ODBC Driver 17 for SQL Server}};"
|
|
48
|
+
f"SERVER={config['server']},{config['port']};"
|
|
49
|
+
f"DATABASE={config.get('database', '')};"
|
|
50
|
+
f"Trusted_Connection=yes;"
|
|
51
|
+
f"Encrypt={encrypt};TrustServerCertificate={trust};"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def connect(connection_string: str):
|
|
57
|
+
if not PYODBC_AVAILABLE:
|
|
58
|
+
raise ImportError("pyodbc is required: pip install pyodbc")
|
|
59
|
+
return pyodbc.connect(parse_connection_string(connection_string), autocommit=True)
|
|
60
|
+
|
|
61
|
+
def placeholder() -> str:
|
|
62
|
+
return "?"
|
|
@@ -1 +1 @@
|
|
|
1
|
-
from .provider import PSYCOPG2_AVAILABLE, connect, placeholder
|
|
1
|
+
from .provider import PSYCOPG2_AVAILABLE, connect, placeholder
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
try:
|
|
2
|
-
import psycopg2
|
|
3
|
-
import psycopg2.extras
|
|
4
|
-
PSYCOPG2_AVAILABLE = True
|
|
5
|
-
except ImportError:
|
|
6
|
-
psycopg2 = None
|
|
7
|
-
PSYCOPG2_AVAILABLE = False
|
|
8
|
-
|
|
9
|
-
def connect(connection_string: str):
|
|
10
|
-
if not PSYCOPG2_AVAILABLE:
|
|
11
|
-
raise ImportError("psycopg2 is required: pip install psycopg2-binary")
|
|
12
|
-
return psycopg2.connect(connection_string)
|
|
13
|
-
|
|
14
|
-
def placeholder() -> str:
|
|
15
|
-
return "%s"
|
|
1
|
+
try:
|
|
2
|
+
import psycopg2
|
|
3
|
+
import psycopg2.extras
|
|
4
|
+
PSYCOPG2_AVAILABLE = True
|
|
5
|
+
except ImportError:
|
|
6
|
+
psycopg2 = None
|
|
7
|
+
PSYCOPG2_AVAILABLE = False
|
|
8
|
+
|
|
9
|
+
def connect(connection_string: str):
|
|
10
|
+
if not PSYCOPG2_AVAILABLE:
|
|
11
|
+
raise ImportError("psycopg2 is required: pip install psycopg2-binary")
|
|
12
|
+
return psycopg2.connect(connection_string)
|
|
13
|
+
|
|
14
|
+
def placeholder() -> str:
|
|
15
|
+
return "%s"
|
|
@@ -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 "?"
|