duckling-orm 0.0.3__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.
- duckling/__init__.py +123 -0
- duckling/connection.py +173 -0
- duckling/document.py +598 -0
- duckling/exceptions.py +33 -0
- duckling/fields.py +261 -0
- duckling/init.py +142 -0
- duckling/operators.py +148 -0
- duckling/query.py +391 -0
- duckling_orm-0.0.3.dist-info/METADATA +349 -0
- duckling_orm-0.0.3.dist-info/RECORD +13 -0
- duckling_orm-0.0.3.dist-info/WHEEL +5 -0
- duckling_orm-0.0.3.dist-info/licenses/LICENSE +202 -0
- duckling_orm-0.0.3.dist-info/top_level.txt +1 -0
duckling/query.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Query builder for Duckling — fluent, chainable queries inspired by Beanie.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
# Chain methods to build queries
|
|
6
|
+
users = await User.find(User.age > 25).sort("+name").limit(10).to_list()
|
|
7
|
+
user = await User.find_one(User.name == "Alice")
|
|
8
|
+
count = await User.find(User.active == True).count()
|
|
9
|
+
|
|
10
|
+
# Projection (select specific fields)
|
|
11
|
+
names = await User.find().project(name=1, email=1).to_list()
|
|
12
|
+
|
|
13
|
+
# Aggregation
|
|
14
|
+
stats = await User.find().aggregate(avg_age=Avg("age"), total=Count())
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
from typing import Any, Generic, Optional, Type, TypeVar
|
|
21
|
+
|
|
22
|
+
from .connection import get_session
|
|
23
|
+
from .fields import Expression, FieldProxy, SortDirection
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ──────────────────────────────────────────────
|
|
29
|
+
# Aggregation functions
|
|
30
|
+
# ──────────────────────────────────────────────
|
|
31
|
+
class AggFunc:
|
|
32
|
+
"""Base aggregation function."""
|
|
33
|
+
|
|
34
|
+
def to_sql(self) -> str:
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Count(AggFunc):
|
|
39
|
+
def __init__(self, field: str = "*"):
|
|
40
|
+
self.field = field
|
|
41
|
+
|
|
42
|
+
def to_sql(self) -> str:
|
|
43
|
+
if self.field == "*":
|
|
44
|
+
return "COUNT(*)"
|
|
45
|
+
return f'COUNT("{self.field}")'
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Sum(AggFunc):
|
|
49
|
+
def __init__(self, field: str):
|
|
50
|
+
self.field = field
|
|
51
|
+
|
|
52
|
+
def to_sql(self) -> str:
|
|
53
|
+
return f'SUM("{self.field}")'
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Avg(AggFunc):
|
|
57
|
+
def __init__(self, field: str):
|
|
58
|
+
self.field = field
|
|
59
|
+
|
|
60
|
+
def to_sql(self) -> str:
|
|
61
|
+
return f'AVG("{self.field}")'
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Min(AggFunc):
|
|
65
|
+
def __init__(self, field: str):
|
|
66
|
+
self.field = field
|
|
67
|
+
|
|
68
|
+
def to_sql(self) -> str:
|
|
69
|
+
return f'MIN("{self.field}")'
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Max(AggFunc):
|
|
73
|
+
def __init__(self, field: str):
|
|
74
|
+
self.field = field
|
|
75
|
+
|
|
76
|
+
def to_sql(self) -> str:
|
|
77
|
+
return f'MAX("{self.field}")'
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class CountDistinct(AggFunc):
|
|
81
|
+
def __init__(self, field: str):
|
|
82
|
+
self.field = field
|
|
83
|
+
|
|
84
|
+
def to_sql(self) -> str:
|
|
85
|
+
return f'COUNT(DISTINCT "{self.field}")'
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ──────────────────────────────────────────────
|
|
89
|
+
# FindQuery — the main query builder
|
|
90
|
+
# ──────────────────────────────────────────────
|
|
91
|
+
class FindQuery(Generic[T]):
|
|
92
|
+
"""
|
|
93
|
+
A fluent, chainable query builder for finding documents.
|
|
94
|
+
|
|
95
|
+
Mirrors Beanie's query interface:
|
|
96
|
+
results = await Model.find(condition).sort(...).skip(...).limit(...).to_list()
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
document_class: Type[T],
|
|
102
|
+
*conditions: Expression,
|
|
103
|
+
) -> None:
|
|
104
|
+
self._document_class = document_class
|
|
105
|
+
self._conditions: list[Expression] = list(conditions)
|
|
106
|
+
self._sort_clauses: list[tuple[str, SortDirection]] = []
|
|
107
|
+
self._limit_val: Optional[int] = None
|
|
108
|
+
self._skip_val: int = 0
|
|
109
|
+
self._projection: Optional[list[str]] = None
|
|
110
|
+
self._group_by: Optional[list[str]] = None
|
|
111
|
+
|
|
112
|
+
# ── Chainable methods ─────────────────────
|
|
113
|
+
|
|
114
|
+
def find(self, *conditions: Expression) -> FindQuery[T]:
|
|
115
|
+
"""Add additional filter conditions (AND)."""
|
|
116
|
+
self._conditions.extend(conditions)
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def sort(self, *keys: str | tuple[str, SortDirection] | tuple[str, int]) -> FindQuery[T]:
|
|
120
|
+
"""
|
|
121
|
+
Add sort clauses. Accepts:
|
|
122
|
+
- "+field" / "-field" strings
|
|
123
|
+
- (field_name, SortDirection) tuples
|
|
124
|
+
- FieldProxy.asc() / FieldProxy.desc() results
|
|
125
|
+
"""
|
|
126
|
+
for key in keys:
|
|
127
|
+
if isinstance(key, str):
|
|
128
|
+
if key.startswith("-"):
|
|
129
|
+
self._sort_clauses.append((key[1:], SortDirection.DESCENDING))
|
|
130
|
+
elif key.startswith("+"):
|
|
131
|
+
self._sort_clauses.append((key[1:], SortDirection.ASCENDING))
|
|
132
|
+
else:
|
|
133
|
+
self._sort_clauses.append((key, SortDirection.ASCENDING))
|
|
134
|
+
elif isinstance(key, tuple) and len(key) == 2:
|
|
135
|
+
field_name, direction = key
|
|
136
|
+
if isinstance(direction, int):
|
|
137
|
+
direction = SortDirection(direction)
|
|
138
|
+
self._sort_clauses.append((field_name, direction))
|
|
139
|
+
return self
|
|
140
|
+
|
|
141
|
+
def limit(self, n: int) -> FindQuery[T]:
|
|
142
|
+
"""Limit the number of results."""
|
|
143
|
+
self._limit_val = n
|
|
144
|
+
return self
|
|
145
|
+
|
|
146
|
+
def skip(self, n: int) -> FindQuery[T]:
|
|
147
|
+
"""Skip the first n results."""
|
|
148
|
+
self._skip_val = n
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
def project(self, *fields: str, **named_fields: int) -> FindQuery[T]:
|
|
152
|
+
"""
|
|
153
|
+
Select specific fields (projection).
|
|
154
|
+
|
|
155
|
+
.project("name", "email")
|
|
156
|
+
.project(name=1, email=1)
|
|
157
|
+
"""
|
|
158
|
+
proj = list(fields)
|
|
159
|
+
for name, include in named_fields.items():
|
|
160
|
+
if include:
|
|
161
|
+
proj.append(name)
|
|
162
|
+
self._projection = proj
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
# ── SQL Generation ────────────────────────
|
|
166
|
+
|
|
167
|
+
def _get_table_name(self) -> str:
|
|
168
|
+
return self._document_class._get_table_name()
|
|
169
|
+
|
|
170
|
+
def _build_where(self) -> tuple[str, list]:
|
|
171
|
+
if not self._conditions:
|
|
172
|
+
return "", []
|
|
173
|
+
|
|
174
|
+
parts = []
|
|
175
|
+
params = []
|
|
176
|
+
for cond in self._conditions:
|
|
177
|
+
sql, p = cond.to_sql()
|
|
178
|
+
parts.append(sql)
|
|
179
|
+
params.extend(p)
|
|
180
|
+
|
|
181
|
+
return " AND ".join(parts), params
|
|
182
|
+
|
|
183
|
+
def _build_select_sql(self) -> tuple[str, list]:
|
|
184
|
+
table = self._get_table_name()
|
|
185
|
+
|
|
186
|
+
# Columns
|
|
187
|
+
if self._projection:
|
|
188
|
+
cols = ", ".join(f'"{c}"' for c in self._projection)
|
|
189
|
+
else:
|
|
190
|
+
cols = "*"
|
|
191
|
+
|
|
192
|
+
sql = f'SELECT {cols} FROM "{table}"'
|
|
193
|
+
params: list = []
|
|
194
|
+
|
|
195
|
+
# WHERE
|
|
196
|
+
where_sql, where_params = self._build_where()
|
|
197
|
+
if where_sql:
|
|
198
|
+
sql += f" WHERE {where_sql}"
|
|
199
|
+
params.extend(where_params)
|
|
200
|
+
|
|
201
|
+
# ORDER BY
|
|
202
|
+
if self._sort_clauses:
|
|
203
|
+
order_parts = []
|
|
204
|
+
for field_name, direction in self._sort_clauses:
|
|
205
|
+
dir_str = "ASC" if direction == SortDirection.ASCENDING else "DESC"
|
|
206
|
+
order_parts.append(f'"{field_name}" {dir_str}')
|
|
207
|
+
sql += " ORDER BY " + ", ".join(order_parts)
|
|
208
|
+
|
|
209
|
+
# LIMIT / OFFSET
|
|
210
|
+
if self._limit_val is not None:
|
|
211
|
+
sql += f" LIMIT {self._limit_val}"
|
|
212
|
+
if self._skip_val:
|
|
213
|
+
sql += f" OFFSET {self._skip_val}"
|
|
214
|
+
|
|
215
|
+
return sql, params
|
|
216
|
+
|
|
217
|
+
def _build_count_sql(self) -> tuple[str, list]:
|
|
218
|
+
table = self._get_table_name()
|
|
219
|
+
sql = f'SELECT COUNT(*) FROM "{table}"'
|
|
220
|
+
params: list = []
|
|
221
|
+
|
|
222
|
+
where_sql, where_params = self._build_where()
|
|
223
|
+
if where_sql:
|
|
224
|
+
sql += f" WHERE {where_sql}"
|
|
225
|
+
params.extend(where_params)
|
|
226
|
+
|
|
227
|
+
return sql, params
|
|
228
|
+
|
|
229
|
+
def _build_delete_sql(self) -> tuple[str, list]:
|
|
230
|
+
table = self._get_table_name()
|
|
231
|
+
sql = f'DELETE FROM "{table}"'
|
|
232
|
+
params: list = []
|
|
233
|
+
|
|
234
|
+
where_sql, where_params = self._build_where()
|
|
235
|
+
if where_sql:
|
|
236
|
+
sql += f" WHERE {where_sql}"
|
|
237
|
+
params.extend(where_params)
|
|
238
|
+
|
|
239
|
+
return sql, params
|
|
240
|
+
|
|
241
|
+
def _build_update_sql(self, updates: dict[str, Any]) -> tuple[str, list]:
|
|
242
|
+
table = self._get_table_name()
|
|
243
|
+
set_parts = []
|
|
244
|
+
params: list = []
|
|
245
|
+
|
|
246
|
+
for col, val in updates.items():
|
|
247
|
+
set_parts.append(f'"{col}" = ?')
|
|
248
|
+
params.append(val)
|
|
249
|
+
|
|
250
|
+
sql = f'UPDATE "{table}" SET {", ".join(set_parts)}'
|
|
251
|
+
|
|
252
|
+
where_sql, where_params = self._build_where()
|
|
253
|
+
if where_sql:
|
|
254
|
+
sql += f" WHERE {where_sql}"
|
|
255
|
+
params.extend(where_params)
|
|
256
|
+
|
|
257
|
+
return sql, params
|
|
258
|
+
|
|
259
|
+
# ── Execution (async) ─────────────────────
|
|
260
|
+
|
|
261
|
+
async def to_list(self) -> list[T]:
|
|
262
|
+
"""Execute query and return list of document instances."""
|
|
263
|
+
sql, params = self._build_select_sql()
|
|
264
|
+
session = get_session()
|
|
265
|
+
rows = await session.async_fetchall(sql, params)
|
|
266
|
+
|
|
267
|
+
if not rows:
|
|
268
|
+
return []
|
|
269
|
+
|
|
270
|
+
# Get column names
|
|
271
|
+
col_names = self._projection or list(self._document_class.model_fields.keys())
|
|
272
|
+
if not self._projection:
|
|
273
|
+
col_names = self._document_class._get_column_names()
|
|
274
|
+
|
|
275
|
+
return [self._document_class._from_row(row, col_names) for row in rows]
|
|
276
|
+
|
|
277
|
+
async def first_or_none(self) -> Optional[T]:
|
|
278
|
+
"""Return the first result or None."""
|
|
279
|
+
self._limit_val = 1
|
|
280
|
+
results = await self.to_list()
|
|
281
|
+
return results[0] if results else None
|
|
282
|
+
|
|
283
|
+
async def count(self) -> int:
|
|
284
|
+
"""Return the count of matching documents."""
|
|
285
|
+
sql, params = self._build_count_sql()
|
|
286
|
+
session = get_session()
|
|
287
|
+
row = await session.async_fetchone(sql, params)
|
|
288
|
+
return row[0] if row else 0
|
|
289
|
+
|
|
290
|
+
async def exists(self) -> bool:
|
|
291
|
+
"""Check if any matching documents exist."""
|
|
292
|
+
return (await self.count()) > 0
|
|
293
|
+
|
|
294
|
+
async def delete(self) -> int:
|
|
295
|
+
"""Delete all matching documents. Returns number of deleted rows."""
|
|
296
|
+
sql, params = self._build_delete_sql()
|
|
297
|
+
session = get_session()
|
|
298
|
+
result = await session.async_execute(sql, params)
|
|
299
|
+
return result.fetchone()[0] if result.description else 0
|
|
300
|
+
|
|
301
|
+
async def update(self, updates: dict[str, Any]) -> int:
|
|
302
|
+
"""Update all matching documents with the given values."""
|
|
303
|
+
sql, params = self._build_update_sql(updates)
|
|
304
|
+
session = get_session()
|
|
305
|
+
await session.async_execute(sql, params)
|
|
306
|
+
return 0 # DuckDB doesn't return affected rows easily
|
|
307
|
+
|
|
308
|
+
async def aggregate(self, **agg_funcs: AggFunc) -> dict[str, Any]:
|
|
309
|
+
"""
|
|
310
|
+
Run aggregation functions on matching documents.
|
|
311
|
+
|
|
312
|
+
result = await User.find().aggregate(
|
|
313
|
+
avg_age=Avg("age"),
|
|
314
|
+
total=Count(),
|
|
315
|
+
max_age=Max("age"),
|
|
316
|
+
)
|
|
317
|
+
"""
|
|
318
|
+
table = self._get_table_name()
|
|
319
|
+
agg_parts = []
|
|
320
|
+
for alias, func in agg_funcs.items():
|
|
321
|
+
agg_parts.append(f'{func.to_sql()} AS "{alias}"')
|
|
322
|
+
|
|
323
|
+
sql = f'SELECT {", ".join(agg_parts)} FROM "{table}"'
|
|
324
|
+
params: list = []
|
|
325
|
+
|
|
326
|
+
where_sql, where_params = self._build_where()
|
|
327
|
+
if where_sql:
|
|
328
|
+
sql += f" WHERE {where_sql}"
|
|
329
|
+
params.extend(where_params)
|
|
330
|
+
|
|
331
|
+
session = get_session()
|
|
332
|
+
row = await session.async_fetchone(sql, params)
|
|
333
|
+
if row is None:
|
|
334
|
+
return {alias: None for alias in agg_funcs}
|
|
335
|
+
return dict(zip(agg_funcs.keys(), row))
|
|
336
|
+
|
|
337
|
+
async def to_df(self):
|
|
338
|
+
"""Execute query and return a pandas DataFrame."""
|
|
339
|
+
sql, params = self._build_select_sql()
|
|
340
|
+
session = get_session()
|
|
341
|
+
return await session.async_fetchdf(sql, params)
|
|
342
|
+
|
|
343
|
+
# ── Execution (sync) ──────────────────────
|
|
344
|
+
|
|
345
|
+
def to_list_sync(self) -> list[T]:
|
|
346
|
+
"""Synchronous version of to_list()."""
|
|
347
|
+
sql, params = self._build_select_sql()
|
|
348
|
+
session = get_session()
|
|
349
|
+
rows = session.fetchall(sql, params)
|
|
350
|
+
|
|
351
|
+
if not rows:
|
|
352
|
+
return []
|
|
353
|
+
|
|
354
|
+
col_names = self._projection or self._document_class._get_column_names()
|
|
355
|
+
return [self._document_class._from_row(row, col_names) for row in rows]
|
|
356
|
+
|
|
357
|
+
def first_or_none_sync(self) -> Optional[T]:
|
|
358
|
+
"""Synchronous version of first_or_none()."""
|
|
359
|
+
self._limit_val = 1
|
|
360
|
+
results = self.to_list_sync()
|
|
361
|
+
return results[0] if results else None
|
|
362
|
+
|
|
363
|
+
def count_sync(self) -> int:
|
|
364
|
+
"""Synchronous version of count()."""
|
|
365
|
+
sql, params = self._build_count_sql()
|
|
366
|
+
session = get_session()
|
|
367
|
+
row = session.fetchone(sql, params)
|
|
368
|
+
return row[0] if row else 0
|
|
369
|
+
|
|
370
|
+
# ── Async iteration ───────────────────────
|
|
371
|
+
|
|
372
|
+
def __aiter__(self):
|
|
373
|
+
return FindQueryIterator(self)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class FindQueryIterator:
|
|
377
|
+
"""Async iterator for FindQuery results."""
|
|
378
|
+
|
|
379
|
+
def __init__(self, query: FindQuery) -> None:
|
|
380
|
+
self._query = query
|
|
381
|
+
self._results: Optional[list] = None
|
|
382
|
+
self._index = 0
|
|
383
|
+
|
|
384
|
+
async def __anext__(self):
|
|
385
|
+
if self._results is None:
|
|
386
|
+
self._results = await self._query.to_list()
|
|
387
|
+
if self._index >= len(self._results):
|
|
388
|
+
raise StopAsyncIteration
|
|
389
|
+
item = self._results[self._index]
|
|
390
|
+
self._index += 1
|
|
391
|
+
return item
|