@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.
Files changed (82) hide show
  1. package/README.md +189 -304
  2. package/dist/an5Adapter.d.ts +85 -7
  3. package/dist/an5Adapter.js +736 -64
  4. package/dist/base/metadata.d.ts +10 -0
  5. package/dist/base/metadata.js +107 -0
  6. package/dist/base/sql.d.ts +19 -1
  7. package/dist/base/sql.js +152 -24
  8. package/dist/base/types.d.ts +4 -1
  9. package/dist/browser.d.ts +5 -2
  10. package/dist/browser.js +10 -2
  11. package/dist/googlesheets/adapter.d.ts +1 -0
  12. package/dist/googlesheets/adapter.js +9 -2
  13. package/dist/googlesheets/config.d.ts +4 -4
  14. package/dist/googlesheets/helpers.js +2 -0
  15. package/dist/googlesheets/parseConnectionString.js +5 -2
  16. package/dist/googlesheets/sqlExecutor.js +29 -24
  17. package/dist/googlesheets/tableClient.js +14 -5
  18. package/dist/index.d.ts +5 -1
  19. package/dist/index.js +12 -1
  20. package/dist/mssql/engine.js +8 -4
  21. package/dist/mysql/engine.js +1 -1
  22. package/dist/postgres/engine.js +1 -1
  23. package/dist/sqlite/browserEngine.d.ts +27 -0
  24. package/dist/sqlite/browserEngine.js +161 -0
  25. package/dist/sqlite/engine.js +15 -3
  26. package/dist/sqlite/index.d.ts +1 -0
  27. package/dist/sqlite/index.js +1 -0
  28. package/dist/typed.d.ts +5 -0
  29. package/dist/typed.js +2 -0
  30. package/dotnet/An5Adapter.cs +1089 -275
  31. package/dotnet/Base/Core.cs +340 -82
  32. package/dotnet/Mssql/MssqlEngine.cs +145 -144
  33. package/dotnet/Postgres/PostgresEngine.cs +121 -121
  34. package/golang/an5_adapter.go +314 -0
  35. package/golang/base/base.go +100 -0
  36. package/golang/base/metadata.go +116 -0
  37. package/golang/base/where.go +311 -0
  38. package/golang/go.mod +3 -0
  39. package/golang/mssql/mssql.go +32 -0
  40. package/golang/postgres/postgres.go +37 -0
  41. package/golang/sqlite/sqlite.go +14 -0
  42. package/golang/sqlite_test.go +42 -0
  43. package/golang/table_client.go +1329 -0
  44. package/package.json +18 -8
  45. package/python/an5_adapter.py +119 -97
  46. package/python/base/__init__.py +3 -3
  47. package/python/base/dialects.py +11 -8
  48. package/python/base/metadata.py +35 -10
  49. package/python/base/sql.py +172 -121
  50. package/python/mssql/__init__.py +1 -1
  51. package/python/mssql/provider.py +62 -62
  52. package/python/postgres/__init__.py +1 -1
  53. package/python/postgres/provider.py +15 -15
  54. package/python/table_client.py +619 -216
  55. package/typescript/src/an5Adapter.ts +1220 -505
  56. package/typescript/src/base/index.ts +3 -3
  57. package/typescript/src/base/metadata.ts +148 -28
  58. package/typescript/src/base/sql.ts +264 -98
  59. package/typescript/src/base/types.ts +27 -24
  60. package/typescript/src/browser.ts +10 -6
  61. package/typescript/src/config.ts +95 -95
  62. package/typescript/src/googlesheets/adapter.ts +269 -259
  63. package/typescript/src/googlesheets/an5SheetsAdapter.ts +3 -3
  64. package/typescript/src/googlesheets/config.ts +47 -47
  65. package/typescript/src/googlesheets/helpers.ts +144 -143
  66. package/typescript/src/googlesheets/index.ts +3 -3
  67. package/typescript/src/googlesheets/parseConnectionString.ts +48 -46
  68. package/typescript/src/googlesheets/retry.ts +126 -126
  69. package/typescript/src/googlesheets/sqlExecutor.ts +304 -301
  70. package/typescript/src/googlesheets/tableClient.ts +461 -455
  71. package/typescript/src/googlesheets/types.ts +6 -6
  72. package/typescript/src/index.ts +32 -19
  73. package/typescript/src/mssql/engine.ts +134 -132
  74. package/typescript/src/mssql/index.ts +1 -1
  75. package/typescript/src/mysql/engine.ts +80 -80
  76. package/typescript/src/mysql/index.ts +1 -1
  77. package/typescript/src/postgres/engine.ts +87 -87
  78. package/typescript/src/postgres/index.ts +1 -1
  79. package/typescript/src/sqlite/browserEngine.ts +192 -0
  80. package/typescript/src/sqlite/engine.ts +66 -55
  81. package/typescript/src/sqlite/index.ts +3 -1
  82. package/typescript/src/typed.ts +52 -0
@@ -1,216 +1,619 @@
1
- import json
2
- import uuid
3
- from typing import Dict, List, Optional
4
- try:
5
- from .base import DIALECT_MSSQL, DIALECT_POSTGRES, model_fields, _build_order_by, _parse_where, _quote, _quote_table, _resolve_table
6
- except ImportError:
7
- from base import DIALECT_MSSQL, DIALECT_POSTGRES, model_fields, _build_order_by, _parse_where, _quote, _quote_table, _resolve_table
8
-
9
- # ─── Table Client ───────────────────────────────────────────────────────────────────
10
-
11
- class AdapterTableClient:
12
- def __init__(self, adapter: An5Adapter, model_name: str):
13
- self._adapter = adapter
14
- self._model = model_name
15
- self._dialect = adapter._dialect
16
-
17
- @property
18
- def _table(self) -> str:
19
- return _resolve_table(self._model)
20
-
21
- @property
22
- def _table_sql(self) -> str:
23
- return _quote_table(self._table, self._dialect)
24
-
25
- @property
26
- def _nolock(self) -> str:
27
- return "" if self._dialect == DIALECT_POSTGRES else " WITH (NOLOCK)"
28
-
29
- @property
30
- def _fields(self) -> List[Dict]:
31
- fields = model_fields.get(self._model, [])
32
- if isinstance(fields, dict):
33
- raise TypeError(
34
- "AN5 Python metadata is out of date. Regenerate with @an5/orm >= 1.0.4 "
35
- "so MODEL_FIELDS uses a list of field objects."
36
- )
37
- return fields
38
-
39
- def _pagination(self, take: Optional[int], skip: int, order_sql: str) -> str:
40
- if take is None:
41
- return ""
42
- if self._dialect == DIALECT_POSTGRES:
43
- return f" LIMIT {take} OFFSET {skip}"
44
- order_prefix = "" if order_sql else " ORDER BY (SELECT NULL)"
45
- return f"{order_prefix} OFFSET {skip} ROWS FETCH NEXT {take} ROWS ONLY"
46
-
47
- def find_many(self, where=None, order_by=None, skip: int = 0, take: Optional[int] = None, select=None) -> List[Dict]:
48
- params: Dict = {}
49
- where_sql = _parse_where(self._model, where, params, self._dialect)
50
- order_sql = _build_order_by(order_by, self._dialect)
51
-
52
- query = f"SELECT * FROM {self._table_sql}{self._nolock}"
53
- if where_sql:
54
- query += f" WHERE {where_sql}"
55
- if order_sql:
56
- query += f" {order_sql}"
57
- query += self._pagination(take, skip, order_sql)
58
- return self._adapter.exec(query, list(params.values()))
59
-
60
- def find_first(self, where=None, order_by=None, select=None) -> Optional[Dict]:
61
- rows = self.find_many(where=where, order_by=order_by, take=1, select=select)
62
- return rows[0] if rows else None
63
-
64
- def find_unique(self, where: Dict) -> Optional[Dict]:
65
- return self.find_first(where=where)
66
-
67
- def count(self, where=None) -> int:
68
- params: Dict = {}
69
- where_sql = _parse_where(self._model, where, params, self._dialect)
70
- query = f"SELECT COUNT(*) AS cnt FROM {self._table_sql}{self._nolock}"
71
- if where_sql:
72
- query += f" WHERE {where_sql}"
73
- rows = self._adapter.exec(query, list(params.values()))
74
- return int(rows[0]["cnt"]) if rows else 0
75
-
76
- def create(self, data: Dict) -> Dict:
77
- id_field = next((f for f in self._fields if f.get("isId")), None)
78
- if id_field and id_field["name"] not in data:
79
- data = {**data, id_field["name"]: str(uuid.uuid4())}
80
-
81
- cols = [k for k, v in data.items() if v is not None]
82
- values = [data[c] for c in cols]
83
- placeholders = ", ".join(["?" if self._dialect == DIALECT_MSSQL else "%s"] * len(cols))
84
- col_list = ", ".join(_quote(c, self._dialect) for c in cols)
85
- query = f"INSERT INTO {self._table_sql} ({col_list}) VALUES ({placeholders})"
86
- self._adapter.execute(query, values)
87
-
88
- if id_field:
89
- return self.find_first(where={id_field["name"]: data[id_field["name"]]}) or data
90
- return data
91
-
92
- def create_many(self, data: List[Dict], skip_duplicates: bool = False) -> Dict:
93
- count = 0
94
- for row in data:
95
- try:
96
- self.create(row)
97
- count += 1
98
- except Exception:
99
- if not skip_duplicates:
100
- raise
101
- return {"count": count}
102
-
103
- def update(self, where: Dict, data: Dict) -> Optional[Dict]:
104
- params: Dict = {}
105
- where_sql = _parse_where(self._model, where, params, self._dialect, "w_")
106
- set_parts: List[str] = []
107
- set_values: List = []
108
- placeholder = "?" if self._dialect == DIALECT_MSSQL else "%s"
109
- for col, val in data.items():
110
- if val is not None:
111
- set_parts.append(f"{_quote(col, self._dialect)} = {placeholder}")
112
- set_values.append(val)
113
-
114
- all_values = set_values + list(params.values())
115
- query = f"UPDATE {self._table_sql} SET {', '.join(set_parts)}"
116
- if where_sql:
117
- query += f" WHERE {where_sql}"
118
- self._adapter.execute(query, all_values)
119
- return self.find_first(where=where)
120
-
121
- def update_many(self, where: Optional[Dict], data: Dict) -> Dict:
122
- params: Dict = {}
123
- where_sql = _parse_where(self._model, where, params, self._dialect, "w_")
124
- set_parts: List[str] = []
125
- set_values: List = []
126
- placeholder = "?" if self._dialect == DIALECT_MSSQL else "%s"
127
- for col, val in data.items():
128
- set_parts.append(f"{_quote(col, self._dialect)} = {placeholder}")
129
- set_values.append(val)
130
- all_values = set_values + list(params.values())
131
- query = f"UPDATE {self._table_sql} SET {', '.join(set_parts)}"
132
- if where_sql:
133
- query += f" WHERE {where_sql}"
134
- count = self._adapter.execute(query, all_values)
135
- return {"count": count}
136
-
137
- def delete(self, where: Dict) -> Optional[Dict]:
138
- existing = self.find_first(where=where)
139
- params: Dict = {}
140
- where_sql = _parse_where(self._model, where, params, self._dialect)
141
- query = f"DELETE FROM {self._table_sql} WHERE {where_sql}"
142
- self._adapter.execute(query, list(params.values()))
143
- return existing
144
-
145
- def delete_many(self, where: Optional[Dict] = None) -> Dict:
146
- params: Dict = {}
147
- where_sql = _parse_where(self._model, where, params, self._dialect)
148
- query = f"DELETE FROM {self._table_sql}"
149
- if where_sql:
150
- query += f" WHERE {where_sql}"
151
- count = self._adapter.execute(query, list(params.values()))
152
- return {"count": count}
153
-
154
- def upsert(self, where: Dict, create: Dict, update: Dict) -> Dict:
155
- existing = self.find_first(where=where)
156
- if existing:
157
- return self.update(where=where, data=update) or existing
158
- return self.create(data=create)
159
-
160
- def aggregate(self, where=None, _count=None, _sum=None, _avg=None, _min=None, _max=None) -> Dict:
161
- params: Dict = {}
162
- where_sql = _parse_where(self._model, where, params, self._dialect)
163
- aggs: List[str] = []
164
- if _count:
165
- aggs.append("COUNT(*) AS _count")
166
- if _sum:
167
- for f in (_sum if isinstance(_sum, list) else [_sum]):
168
- aggs.append(f"SUM({_quote(f, self._dialect)}) AS _sum_{f}")
169
- if _avg:
170
- for f in (_avg if isinstance(_avg, list) else [_avg]):
171
- aggs.append(f"AVG({_quote(f, self._dialect)}) AS _avg_{f}")
172
- if _min:
173
- for f in (_min if isinstance(_min, list) else [_min]):
174
- aggs.append(f"MIN({_quote(f, self._dialect)}) AS _min_{f}")
175
- if _max:
176
- for f in (_max if isinstance(_max, list) else [_max]):
177
- aggs.append(f"MAX({_quote(f, self._dialect)}) AS _max_{f}")
178
- if not aggs:
179
- aggs = ["COUNT(*) AS _count"]
180
-
181
- query = f"SELECT {', '.join(aggs)} FROM {self._table_sql}"
182
- if where_sql:
183
- query += f" WHERE {where_sql}"
184
- rows = self._adapter.exec(query, list(params.values()))
185
- return rows[0] if rows else {}
186
-
187
- def vector_search(self, vector: List[float], take: int = 10, where=None, vector_field: str = "embedding", distance_metric: str = "cosine") -> List[Dict]:
188
- rows = self.find_many(where=where)
189
- scored = []
190
- for row in rows:
191
- raw = row.get(vector_field)
192
- if raw is None:
193
- continue
194
- try:
195
- vec = json.loads(raw) if isinstance(raw, str) else list(raw)
196
- except Exception:
197
- continue
198
- if not vec or len(vec) != len(vector):
199
- continue
200
-
201
- dot = sum(a * b for a, b in zip(vector, vec))
202
- m1 = sum(a ** 2 for a in vector) ** 0.5
203
- m2 = sum(b ** 2 for b in vec) ** 0.5
204
- cosine = dot / (m1 * m2) if m1 and m2 else 0.0
205
-
206
- if distance_metric == "cosine":
207
- dist = 1.0 - cosine
208
- elif distance_metric == "dot":
209
- dist = -dot
210
- else:
211
- dist = sum((a - b) ** 2 for a, b in zip(vector, vec)) ** 0.5
212
-
213
- scored.append((row, dist))
214
-
215
- scored.sort(key=lambda x: x[1])
216
- return [{**row, "distance": dist} for row, dist in scored[:take]]
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.")