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,421 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from .enums import JoinType
7
+
8
+ if TYPE_CHECKING:
9
+ from sqlalchemy.orm import RelationshipProperty
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class JoinNode:
14
+ """Represents a single JOIN operation in the query's JOIN graph.
15
+
16
+ JoinNode is a tree node that tracks metadata about a relationship traversal
17
+ in a Django-style query. Each node corresponds to one relationship in a path
18
+ like "building__city__country", forming a tree structure where the root
19
+ represents the base model and children represent joined relationships.
20
+
21
+ The node stores usage flags that influence JOIN type optimization during
22
+ query compilation. For example, a node marked with used_in_filter=True may
23
+ be promoted from LEFT JOIN to INNER JOIN if it doesn't appear in OR/NOT
24
+ contexts or nullable checks.
25
+
26
+ This is a pure data container with minimal logic - JOIN type optimization
27
+ and SQL compilation are handled by JoinGraph and QueryCompiler respectively.
28
+
29
+ Attributes:
30
+ path: Django-style relationship path from root model using double
31
+ underscores. Examples: "", "building", "building__city__country".
32
+ Empty string for synthetic root node.
33
+ alias: SQLAlchemy aliased class for this joined table. Populated during
34
+ SQL compilation by QueryCompiler, not during graph construction.
35
+ join_type: Current JOIN type (INNER or LEFT). May be mutated by
36
+ JoinGraph optimization passes based on usage flags.
37
+ relationship: SQLAlchemy RelationshipProperty defining the foreign key
38
+ relationship. None only for the synthetic root node.
39
+ parent: Parent node in the JOIN tree. None for root node.
40
+ children: Direct child nodes representing relationships joined from this
41
+ node's model.
42
+ used_in_filter: True if WHERE clause references columns through this
43
+ JOIN. Enables LEFT → INNER promotion unless other flags prevent it.
44
+ used_in_or_context: True if filter using this JOIN is inside OR clause.
45
+ Prevents LEFT → INNER promotion to preserve NULL rows.
46
+ has_nullable_check: True if filter is IS NULL or IS NOT NULL check.
47
+ Prevents LEFT → INNER promotion as it requires NULL rows.
48
+ used_in_negation: True if filter using this JOIN is inside NOT/exclude().
49
+ Prevents LEFT → INNER promotion to preserve NULL rows.
50
+ used_in_order_by: True if ORDER BY references columns through this JOIN.
51
+ Keeps JOIN in query even if not used elsewhere.
52
+ used_in_annotation: True if annotate() references columns through this
53
+ JOIN. Keeps JOIN in query even if not used elsewhere.
54
+
55
+ Example:
56
+ >>> # Building JOIN graph for: Product.objects.filter(
57
+ >>> # category__parent__name="Electronics"
58
+ >>> # )
59
+ >>> root = JoinNode(path="", alias=Product)
60
+ >>> category_node = JoinNode(
61
+ ... path="category",
62
+ ... relationship=Product.category.property,
63
+ ... used_in_filter=True,
64
+ ... parent=root
65
+ ... )
66
+ >>> parent_node = JoinNode(
67
+ ... path="category__parent",
68
+ ... relationship=Category.parent.property,
69
+ ... used_in_filter=True,
70
+ ... parent=category_node
71
+ ... )
72
+ >>> print(parent_node.depth) # 2
73
+ >>> print(parent_node.segment) # "parent"
74
+ >>> print(parent_node.ancestors()) # [category_node]
75
+ >>>
76
+ >>> # OR context prevents promotion
77
+ >>> or_node = JoinNode(
78
+ ... path="supplier",
79
+ ... used_in_filter=True,
80
+ ... used_in_or_context=True
81
+ ... )
82
+ >>> print(or_node.is_left) # True (stays LEFT JOIN)
83
+
84
+ Note:
85
+ Performance: Property access is O(1) except ancestors() which is O(d)
86
+ where d is depth. Tree operations like add_child() are O(1).
87
+
88
+ The node is immutable after creation except for join_type which may be
89
+ updated by optimization passes in planning. Usage flags should be set during initial
90
+ construction or via JoinGraph.add_node() which handles flag merging.
91
+
92
+ Hash and equality are based solely on path, allowing nodes to be used
93
+ in sets and dicts for deduplication during graph construction.
94
+ """
95
+
96
+ path: str
97
+ alias: Any
98
+ join_type: JoinType = JoinType.LEFT
99
+ relationship: RelationshipProperty | None = None
100
+ parent: JoinNode | None = None
101
+ children: list[JoinNode] = field(default_factory=list)
102
+
103
+ # ── Usage flags (set by the query builder) ──
104
+ used_in_filter: bool = False
105
+ used_in_or_context: bool = False
106
+ has_nullable_check: bool = False
107
+ used_in_negation: bool = False
108
+ used_in_order_by: bool = False
109
+ used_in_annotation: bool = False
110
+
111
+ # ── Internal ──
112
+ _depth: int | None = field(default=None, repr=False)
113
+
114
+ @property
115
+ def depth(self) -> int:
116
+ """Calculate depth in JOIN tree with caching.
117
+
118
+ Depth represents the number of relationship traversals from root model.
119
+ Root node has depth 0, direct relationships have depth 1, etc. The value
120
+ is computed once on first access and cached for subsequent calls.
121
+
122
+ Returns:
123
+ Integer depth where 0 is root, 1 is direct relationship, 2 is nested
124
+ relationship, etc. Computed by counting "__" separators in path.
125
+
126
+ Example:
127
+ >>> root = JoinNode(path="", alias=Product)
128
+ >>> root.depth # 0
129
+ >>> category = JoinNode(path="category", parent=root)
130
+ >>> category.depth # 1
131
+ >>> parent = JoinNode(path="category__parent", parent=category)
132
+ >>> parent.depth # 2
133
+
134
+ Note:
135
+ Performance: O(n) on first call where n is path length, O(1) on
136
+ subsequent calls due to caching. Cache is stored in _depth field.
137
+ """
138
+ if self._depth is None:
139
+ self._depth = self.path.count("__") + 1 if self.path else 0
140
+ return self._depth
141
+
142
+ @property
143
+ def is_root(self) -> bool:
144
+ """Check if this is the synthetic root node.
145
+
146
+ Root node represents the base model being queried and has no parent or
147
+ path. All relationship JOINs are descendants of the root.
148
+
149
+ Returns:
150
+ True if this is the root node (empty path and no parent), False
151
+ otherwise.
152
+
153
+ Example:
154
+ >>> root = JoinNode(path="", alias=Product)
155
+ >>> root.is_root # True
156
+ >>> child = JoinNode(path="category", parent=root)
157
+ >>> child.is_root # False
158
+ """
159
+ return self.path == "" and self.parent is None
160
+
161
+ @property
162
+ def segment(self) -> str:
163
+ """Extract the final relationship name from the path.
164
+
165
+ Returns the last component of the double-underscore separated path,
166
+ representing the relationship name on the parent model. For root node
167
+ or single-level paths, returns the entire path.
168
+
169
+ Returns:
170
+ Final path segment (relationship name). Empty string for root node.
171
+
172
+ Example:
173
+ >>> node = JoinNode(path="building__city__country")
174
+ >>> node.segment # "country"
175
+ >>> node = JoinNode(path="category")
176
+ >>> node.segment # "category"
177
+ >>> root = JoinNode(path="")
178
+ >>> root.segment # ""
179
+ """
180
+ return self.path.rsplit("__", 1)[-1] if self.path else ""
181
+
182
+ @property
183
+ def parent_path(self) -> str:
184
+ """Extract the parent node's path from this node's path.
185
+
186
+ Removes the final segment from the path to get the parent's path. Used
187
+ during graph construction to locate parent nodes when building the tree.
188
+
189
+ Returns:
190
+ Path of parent node. Empty string for direct children of root.
191
+
192
+ Example:
193
+ >>> node = JoinNode(path="building__city__country")
194
+ >>> node.parent_path # "building__city"
195
+ >>> node = JoinNode(path="category")
196
+ >>> node.parent_path # "">>> root = JoinNode(path="")
197
+ >>> root.parent_path # ""
198
+ """
199
+ parts = self.path.rsplit("__", 1)
200
+ return parts[0] if len(parts) > 1 else ""
201
+
202
+ @property
203
+ def is_inner(self) -> bool:
204
+ """Check if this node uses INNER JOIN.
205
+
206
+ Returns:
207
+ True if join_type is INNER, False otherwise.
208
+
209
+ Example:
210
+ >>> node = JoinNode(path="category", join_type=JoinType.INNER)
211
+ >>> node.is_inner # True
212
+ >>> node.is_left # False
213
+ """
214
+ return self.join_type == JoinType.INNER
215
+
216
+ @property
217
+ def is_left(self) -> bool:
218
+ """Check if this node uses LEFT JOIN.
219
+
220
+ Returns:
221
+ True if join_type is LEFT, False otherwise.
222
+
223
+ Example:
224
+ >>> node = JoinNode(path="category", join_type=JoinType.LEFT)
225
+ >>> node.is_left # True
226
+ >>> node.is_inner # False
227
+ """
228
+ return self.join_type == JoinType.LEFT
229
+
230
+ def add_child(self, child: JoinNode) -> None:
231
+ """Attach a direct child while preserving the graph's tree invariants."""
232
+ if not isinstance(child, JoinNode):
233
+ raise TypeError("child must be a JoinNode instance.")
234
+ if child is self:
235
+ raise ValueError("A join node cannot be its own child.")
236
+ if child.parent is not None and child.parent is not self:
237
+ raise ValueError(
238
+ f"Join node {child.path!r} already belongs to another parent."
239
+ )
240
+ if child.parent_path != self.path:
241
+ raise ValueError(
242
+ f"Join node {child.path!r} is not a direct child of {self.path!r}."
243
+ )
244
+ if any(
245
+ existing is child or existing.path == child.path
246
+ for existing in self.children
247
+ ):
248
+ raise ValueError(f"Duplicate child path {child.path!r}.")
249
+
250
+ current: JoinNode | None = self
251
+ while current is not None:
252
+ if current is child:
253
+ raise ValueError("Attaching this node would create a cycle.")
254
+ current = current.parent
255
+
256
+ child.parent = self
257
+ self.children.append(child)
258
+
259
+ def ancestors(self) -> list[JoinNode]:
260
+ """Return all ancestor nodes from parent to root (excluding root).
261
+
262
+ Walks up the parent chain collecting nodes until reaching the root node.
263
+ Useful for JOIN dependency analysis and path validation. The root node
264
+ itself is excluded from the result.
265
+
266
+ Returns:
267
+ List of ancestor nodes ordered from immediate parent to most distant
268
+ ancestor (closest to root). Empty list if this is root or direct
269
+ child of root.
270
+
271
+ Example:
272
+ >>> # Tree: root -> building -> city -> country
273
+ >>> country.ancestors()
274
+ >>> # Returns: [city, building]>>>
275
+ >>> building.ancestors()
276
+ >>> # Returns: [] (direct child of root)
277
+ >>>
278
+ >>> root.ancestors()
279
+ >>> # Returns: [] (is root)
280
+
281
+ Note:
282
+ Performance: O(d) where d is depth. Allocates a new list on each
283
+ call - cache result if calling multiple times.
284
+ """
285
+ result: list[JoinNode] = []
286
+ current = self.parent
287
+ while current is not None and not current.is_root:
288
+ result.append(current)
289
+ current = current.parent
290
+ return result
291
+
292
+ def __hash__(self) -> int:
293
+ """Hash based on path for use in sets and dicts.
294
+
295
+ Allows JoinNode instances to be used as dictionary keys and set members
296
+ for deduplication during graph construction. Only path is used for hash
297
+ to ensure nodes with same path are considered identical.
298
+
299
+ Returns:
300
+ Hash value based on path string.
301
+
302
+ Example:
303
+ >>> node1 = JoinNode(path="category", used_in_filter=True)
304
+ >>> node2 = JoinNode(path="category", used_in_filter=False)
305
+ >>> hash(node1) == hash(node2) # True
306
+ >>> {node1, node2} # Set with one element
307
+ """
308
+ return hash(self.path)
309
+
310
+ def __eq__(self, other: object) -> bool:
311
+ """Compare nodes by path for equality.
312
+
313
+ Two nodes are equal if they have the same path, regardless of other
314
+ attributes. This enables deduplication in sets and dicts during graph
315
+ construction.
316
+
317
+ Args:
318
+ other: Object to compare with. Must be JoinNode instance.
319
+
320
+ Returns:
321
+ True if other is JoinNode with same path, False otherwise.
322
+
323
+ Example:
324
+ >>> node1 = JoinNode(path="category", join_type=JoinType.INNER)
325
+ >>> node2 = JoinNode(path="category", join_type=JoinType.LEFT)
326
+ >>> node1 == node2 # True (same path)
327
+ >>> node3 = JoinNode(path="supplier")
328
+ >>> node1 == node3 # False (different path)
329
+ """
330
+ if not isinstance(other, JoinNode):
331
+ return NotImplemented
332
+ return self.path == other.path
333
+
334
+ def __repr__(self) -> str:
335
+ """Generate debug representation with path, type, and active flags.
336
+
337
+ Creates a compact string showing the node's path, JOIN type, and any
338
+ active usage flags. Flags are abbreviated: F=filter, OR=or_context,
339
+ NULL=nullable_check, NEG=negation, ORD=order_by, ANN=annotation.
340
+
341
+ Returns:
342
+ String representation suitable for debugging and logging.
343
+
344
+ Example:
345
+ >>> node = JoinNode(
346
+ ... path="category",
347
+ ... join_type=JoinType.INNER,
348
+ ... used_in_filter=True,
349
+ ... used_in_or_context=True
350
+ ... )
351
+ >>> repr(node)
352
+ "JoinNode('category', inner [F,OR])"
353
+ >>>
354
+ >>> root = JoinNode(path="", alias=Product)
355
+ >>> repr(root)
356
+ "JoinNode('', left)"
357
+ """
358
+ flags = []
359
+ if self.used_in_filter:
360
+ flags.append("F")
361
+ if self.used_in_or_context:
362
+ flags.append("OR")
363
+ if self.has_nullable_check:
364
+ flags.append("NULL")
365
+ if self.used_in_negation:
366
+ flags.append("NEG")
367
+ if self.used_in_order_by:
368
+ flags.append("ORD")
369
+ if self.used_in_annotation:
370
+ flags.append("ANN")
371
+ flag_str = f" [{','.join(flags)}]" if flags else ""
372
+ return f"JoinNode({self.path!r}, {self.join_type.value}{flag_str})"
373
+
374
+ def _clone(self) -> JoinNode:
375
+ """Create shallow copy of this node without parent/child relationships.
376
+
377
+ Copies all attributes except parent and children, which are reset to
378
+ None and empty list respectively. Used by JoinGraph.clone() to duplicate
379
+ the graph structure while preserving node state.
380
+
381
+ Returns:
382
+ New JoinNode with same attributes but no tree relationships.
383
+
384
+ Example:
385
+ >>> original = JoinNode(
386
+ ... path="category",
387
+ ... used_in_filter=True,
388
+ ... parent=root
389
+ ... )
390
+ >>> clone = original._clone()
391
+ >>> clone.path # "category"
392
+ >>> clone.used_in_filter # True
393
+ >>> clone.parent # None (not copied)
394
+ >>> clone is original # False
395
+
396
+ Note:
397
+ Performance: O(1) operation. Uses __new__ to bypass dataclass
398
+ __init__ for maximum speed. Parent/child relationships must be
399
+ reconstructed by caller (typically JoinGraph.clone()).
400
+ """
401
+ node = JoinNode.__new__(JoinNode) # skip dataclass __init__
402
+
403
+ # Copy all fields manually (fastest)
404
+ node.path = self.path
405
+ node.alias = self.alias
406
+ node.join_type = self.join_type
407
+ node.relationship = self.relationship
408
+
409
+ node.parent = None # filled later
410
+ node.children = [] # filled later
411
+
412
+ node.used_in_filter = self.used_in_filter
413
+ node.used_in_or_context = self.used_in_or_context
414
+ node.has_nullable_check = self.has_nullable_check
415
+ node.used_in_negation = self.used_in_negation
416
+ node.used_in_order_by = self.used_in_order_by
417
+ node.used_in_annotation = self.used_in_annotation
418
+
419
+ node._depth = self._depth
420
+
421
+ return node
@@ -0,0 +1,4 @@
1
+ from .compound import CompoundQuerySet
2
+ from .queryset import QuerySet
3
+
4
+ __all__ = ["CompoundQuerySet", "QuerySet"]