async-orm-kit 1.0.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.
Files changed (92) hide show
  1. async_orm_kit/__init__.py +151 -0
  2. async_orm_kit/_logging.py +58 -0
  3. async_orm_kit/_version.py +1 -0
  4. async_orm_kit/config.py +31 -0
  5. async_orm_kit/exceptions.py +353 -0
  6. async_orm_kit/integrations/__init__.py +15 -0
  7. async_orm_kit/integrations/alembic.py +56 -0
  8. async_orm_kit/integrations/fastapi.py +134 -0
  9. async_orm_kit/integrations/storage.py +213 -0
  10. async_orm_kit/manager/__init__.py +3 -0
  11. async_orm_kit/manager/manager.py +568 -0
  12. async_orm_kit/models/__init__.py +3 -0
  13. async_orm_kit/models/base.py +568 -0
  14. async_orm_kit/models/fields/__init__.py +23 -0
  15. async_orm_kit/models/fields/exceptions.py +10 -0
  16. async_orm_kit/models/fields/typed/__init__.py +11 -0
  17. async_orm_kit/models/fields/typed/base.py +155 -0
  18. async_orm_kit/models/fields/typed/file.py +697 -0
  19. async_orm_kit/models/fields/typed/string.py +38 -0
  20. async_orm_kit/models/fields/validators.py +110 -0
  21. async_orm_kit/models/meta.py +181 -0
  22. async_orm_kit/models/mixins.py +19 -0
  23. async_orm_kit/observers/__init__.py +4 -0
  24. async_orm_kit/observers/mixin.py +26 -0
  25. async_orm_kit/observers/model_observer.py +20 -0
  26. async_orm_kit/observers/queryset_observer.py +38 -0
  27. async_orm_kit/py.typed +0 -0
  28. async_orm_kit/query/__init__.py +0 -0
  29. async_orm_kit/query/aggregations/__init__.py +8 -0
  30. async_orm_kit/query/aggregations/avg.py +71 -0
  31. async_orm_kit/query/aggregations/base.py +317 -0
  32. async_orm_kit/query/aggregations/count.py +115 -0
  33. async_orm_kit/query/aggregations/max.py +76 -0
  34. async_orm_kit/query/aggregations/min.py +76 -0
  35. async_orm_kit/query/aggregations/sum.py +73 -0
  36. async_orm_kit/query/compiler.py +574 -0
  37. async_orm_kit/query/debugger.py +269 -0
  38. async_orm_kit/query/expressions/__init__.py +62 -0
  39. async_orm_kit/query/expressions/analysis.py +56 -0
  40. async_orm_kit/query/expressions/case.py +321 -0
  41. async_orm_kit/query/expressions/collections.py +251 -0
  42. async_orm_kit/query/expressions/f.py +362 -0
  43. async_orm_kit/query/expressions/functions.py +202 -0
  44. async_orm_kit/query/expressions/subquery.py +206 -0
  45. async_orm_kit/query/expressions/value.py +101 -0
  46. async_orm_kit/query/expressions/window.py +95 -0
  47. async_orm_kit/query/lookups/__init__.py +121 -0
  48. async_orm_kit/query/lookups/conditions.py +315 -0
  49. async_orm_kit/query/lookups/correlated.py +86 -0
  50. async_orm_kit/query/lookups/json_paths.py +167 -0
  51. async_orm_kit/query/lookups/parser.py +276 -0
  52. async_orm_kit/query/lookups/resolver/__init__.py +5 -0
  53. async_orm_kit/query/lookups/resolver/context.py +150 -0
  54. async_orm_kit/query/lookups/resolver/resolver.py +572 -0
  55. async_orm_kit/query/lookups/resolver/types.py +150 -0
  56. async_orm_kit/query/mixins/__init__.py +0 -0
  57. async_orm_kit/query/mixins/combinable.py +376 -0
  58. async_orm_kit/query/mixins/orderable.py +208 -0
  59. async_orm_kit/query/observability.py +346 -0
  60. async_orm_kit/query/planning/__init__.py +0 -0
  61. async_orm_kit/query/planning/directives.py +117 -0
  62. async_orm_kit/query/planning/factory.py +40 -0
  63. async_orm_kit/query/planning/passes/__init__.py +15 -0
  64. async_orm_kit/query/planning/passes/base.py +47 -0
  65. async_orm_kit/query/planning/passes/cartesian_detection.py +74 -0
  66. async_orm_kit/query/planning/passes/depth_splitting.py +78 -0
  67. async_orm_kit/query/planning/passes/join_promotion.py +81 -0
  68. async_orm_kit/query/planning/passes/path_conflicts.py +103 -0
  69. async_orm_kit/query/planning/passes/redundant_path.py +67 -0
  70. async_orm_kit/query/planning/passes/strategy_selection.py +49 -0
  71. async_orm_kit/query/planning/planner.py +160 -0
  72. async_orm_kit/query/planning/protocols.py +78 -0
  73. async_orm_kit/query/postgres/__init__.py +0 -0
  74. async_orm_kit/query/postgres/optimizer.py +189 -0
  75. async_orm_kit/query/q.py +596 -0
  76. async_orm_kit/query/relations/__init__.py +0 -0
  77. async_orm_kit/query/relations/enums.py +16 -0
  78. async_orm_kit/query/relations/graph.py +908 -0
  79. async_orm_kit/query/relations/node.py +421 -0
  80. async_orm_kit/queryset/__init__.py +4 -0
  81. async_orm_kit/queryset/builder.py +1259 -0
  82. async_orm_kit/queryset/compound.py +379 -0
  83. async_orm_kit/queryset/executor.py +718 -0
  84. async_orm_kit/queryset/queryset.py +1726 -0
  85. async_orm_kit/queryset/state.py +164 -0
  86. async_orm_kit/queryset/validators.py +361 -0
  87. async_orm_kit/queryset/writer.py +755 -0
  88. async_orm_kit/types.py +23 -0
  89. async_orm_kit-1.0.0.dist-info/METADATA +318 -0
  90. async_orm_kit-1.0.0.dist-info/RECORD +92 -0
  91. async_orm_kit-1.0.0.dist-info/WHEEL +4 -0
  92. async_orm_kit-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,379 @@
1
+ """Lazy, immutable SQL set operations over compatible QuerySets.
2
+
3
+ UNION, UNION ALL, INTERSECT and EXCEPT are SQL set operations: unlike joining
4
+ related objects, they combine equal-shaped rows. The wrapper deliberately
5
+ limits operations to compatible QuerySets bound to the SAME session and leaves
6
+ SQLAlchemy in charge of SQL rendering. SQL query values remain bound parameters.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import AsyncIterator
12
+ from typing import Any
13
+
14
+ from sqlalchemy import (
15
+ column,
16
+ except_,
17
+ func,
18
+ inspect as sa_inspect,
19
+ intersect,
20
+ select,
21
+ union,
22
+ union_all,
23
+ )
24
+ from sqlalchemy.sql.elements import ColumnElement
25
+
26
+ from async_orm_kit.exceptions import DatabaseError, QueryStateError
27
+
28
+ _OPERATIONS = {
29
+ "union": union,
30
+ "union_all": union_all,
31
+ "intersect": intersect,
32
+ "except": except_,
33
+ }
34
+
35
+
36
+ def _python_result_type(column) -> type | None:
37
+ """Best-effort SQLAlchemy type check, without evaluating bound SQL values.
38
+
39
+ Dialect-specific/custom types may not expose ``python_type``. In that case
40
+ the database remains the source of truth for compound-column coercion.
41
+ """
42
+ try:
43
+ return column.type.python_type
44
+ except (AttributeError, NotImplementedError, TypeError):
45
+ return None
46
+
47
+
48
+ def _query_shape(queryset):
49
+ state = queryset._state
50
+ if state.for_update is not None:
51
+ raise QueryStateError("Set operations do not support row locking")
52
+ if state.select_related or state.prefetch_related:
53
+ raise QueryStateError("Set operations do not support eager-loading directives")
54
+ if state.order_by or state.limit_val is not None or state.offset_val is not None:
55
+ raise QueryStateError(
56
+ "Set-operation branches cannot have ORDER BY, LIMIT or OFFSET; "
57
+ "apply them to the combined result instead"
58
+ )
59
+ if state.distinct_on:
60
+ raise QueryStateError("Set operations do not support DISTINCT ON branches")
61
+
62
+ if state.values_select is not None:
63
+ keys = tuple(name for name, _ in state.values_select)
64
+ return "values", keys + tuple(
65
+ key for key in state.annotations if key not in keys
66
+ )
67
+ if state.values_list_select is not None:
68
+ keys = tuple(name for name, _ in state.values_list_select)
69
+ return ("flat" if state.values_list_flat else "tuples"), (
70
+ keys + tuple(key for key in state.annotations if key not in keys)
71
+ )
72
+ if state.annotations or state.only_fields is not None:
73
+ raise QueryStateError(
74
+ "Model set operations do not support annotations or deferred fields; "
75
+ "use matching values() or values_list() projections"
76
+ )
77
+ mapper = sa_inspect(queryset.model)
78
+ mapper = mapper.mapper if mapper.is_aliased_class else mapper
79
+ return "model", tuple(attr.key for attr in mapper.column_attrs)
80
+
81
+
82
+ class CompoundQuerySet:
83
+ """Result of combining compatible QuerySets with SQL set operators.
84
+
85
+ The result is awaitable and supports ``.order_by()``, ``.limit()``,
86
+ ``.offset()``, ``.count()``, ``.exists()``, ``.sql()`` and streaming.
87
+ ``filter()`` and ``exclude()`` apply to the compound result after the set
88
+ operation, before ordering and pagination. Only projected names are valid;
89
+ this does not implicitly join related tables.
90
+ """
91
+
92
+ __slots__ = (
93
+ "model",
94
+ "session",
95
+ "_mode",
96
+ "_fields",
97
+ "_statement",
98
+ "_cache",
99
+ )
100
+
101
+ def __init__(self, querysets, operation: str):
102
+ from async_orm_kit.queryset.queryset import QuerySet
103
+
104
+ if operation not in _OPERATIONS:
105
+ raise ValueError(f"Unsupported set operation: {operation}")
106
+ if len(querysets) < 2 or not all(isinstance(qs, QuerySet) for qs in querysets):
107
+ raise TypeError("Set operations require at least two QuerySets")
108
+ first = querysets[0]
109
+ shape = _query_shape(first)
110
+ for other in querysets[1:]:
111
+ if other.session is not first.session:
112
+ raise ValueError(
113
+ "Set-operation branches must use the same AsyncSession"
114
+ )
115
+ if other.model is not first.model:
116
+ raise ValueError(
117
+ "Set-operation branches must query the same mapped model"
118
+ )
119
+ if _query_shape(other) != shape:
120
+ raise QueryStateError(
121
+ "Set-operation branches must have matching projections"
122
+ )
123
+
124
+ self.model = first.model
125
+ self.session = first.session
126
+ self._mode, self._fields = shape
127
+ statements = [qs._compiler.compile(qs.state) for qs in querysets]
128
+ widths = [len(stmt.selected_columns) for stmt in statements]
129
+ if len(set(widths)) != 1:
130
+ raise QueryStateError(
131
+ "Set-operation branches must return an identical number of SQL columns"
132
+ )
133
+ # Equal labels and column counts are insufficient for compound SELECTs.
134
+ # For example, UNION of an integer-valued annotation with a text-valued
135
+ # annotation can return rows with inconsistent Python value types (or
136
+ # produce a dialect-specific coercion error). Reject known incompatible
137
+ # Python result types before execution; leave custom/unknown SQLAlchemy
138
+ # types to the database rather than guessing about their compatibility.
139
+ expected_types = tuple(
140
+ _python_result_type(column) for column in statements[0].selected_columns
141
+ )
142
+ for statement in statements[1:]:
143
+ for index, (expected, selected_column) in enumerate(
144
+ zip(expected_types, statement.selected_columns, strict=False), start=1
145
+ ):
146
+ actual = _python_result_type(selected_column)
147
+ if (
148
+ expected is not None
149
+ and actual is not None
150
+ and expected is not actual
151
+ ):
152
+ raise QueryStateError(
153
+ f"Set-operation column {index} has incompatible result "
154
+ f"types: {expected.__name__} versus {actual.__name__}; "
155
+ "cast projections explicitly to a common type"
156
+ )
157
+ self._statement = _OPERATIONS[operation](*statements)
158
+ self._cache = None
159
+
160
+ def _clone(self):
161
+ clone = object.__new__(type(self))
162
+ clone.model = self.model
163
+ clone.session = self.session
164
+ clone._mode = self._mode
165
+ clone._fields = self._fields
166
+ clone._statement = self._statement
167
+ clone._cache = None
168
+ return clone
169
+
170
+ def _filter_result(self, predicate, *, negate: bool):
171
+ from sqlalchemy import and_, or_
172
+
173
+ from async_orm_kit.query.lookups import LOOKUP_OPERATORS, STANDARD_LOOKUPS
174
+ from async_orm_kit.query.lookups.conditions import ConditionBuilder
175
+ from async_orm_kit.query.lookups.json_paths import json_lookup
176
+ from async_orm_kit.query.lookups.resolver import ResolverContext
177
+ from async_orm_kit.query.q import Q
178
+
179
+ statement = self._statement
180
+ if (
181
+ statement._limit_clause is not None
182
+ or statement._offset_clause is not None
183
+ or tuple(statement._order_by_clauses)
184
+ ):
185
+ raise QueryStateError(
186
+ "Apply filter()/exclude() before order_by(), limit() and offset() "
187
+ "on a set-operation result"
188
+ )
189
+ source = statement.subquery()
190
+
191
+ class _Resolver:
192
+ query_state = type("_State", (), {"custom_lookups": {}})()
193
+
194
+ builder = ConditionBuilder(_Resolver())
195
+
196
+ def leaf(key, value):
197
+ if not isinstance(key, str) or not key:
198
+ raise TypeError("Compound filters require nonempty field lookup names")
199
+ name, _, lookup = key.partition("__")
200
+ if name not in self._fields or name not in source.c:
201
+ raise QueryStateError(
202
+ f"Field {name!r} is not projected by the set result"
203
+ )
204
+ field = source.c[name]
205
+ lookup = lookup or "exact"
206
+ if getattr(value, "resolve", None) is not None:
207
+ raise QueryStateError("Compound filters require literal lookup values")
208
+ resolved = json_lookup(field, lookup, value)
209
+ if isinstance(resolved, ColumnElement):
210
+ return resolved
211
+ if resolved is not None:
212
+ field, lookup = resolved
213
+ if lookup not in LOOKUP_OPERATORS and lookup not in STANDARD_LOOKUPS:
214
+ raise QueryStateError(f"Unsupported compound result lookup: {lookup!r}")
215
+ return builder.build(field, lookup, value, context=ResolverContext())
216
+
217
+ def resolve(q):
218
+ clauses = []
219
+ for child in q.children:
220
+ if isinstance(child, Q):
221
+ clauses.append(resolve(child))
222
+ else:
223
+ _, key, value = child
224
+ clauses.append(leaf(key, value))
225
+ if not clauses:
226
+ return None
227
+ result = or_(*clauses) if q.connector == Q.OR else and_(*clauses)
228
+ return result.is_not(True) if q.negated else result
229
+
230
+ expression = resolve(predicate)
231
+ if expression is None:
232
+ return self._clone()
233
+ if negate:
234
+ expression = expression.is_not(True)
235
+ clone = self._clone()
236
+ clone._statement = select(*(source.c[key] for key in self._fields)).where(
237
+ expression
238
+ )
239
+ return clone
240
+
241
+ def filter(self, *conditions, **lookups):
242
+ """Filter the combined result by projected fields before pagination."""
243
+ from async_orm_kit.query.q import Q
244
+
245
+ if any(not isinstance(arg, Q) for arg in conditions):
246
+ raise TypeError("Compound filter() accepts only positional Q objects")
247
+ q = Q(*conditions, **lookups)
248
+ return self._filter_result(q, negate=False)
249
+
250
+ def exclude(self, *conditions, **lookups):
251
+ """Exclude matching combined rows, retaining nullable comparisons."""
252
+ from async_orm_kit.query.q import Q
253
+
254
+ if any(not isinstance(arg, Q) for arg in conditions):
255
+ raise TypeError("Compound exclude() accepts only positional Q objects")
256
+ q = Q(*conditions, **lookups)
257
+ return self._filter_result(q, negate=True)
258
+
259
+ def order_by(self, *fields: str):
260
+ """Order by a projected column name; ``-field`` means descending."""
261
+ clone = self._clone()
262
+ clauses = []
263
+ for field in fields:
264
+ if not isinstance(field, str) or not field:
265
+ raise TypeError(
266
+ "Set operation order_by fields must be non-empty strings"
267
+ )
268
+ key = field[1:] if field.startswith("-") else field
269
+ if not key or key not in self._fields:
270
+ raise ValueError(
271
+ f"Set operation cannot order by unknown column {field!r}"
272
+ )
273
+ expr: Any = column(key)
274
+ clauses.append(expr.desc() if field.startswith("-") else expr.asc())
275
+ clone._statement = clone._statement.order_by(None).order_by(*clauses)
276
+ return clone
277
+
278
+ def limit(self, value: int):
279
+ if type(value) is not int or value < 0:
280
+ raise ValueError("limit must be a nonnegative integer")
281
+ clone = self._clone()
282
+ clone._statement = clone._statement.limit(value)
283
+ return clone
284
+
285
+ def offset(self, value: int):
286
+ if type(value) is not int or value < 0:
287
+ raise ValueError("offset must be a nonnegative integer")
288
+ clone = self._clone()
289
+ clone._statement = clone._statement.offset(value)
290
+ return clone
291
+
292
+ def sql(self, dialect=None) -> str:
293
+ """Compile parameterized SQL without interpolating user values."""
294
+ if dialect is None:
295
+ bind = getattr(self.session, "bind", None)
296
+ if bind is not None:
297
+ dialect = bind.dialect
298
+ return str(self._statement.compile(dialect=dialect))
299
+
300
+ def cte(self, name: str | None = None):
301
+ """Expose the resulting set operation as a SQLAlchemy CTE."""
302
+ return self._statement.cte(name=name)
303
+
304
+ def subquery(self, name: str | None = None):
305
+ """Expose the resulting set operation as a SQLAlchemy subquery."""
306
+ return self._statement.subquery(name=name)
307
+
308
+ def _execution_statement(self):
309
+ if self._mode == "model":
310
+ # A set operation selects mapped table columns, not ORM entities.
311
+ # from_statement() hydrates them through SQLAlchemy's identity map.
312
+ return select(self.model).from_statement(self._statement)
313
+ return self._statement
314
+
315
+ def _convert(self, row):
316
+ if self._mode == "model":
317
+ return row[0]
318
+ if self._mode == "values":
319
+ return dict(zip(self._fields, row, strict=False))
320
+ if self._mode == "flat":
321
+ return row[0]
322
+ return tuple(row)
323
+
324
+ async def _fetch(self):
325
+ if self._cache is not None:
326
+ return self._cache
327
+ try:
328
+ result = await self.session.execute(self._execution_statement())
329
+ rows = result.unique().all() if self._mode == "model" else result.all()
330
+ self._cache = [self._convert(row) for row in rows]
331
+ return self._cache
332
+ except Exception as exc:
333
+ raise DatabaseError("Error executing SQL set operation", cause=exc) from exc
334
+
335
+ def __await__(self):
336
+ return self._fetch().__await__()
337
+
338
+ async def first(self):
339
+ rows = await self.limit(1)
340
+ return rows[0] if rows else None
341
+
342
+ async def count(self) -> int:
343
+ """Count the rows returned by the set operation including its pagination."""
344
+ stmt = select(func.count()).select_from(self._statement.subquery())
345
+ try:
346
+ return (await self.session.execute(stmt)).scalar_one()
347
+ except Exception as exc:
348
+ raise DatabaseError("Error counting SQL set operation", cause=exc) from exc
349
+
350
+ async def exists(self) -> bool:
351
+ stmt = select(self._statement.exists())
352
+ try:
353
+ return bool((await self.session.execute(stmt)).scalar_one())
354
+ except Exception as exc:
355
+ raise DatabaseError("Error checking SQL set operation", cause=exc) from exc
356
+
357
+ async def stream(self) -> AsyncIterator[Any]:
358
+ """Stream set-operation results and close the SQLAlchemy cursor."""
359
+ try:
360
+ result = await self.session.stream(self._execution_statement())
361
+ except Exception as exc:
362
+ raise DatabaseError(
363
+ "Error opening SQL set-operation stream", cause=exc
364
+ ) from exc
365
+
366
+ try:
367
+ iterator = result.unique() if self._mode == "model" else result
368
+ async for row in iterator:
369
+ yield self._convert(row)
370
+ except Exception as exc:
371
+ raise DatabaseError("Error streaming SQL set operation", cause=exc) from exc
372
+ finally:
373
+ await result.close()
374
+
375
+ def __aiter__(self):
376
+ return self.stream()
377
+
378
+
379
+ __all__ = ["CompoundQuerySet"]