an5-adapters 0.2.0__py3-none-any.whl

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.
an5_adapter.py ADDED
@@ -0,0 +1,97 @@
1
+ """Standalone Python runtime adapter for AN5 ORM."""
2
+
3
+ from typing import Dict, List, Optional
4
+
5
+ try:
6
+ from .base import DIALECT_MSSQL, DIALECT_POSTGRES, detect_dialect, set_adapter_metadata
7
+ from .mssql import connect as connect_mssql
8
+ from .postgres import connect as connect_postgres
9
+ from .table_client import AdapterTableClient
10
+ except ImportError:
11
+ from base import DIALECT_MSSQL, DIALECT_POSTGRES, detect_dialect, set_adapter_metadata
12
+ from mssql import connect as connect_mssql
13
+ from postgres import connect as connect_postgres
14
+ from table_client import AdapterTableClient
15
+
16
+ # Backward-compatible aliases used by tests and older imports.
17
+ _detect_dialect = detect_dialect
18
+ try:
19
+ from .mssql import parse_connection_string as _parse_connection_string
20
+ except ImportError:
21
+ from mssql import parse_connection_string as _parse_connection_string
22
+
23
+ class An5Adapter:
24
+ def __init__(self, connection_string: str):
25
+ self._dialect = detect_dialect(connection_string)
26
+ self._conn_str = connection_string
27
+
28
+ def _connect(self):
29
+ if self._dialect == DIALECT_POSTGRES:
30
+ return connect_postgres(self._conn_str)
31
+ return connect_mssql(self._conn_str)
32
+
33
+ def _to_dicts(self, cursor, query: str) -> List[Dict]:
34
+ if cursor.description:
35
+ cols = [col[0] for col in cursor.description]
36
+ rows = cursor.fetchall() if cursor.description else []
37
+ return [dict(zip(cols, row)) for row in rows]
38
+ return []
39
+
40
+ def exec(self, query: str, params: Optional[List] = None) -> List[Dict]:
41
+ conn = self._connect()
42
+ try:
43
+ cursor = conn.cursor()
44
+ cursor.execute(query, params or [])
45
+ return self._to_dicts(cursor, query)
46
+ finally:
47
+ conn.close()
48
+
49
+ def execute(self, query: str, params: Optional[List] = None) -> int:
50
+ conn = self._connect()
51
+ try:
52
+ cursor = conn.cursor()
53
+ cursor.execute(query, params or [])
54
+ return cursor.rowcount
55
+ finally:
56
+ conn.close()
57
+
58
+ def query_raw(self, query: str, *values) -> List[Dict]:
59
+ return self.exec(query, list(values))
60
+
61
+ def execute_raw(self, query: str, *values) -> int:
62
+ return self.execute(query, list(values))
63
+
64
+ def table(self, model_name: str) -> AdapterTableClient:
65
+ return AdapterTableClient(self, model_name)
66
+
67
+ def __getattr__(self, model_name: str) -> AdapterTableClient:
68
+ return self.table(model_name)
69
+
70
+ def transaction(self, fn):
71
+ conn = self._connect()
72
+ conn.autocommit = False
73
+ try:
74
+ result = fn(self)
75
+ conn.commit()
76
+ return result
77
+ except Exception:
78
+ conn.rollback()
79
+ raise
80
+ finally:
81
+ if self._dialect == DIALECT_POSTGRES:
82
+ conn.autocommit = True
83
+ conn.close()
84
+
85
+ def create_an5_adapter(connection_string: str) -> An5Adapter:
86
+ return An5Adapter(connection_string)
87
+
88
+ __all__ = [
89
+ "An5Adapter",
90
+ "AdapterTableClient",
91
+ "create_an5_adapter",
92
+ "DIALECT_MSSQL",
93
+ "DIALECT_POSTGRES",
94
+ "_detect_dialect",
95
+ "_parse_connection_string",
96
+ "set_adapter_metadata",
97
+ ]
@@ -0,0 +1,277 @@
1
+ Metadata-Version: 2.4
2
+ Name: an5-adapters
3
+ Version: 0.2.0
4
+ Summary: Python adapter helpers for AN5 ORM.
5
+ Author: an5ORM
6
+ License: MIT
7
+ Keywords: an5,orm,adapter
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: pyodbc>=5.0
13
+ Requires-Dist: psycopg2-binary>=2.9
14
+
15
+ # an5Adapters
16
+
17
+ Standalone runtime adapters for AN5 ORM. Provides connection pooling, query execution, typed table clients in TypeScript, Python, .NET, and Google Sheets API.
18
+
19
+ Adapters are runtime packages only. They do not import generated `an5Client` artifacts; generated clients or applications can pass model metadata explicitly when table-name mapping or field type coercion is needed.
20
+
21
+ ## Features
22
+
23
+ - **Connection pooling** — Managed connection pools with configurable limits
24
+ - **Type-safe table clients** — Generic CRUD operations with type inference
25
+ - **Full query support** — WHERE, ORDER BY, pagination, aggregates
26
+ - **Vector search** — Cosine, euclidean, and dot product similarity
27
+ - **Transactions** — Begin/commit/rollback with automatic cleanup
28
+ - **Cross-language** — Same API in TypeScript, Python, and .NET
29
+ - **Google Sheets** — Use spreadsheets as a database with the same CRUD API
30
+
31
+ ## Installation
32
+
33
+ ### TypeScript
34
+
35
+ ```bash
36
+ npm install an5-adapters
37
+ ```
38
+
39
+ ### Python
40
+
41
+ ```bash
42
+ pip install an5-adapters
43
+ ```
44
+
45
+ ### .NET
46
+
47
+ ```bash
48
+ dotnet add package An5Adapters
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ ### TypeScript
54
+
55
+ ```typescript
56
+ import { createAn5Adapter, setAdapterMetadata } from 'an5-adapters';
57
+
58
+ setAdapterMetadata({
59
+ modelToTable: { User: 'dbo.users' },
60
+ modelFields: {
61
+ User: {
62
+ id: { ts: 'string', sql: 'uniqueidentifier', isId: true },
63
+ active: { ts: 'boolean', sql: 'bit' },
64
+ },
65
+ },
66
+ });
67
+
68
+ const db = createAn5Adapter({
69
+ connectionString: 'sqlserver://localhost:1433;database=mydb;user=sa;password=pass',
70
+ });
71
+
72
+ // Table client
73
+ const users = db.table<User>('users');
74
+ await users.findMany({ where: { active: true }, take: 10 });
75
+
76
+ // Raw queries
77
+ const rows = await db.exec('SELECT * FROM users WHERE id = @id', { id: '123' });
78
+
79
+ // Transactions
80
+ await db.$transaction(async (tx) => {
81
+ await tx.table('users').create({ data: { name: 'John' } });
82
+ });
83
+ ```
84
+
85
+ ### Python
86
+
87
+ ```python
88
+ from an5_adapter import create_an5_adapter
89
+ from base.metadata import set_adapter_metadata
90
+
91
+ set_adapter_metadata({
92
+ "modelToTable": {"User": "dbo.users"},
93
+ "modelFields": {
94
+ "User": {
95
+ "id": {"py": "str", "sql": "uniqueidentifier", "isId": True},
96
+ "active": {"py": "bool", "sql": "bit"},
97
+ }
98
+ },
99
+ })
100
+
101
+ db = create_an5_adapter("sqlserver://localhost:1433;database=mydb;user=sa;password=pass")
102
+
103
+ # Table client
104
+ users = db.table("User")
105
+ users.find_many(where={"active": True}, take=10)
106
+
107
+ # Raw queries
108
+ rows = db.exec("SELECT * FROM users WHERE id = ?", params=["123"])
109
+
110
+ # Transactions
111
+ db.transaction(lambda tx: tx.table("User").create({"name": "John"}))
112
+ ```
113
+
114
+ ### .NET
115
+
116
+ ```csharp
117
+ using An5Orm;
118
+
119
+ var db = new An5Adapter(connectionString);
120
+
121
+ // Table client
122
+ var users = db.Table<User>("dbo.users");
123
+ var activeUsers = users.FindMany("IsActive = @p", new { p = true });
124
+
125
+ // Raw queries
126
+ var rows = db.QueryRaw("SELECT * FROM users WHERE Id = @id", new { id = "123" });
127
+
128
+ // Transactions
129
+ db.Transaction(tx => {
130
+ tx.Table<User>("dbo.users").Create(new User { Name = "John" });
131
+ });
132
+ ```
133
+
134
+ ### Google Sheets
135
+
136
+ ```typescript
137
+ import { createAn5SheetsAdapter } from 'an5-adapters';
138
+
139
+ const db = createAn5SheetsAdapter({
140
+ spreadsheetId: '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms',
141
+ // Option 1: client email + private key
142
+ clientEmail: 'sa@project.iam.gserviceaccount.com',
143
+ privateKey: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----',
144
+ // Option 2: full service account JSON
145
+ // credentials: { client_email: '...', private_key: '...' },
146
+ // Optional: map model names to sheet names
147
+ sheetMapping: { users: 'UsersData', orders: 'OrdersData' },
148
+ });
149
+
150
+ // Table client (same API as SQL adapters)
151
+ const users = db.table<User>('users');
152
+ await users.findMany({ where: { active: true }, take: 10 });
153
+ await users.create({ data: { name: 'John', email: 'john@example.com' } });
154
+ await users.update({ where: { email: 'john@example.com' }, data: { name: 'Johnny' } });
155
+ await users.delete({ where: { email: 'john@example.com' } });
156
+
157
+ // Raw range access (Google Sheets specific)
158
+ const rawData = await db.readRange('Sheet1!A1:C10');
159
+ await db.writeRange('Sheet1!A1:B2', [['Name', 'Age'], ['Alice', '30']]);
160
+ await db.appendRange('Sheet1!A:A', [['Bob', '25']]);
161
+
162
+ // Auto-creates sheet + header row on first create()
163
+ await db.table('orders').create({ data: { id: '1', total: 100 } });
164
+
165
+ // List, delete sheets
166
+ const sheets = await db.listSheets();
167
+ await db.deleteSheet('OldSheet');
168
+
169
+ // Clear data (keeps headers) or delete all rows
170
+ await db.table('users').clear();
171
+ await db.table('users').deleteAll();
172
+ ```
173
+
174
+ ### Integrated factory (auto-detect adapter)
175
+
176
+ ```typescript
177
+ import { createAn5Adapter, createAdapter, An5Adapter } from 'an5-adapters';
178
+
179
+ // Auto-detects from connection string
180
+ const sqlDb = createAn5Adapter({ connectionString: 'sqlserver://localhost:1433;database=mydb;user=sa;password=pass' });
181
+
182
+ const sheetsDb = createAn5Adapter({
183
+ connectionString: 'googlesheets://spreadsheetId;clientEmail=sa@project.iam.gserviceaccount.com;privateKey=...',
184
+ });
185
+
186
+ // Or use the Sheets config object directly (also auto-detected)
187
+ const sheetsDb2 = createAdapter({
188
+ spreadsheetId: '1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms',
189
+ clientEmail: 'sa@project.iam.gserviceaccount.com',
190
+ privateKey: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----',
191
+ });
192
+
193
+ // Constructor form also delegates googlesheets:// to the Sheets adapter
194
+ const sheetsDb3 = new An5Adapter({
195
+ connectionString: 'googlesheets://spreadsheetId;clientEmail=sa@project.iam.gserviceaccount.com;privateKey=...',
196
+ });
197
+ ```
198
+
199
+ **Notes:**
200
+ - Each model/table maps to a **sheet tab** (first row = headers)
201
+ - Sheets without header rows get auto-created on first `create()`
202
+ - Type coercion can use optional adapter metadata (`setAdapterMetadata`) for field types and model-to-table mapping
203
+ - Numeric strings (without leading zeros) are auto-coerced; `"00123"` stays string
204
+ - Boolean strings `"true"` / `"false"` are auto-coerced
205
+ - Sheet names with spaces are automatically escaped (A1 notation)
206
+ - Supports service account JSON or individual `clientEmail`+`privateKey`
207
+ - Automatic retry with exponential backoff for rate limits (429/500/503)
208
+
209
+ ### Provider Imports
210
+
211
+ Use the package root for normal applications:
212
+
213
+ ```typescript
214
+ import { createAn5Adapter, createAn5SheetsAdapter } from 'an5-adapters';
215
+ ```
216
+
217
+ Provider folders are still available to source-level consumers through `typescript/*`, but the public factory in `typescript/an5Adapter.ts` is the preferred entry point. The old `unified.ts` entry point has been removed because the factory now lives directly in `An5Adapter`.
218
+
219
+ ## API Reference
220
+
221
+ ### An5Adapter / An5SheetsAdapter
222
+
223
+ | Method | Description |
224
+ |--------|-------------|
225
+ | `exec(query, params)` | Execute query, return rows (SQL only) |
226
+ | `table<T>(name)` | Get typed table client |
227
+ | `$transaction(fn)` | Execute in transaction |
228
+ | `$connect()` | Open connection / authenticate |
229
+ | `$disconnect()` | Close connection / clear auth |
230
+ | `readRange(range)` | Read raw sheet range (Sheets only) |
231
+ | `writeRange(range, values)` | Write raw sheet range (Sheets only) |
232
+ | `appendRange(range, values)` | Append rows to sheet (Sheets only) |
233
+ | `listSheets()` | List all sheet tab names (Sheets only) |
234
+ | `deleteSheet(name)` | Delete a sheet tab (Sheets only) |
235
+
236
+ ### AdapterTableClient / SheetsTableClient
237
+
238
+ | Method | Description |
239
+ |--------|-------------|
240
+ | `findMany(args)` | Query multiple rows |
241
+ | `findFirst(args)` | Query single row |
242
+ | `findUnique(where)` | Find by unique key |
243
+ | `count(where)` | Count rows |
244
+ | `create(data)` | Insert row |
245
+ | `createMany(data)` | Bulk insert |
246
+ | `update(where, data)` | Update row |
247
+ | `updateMany(where, data)` | Update multiple rows |
248
+ | `delete(where)` | Delete row |
249
+ | `deleteMany(where)` | Delete multiple rows |
250
+ | `upsert(where, create, update)` | Insert or update |
251
+ | `aggregate(args)` | SUM, AVG, MIN, MAX, COUNT |
252
+ | `groupBy(args)` | Group by fields |
253
+ | `vectorSearch(args)` | Semantic similarity search |
254
+ | `clear()` | Clear all data rows, keep headers (Sheets only) |
255
+ | `deleteAll()` | Delete all data rows including headers (Sheets only) |
256
+
257
+ ## Provider Layout
258
+
259
+ - TypeScript providers live under `typescript/{base,mssql,postgres,mysql,sqlite,googlesheets}`.
260
+ - Python providers live under `python/{base,mssql,postgres}`, with `python/an5_adapter.py` kept as the public facade.
261
+ - .NET providers live under `dotnet/{Base,Mssql,Postgres}`, with `dotnet/an5Adapter.cs` kept as the public facade.
262
+ - Adapters do not depend on generated `an5-client` artifacts; generated clients may pass metadata in explicitly when they need model/table mapping.
263
+
264
+ ## Testing
265
+
266
+ ```bash
267
+ # TypeScript/Node
268
+ node test/unit.test.js
269
+
270
+ # Python
271
+ python -m compileall python
272
+ python test/smoke.py
273
+ ```
274
+
275
+ ## License
276
+
277
+ MIT
@@ -0,0 +1,14 @@
1
+ an5_adapter.py,sha256=4inAATh5FdShLWJv77RDdh0DPAf2Mx-4lcDOfTXrfqM,3215
2
+ table_client.py,sha256=5rLF3U5p8vPyc3pngZjDQE0zRN71ppoaIQZeSx0LJAA,9188
3
+ base/__init__.py,sha256=ttdMZrFMwrkPeegf-LoeLWXIIott-LtFfKSz_wRmCPI,228
4
+ base/dialects.py,sha256=j-aDD0pBMIOEFXpaxtJ0HykEatF6VUbEYkdbLT5gzj8,277
5
+ base/metadata.py,sha256=ozEwwQi9vR1W1ORzS-ipckz908P5d6I9olFSug0hcX0,400
6
+ base/sql.py,sha256=rAmaHM3eu5whnaL9quDb461i9gq8iHQX8b2xk2spJ70,5563
7
+ mssql/__init__.py,sha256=ipc1F1Or3Rn2JRM2_Zmtx2yixQ89id0kPPfIgtNbkrs,86
8
+ mssql/provider.py,sha256=QMWZey674W9u60n2oURuLySQSzxfKaAJ0Thipa8Sn-8,2054
9
+ postgres/__init__.py,sha256=ldDHixTERLgs8ieOIdidzyCzfNzzwuCbz9JjWhMMfOA,63
10
+ postgres/provider.py,sha256=gldk1MiVnXzR5_7jmtuNtWUZghyuPpyPIN6sRfgOw1Y,391
11
+ an5_adapters-0.2.0.dist-info/METADATA,sha256=P4QqTqEO-Wwg1-7Fk1WK0Po6n-BtfevWa7CYF9ZVgJ0,9092
12
+ an5_adapters-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
13
+ an5_adapters-0.2.0.dist-info/top_level.txt,sha256=cdCq2J7feHvCYWx440osNWpRHHBtYsuMCkXTYRQ8ZQ0,45
14
+ an5_adapters-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,5 @@
1
+ an5_adapter
2
+ base
3
+ mssql
4
+ postgres
5
+ table_client
base/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .dialects import DIALECT_MSSQL, DIALECT_POSTGRES, detect_dialect
2
+ from .metadata import model_to_table, model_fields, set_adapter_metadata
3
+ from .sql import _quote, _parse_where, _build_order_by, _resolve_table, _quote_table
base/dialects.py ADDED
@@ -0,0 +1,8 @@
1
+ DIALECT_MSSQL = "mssql"
2
+ DIALECT_POSTGRES = "postgres"
3
+
4
+ def detect_dialect(connection_string: str) -> str:
5
+ cs = connection_string.strip().lower()
6
+ if cs.startswith("postgres://") or cs.startswith("postgresql://"):
7
+ return DIALECT_POSTGRES
8
+ return DIALECT_MSSQL
base/metadata.py ADDED
@@ -0,0 +1,10 @@
1
+ from typing import Any, Dict
2
+
3
+ model_to_table: Dict[str, str] = {}
4
+ model_fields: Dict[str, Any] = {}
5
+
6
+ def set_adapter_metadata(metadata: Dict[str, Any]) -> None:
7
+ model_to_table.clear()
8
+ model_to_table.update(metadata.get("model_to_table") or metadata.get("modelToTable") or {})
9
+ model_fields.clear()
10
+ model_fields.update(metadata.get("model_fields") or metadata.get("modelFields") or {})
base/sql.py ADDED
@@ -0,0 +1,121 @@
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 _quote(name: str, dialect: str) -> str:
8
+ if dialect == DIALECT_POSTGRES:
9
+ if name.startswith("[") and name.endswith("]"):
10
+ return f'"{name[1:-1]}"'
11
+ return f'"{name}"'
12
+ return name if name.startswith("[") else f"[{name}]"
13
+
14
+
15
+ # ─── Connection helpers live in provider modules ────────────────────────────────
16
+
17
+ # ─── Where clause builder ──────────────────────────────────────────────────────────
18
+
19
+ def _parse_where(model_name: str, where: Optional[Dict], params: Dict, dialect: str, prefix: str = "") -> str:
20
+ if not where:
21
+ return ""
22
+ conditions: List[str] = []
23
+
24
+ for key, value in where.items():
25
+ if key == "OR" and isinstance(value, list):
26
+ sub = [_parse_where(model_name, v, params, dialect, f"{prefix}or{i}_") for i, v in enumerate(value)]
27
+ sub = [s for s in sub if s]
28
+ if sub:
29
+ conditions.append(f"({' OR '.join(sub)})")
30
+ elif key == "AND" and isinstance(value, list):
31
+ sub = [_parse_where(model_name, v, params, dialect, f"{prefix}and{i}_") for i, v in enumerate(value)]
32
+ sub = [s for s in sub if s]
33
+ if sub:
34
+ conditions.append(f"({' AND '.join(sub)})")
35
+ else:
36
+ col = _quote(key, dialect)
37
+ pname = f"{prefix}{key}"
38
+ placeholder = "%s" if dialect == DIALECT_POSTGRES else "?"
39
+ if value is None:
40
+ conditions.append(f"{col} IS NULL")
41
+ elif isinstance(value, dict):
42
+ if "not" in value:
43
+ if value["not"] is None:
44
+ conditions.append(f"{col} IS NOT NULL")
45
+ else:
46
+ params[f"{pname}_not"] = value["not"]
47
+ conditions.append(f"{col} <> {placeholder}")
48
+ if "equals" in value:
49
+ params[f"{pname}_eq"] = value["equals"]
50
+ conditions.append(f"{col} = {placeholder}")
51
+ if "contains" in value:
52
+ params[f"{pname}_co"] = f"%{value['contains']}%"
53
+ conditions.append(f"{col} LIKE {placeholder}")
54
+ if "startsWith" in value:
55
+ params[f"{pname}_sw"] = f"{value['startsWith']}%"
56
+ conditions.append(f"{col} LIKE {placeholder}")
57
+ if "endsWith" in value:
58
+ params[f"{pname}_ew"] = f"%{value['endsWith']}"
59
+ conditions.append(f"{col} LIKE {placeholder}")
60
+ if "gte" in value:
61
+ params[f"{pname}_gte"] = value["gte"]
62
+ conditions.append(f"{col} >= {placeholder}")
63
+ if "lte" in value:
64
+ params[f"{pname}_lte"] = value["lte"]
65
+ conditions.append(f"{col} <= {placeholder}")
66
+ if "gt" in value:
67
+ params[f"{pname}_gt"] = value["gt"]
68
+ conditions.append(f"{col} > {placeholder}")
69
+ if "lt" in value:
70
+ params[f"{pname}_lt"] = value["lt"]
71
+ conditions.append(f"{col} < {placeholder}")
72
+ if "in" in value:
73
+ vals = value["in"]
74
+ if vals:
75
+ placeholders = ", ".join([placeholder] * len(vals))
76
+ for i, v in enumerate(vals):
77
+ params[f"{pname}_in{i}"] = v
78
+ conditions.append(f"{col} IN ({placeholders})")
79
+ else:
80
+ conditions.append("1=0")
81
+ else:
82
+ params[pname] = value
83
+ conditions.append(f"{col} = {placeholder}")
84
+
85
+ return " AND ".join(conditions)
86
+
87
+
88
+ def _build_order_by(order_by: Any, dialect: str) -> str:
89
+ if not order_by:
90
+ return ""
91
+ entries = order_by if isinstance(order_by, list) else [order_by]
92
+ parts: List[str] = []
93
+ for entry in entries:
94
+ for col, direction in entry.items():
95
+ parts.append(f"{_quote(col, dialect)} {str(direction).upper()}")
96
+ return f"ORDER BY {', '.join(parts)}" if parts else ""
97
+
98
+
99
+ # ─── Table name resolution ─────────────────────────────────────────────────────────
100
+
101
+ def _resolve_table(model_name: str) -> str:
102
+ if model_name in model_to_table:
103
+ return model_to_table[model_name]
104
+ camel = model_name[0].lower() + model_name[1:] if model_name else model_name
105
+ if camel in model_to_table:
106
+ return model_to_table[camel]
107
+ lower = model_name.lower()
108
+ if lower in model_to_table:
109
+ return model_to_table[lower]
110
+ return model_name
111
+
112
+
113
+ def _quote_table(t: str, dialect: str) -> str:
114
+ q = lambda s: f'"{s}"' if dialect == DIALECT_POSTGRES else f"[{s}]"
115
+ if t.startswith("[") or t.startswith('"'):
116
+ return t
117
+ if "." in t:
118
+ return ".".join(q(p) for p in t.split("."))
119
+ return q(t)
120
+
121
+
mssql/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .provider import PYODBC_AVAILABLE, connect, parse_connection_string, placeholder
mssql/provider.py ADDED
@@ -0,0 +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 "?"
postgres/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .provider import PSYCOPG2_AVAILABLE, connect, placeholder
postgres/provider.py ADDED
@@ -0,0 +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"
table_client.py ADDED
@@ -0,0 +1,216 @@
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]]