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/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