@an5/adapters 0.2.4 → 0.2.6
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 +189 -304
- package/dist/an5Adapter.d.ts +85 -7
- package/dist/an5Adapter.js +736 -64
- 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 +4 -1
- package/dist/browser.d.ts +5 -2
- package/dist/browser.js +10 -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 +12 -1
- package/dist/mssql/engine.js +8 -4
- package/dist/mysql/engine.js +1 -1
- 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 +1089 -275
- package/dotnet/Base/Core.cs +340 -82
- package/dotnet/Mssql/MssqlEngine.cs +145 -144
- package/dotnet/Postgres/PostgresEngine.cs +121 -121
- 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 +18 -8
- package/python/an5_adapter.py +119 -97
- package/python/base/__init__.py +3 -3
- package/python/base/dialects.py +11 -8
- package/python/base/metadata.py +35 -10
- package/python/base/sql.py +172 -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/table_client.py +619 -216
- package/typescript/src/an5Adapter.ts +1220 -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 +27 -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 +32 -19
- 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/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/table_client.py
CHANGED
|
@@ -1,216 +1,619 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
from base import DIALECT_MSSQL, DIALECT_POSTGRES, model_fields, _build_order_by, _parse_where, _quote, _quote_table, _resolve_table
|
|
8
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
return
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Any, Dict, Generic, List, Optional, TypeVar
|
|
6
|
+
try:
|
|
7
|
+
from .base import DIALECT_MSSQL, DIALECT_POSTGRES, model_fields, _build_order_by, _parse_where, _quote, _quote_table, _resolve_table, get_relations_for_model
|
|
8
|
+
except ImportError:
|
|
9
|
+
from base import DIALECT_MSSQL, DIALECT_POSTGRES, model_fields, _build_order_by, _parse_where, _quote, _quote_table, _resolve_table, get_relations_for_model
|
|
10
|
+
|
|
11
|
+
# ─── Select / Include helpers ────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
def _selected_fields(select: Any) -> List[str]:
|
|
14
|
+
"""Extract scalar column names from a select object (list or dict with truthy values)."""
|
|
15
|
+
if not select:
|
|
16
|
+
return []
|
|
17
|
+
if isinstance(select, str):
|
|
18
|
+
return [select] if select else []
|
|
19
|
+
if isinstance(select, list):
|
|
20
|
+
return [f for f in select if isinstance(f, str) and f]
|
|
21
|
+
if isinstance(select, dict):
|
|
22
|
+
return [str(k) for k, v in select.items() if v]
|
|
23
|
+
return []
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _has_relation_select(select: Any, relations: Dict) -> bool:
|
|
27
|
+
for key in _selected_fields(select):
|
|
28
|
+
if key == "_count" or key in relations:
|
|
29
|
+
return True
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _project_fields(row: Dict, select: Any) -> Dict:
|
|
34
|
+
"""Keep only selected fields on a row (mirrors TypeScript projectFields)."""
|
|
35
|
+
if not row or not select:
|
|
36
|
+
return row
|
|
37
|
+
projected = {}
|
|
38
|
+
for key in _selected_fields(select):
|
|
39
|
+
if key in row:
|
|
40
|
+
projected[key] = row[key]
|
|
41
|
+
if "_count" in row:
|
|
42
|
+
projected["_count"] = row["_count"]
|
|
43
|
+
return projected
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _to_list(value: Any) -> List:
|
|
47
|
+
if value is None:
|
|
48
|
+
return []
|
|
49
|
+
if isinstance(value, list):
|
|
50
|
+
return value
|
|
51
|
+
if isinstance(value, tuple):
|
|
52
|
+
return list(value)
|
|
53
|
+
return [value]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve_includes(model_name: str, rows: List[Dict], include: Any, adapter: Any) -> None:
|
|
57
|
+
"""Eager-load relation rows and attach them onto each row (mirrors TypeScript resolveIncludes)."""
|
|
58
|
+
if not rows or not include:
|
|
59
|
+
return
|
|
60
|
+
if not isinstance(include, dict):
|
|
61
|
+
return
|
|
62
|
+
model_relations = get_relations_for_model(model_name)
|
|
63
|
+
|
|
64
|
+
for key, value in include.items():
|
|
65
|
+
if not value:
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
if key == "_count":
|
|
69
|
+
for row in rows:
|
|
70
|
+
if "_count" not in row or row["_count"] is None:
|
|
71
|
+
row["_count"] = {}
|
|
72
|
+
for rel_key, relation in model_relations.items():
|
|
73
|
+
if relation.get("relationType") != "many":
|
|
74
|
+
continue
|
|
75
|
+
unique_keys = list({row.get(relation["localKey"]) for row in rows if row.get(relation["localKey"]) is not None})
|
|
76
|
+
if unique_keys:
|
|
77
|
+
rel_client = adapter.table(relation["modelName"])
|
|
78
|
+
related = rel_client.find_many(where={relation["foreignKey"]: {"in": unique_keys}})
|
|
79
|
+
count_map = {}
|
|
80
|
+
for r in related:
|
|
81
|
+
k = r.get(relation["foreignKey"])
|
|
82
|
+
count_map[k] = count_map.get(k, 0) + 1
|
|
83
|
+
for row in rows:
|
|
84
|
+
k = row.get(relation["localKey"])
|
|
85
|
+
row["_count"][rel_key] = count_map.get(k, 0)
|
|
86
|
+
else:
|
|
87
|
+
for row in rows:
|
|
88
|
+
row["_count"][rel_key] = 0
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
relation = model_relations.get(key)
|
|
92
|
+
if not relation:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
is_many = relation.get("relationType") == "many"
|
|
96
|
+
local_key = relation["localKey"]
|
|
97
|
+
foreign_key = relation["foreignKey"]
|
|
98
|
+
join_key = local_key if is_many else foreign_key
|
|
99
|
+
match_key = foreign_key if is_many else local_key
|
|
100
|
+
unique_keys = list({row.get(join_key) for row in rows if row.get(join_key) is not None})
|
|
101
|
+
|
|
102
|
+
if not unique_keys:
|
|
103
|
+
for row in rows:
|
|
104
|
+
row[key] = [] if is_many else None
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
sub_args = value if isinstance(value, dict) else {}
|
|
108
|
+
sub_where = {match_key: {"in": unique_keys}}
|
|
109
|
+
if sub_args.get("where"):
|
|
110
|
+
sub_where.update(sub_args["where"])
|
|
111
|
+
|
|
112
|
+
rel_client = adapter.table(relation["modelName"])
|
|
113
|
+
related = rel_client.find_many(
|
|
114
|
+
where=sub_where,
|
|
115
|
+
order_by=sub_args.get("orderBy"),
|
|
116
|
+
take=sub_args.get("take"),
|
|
117
|
+
skip=sub_args.get("skip"),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if sub_args.get("include"):
|
|
121
|
+
_resolve_includes(relation["modelName"], related, sub_args["include"], adapter)
|
|
122
|
+
|
|
123
|
+
output_rows = related
|
|
124
|
+
if sub_args.get("select"):
|
|
125
|
+
output_rows = [_project_fields(r, sub_args["select"]) for r in related]
|
|
126
|
+
|
|
127
|
+
group_map = {}
|
|
128
|
+
for i, r in enumerate(related):
|
|
129
|
+
k = r.get(foreign_key if is_many else local_key)
|
|
130
|
+
group_map.setdefault(k, []).append(output_rows[i])
|
|
131
|
+
|
|
132
|
+
for row in rows:
|
|
133
|
+
k = row.get(local_key if is_many else foreign_key)
|
|
134
|
+
matches = group_map.get(k) or []
|
|
135
|
+
row[key] = matches if is_many else (matches[0] if matches else None)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _split_relation_writes(model_name: str, data: Dict) -> (Dict, Dict):
|
|
139
|
+
"""Separate relation-write objects from scalar data using model metadata."""
|
|
140
|
+
relations = get_relations_for_model(model_name)
|
|
141
|
+
scalars = {}
|
|
142
|
+
rel_writes = {}
|
|
143
|
+
for k, v in data.items():
|
|
144
|
+
if k in relations and isinstance(v, dict) and v is not None:
|
|
145
|
+
rel_writes[k] = v
|
|
146
|
+
else:
|
|
147
|
+
scalars[k] = v
|
|
148
|
+
return scalars, rel_writes
|
|
149
|
+
|
|
150
|
+
# ─── Table Client ───────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
def _selected_aggregate_fields(fields: Any) -> List[str]:
|
|
153
|
+
if not fields:
|
|
154
|
+
return []
|
|
155
|
+
if isinstance(fields, str):
|
|
156
|
+
return [fields]
|
|
157
|
+
if isinstance(fields, list):
|
|
158
|
+
return [f for f in fields if isinstance(f, str) and f]
|
|
159
|
+
if isinstance(fields, dict):
|
|
160
|
+
return [str(f) for f, enabled in fields.items() if enabled]
|
|
161
|
+
return []
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _normalize_by_fields(by: Any) -> List[str]:
|
|
165
|
+
if isinstance(by, str):
|
|
166
|
+
return [by]
|
|
167
|
+
if isinstance(by, list):
|
|
168
|
+
return [f for f in by if isinstance(f, str) and f]
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _to_non_negative_int(value: Any, fallback: int = 0) -> int:
|
|
173
|
+
try:
|
|
174
|
+
n = int(value)
|
|
175
|
+
return n if n >= 0 else fallback
|
|
176
|
+
except Exception:
|
|
177
|
+
return fallback
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _append_update_set(set_parts: List[str], values: List[Any], col: str, val: Any, dialect: str) -> None:
|
|
181
|
+
quoted = _quote(col, dialect)
|
|
182
|
+
placeholder = "?" if dialect == DIALECT_MSSQL else "%s"
|
|
183
|
+
if isinstance(val, dict):
|
|
184
|
+
if "increment" in val:
|
|
185
|
+
set_parts.append(f"{quoted} = {quoted} + {placeholder}")
|
|
186
|
+
values.append(val["increment"])
|
|
187
|
+
return
|
|
188
|
+
if "decrement" in val:
|
|
189
|
+
set_parts.append(f"{quoted} = {quoted} - {placeholder}")
|
|
190
|
+
values.append(val["decrement"])
|
|
191
|
+
return
|
|
192
|
+
if "multiply" in val:
|
|
193
|
+
set_parts.append(f"{quoted} = {quoted} * {placeholder}")
|
|
194
|
+
values.append(val["multiply"])
|
|
195
|
+
return
|
|
196
|
+
if "divide" in val:
|
|
197
|
+
set_parts.append(f"{quoted} = {quoted} / {placeholder}")
|
|
198
|
+
values.append(val["divide"])
|
|
199
|
+
return
|
|
200
|
+
if "set" in val:
|
|
201
|
+
set_parts.append(f"{quoted} = {placeholder}")
|
|
202
|
+
values.append(val["set"])
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
set_parts.append(f"{quoted} = {placeholder}")
|
|
206
|
+
values.append(val)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
T = TypeVar("T")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class AdapterTableClient(Generic[T]):
|
|
213
|
+
"""Typed table client.
|
|
214
|
+
|
|
215
|
+
Bind a row type for ORM-standard autocomplete, e.g.
|
|
216
|
+
``AdapterTableClient[UserRow]`` where ``UserRow`` is the generated
|
|
217
|
+
``TypedDict`` describing the model's row shape (the runtime returns
|
|
218
|
+
plain dicts). Unparameterized ``AdapterTableClient`` behaves as before.
|
|
219
|
+
"""
|
|
220
|
+
def __init__(self, adapter: An5Adapter, model_name: str):
|
|
221
|
+
self._adapter = adapter
|
|
222
|
+
self._model = model_name
|
|
223
|
+
self._dialect = adapter._dialect
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def _table(self) -> str:
|
|
227
|
+
return _resolve_table(self._model)
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def _table_sql(self) -> str:
|
|
231
|
+
return _quote_table(self._table, self._dialect)
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def _nolock(self) -> str:
|
|
235
|
+
return "" if self._dialect == DIALECT_POSTGRES else " WITH (NOLOCK)"
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def _fields(self) -> List[Dict]:
|
|
239
|
+
fields = model_fields.get(self._model, [])
|
|
240
|
+
if isinstance(fields, dict):
|
|
241
|
+
raise TypeError(
|
|
242
|
+
"AN5 Python metadata is out of date. Regenerate with @an5/orm >= 1.0.4 "
|
|
243
|
+
"so MODEL_FIELDS uses a list of field objects."
|
|
244
|
+
)
|
|
245
|
+
return fields
|
|
246
|
+
|
|
247
|
+
def _pagination(self, take: Optional[int], skip: int, order_sql: str) -> str:
|
|
248
|
+
if take is None:
|
|
249
|
+
return ""
|
|
250
|
+
if self._dialect == DIALECT_POSTGRES:
|
|
251
|
+
return f" LIMIT {take} OFFSET {skip}"
|
|
252
|
+
order_prefix = "" if order_sql else " ORDER BY (SELECT NULL)"
|
|
253
|
+
return f"{order_prefix} OFFSET {skip} ROWS FETCH NEXT {take} ROWS ONLY"
|
|
254
|
+
|
|
255
|
+
def find_many(self, where=None, order_by=None, skip: int = 0, take: Optional[int] = None, select=None, include=None) -> List[T]:
|
|
256
|
+
params: Dict = {}
|
|
257
|
+
where_sql = _parse_where(self._model, where, params, self._dialect)
|
|
258
|
+
order_sql = _build_order_by(order_by, self._dialect)
|
|
259
|
+
|
|
260
|
+
relations = get_relations_for_model(self._model)
|
|
261
|
+
cols = "SELECT *"
|
|
262
|
+
if select and len(_selected_fields(select)) > 0 and not _has_relation_select(select, relations):
|
|
263
|
+
cols = "SELECT " + ", ".join(_quote(c, self._dialect) for c in _selected_fields(select))
|
|
264
|
+
|
|
265
|
+
query = f"{cols} FROM {self._table_sql}{self._nolock}"
|
|
266
|
+
if where_sql:
|
|
267
|
+
query += f" WHERE {where_sql}"
|
|
268
|
+
if order_sql:
|
|
269
|
+
query += f" {order_sql}"
|
|
270
|
+
query += self._pagination(take, skip, order_sql)
|
|
271
|
+
|
|
272
|
+
rows = self._adapter.exec(query, list(params.values()))
|
|
273
|
+
|
|
274
|
+
if include:
|
|
275
|
+
_resolve_includes(self._model, rows, include, self._adapter)
|
|
276
|
+
if select:
|
|
277
|
+
if _has_relation_select(select, relations):
|
|
278
|
+
_resolve_includes(self._model, rows, select, self._adapter)
|
|
279
|
+
rows = [_project_fields(r, select) for r in rows]
|
|
280
|
+
return rows
|
|
281
|
+
|
|
282
|
+
def find_first(self, where=None, order_by=None, select=None, include=None) -> Optional[T]:
|
|
283
|
+
rows = self.find_many(where=where, order_by=order_by, take=1, select=select, include=include)
|
|
284
|
+
return rows[0] if rows else None
|
|
285
|
+
|
|
286
|
+
def find_unique(self, where: Dict, select=None, include=None) -> Optional[T]:
|
|
287
|
+
return self.find_first(where=where, select=select, include=include)
|
|
288
|
+
|
|
289
|
+
def count(self, where=None) -> int:
|
|
290
|
+
params: Dict = {}
|
|
291
|
+
where_sql = _parse_where(self._model, where, params, self._dialect)
|
|
292
|
+
query = f"SELECT COUNT(*) AS cnt FROM {self._table_sql}{self._nolock}"
|
|
293
|
+
if where_sql:
|
|
294
|
+
query += f" WHERE {where_sql}"
|
|
295
|
+
rows = self._adapter.exec(query, list(params.values()))
|
|
296
|
+
return int(rows[0]["cnt"]) if rows else 0
|
|
297
|
+
|
|
298
|
+
def create(self, data: Dict, include=None, select=None) -> T:
|
|
299
|
+
id_field = next((f for f in self._fields if f.get("isId")), None)
|
|
300
|
+
if id_field and id_field["name"] not in data:
|
|
301
|
+
data = {**data, id_field["name"]: str(uuid.uuid4())}
|
|
302
|
+
|
|
303
|
+
data, relation_writes = _split_relation_writes(self._model, data)
|
|
304
|
+
|
|
305
|
+
cols = [k for k, v in data.items() if v is not None]
|
|
306
|
+
values = [data[c] for c in cols]
|
|
307
|
+
placeholders = ", ".join(["?" if self._dialect == DIALECT_MSSQL else "%s"] * len(cols))
|
|
308
|
+
col_list = ", ".join(_quote(c, self._dialect) for c in cols)
|
|
309
|
+
query = f"INSERT INTO {self._table_sql} ({col_list}) VALUES ({placeholders})"
|
|
310
|
+
self._adapter.execute(query, values)
|
|
311
|
+
|
|
312
|
+
if id_field:
|
|
313
|
+
created = self.find_first(where={id_field["name"]: data[id_field["name"]]}) or data
|
|
314
|
+
else:
|
|
315
|
+
created = data
|
|
316
|
+
|
|
317
|
+
# Handle nested relation writes
|
|
318
|
+
relations = get_relations_for_model(self._model)
|
|
319
|
+
for rel_key, rel_write in relation_writes.items():
|
|
320
|
+
relation = relations.get(rel_key)
|
|
321
|
+
if not relation:
|
|
322
|
+
continue
|
|
323
|
+
child_client = self._adapter.table(relation["modelName"])
|
|
324
|
+
if "create" in rel_write:
|
|
325
|
+
create_items = _to_list(rel_write["create"])
|
|
326
|
+
for item in create_items:
|
|
327
|
+
merged = {**item, relation["foreignKey"]: created.get(relation["localKey"])}
|
|
328
|
+
child_client.create(data=merged)
|
|
329
|
+
|
|
330
|
+
if include:
|
|
331
|
+
_resolve_includes(self._model, [created], include, self._adapter)
|
|
332
|
+
if select:
|
|
333
|
+
_resolve_includes(self._model, [created], select, self._adapter)
|
|
334
|
+
return _project_fields(created, select)
|
|
335
|
+
return created
|
|
336
|
+
|
|
337
|
+
def create_many(self, data: List[Dict], skip_duplicates: bool = False) -> Dict:
|
|
338
|
+
count = 0
|
|
339
|
+
for row in data:
|
|
340
|
+
try:
|
|
341
|
+
self.create(row)
|
|
342
|
+
count += 1
|
|
343
|
+
except Exception:
|
|
344
|
+
if not skip_duplicates:
|
|
345
|
+
raise
|
|
346
|
+
return {"count": count}
|
|
347
|
+
|
|
348
|
+
def update(self, where: Dict, data: Dict, include=None, select=None) -> Optional[T]:
|
|
349
|
+
params: Dict = {}
|
|
350
|
+
where_sql = _parse_where(self._model, where, params, self._dialect, "w_")
|
|
351
|
+
data, relation_writes = _split_relation_writes(self._model, data)
|
|
352
|
+
set_parts: List[str] = []
|
|
353
|
+
set_values: List = []
|
|
354
|
+
for col, val in data.items():
|
|
355
|
+
if val is not None:
|
|
356
|
+
_append_update_set(set_parts, set_values, col, val, self._dialect)
|
|
357
|
+
|
|
358
|
+
if set_parts:
|
|
359
|
+
all_values = set_values + list(params.values())
|
|
360
|
+
query = f"UPDATE {self._table_sql} SET {', '.join(set_parts)}"
|
|
361
|
+
if where_sql:
|
|
362
|
+
query += f" WHERE {where_sql}"
|
|
363
|
+
self._adapter.execute(query, all_values)
|
|
364
|
+
|
|
365
|
+
updated = self.find_first(where=where)
|
|
366
|
+
|
|
367
|
+
# Handle nested relation writes
|
|
368
|
+
relations = get_relations_for_model(self._model)
|
|
369
|
+
for rel_key, rel_write in relation_writes.items():
|
|
370
|
+
relation = relations.get(rel_key)
|
|
371
|
+
if not relation:
|
|
372
|
+
continue
|
|
373
|
+
child_client = self._adapter.table(relation["modelName"])
|
|
374
|
+
if "create" in rel_write:
|
|
375
|
+
create_items = _to_list(rel_write["create"])
|
|
376
|
+
for item in create_items:
|
|
377
|
+
merged = {**item, relation["foreignKey"]: (updated or {}).get(relation["localKey"])}
|
|
378
|
+
child_client.create(data=merged)
|
|
379
|
+
if "update" in rel_write:
|
|
380
|
+
update_obj = rel_write["update"]
|
|
381
|
+
child_client.update(where=update_obj.get("where"), data=update_obj.get("data") or {})
|
|
382
|
+
if "disconnect" in rel_write:
|
|
383
|
+
child_client.update_many(where=rel_write["disconnect"], data={relation["foreignKey"]: None})
|
|
384
|
+
|
|
385
|
+
if include and updated:
|
|
386
|
+
_resolve_includes(self._model, [updated], include, self._adapter)
|
|
387
|
+
if select and updated:
|
|
388
|
+
_resolve_includes(self._model, [updated], select, self._adapter)
|
|
389
|
+
return _project_fields(updated, select)
|
|
390
|
+
return updated
|
|
391
|
+
|
|
392
|
+
def update_many(self, where: Optional[Dict], data: Dict) -> Dict:
|
|
393
|
+
params: Dict = {}
|
|
394
|
+
where_sql = _parse_where(self._model, where, params, self._dialect, "w_")
|
|
395
|
+
set_parts: List[str] = []
|
|
396
|
+
set_values: List = []
|
|
397
|
+
for col, val in data.items():
|
|
398
|
+
if val is not None:
|
|
399
|
+
_append_update_set(set_parts, set_values, col, val, self._dialect)
|
|
400
|
+
|
|
401
|
+
if not set_parts:
|
|
402
|
+
return {"count": 0}
|
|
403
|
+
|
|
404
|
+
all_values = set_values + list(params.values())
|
|
405
|
+
query = f"UPDATE {self._table_sql} SET {', '.join(set_parts)}"
|
|
406
|
+
if where_sql:
|
|
407
|
+
query += f" WHERE {where_sql}"
|
|
408
|
+
count = self._adapter.execute(query, all_values)
|
|
409
|
+
return {"count": count}
|
|
410
|
+
|
|
411
|
+
def delete(self, where: Dict) -> Optional[T]:
|
|
412
|
+
existing = self.find_first(where=where)
|
|
413
|
+
params: Dict = {}
|
|
414
|
+
where_sql = _parse_where(self._model, where, params, self._dialect)
|
|
415
|
+
query = f"DELETE FROM {self._table_sql} WHERE {where_sql}"
|
|
416
|
+
self._adapter.execute(query, list(params.values()))
|
|
417
|
+
return existing
|
|
418
|
+
|
|
419
|
+
def delete_many(self, where: Optional[Dict] = None) -> Dict:
|
|
420
|
+
params: Dict = {}
|
|
421
|
+
where_sql = _parse_where(self._model, where, params, self._dialect)
|
|
422
|
+
query = f"DELETE FROM {self._table_sql}"
|
|
423
|
+
if where_sql:
|
|
424
|
+
query += f" WHERE {where_sql}"
|
|
425
|
+
count = self._adapter.execute(query, list(params.values()))
|
|
426
|
+
return {"count": count}
|
|
427
|
+
|
|
428
|
+
def upsert(self, where: Dict, create: Dict, update: Dict) -> T:
|
|
429
|
+
existing = self.find_first(where=where)
|
|
430
|
+
if existing:
|
|
431
|
+
return self.update(where=where, data=update) or existing
|
|
432
|
+
return self.create(data=create)
|
|
433
|
+
|
|
434
|
+
def aggregate(self, where=None, _count=None, _sum=None, _avg=None, _min=None, _max=None) -> Dict:
|
|
435
|
+
params: Dict = {}
|
|
436
|
+
where_sql = _parse_where(self._model, where, params, self._dialect)
|
|
437
|
+
aggs: List[str] = []
|
|
438
|
+
if _count:
|
|
439
|
+
aggs.append("COUNT(*) AS _count")
|
|
440
|
+
if _sum:
|
|
441
|
+
for f in _selected_aggregate_fields(_sum):
|
|
442
|
+
aggs.append(f"SUM({_quote(f, self._dialect)}) AS _sum_{f}")
|
|
443
|
+
if _avg:
|
|
444
|
+
for f in _selected_aggregate_fields(_avg):
|
|
445
|
+
aggs.append(f"AVG({_quote(f, self._dialect)}) AS _avg_{f}")
|
|
446
|
+
if _min:
|
|
447
|
+
for f in _selected_aggregate_fields(_min):
|
|
448
|
+
aggs.append(f"MIN({_quote(f, self._dialect)}) AS _min_{f}")
|
|
449
|
+
if _max:
|
|
450
|
+
for f in _selected_aggregate_fields(_max):
|
|
451
|
+
aggs.append(f"MAX({_quote(f, self._dialect)}) AS _max_{f}")
|
|
452
|
+
if not aggs:
|
|
453
|
+
raise ValueError("Aggregate requires at least one aggregator field")
|
|
454
|
+
|
|
455
|
+
query = f"SELECT {', '.join(aggs)} FROM {self._table_sql}"
|
|
456
|
+
if where_sql:
|
|
457
|
+
query += f" WHERE {where_sql}"
|
|
458
|
+
rows = self._adapter.exec(query, list(params.values()))
|
|
459
|
+
return rows[0] if rows else {}
|
|
460
|
+
|
|
461
|
+
def group_by(self, by, where=None, order_by=None, skip: int = 0, take: Optional[int] = None,
|
|
462
|
+
_sum=None, _avg=None, _min=None, _max=None) -> List[Dict]:
|
|
463
|
+
params: Dict = {}
|
|
464
|
+
where_sql = _parse_where(self._model, where, params, self._dialect)
|
|
465
|
+
by_fields = _normalize_by_fields(by)
|
|
466
|
+
if not by_fields:
|
|
467
|
+
raise ValueError("group_by requires 'by' fields")
|
|
468
|
+
|
|
469
|
+
by_cols = ", ".join(_quote(f, self._dialect) for f in by_fields)
|
|
470
|
+
aggs = ["COUNT(*) AS _count"]
|
|
471
|
+
for f in _selected_aggregate_fields(_sum):
|
|
472
|
+
aggs.append(f"SUM({_quote(f, self._dialect)}) AS _sum_{f}")
|
|
473
|
+
for f in _selected_aggregate_fields(_avg):
|
|
474
|
+
aggs.append(f"AVG({_quote(f, self._dialect)}) AS _avg_{f}")
|
|
475
|
+
for f in _selected_aggregate_fields(_min):
|
|
476
|
+
aggs.append(f"MIN({_quote(f, self._dialect)}) AS _min_{f}")
|
|
477
|
+
for f in _selected_aggregate_fields(_max):
|
|
478
|
+
aggs.append(f"MAX({_quote(f, self._dialect)}) AS _max_{f}")
|
|
479
|
+
|
|
480
|
+
query = f"SELECT {by_cols}, {', '.join(aggs)} FROM {self._table_sql}"
|
|
481
|
+
if where_sql:
|
|
482
|
+
query += f" WHERE {where_sql}"
|
|
483
|
+
query += f" GROUP BY {by_cols}"
|
|
484
|
+
order_sql = _build_order_by(order_by, self._dialect)
|
|
485
|
+
has_take = take is not None
|
|
486
|
+
has_skip = skip is not None and skip > 0
|
|
487
|
+
if order_sql:
|
|
488
|
+
query += f" {order_sql}"
|
|
489
|
+
elif has_take or has_skip:
|
|
490
|
+
query += f" ORDER BY {by_cols}"
|
|
491
|
+
if has_take or has_skip:
|
|
492
|
+
final_skip = _to_non_negative_int(skip)
|
|
493
|
+
if self._dialect == DIALECT_POSTGRES:
|
|
494
|
+
if has_take:
|
|
495
|
+
query += f" LIMIT {_to_non_negative_int(take, 1)}"
|
|
496
|
+
query += f" OFFSET {final_skip}"
|
|
497
|
+
else:
|
|
498
|
+
query += f" OFFSET {final_skip} ROWS"
|
|
499
|
+
if has_take:
|
|
500
|
+
query += f" FETCH NEXT {_to_non_negative_int(take, 1)} ROWS ONLY"
|
|
501
|
+
|
|
502
|
+
return self._adapter.exec(query, list(params.values()))
|
|
503
|
+
|
|
504
|
+
def vector_search(self, vector: List[float], take: int = 10, where=None, vector_field: str = "embedding", distance_metric: str = "cosine") -> List[Dict]:
|
|
505
|
+
dim = len(vector)
|
|
506
|
+
vec_json = json.dumps(vector)
|
|
507
|
+
params: Dict = {}
|
|
508
|
+
where_sql = _parse_where(self._model, where, params, self._dialect)
|
|
509
|
+
|
|
510
|
+
# 1. Primary path: Native database SQL vector query execution (MSSQL VECTOR_DISTANCE / Postgres pgvector)
|
|
511
|
+
try:
|
|
512
|
+
field_sql = _quote(vector_field, self._dialect)
|
|
513
|
+
if self._dialect == DIALECT_POSTGRES:
|
|
514
|
+
op = "<=>" if distance_metric == "cosine" else ("<->" if distance_metric == "euclidean" else "<#>")
|
|
515
|
+
query = f"SELECT *, ({field_sql} {op} %s::vector) AS distance FROM {self._table_sql}"
|
|
516
|
+
query_params = [vec_json] + list(params.values())
|
|
517
|
+
if where_sql:
|
|
518
|
+
query += f" WHERE {field_sql} IS NOT NULL AND ({where_sql})"
|
|
519
|
+
else:
|
|
520
|
+
query += f" WHERE {field_sql} IS NOT NULL"
|
|
521
|
+
query += f" ORDER BY distance ASC LIMIT {take}"
|
|
522
|
+
else:
|
|
523
|
+
placeholder = "?"
|
|
524
|
+
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}"
|
|
525
|
+
query_params = [vec_json] + list(params.values())
|
|
526
|
+
if where_sql:
|
|
527
|
+
query += f" WHERE {field_sql} IS NOT NULL AND ({where_sql})"
|
|
528
|
+
else:
|
|
529
|
+
query += f" WHERE {field_sql} IS NOT NULL"
|
|
530
|
+
query += " ORDER BY distance ASC"
|
|
531
|
+
|
|
532
|
+
native_rows = self._adapter.exec(query, query_params)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
if native_rows is not None:
|
|
536
|
+
return native_rows
|
|
537
|
+
except Exception:
|
|
538
|
+
pass
|
|
539
|
+
|
|
540
|
+
# 2. Secondary fallback: In-memory similarity computation if DB engine lacks native vector extension
|
|
541
|
+
rows = self.find_many(where=where)
|
|
542
|
+
scored = []
|
|
543
|
+
for row in rows:
|
|
544
|
+
raw = row.get(vector_field)
|
|
545
|
+
if raw is None:
|
|
546
|
+
continue
|
|
547
|
+
try:
|
|
548
|
+
vec = json.loads(raw) if isinstance(raw, str) else list(raw)
|
|
549
|
+
except Exception:
|
|
550
|
+
continue
|
|
551
|
+
if not vec or len(vec) != len(vector):
|
|
552
|
+
continue
|
|
553
|
+
|
|
554
|
+
dot = sum(a * b for a, b in zip(vector, vec))
|
|
555
|
+
m1 = sum(a ** 2 for a in vector) ** 0.5
|
|
556
|
+
m2 = sum(b ** 2 for b in vec) ** 0.5
|
|
557
|
+
cosine = dot / (m1 * m2) if m1 and m2 else 0.0
|
|
558
|
+
|
|
559
|
+
if distance_metric == "cosine":
|
|
560
|
+
dist = 1.0 - cosine
|
|
561
|
+
elif distance_metric == "dot":
|
|
562
|
+
dist = -dot
|
|
563
|
+
else:
|
|
564
|
+
dist = sum((a - b) ** 2 for a, b in zip(vector, vec)) ** 0.5
|
|
565
|
+
|
|
566
|
+
scored.append((row, dist))
|
|
567
|
+
|
|
568
|
+
scored.sort(key=lambda x: x[1])
|
|
569
|
+
return [{**row, "distance": dist} for row, dist in scored[:take]]
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
class ViewClient:
|
|
573
|
+
"""Read-only view client for querying database views without permitting mutations."""
|
|
574
|
+
|
|
575
|
+
def __init__(self, adapter: Any, view_name: str):
|
|
576
|
+
self._client = AdapterTableClient(adapter, view_name)
|
|
577
|
+
self.view_name = view_name
|
|
578
|
+
|
|
579
|
+
def find_many(self, *args, **kwargs):
|
|
580
|
+
return self._client.find_many(*args, **kwargs)
|
|
581
|
+
|
|
582
|
+
def find_first(self, *args, **kwargs):
|
|
583
|
+
return self._client.find_first(*args, **kwargs)
|
|
584
|
+
|
|
585
|
+
def find_unique(self, *args, **kwargs):
|
|
586
|
+
return self._client.find_unique(*args, **kwargs)
|
|
587
|
+
|
|
588
|
+
def count(self, *args, **kwargs):
|
|
589
|
+
return self._client.count(*args, **kwargs)
|
|
590
|
+
|
|
591
|
+
def aggregate(self, *args, **kwargs):
|
|
592
|
+
return self._client.aggregate(*args, **kwargs)
|
|
593
|
+
|
|
594
|
+
def group_by(self, *args, **kwargs):
|
|
595
|
+
return self._client.group_by(*args, **kwargs)
|
|
596
|
+
|
|
597
|
+
def vector_search(self, *args, **kwargs):
|
|
598
|
+
return self._client.vector_search(*args, **kwargs)
|
|
599
|
+
|
|
600
|
+
def create(self, *args, **kwargs):
|
|
601
|
+
raise PermissionError(f"View '{self.view_name}' is read-only. Mutation operations (create) are not allowed.")
|
|
602
|
+
|
|
603
|
+
def create_many(self, *args, **kwargs):
|
|
604
|
+
raise PermissionError(f"View '{self.view_name}' is read-only. Mutation operations (create_many) are not allowed.")
|
|
605
|
+
|
|
606
|
+
def update(self, *args, **kwargs):
|
|
607
|
+
raise PermissionError(f"View '{self.view_name}' is read-only. Mutation operations (update) are not allowed.")
|
|
608
|
+
|
|
609
|
+
def update_many(self, *args, **kwargs):
|
|
610
|
+
raise PermissionError(f"View '{self.view_name}' is read-only. Mutation operations (update_many) are not allowed.")
|
|
611
|
+
|
|
612
|
+
def delete(self, *args, **kwargs):
|
|
613
|
+
raise PermissionError(f"View '{self.view_name}' is read-only. Mutation operations (delete) are not allowed.")
|
|
614
|
+
|
|
615
|
+
def delete_many(self, *args, **kwargs):
|
|
616
|
+
raise PermissionError(f"View '{self.view_name}' is read-only. Mutation operations (delete_many) are not allowed.")
|
|
617
|
+
|
|
618
|
+
def upsert(self, *args, **kwargs):
|
|
619
|
+
raise PermissionError(f"View '{self.view_name}' is read-only. Mutation operations (upsert) are not allowed.")
|