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,160 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+
5
+ from async_orm_kit._logging import get_logger
6
+ from async_orm_kit.exceptions import RelationshipError
7
+
8
+ from .directives import LoadDirective, LoadStrategy, QueryContext
9
+ from .passes.base import PassResult, PlanningPass
10
+ from .protocols import JoinGraphReadable
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ class QueryPlanner:
16
+ """
17
+ Orchestrates planning passes in a defined order.
18
+
19
+ The planner itself holds NO planning logic — it only:
20
+ 1. Manages the pass pipeline
21
+ 2. Threads state between passes
22
+ 3. Collects audit information
23
+
24
+ This is the Strategy + Chain of Responsibility pattern:
25
+ passes can be added, removed, or reordered without
26
+ touching any other code.
27
+ """
28
+
29
+ def __init__(self, passes: Sequence[PlanningPass] | None = None):
30
+ self._passes: list[PlanningPass] = list(passes or [])
31
+ self._last_results: list[PassResult] = []
32
+
33
+ def add_pass(self, planning_pass: PlanningPass) -> QueryPlanner:
34
+ """Fluent API for building the pipeline."""
35
+ self._passes.append(planning_pass)
36
+ return self
37
+
38
+ def plan(
39
+ self,
40
+ graph: JoinGraphReadable,
41
+ context: QueryContext,
42
+ ) -> list[LoadDirective]:
43
+ """
44
+ Run all passes in sequence.
45
+
46
+ Each pass receives the directives produced by the previous pass.
47
+ The first pass receives the initial directives built from
48
+ the QueryContext's raw paths.
49
+ """
50
+ self._last_results = []
51
+
52
+ # Build initial directives from raw user intent
53
+ directives = self._build_initial_directives(graph, context)
54
+
55
+ # Run each pass
56
+ for planning_pass in self._passes:
57
+ result = planning_pass.execute(graph, context, directives)
58
+ self._last_results.append(result)
59
+
60
+ if result.directives:
61
+ directives = result.directives
62
+
63
+ if result.warnings:
64
+ for w in result.warnings:
65
+ logger.warning(
66
+ "planning_warning",
67
+ pass_name=planning_pass.name,
68
+ detail=w,
69
+ )
70
+
71
+ if result.mutations:
72
+ for m in result.mutations:
73
+ logger.info(
74
+ "planning_mutation",
75
+ pass_name=planning_pass.name,
76
+ detail=m,
77
+ )
78
+
79
+ return directives
80
+
81
+ def _build_initial_directives(
82
+ self,
83
+ graph: JoinGraphReadable,
84
+ context: QueryContext,
85
+ ) -> list[LoadDirective]:
86
+ """
87
+ Translate raw user paths into initial LoadDirectives.
88
+
89
+ This is pure translation — no intelligence yet.
90
+ The passes add the intelligence.
91
+ """
92
+ directives: list[LoadDirective] = []
93
+
94
+ for path in sorted(context.select_related_paths):
95
+ relationships = graph.resolve_relationship_path(path)
96
+ if relationships is None:
97
+ raise RelationshipError(f"Invalid select_related path {path!r}.")
98
+ if any(relationship.uselist for relationship in relationships):
99
+ raise RelationshipError(
100
+ f"select_related path {path!r} crosses a collection relationship; "
101
+ "use prefetch_related() instead."
102
+ )
103
+ directives.append(
104
+ LoadDirective(
105
+ path=path,
106
+ strategy=LoadStrategy.JOINEDLOAD,
107
+ reason="User-requested select_related",
108
+ )
109
+ )
110
+
111
+ for path in sorted(context.prefetch_paths):
112
+ if path in context.select_related_paths:
113
+ continue # select_related takes precedence for the exact path
114
+
115
+ relationships = graph.resolve_relationship_path(path)
116
+ if relationships is None:
117
+ raise RelationshipError(f"Invalid prefetch_related path {path!r}.")
118
+ if relationships[-1].uselist:
119
+ reason = "User-requested prefetch (collection)"
120
+ else:
121
+ reason = "User-requested prefetch (scalar relationship)"
122
+ strategy = LoadStrategy.SELECTINLOAD
123
+
124
+ directives.append(
125
+ LoadDirective(
126
+ path=path,
127
+ strategy=strategy,
128
+ reason=reason,
129
+ )
130
+ )
131
+
132
+ return directives
133
+
134
+ def explain(self) -> str:
135
+ """
136
+ Human-readable explanation of what each pass did.
137
+ Invaluable for debugging slow queries.
138
+ """
139
+ lines = ["Query Plan Explanation", "=" * 40]
140
+
141
+ for result in self._last_results:
142
+ lines.append(f"\n── {result.pass_name} ──")
143
+
144
+ if not result.had_effect:
145
+ lines.append(" (no effect)")
146
+ continue
147
+
148
+ for m in result.mutations:
149
+ lines.append(f" [MUTATE] {m}")
150
+ for w in result.warnings:
151
+ lines.append(f" [WARN] {w}")
152
+
153
+ lines.append("\n── Final Directives ──")
154
+ if self._last_results and self._last_results[-1].directives:
155
+ for d in self._last_results[-1].directives:
156
+ marker = " ⚠ DEMOTED" if d.was_demoted else ""
157
+ lines.append(f" {d.path:40s} → {d.strategy.name:15s}{marker}")
158
+ lines.append(f" reason: {d.reason}")
159
+
160
+ return "\n".join(lines)
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Protocol, runtime_checkable
5
+
6
+
7
+ @runtime_checkable
8
+ class JoinNodeReadable(Protocol):
9
+ """Read-only view of a single join node."""
10
+
11
+ @property
12
+ def path(self) -> str: ...
13
+
14
+ @property
15
+ def join_type(self) -> str: ...
16
+
17
+ @property
18
+ def used_in_filter(self) -> bool: ...
19
+
20
+ @property
21
+ def used_in_or_context(self) -> bool: ...
22
+
23
+ @property
24
+ def has_nullable_check(self) -> bool: ...
25
+
26
+ @property
27
+ def used_in_negation(self) -> bool: ...
28
+
29
+ @property
30
+ def parent(self) -> JoinNodeReadable | None: ...
31
+
32
+ @property
33
+ def relationship(self) -> RelationshipReadable | None: ...
34
+
35
+
36
+ @runtime_checkable
37
+ class RelationshipReadable(Protocol):
38
+ """What the planner needs to know about a relationship."""
39
+
40
+ @property
41
+ def uselist(self) -> bool: ...
42
+
43
+ @property
44
+ def lazy(self) -> str: ...
45
+
46
+
47
+ @runtime_checkable
48
+ class JoinGraphReadable(Protocol):
49
+ """
50
+ Read-only view of the join graph.
51
+
52
+ The planner NEVER mutates the graph through this protocol.
53
+ Mutation (like join promotion) goes through a separate
54
+ mutable interface.
55
+ """
56
+
57
+ def has_node(self, path: str) -> bool: ...
58
+
59
+ def get_node(self, path: str) -> JoinNodeReadable | None: ...
60
+
61
+ @property
62
+ def join_order(self) -> Sequence[JoinNodeReadable]: ...
63
+
64
+ def resolve_relationship(self, path: str) -> RelationshipReadable | None: ...
65
+
66
+ def resolve_relationship_path(
67
+ self, path: str
68
+ ) -> Sequence[RelationshipReadable] | None: ...
69
+
70
+
71
+ @runtime_checkable
72
+ class JoinGraphMutable(Protocol):
73
+ """
74
+ Narrow mutable interface — only for passes that need
75
+ to modify join types (e.g., promotion pass).
76
+ """
77
+
78
+ def set_join_type(self, path: str, join_type: str) -> None: ...
File without changes
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from sqlalchemy import cast, exists, inspect as sa_inspect, literal, select
6
+ from sqlalchemy.dialects.postgresql import JSONPATH
7
+
8
+ from async_orm_kit._logging import get_logger
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ class PostgresOptimizer:
14
+ """
15
+ PostgreSQL-specific query optimizations.
16
+
17
+ Features:
18
+ - LATERAL joins for top-N per group
19
+ - EXISTS optimization for relationship filters
20
+ - JSONB operator support
21
+ - Array operations
22
+ """
23
+
24
+ @staticmethod
25
+ def is_postgres(session) -> bool:
26
+ """Check if current database is PostgreSQL"""
27
+ dialect_name = session.bind.dialect.name
28
+ return dialect_name == "postgresql"
29
+
30
+ @staticmethod
31
+ def lateral_join_top_n(
32
+ base_model: type,
33
+ related_model: type,
34
+ relationship_fk: str,
35
+ order_by_field: str,
36
+ limit: int = 1,
37
+ descending: bool = True,
38
+ ):
39
+ """
40
+ Create LATERAL join for top-N per parent.
41
+
42
+ Example: Get latest post per user
43
+
44
+ Usage:
45
+ lateral_subq = PostgresOptimizer.lateral_join_top_n(
46
+ User, Post, "user_id", "created_at", limit=1
47
+ )
48
+ stmt = select(User).join(lateral_subq, isouter=True)
49
+ """
50
+ primary_key: Any = sa_inspect(base_model).primary_key
51
+ if len(primary_key) != 1:
52
+ raise ValueError("lateral_join_top_n requires a single-column primary key")
53
+ order_col = getattr(related_model, order_by_field)
54
+ fk_col = getattr(related_model, relationship_fk)
55
+
56
+ direction = order_col.desc() if descending else order_col.asc()
57
+
58
+ lateral_subq = (
59
+ select(related_model)
60
+ .where(fk_col == getattr(base_model, primary_key[0].key))
61
+ .order_by(direction)
62
+ .limit(limit)
63
+ .lateral()
64
+ )
65
+
66
+ return lateral_subq
67
+
68
+ @staticmethod
69
+ def optimize_exists_filter(base_model: type, relationship_attr, condition):
70
+ """
71
+ Use EXISTS instead of JOIN for relationship filters.
72
+
73
+ More efficient for large collections.
74
+
75
+ Usage:
76
+ exists_cond = PostgresOptimizer.optimize_exists_filter(
77
+ User, User.posts, Post.published == True
78
+ )
79
+ stmt = select(User).where(exists_cond)
80
+ """
81
+ related_model = relationship_attr.property.mapper.class_
82
+
83
+ exists_subq = (
84
+ exists()
85
+ .where(
86
+ getattr(
87
+ related_model,
88
+ next(iter(relationship_attr.property.remote_side)).name,
89
+ )
90
+ == getattr(
91
+ base_model, relationship_attr.property.local_remote_pairs[0][0].name
92
+ )
93
+ )
94
+ .where(condition)
95
+ )
96
+
97
+ return exists_subq
98
+
99
+ @staticmethod
100
+ def jsonb_contains(column, value: dict):
101
+ """
102
+ PostgreSQL JSONB @> operator.
103
+
104
+ Usage:
105
+ User.metadata.op('@>')({"role": "admin"})
106
+ """
107
+ return column.op("@>")(value)
108
+
109
+ @staticmethod
110
+ def jsonb_path_exists(column, path: str):
111
+ """
112
+ PostgreSQL JSONB path query.
113
+
114
+ Usage:
115
+ jsonb_path_exists(User.data, '$.address.city')
116
+ """
117
+ if not isinstance(path, str) or not path.strip():
118
+ raise ValueError("JSONPath must be a non-empty string.")
119
+ normalized_path = path.strip()
120
+ if not normalized_path.startswith("$"):
121
+ normalized_path = f"$.{normalized_path}"
122
+ # Bind the path as data. Never interpolate a caller-controlled path into SQL.
123
+ return column.op("@?")(cast(literal(normalized_path), JSONPATH))
124
+
125
+ @staticmethod
126
+ def array_contains(column, value):
127
+ """
128
+ PostgreSQL array @> operator.
129
+
130
+ Usage:
131
+ User.tags.op('@>')(["python", "sql"])
132
+ """
133
+ return column.op("@>")(value)
134
+
135
+ @staticmethod
136
+ def explain_analyze(session, stmt, verbose: bool = False) -> str:
137
+ """Run parameterized EXPLAIN ANALYZE on PostgreSQL.
138
+
139
+ Values remain DBAPI parameters. This avoids executing SQL produced by
140
+ interpolating literal values into a text statement.
141
+ """
142
+ if type(verbose) is not bool:
143
+ raise TypeError("verbose must be a bool.")
144
+ if not PostgresOptimizer.is_postgres(session):
145
+ logger.warning("EXPLAIN ANALYZE only available on PostgreSQL")
146
+ return ""
147
+
148
+ bind = session.get_bind()
149
+ compiled = stmt.compile(
150
+ dialect=bind.dialect,
151
+ compile_kwargs={"render_postcompile": True},
152
+ )
153
+ options = ["ANALYZE", "BUFFERS"]
154
+ if verbose:
155
+ options.append("VERBOSE")
156
+
157
+ parameters = (
158
+ tuple(compiled.params[name] for name in compiled.positiontup)
159
+ if compiled.positional
160
+ else compiled.params
161
+ )
162
+ result = session.connection().exec_driver_sql(
163
+ f"EXPLAIN ({', '.join(options)}) {compiled.string}",
164
+ parameters,
165
+ )
166
+ rows = result.fetchall()
167
+ return "\n".join(str(row[0]) for row in rows)
168
+
169
+ @staticmethod
170
+ def suggest_index(session, stmt) -> list[str]:
171
+ """
172
+ Analyze query plan and suggest missing indexes.
173
+
174
+ Returns list of suggested CREATE INDEX statements.
175
+ """
176
+ if not PostgresOptimizer.is_postgres(session):
177
+ return []
178
+
179
+ plan = PostgresOptimizer.explain_analyze(session, stmt)
180
+ suggestions = []
181
+
182
+ # Simple heuristic: look for Seq Scan in plan
183
+ if "Seq Scan" in plan:
184
+ logger.warning("Sequential scan detected - consider adding index")
185
+ # Parse table and filter conditions from plan
186
+ # This is simplified - production version would parse EXPLAIN output
187
+ suggestions.append("-- Analyze EXPLAIN output and add appropriate indexes")
188
+
189
+ return suggestions