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 +97 -0
- an5_adapters-0.2.0.dist-info/METADATA +277 -0
- an5_adapters-0.2.0.dist-info/RECORD +14 -0
- an5_adapters-0.2.0.dist-info/WHEEL +5 -0
- an5_adapters-0.2.0.dist-info/top_level.txt +5 -0
- base/__init__.py +3 -0
- base/dialects.py +8 -0
- base/metadata.py +10 -0
- base/sql.py +121 -0
- mssql/__init__.py +1 -0
- mssql/provider.py +62 -0
- postgres/__init__.py +1 -0
- postgres/provider.py +15 -0
- table_client.py +216 -0
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,,
|
base/__init__.py
ADDED
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]]
|