snake-orm 0.1.0b1__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.
- snake_orm-0.1.0b1.dist-info/METADATA +366 -0
- snake_orm-0.1.0b1.dist-info/RECORD +198 -0
- snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
- snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
- snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
- snakeorm/__init__.py +214 -0
- snakeorm/advisor.py +109 -0
- snakeorm/cli/__init__.py +3 -0
- snakeorm/cli/app.py +992 -0
- snakeorm/cli/discovery.py +179 -0
- snakeorm/cli/hooks.py +86 -0
- snakeorm/compiler/__init__.py +3 -0
- snakeorm/compiler/compiler.py +330 -0
- snakeorm/connection.py +228 -0
- snakeorm/contrib/__init__.py +17 -0
- snakeorm/contrib/asgi.py +192 -0
- snakeorm/contrib/config.py +264 -0
- snakeorm/contrib/deliver.py +256 -0
- snakeorm/contrib/django.py +245 -0
- snakeorm/contrib/sidecar.py +44 -0
- snakeorm/contrib/wsgi.py +176 -0
- snakeorm/core/__init__.py +1 -0
- snakeorm/core/config.py +173 -0
- snakeorm/core/converters.py +172 -0
- snakeorm/core/exceptions.py +175 -0
- snakeorm/core/placement.py +15 -0
- snakeorm/core/sentinels.py +37 -0
- snakeorm/core/signals.py +114 -0
- snakeorm/debug/__init__.py +42 -0
- snakeorm/debug/assets/css/panel.css +616 -0
- snakeorm/debug/assets/css/snake_orm_app.css +293 -0
- snakeorm/debug/assets/img/icon.svg +5 -0
- snakeorm/debug/assets/js/history.js +425 -0
- snakeorm/debug/assets/js/language.js +283 -0
- snakeorm/debug/assets/js/panel.js +241 -0
- snakeorm/debug/assets/js/snake_orm_app.js +82 -0
- snakeorm/debug/assets/pages/cli.html +28 -0
- snakeorm/debug/assets/pages/config.html +34 -0
- snakeorm/debug/assets/pages/database_first.html +29 -0
- snakeorm/debug/assets/pages/help.html +21 -0
- snakeorm/debug/assets/pages/history.html +27 -0
- snakeorm/debug/capture.py +234 -0
- snakeorm/debug/channel.py +131 -0
- snakeorm/debug/collector.py +144 -0
- snakeorm/debug/config.py +149 -0
- snakeorm/debug/html.py +578 -0
- snakeorm/debug/matrix.py +60 -0
- snakeorm/debug/origin.py +42 -0
- snakeorm/debug/otel/__init__.py +68 -0
- snakeorm/debug/otel/context.py +43 -0
- snakeorm/debug/otel/exporter.py +277 -0
- snakeorm/debug/otel/payload.py +97 -0
- snakeorm/debug/otel/report.py +29 -0
- snakeorm/debug/otel/spans.py +393 -0
- snakeorm/debug/otel/summary.py +59 -0
- snakeorm/debug/record.py +54 -0
- snakeorm/debug/report.py +311 -0
- snakeorm/debug/testing.py +23 -0
- snakeorm/decorators/__init__.py +13 -0
- snakeorm/decorators/abstract.py +26 -0
- snakeorm/decorators/db_first.py +104 -0
- snakeorm/decorators/function.py +28 -0
- snakeorm/decorators/model.py +268 -0
- snakeorm/decorators/polymorphic.py +140 -0
- snakeorm/decorators/result.py +167 -0
- snakeorm/decorators/row.py +90 -0
- snakeorm/decorators/trigger.py +47 -0
- snakeorm/decorators/view.py +149 -0
- snakeorm/dialects/__init__.py +6 -0
- snakeorm/dialects/base.py +373 -0
- snakeorm/dialects/capabilities.py +571 -0
- snakeorm/dialects/literals.py +46 -0
- snakeorm/dialects/matrix.py +384 -0
- snakeorm/dialects/mysql.py +519 -0
- snakeorm/dialects/postgres.py +504 -0
- snakeorm/dialects/sqlite.py +417 -0
- snakeorm/drivers/__init__.py +18 -0
- snakeorm/drivers/asyncbase.py +73 -0
- snakeorm/drivers/asyncdecorators.py +244 -0
- snakeorm/drivers/asyncpool.py +228 -0
- snakeorm/drivers/asyncpsycopg.py +124 -0
- snakeorm/drivers/asyncpymysql.py +50 -0
- snakeorm/drivers/asyncsqlite.py +30 -0
- snakeorm/drivers/base.py +76 -0
- snakeorm/drivers/failures.py +181 -0
- snakeorm/drivers/logging.py +167 -0
- snakeorm/drivers/pool.py +352 -0
- snakeorm/drivers/psycopg.py +188 -0
- snakeorm/drivers/pymysql.py +176 -0
- snakeorm/drivers/savepoints.py +46 -0
- snakeorm/drivers/sqlite.py +170 -0
- snakeorm/drivers/threaded.py +143 -0
- snakeorm/drivers/timeout.py +87 -0
- snakeorm/dto/__init__.py +55 -0
- snakeorm/dto/read.py +231 -0
- snakeorm/dto/region.py +326 -0
- snakeorm/dto/resolve.py +354 -0
- snakeorm/dto/spec.py +201 -0
- snakeorm/expressions/__init__.py +88 -0
- snakeorm/expressions/conditional.py +178 -0
- snakeorm/expressions/expression.py +810 -0
- snakeorm/expressions/functions.py +71 -0
- snakeorm/expressions/keys.py +299 -0
- snakeorm/expressions/paths.py +43 -0
- snakeorm/expressions/scalar.py +293 -0
- snakeorm/expressions/window.py +248 -0
- snakeorm/fields/__init__.py +68 -0
- snakeorm/fields/check.py +79 -0
- snakeorm/fields/column.py +316 -0
- snakeorm/fields/enum.py +64 -0
- snakeorm/fields/index.py +46 -0
- snakeorm/fields/relationship.py +738 -0
- snakeorm/fields/typed.py +736 -0
- snakeorm/helpers/__init__.py +1 -0
- snakeorm/helpers/annotations.py +21 -0
- snakeorm/helpers/inheritance.py +41 -0
- snakeorm/helpers/pyliteral.py +52 -0
- snakeorm/helpers/pytype.py +50 -0
- snakeorm/introspection/__init__.py +13 -0
- snakeorm/introspection/base.py +30 -0
- snakeorm/introspection/drift.py +78 -0
- snakeorm/introspection/models.py +633 -0
- snakeorm/introspection/mysql.py +367 -0
- snakeorm/introspection/postgres.py +397 -0
- snakeorm/introspection/sqlite.py +217 -0
- snakeorm/introspection/unsupported.py +94 -0
- snakeorm/linker/__init__.py +3 -0
- snakeorm/linker/linker.py +521 -0
- snakeorm/metadata/__init__.py +41 -0
- snakeorm/metadata/check.py +37 -0
- snakeorm/metadata/column.py +201 -0
- snakeorm/metadata/enum_storage.py +33 -0
- snakeorm/metadata/fk_action.py +19 -0
- snakeorm/metadata/foreign_key.py +26 -0
- snakeorm/metadata/index.py +43 -0
- snakeorm/metadata/index_method.py +19 -0
- snakeorm/metadata/int_size.py +20 -0
- snakeorm/metadata/json_storage.py +21 -0
- snakeorm/metadata/polymorphic.py +30 -0
- snakeorm/metadata/primary_key.py +23 -0
- snakeorm/metadata/relationship.py +60 -0
- snakeorm/metadata/relationship_kind.py +43 -0
- snakeorm/metadata/routine.py +26 -0
- snakeorm/metadata/server_default.py +20 -0
- snakeorm/metadata/table.py +97 -0
- snakeorm/metadata/table_kind.py +23 -0
- snakeorm/metadata/trigger.py +62 -0
- snakeorm/metadata/type_params.py +348 -0
- snakeorm/migration/__init__.py +88 -0
- snakeorm/migration/asyncrunner.py +168 -0
- snakeorm/migration/autodetect.py +96 -0
- snakeorm/migration/ddl.py +1172 -0
- snakeorm/migration/diff.py +701 -0
- snakeorm/migration/loader.py +77 -0
- snakeorm/migration/operations.py +952 -0
- snakeorm/migration/planner.py +78 -0
- snakeorm/migration/realize.py +420 -0
- snakeorm/migration/renames.py +153 -0
- snakeorm/migration/render.py +1095 -0
- snakeorm/migration/runner.py +314 -0
- snakeorm/migration/squash.py +60 -0
- snakeorm/migration/state.py +107 -0
- snakeorm/model.py +162 -0
- snakeorm/py.typed +0 -0
- snakeorm/query/__init__.py +9 -0
- snakeorm/query/compound.py +288 -0
- snakeorm/query/join_kind.py +19 -0
- snakeorm/query/joined.py +143 -0
- snakeorm/query/query.py +1001 -0
- snakeorm/query/recursive.py +244 -0
- snakeorm/registry/__init__.py +5 -0
- snakeorm/registry/by_module.py +163 -0
- snakeorm/registry/registry.py +166 -0
- snakeorm/session/__init__.py +8 -0
- snakeorm/session/asyncsession.py +638 -0
- snakeorm/session/coercion.py +306 -0
- snakeorm/session/factory.py +29 -0
- snakeorm/session/guards.py +269 -0
- snakeorm/session/isolation.py +23 -0
- snakeorm/session/mapper.py +213 -0
- snakeorm/session/planning.py +815 -0
- snakeorm/session/retry.py +96 -0
- snakeorm/session/session.py +871 -0
- snakeorm/session/shared.py +355 -0
- snakeorm/sql/__init__.py +18 -0
- snakeorm/sql/adapt.py +104 -0
- snakeorm/sql/aggregate.py +232 -0
- snakeorm/sql/condition.py +359 -0
- snakeorm/sql/delete.py +46 -0
- snakeorm/sql/insert.py +149 -0
- snakeorm/sql/joins.py +158 -0
- snakeorm/sql/pk_subquery.py +54 -0
- snakeorm/sql/refs.py +19 -0
- snakeorm/sql/resolver.py +28 -0
- snakeorm/sql/select.py +175 -0
- snakeorm/sql/update.py +82 -0
- snakeorm/sql/value.py +552 -0
- snakeorm/times.py +276 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""`WITH RECURSIVE`: walking an entire hierarchy with ONE query.
|
|
2
|
+
|
|
3
|
+
It brings a NEW capability: "every descendant at any depth" cannot be written with subqueries
|
|
4
|
+
(either you go down level by level with an N+1, or you use recursion). Emitted shape:
|
|
5
|
+
|
|
6
|
+
WITH RECURSIVE snake_rec AS (
|
|
7
|
+
(<anchor>) UNION [ALL]
|
|
8
|
+
SELECT t.<cols> FROM <table> AS t JOIN snake_rec ON t.<child> = snake_rec.<accumulated>
|
|
9
|
+
)
|
|
10
|
+
SELECT <cols> FROM snake_rec [ORDER BY ...] [LIMIT ...]
|
|
11
|
+
|
|
12
|
+
`UNION ALL` by default: deduplicating every step would cost hashing everything accumulated so far,
|
|
13
|
+
and a well-formed tree has no duplicates to drop. Over data with CYCLES that walk never ends —each
|
|
14
|
+
lap yields rows the engine counts as new— so the operator is the CALLER's to pick: `distinct=True`
|
|
15
|
+
emits a plain `UNION`, the lap that repeats contributes nothing, and the recursion stops on its own.
|
|
16
|
+
|
|
17
|
+
A `limit()` is not a substitute. Measured against a real Postgres over a three-row cycle: `UNION
|
|
18
|
+
ALL` with an `order_by()` and a `LIMIT 3` never returns, because the sort has to consume every row
|
|
19
|
+
before it can emit one. The limit bounds what comes BACK, not how far the engine goes.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, replace
|
|
25
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
26
|
+
|
|
27
|
+
from snakeorm.dialects import SnakeDialect
|
|
28
|
+
from snakeorm.core.exceptions import SnakeEmitError
|
|
29
|
+
from snakeorm.expressions import SnakeExpr, SnakeOrder
|
|
30
|
+
from snakeorm.metadata import SnakeTableInfo
|
|
31
|
+
from snakeorm.sql.refs import qualified
|
|
32
|
+
from snakeorm.sql.value import emit_order_key, guard_untabled_order_keys
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from snakeorm.query.query import SnakeQuery
|
|
36
|
+
|
|
37
|
+
T = TypeVar("T")
|
|
38
|
+
|
|
39
|
+
CTE_NAME = "snake_rec"
|
|
40
|
+
"""Name of the CTE. Fixed on purpose: it does not nest (a recursion admits no other inside it)."""
|
|
41
|
+
|
|
42
|
+
SOURCE_ALIAS = "snake_src"
|
|
43
|
+
"""Alias of the table inside the recursive step, mandatory: the CTE has the same column names, so
|
|
44
|
+
without an alias they are ambiguous and Postgres rejects the query (the test against the engine caught it)."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class SnakeRecursive(Generic[T]):
|
|
49
|
+
"""An ANCHOR query that expands itself by following a hop onto itself.
|
|
50
|
+
|
|
51
|
+
`child_column` points upwards (`parent_id`) and `parent_column` identifies the accumulated row
|
|
52
|
+
(`id`): the direction is fixed by which one goes first (swapping them walks the ancestors, a
|
|
53
|
+
perfectly legitimate query).
|
|
54
|
+
|
|
55
|
+
`distinct` picks the set operator that joins each step to what has already been accumulated:
|
|
56
|
+
`False` (the default) emits `UNION ALL`, `True` emits `UNION`. It is the difference between a
|
|
57
|
+
walk that ends over cyclic data and one that does not.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
anchor: SnakeQuery[T]
|
|
61
|
+
table: SnakeTableInfo
|
|
62
|
+
child_column: str
|
|
63
|
+
parent_column: str
|
|
64
|
+
distinct: bool = False
|
|
65
|
+
order_by_keys: tuple[SnakeOrder, ...] = ()
|
|
66
|
+
limit_value: int | None = None
|
|
67
|
+
offset_value: int | None = None
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def model(self) -> type[T]:
|
|
71
|
+
"""The model of the rows: the same as the anchor's. The session uses it to instantiate them."""
|
|
72
|
+
return self.anchor.model
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def projected_columns(self) -> frozenset[str] | None:
|
|
76
|
+
"""Never narrowed: the CTE's columns are the TABLE's, and a narrowed anchor is refused."""
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def has_includes(self) -> bool:
|
|
81
|
+
"""Never: an anchor with includes is rejected at build time, not here."""
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def has_lock(self) -> bool:
|
|
86
|
+
"""Never: a recursive CTE does not lock rows. It is rejected at build time."""
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def has_bounds(self) -> bool:
|
|
91
|
+
"""Tells whether it carries `limit`/`offset` of its own. The compound looks at this."""
|
|
92
|
+
return self.limit_value is not None or self.offset_value is not None
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def has_order(self) -> bool:
|
|
96
|
+
"""Tells whether it carries `order_by` of its own. The compound looks at this."""
|
|
97
|
+
return bool(self.order_by_keys)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def has_cte(self) -> bool:
|
|
101
|
+
"""Always: this is the `WITH RECURSIVE`. The compound asks so it can refuse where a CTE
|
|
102
|
+
cannot be a branch, which is two of the three engines."""
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
def order_by(self, *keys: SnakeExpr[Any] | SnakeOrder) -> SnakeRecursive[T]:
|
|
106
|
+
"""Orders the RESULT (not the anchor nor the step). A bare column orders ascending.
|
|
107
|
+
|
|
108
|
+
The same guard the compound uses, and for the same reason: the `SELECT ... FROM cte` this
|
|
109
|
+
ordering hangs off has only the CTE's columns, so a key that navigates a relationship would
|
|
110
|
+
lose the hop and be written as another column's bare name.
|
|
111
|
+
"""
|
|
112
|
+
normalized = tuple(
|
|
113
|
+
key if isinstance(key, SnakeOrder) else key.asc() for key in keys
|
|
114
|
+
)
|
|
115
|
+
guard_untabled_order_keys(
|
|
116
|
+
normalized,
|
|
117
|
+
self.projected_columns,
|
|
118
|
+
"A WITH RECURSIVE",
|
|
119
|
+
"Order the rows in Python after the traversal, or join the far table in a separate "
|
|
120
|
+
"query.",
|
|
121
|
+
)
|
|
122
|
+
return replace(self, order_by_keys=self.order_by_keys + normalized)
|
|
123
|
+
|
|
124
|
+
def limit(self, limit: int) -> SnakeRecursive[T]:
|
|
125
|
+
"""Bounds the RESULT, and only the result: it does NOT bound the traversal.
|
|
126
|
+
|
|
127
|
+
It read as the safety net against a cycle until somebody measured it. Put an `order_by()`
|
|
128
|
+
in front of it —which is the normal way to ask for a hierarchy— and the engine has to
|
|
129
|
+
produce every row before it can sort them, so the bound never gets its turn and the query
|
|
130
|
+
hangs all the same. What ends a cyclic walk is `recursive(..., distinct=True)`.
|
|
131
|
+
"""
|
|
132
|
+
return replace(self, limit_value=limit)
|
|
133
|
+
|
|
134
|
+
def offset(self, offset: int) -> SnakeRecursive[T]:
|
|
135
|
+
"""Skips rows of the result (it replaces the previous one)."""
|
|
136
|
+
return replace(self, offset_value=offset)
|
|
137
|
+
|
|
138
|
+
def to_sql(self, dialect: SnakeDialect) -> tuple[str, tuple[object, ...]]:
|
|
139
|
+
"""Compiles to `(sql, params)`, with the ANCHOR's params first.
|
|
140
|
+
|
|
141
|
+
That order is mandatory: the anchor comes earlier in the string and Postgres' `%s` is positional.
|
|
142
|
+
"""
|
|
143
|
+
quote = dialect.quote_ident
|
|
144
|
+
columns = ", ".join(quote(column.name) for column in self.table.columns)
|
|
145
|
+
cte = quote(CTE_NAME)
|
|
146
|
+
source = quote(SOURCE_ALIAS)
|
|
147
|
+
# The step's columns go QUALIFIED by the alias: the CTE has the same names.
|
|
148
|
+
step_columns = ", ".join(
|
|
149
|
+
f"{source}.{quote(column.name)}" for column in self.table.columns
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
params: list[object] = []
|
|
153
|
+
anchor_sql, anchor_params = self.anchor.to_sql(dialect)
|
|
154
|
+
params.extend(anchor_params)
|
|
155
|
+
|
|
156
|
+
# The anchor is parenthesised only if the engine admits it (SQLite rejects `((SELECT ...)
|
|
157
|
+
# UNION ALL ...)`). Same flag as the UNION emitter. Without parentheses, an anchor with
|
|
158
|
+
# limit()/offset() of its own is inexpressible (the bounding would read off the whole UNION),
|
|
159
|
+
# so it is rejected in plain words.
|
|
160
|
+
if not dialect.supports_parenthesised_compound and self.anchor.has_bounds:
|
|
161
|
+
raise SnakeEmitError(
|
|
162
|
+
"This engine does not accept parentheses around the anchor of a WITH RECURSIVE, so "
|
|
163
|
+
"an anchor with its own limit()/offset() is inexpressible: the bound would read "
|
|
164
|
+
"off the whole traversal. Drop them from the anchor and bound the RESULT with "
|
|
165
|
+
".limit() on the recursion."
|
|
166
|
+
)
|
|
167
|
+
anchor = (
|
|
168
|
+
f"({anchor_sql})" if dialect.supports_parenthesised_compound else anchor_sql
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
table = qualified(self.table.schema, self.table.name, dialect)
|
|
172
|
+
step = (
|
|
173
|
+
f"SELECT {step_columns} FROM {table} AS {source} "
|
|
174
|
+
f"JOIN {cte} ON {source}.{quote(self.child_column)} "
|
|
175
|
+
f"= {cte}.{quote(self.parent_column)}"
|
|
176
|
+
)
|
|
177
|
+
# The operator the CALLER chose. `UNION` is `UNION DISTINCT`, so the step drops what has
|
|
178
|
+
# already been accumulated and a cyclic walk runs out of new rows instead of running for ever.
|
|
179
|
+
operator = "UNION" if self.distinct else "UNION ALL"
|
|
180
|
+
sql = (
|
|
181
|
+
f"WITH RECURSIVE {cte} AS ({anchor} {operator} {step}) "
|
|
182
|
+
f"SELECT {columns} FROM {cte}"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if self.order_by_keys:
|
|
186
|
+
keys = ", ".join(
|
|
187
|
+
emit_order_key(key, dialect, params, None) for key in self.order_by_keys
|
|
188
|
+
)
|
|
189
|
+
sql = f"{sql} ORDER BY {keys}"
|
|
190
|
+
|
|
191
|
+
clause = dialect.limit_offset(self.limit_value, self.offset_value, params)
|
|
192
|
+
if clause:
|
|
193
|
+
sql = f"{sql} {clause}"
|
|
194
|
+
return sql, tuple(params)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def build_recursive(
|
|
198
|
+
anchor: SnakeQuery[T],
|
|
199
|
+
table: SnakeTableInfo,
|
|
200
|
+
child: SnakeExpr[Any],
|
|
201
|
+
parent: SnakeExpr[Any],
|
|
202
|
+
distinct: bool = False,
|
|
203
|
+
) -> SnakeRecursive[T]:
|
|
204
|
+
"""Builds the recursion, rejecting whatever does not survive a CTE.
|
|
205
|
+
|
|
206
|
+
The hop's two columns must belong to the table that recurses (it joins ONTO ITSELF): a column
|
|
207
|
+
from another model would be emitted all the same and would produce an absurd JOIN, or worse,
|
|
208
|
+
wrong results with no error.
|
|
209
|
+
|
|
210
|
+
`distinct` travels straight through to the emission: it is a choice, not something to validate.
|
|
211
|
+
"""
|
|
212
|
+
if anchor.has_includes:
|
|
213
|
+
raise SnakeEmitError(
|
|
214
|
+
"A query with include(...) cannot be the anchor of a recursion: the columns of the "
|
|
215
|
+
"CTE are those of the table, so the loaded relationships would be lost. Recurse "
|
|
216
|
+
"without include and load the relationships afterwards."
|
|
217
|
+
)
|
|
218
|
+
if anchor.has_lock:
|
|
219
|
+
raise SnakeEmitError(
|
|
220
|
+
"A query with for_update() cannot be the anchor of a recursion: Postgres does not "
|
|
221
|
+
"accept locks inside a recursive CTE. Lock the rows in a separate query."
|
|
222
|
+
)
|
|
223
|
+
if anchor.projected_columns is not None:
|
|
224
|
+
raise SnakeEmitError(
|
|
225
|
+
"A query narrowed with only()/defer() cannot be the anchor of a recursion: the CTE's "
|
|
226
|
+
"columns are the TABLE's, so the step selects every one of them and the anchor would "
|
|
227
|
+
"select a few, and the UNION inside the CTE would not line up. Recurse whole rows and "
|
|
228
|
+
"project afterwards."
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
names = {column.name for column in table.columns}
|
|
232
|
+
for expr in (child, parent):
|
|
233
|
+
if expr.path[-1] not in names:
|
|
234
|
+
raise SnakeEmitError(
|
|
235
|
+
f"Column '{expr.path[-1]}' does not belong to '{table.name}', and a recursion joins "
|
|
236
|
+
f"that table WITH ITSELF. Both columns of the hop have to be its own."
|
|
237
|
+
)
|
|
238
|
+
return SnakeRecursive(
|
|
239
|
+
anchor=anchor,
|
|
240
|
+
table=table,
|
|
241
|
+
child_column=child.path[-1],
|
|
242
|
+
parent_column=parent.path[-1],
|
|
243
|
+
distinct=distinct,
|
|
244
|
+
)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Resolving a model NAME the way the module that wrote it means it.
|
|
2
|
+
|
|
3
|
+
A class name does not identify a model: two apps can each declare a `Customer`, and the registry's
|
|
4
|
+
by-name index keeps whichever registered LAST. Asking it is bug #14, and it does not fail — it
|
|
5
|
+
returns a valid answer about the wrong table.
|
|
6
|
+
|
|
7
|
+
Annotations never had the problem: `get_type_hints` evaluates against the MODULE's globals, so each
|
|
8
|
+
file resolves its own `Post`. This does the same for names that arrive as ARGUMENTS
|
|
9
|
+
(`through="Tagging"`), so one declaration stops having two rules on adjacent lines.
|
|
10
|
+
|
|
11
|
+
Two callers, one reader. `resolve_in_module` answers about ONE name; `type_checking_names` answers
|
|
12
|
+
about the whole block, for `linker.hints_of`, which resolves a model's annotations all at once. They
|
|
13
|
+
share `_type_checking_imports` so a spelling one understands is a spelling the other does too.
|
|
14
|
+
|
|
15
|
+
Two layers, in this order:
|
|
16
|
+
|
|
17
|
+
1. The module's globals. Covers the forward declaration — the class comes later in the same file —
|
|
18
|
+
because `snake_link()` runs at the end. Costs nothing.
|
|
19
|
+
2. The `if TYPE_CHECKING:` block, read from source. Covers the real circular import, where the name
|
|
20
|
+
is never in globals and `get_type_hints` raises `NameError`.
|
|
21
|
+
|
|
22
|
+
If neither has it, it REFUSES naming both ways out. Falling back to the global index is what this
|
|
23
|
+
module exists to stop.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import ast
|
|
29
|
+
import importlib.util
|
|
30
|
+
import inspect
|
|
31
|
+
import sys
|
|
32
|
+
from typing import TYPE_CHECKING
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from types import ModuleType
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_in_module(owner: type, name: str) -> type | None:
|
|
39
|
+
"""The class `name` refers to, as seen from the module that declared `owner`.
|
|
40
|
+
|
|
41
|
+
`None` when the module cannot see it at all — the caller decides how to complain, because it is
|
|
42
|
+
the one that knows what the name was for.
|
|
43
|
+
"""
|
|
44
|
+
module = sys.modules.get(owner.__module__)
|
|
45
|
+
if module is None: # pragma: no cover - a class whose module was unloaded
|
|
46
|
+
return None
|
|
47
|
+
found = getattr(module, name, None)
|
|
48
|
+
if isinstance(found, type):
|
|
49
|
+
return found
|
|
50
|
+
return _from_type_checking(module, name)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def type_checking_names(owner: type) -> dict[str, type]:
|
|
54
|
+
"""Every class the OWNER's module imports under `if TYPE_CHECKING:`, imported for real.
|
|
55
|
+
|
|
56
|
+
The same reading as `resolve_in_module`'s second layer, answering for the whole block instead of
|
|
57
|
+
one name — because the caller that needs it, `get_type_hints`, resolves a model's annotations
|
|
58
|
+
ALL AT ONCE and cannot be asked about them one at a time.
|
|
59
|
+
|
|
60
|
+
Empty when there is no block, no source, or nothing importable in it. That is not a failure: the
|
|
61
|
+
caller passes this as extra scope, and extra scope that adds nothing simply changes nothing.
|
|
62
|
+
"""
|
|
63
|
+
module = sys.modules.get(owner.__module__)
|
|
64
|
+
if module is None: # pragma: no cover - a class whose module was unloaded
|
|
65
|
+
return {}
|
|
66
|
+
found: dict[str, type] = {}
|
|
67
|
+
for module_name, local, attribute in _type_checking_imports(module):
|
|
68
|
+
imported = _import(module_name, attribute)
|
|
69
|
+
if imported is not None:
|
|
70
|
+
found[local] = imported
|
|
71
|
+
return found
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _from_type_checking(module: ModuleType, name: str) -> type | None:
|
|
75
|
+
"""The class a `if TYPE_CHECKING:` import names, imported for real, or `None`.
|
|
76
|
+
|
|
77
|
+
This block is invisible at runtime by design, so the only way to read it is from the SOURCE. It
|
|
78
|
+
is the one case layer 1 cannot cover: a genuine circular import between two modules, where the
|
|
79
|
+
runtime import never happens.
|
|
80
|
+
|
|
81
|
+
Importing the named module HERE is safe in a way it would not have been at declaration time: by
|
|
82
|
+
the time anything asks, the cycle that made the author write `TYPE_CHECKING` is over.
|
|
83
|
+
"""
|
|
84
|
+
for module_name, local, attribute in _type_checking_imports(module):
|
|
85
|
+
if local != name:
|
|
86
|
+
continue
|
|
87
|
+
imported = _import(module_name, attribute)
|
|
88
|
+
if imported is not None:
|
|
89
|
+
return imported
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _type_checking_imports(module: ModuleType) -> list[tuple[str, str, str]]:
|
|
94
|
+
"""Every `from X import Y [as Z]` inside the module's type-checking blocks.
|
|
95
|
+
|
|
96
|
+
`(module, bound name, attribute)`, in source order. It is one reader for the two questions asked
|
|
97
|
+
of the block —one name, or all of them— so a spelling either understands is a spelling both do.
|
|
98
|
+
Splitting it was how `through=` and the annotations came to disagree in the first place.
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
source = inspect.getsource(module)
|
|
102
|
+
except (OSError, TypeError): # pragma: no cover - a module with no source on disk
|
|
103
|
+
return []
|
|
104
|
+
found: list[tuple[str, str, str]] = []
|
|
105
|
+
for statement in ast.parse(source).body:
|
|
106
|
+
if not isinstance(statement, ast.If) or not _is_type_checking(statement.test):
|
|
107
|
+
continue
|
|
108
|
+
for node in statement.body:
|
|
109
|
+
if not isinstance(node, ast.ImportFrom):
|
|
110
|
+
continue
|
|
111
|
+
absolute = _absolute(node, module)
|
|
112
|
+
if absolute is None:
|
|
113
|
+
continue
|
|
114
|
+
for alias in node.names:
|
|
115
|
+
found.append((absolute, alias.asname or alias.name, alias.name))
|
|
116
|
+
return found
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _absolute(node: ast.ImportFrom, module: ModuleType) -> str | None:
|
|
120
|
+
"""The module an import names, with `from .sibling import X` resolved against its package.
|
|
121
|
+
|
|
122
|
+
Relative imports are the ordinary way to write one inside a package, so skipping them would drop
|
|
123
|
+
the pair back onto the bare `NameError` this fallback exists to stop. Resolving them is not a
|
|
124
|
+
guess: the module carries its own `__package__` and `importlib.util.resolve_name` is what the
|
|
125
|
+
import system itself uses. `None` only when there is no package to resolve against.
|
|
126
|
+
"""
|
|
127
|
+
if not node.level:
|
|
128
|
+
return node.module
|
|
129
|
+
package = getattr(module, "__package__", None)
|
|
130
|
+
if (
|
|
131
|
+
not package
|
|
132
|
+
): # pragma: no cover - a relative import outside any package cannot be executed
|
|
133
|
+
return None
|
|
134
|
+
try:
|
|
135
|
+
return importlib.util.resolve_name(
|
|
136
|
+
f"{'.' * node.level}{node.module or ''}", package
|
|
137
|
+
)
|
|
138
|
+
except (
|
|
139
|
+
ImportError,
|
|
140
|
+
ValueError,
|
|
141
|
+
): # pragma: no cover - more dots than the package is deep
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _is_type_checking(test: ast.expr) -> bool:
|
|
146
|
+
"""Whether an `if` guards on `TYPE_CHECKING`, spelled bare or as `typing.TYPE_CHECKING`."""
|
|
147
|
+
if isinstance(test, ast.Name):
|
|
148
|
+
return test.id == "TYPE_CHECKING"
|
|
149
|
+
return isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _import(module_name: str, attribute: str) -> type | None:
|
|
153
|
+
"""Imports `module_name` and returns its `attribute`, or `None` if either step fails.
|
|
154
|
+
|
|
155
|
+
Failures come back as `None` rather than raising: this is one of two layers, and a module that
|
|
156
|
+
cannot be imported is a name this layer cannot answer for — not an error in itself. The caller
|
|
157
|
+
refuses once, with both ways out named.
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
found = getattr(importlib.import_module(module_name), attribute, None)
|
|
161
|
+
except ImportError:
|
|
162
|
+
return None
|
|
163
|
+
return found if isinstance(found, type) else None
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""The Registry: the store of compiled models (class → SnakeTableInfo)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from snakeorm.core.exceptions import SnakeRegistryError
|
|
6
|
+
from snakeorm.metadata import (
|
|
7
|
+
SnakeRelationshipInfo,
|
|
8
|
+
SnakeRoutineInfo,
|
|
9
|
+
SnakeTableInfo,
|
|
10
|
+
SnakeTriggerInfo,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SnakeRegistry:
|
|
15
|
+
"""A class → SnakeTableInfo store (and name → SnakeRoutineInfo alongside it).
|
|
16
|
+
|
|
17
|
+
@snake_model populates it (Phase 1); the linker reads it and replaces the already-linked tables
|
|
18
|
+
(Phase 2). The routines from `snake_function(...)` live apart (`_routines`): they are not
|
|
19
|
+
models, just a DESIRED function the diff compares against.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self._tables: dict[type, SnakeTableInfo] = {}
|
|
24
|
+
self._by_name: dict[str, SnakeTableInfo] = {}
|
|
25
|
+
self._model_by_name: dict[str, type] = {}
|
|
26
|
+
self._table_owner: dict[str, type] = {}
|
|
27
|
+
self._routines: dict[str, SnakeRoutineInfo] = {}
|
|
28
|
+
# Key (table, name): in Postgres a trigger's name is NOT unique on its own.
|
|
29
|
+
self._triggers: dict[tuple[str, str], SnakeTriggerInfo] = {}
|
|
30
|
+
# qualified table -> {discriminator value: class}. Nested (not a composite key) because the
|
|
31
|
+
# mapper asks for the WHOLE map ONCE and resolves each row with `dict.get`.
|
|
32
|
+
self._polymorphic: dict[str, dict[str, type]] = {}
|
|
33
|
+
|
|
34
|
+
def register(self, model: type, table: SnakeTableInfo) -> None:
|
|
35
|
+
"""Registers (or replaces) a model's compiled table.
|
|
36
|
+
|
|
37
|
+
Guard: two DIFFERENT models cannot map to the same qualified name (re-registering the SAME
|
|
38
|
+
model is fine, e.g. after snake_link). Exception: a polymorphic hierarchy shares a table on
|
|
39
|
+
purpose; it is recognised from the metadata (`is_polymorphic_child`), not from a flag, so
|
|
40
|
+
nobody gets to skip the guard "just this once".
|
|
41
|
+
"""
|
|
42
|
+
qualified = f"{table.schema}.{table.name}"
|
|
43
|
+
owner = self._table_owner.get(qualified)
|
|
44
|
+
if table.is_polymorphic_child:
|
|
45
|
+
assert table.polymorphic is not None
|
|
46
|
+
self._tables[model] = table
|
|
47
|
+
self._by_name[model.__name__] = table
|
|
48
|
+
self._model_by_name[model.__name__] = model
|
|
49
|
+
self._polymorphic.setdefault(qualified, {})[
|
|
50
|
+
table.polymorphic.value or ""
|
|
51
|
+
] = model
|
|
52
|
+
return
|
|
53
|
+
if owner is not None and owner is not model:
|
|
54
|
+
raise SnakeRegistryError(
|
|
55
|
+
f"Table collision: '{qualified}' is already registered by "
|
|
56
|
+
f"{owner.__qualname__}. Disambiguate with prefix= or table= in @snake_model."
|
|
57
|
+
)
|
|
58
|
+
self._table_owner[qualified] = model
|
|
59
|
+
self._tables[model] = table
|
|
60
|
+
self._by_name[model.__name__] = table
|
|
61
|
+
self._model_by_name[model.__name__] = model
|
|
62
|
+
|
|
63
|
+
def polymorphic_map(self, table: SnakeTableInfo) -> dict[str, type]:
|
|
64
|
+
"""Every subclass of that table, indexed by its discriminator value.
|
|
65
|
+
|
|
66
|
+
The WHOLE map is returned (not one class per value) because the mapper asks for it ONCE and
|
|
67
|
+
resolves each row with `dict.get`; asking per row was 55% slower on the hottest path. A
|
|
68
|
+
value with no subclass simply is not in the map, and that is NOT an error: it is hydrated as
|
|
69
|
+
the base class (you lose the subclass's fields, not the row).
|
|
70
|
+
"""
|
|
71
|
+
return self._polymorphic.get(f"{table.schema}.{table.name}", {})
|
|
72
|
+
|
|
73
|
+
def table_of(self, model: type) -> SnakeTableInfo | None:
|
|
74
|
+
"""Returns a model's compiled table, or None if it is not registered."""
|
|
75
|
+
return self._tables.get(model)
|
|
76
|
+
|
|
77
|
+
def table_by_name(self, name: str) -> SnakeTableInfo | None:
|
|
78
|
+
"""Returns the table by the model's name (to resolve relation targets)."""
|
|
79
|
+
return self._by_name.get(name)
|
|
80
|
+
|
|
81
|
+
def model_by_name(self, name: str) -> type | None:
|
|
82
|
+
"""Returns the model's CLASS by its name (to instantiate related objects in .include())."""
|
|
83
|
+
return self._model_by_name.get(name)
|
|
84
|
+
|
|
85
|
+
def table_by_qualified(self, qualified: str) -> SnakeTableInfo | None:
|
|
86
|
+
"""Returns the table by its QUALIFIED name (`schema.table`), which IS unique.
|
|
87
|
+
|
|
88
|
+
The class name is not (two apps can each have their own `Customer`, and the index keyed by
|
|
89
|
+
`__name__` is kept by whichever comes last). The qualified one is protected by the collision
|
|
90
|
+
guard in `register()`.
|
|
91
|
+
"""
|
|
92
|
+
return (
|
|
93
|
+
self._tables.get(self._table_owner[qualified])
|
|
94
|
+
if qualified in self._table_owner
|
|
95
|
+
else None
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def model_by_qualified(self, qualified: str) -> type | None:
|
|
99
|
+
"""Returns the CLASS that owns a qualified table. Counterpart of `table_by_qualified`."""
|
|
100
|
+
return self._table_owner.get(qualified)
|
|
101
|
+
|
|
102
|
+
def resolve_relationship(
|
|
103
|
+
self, relationship: SnakeRelationshipInfo
|
|
104
|
+
) -> tuple[SnakeTableInfo | None, type | None]:
|
|
105
|
+
"""Resolves a relation's target to `(table, class)`, unambiguously when ambiguity exists.
|
|
106
|
+
|
|
107
|
+
It prefers the linker's qualified `target_table`; it falls back to the class name only when
|
|
108
|
+
there is none (a relation rebuilt from a migration, which does not carry it). Centralised
|
|
109
|
+
here so that fixing the wrong target is ONE change and not twelve copies.
|
|
110
|
+
"""
|
|
111
|
+
if relationship.target_table:
|
|
112
|
+
table = self.table_by_qualified(relationship.target_table)
|
|
113
|
+
if table is not None:
|
|
114
|
+
return table, self.model_by_qualified(relationship.target_table)
|
|
115
|
+
return self.table_by_name(relationship.target), self.model_by_name(
|
|
116
|
+
relationship.target
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def models(self) -> tuple[type, ...]:
|
|
120
|
+
"""Lists the registered models."""
|
|
121
|
+
return tuple(self._tables)
|
|
122
|
+
|
|
123
|
+
def register_routine(self, routine: SnakeRoutineInfo) -> None:
|
|
124
|
+
"""Registers (or replaces) a DESIRED routine declared with `snake_function(...)`.
|
|
125
|
+
|
|
126
|
+
Keyed by `name`: redeclaring the same name replaces the `body`, like a `CREATE OR REPLACE`.
|
|
127
|
+
"""
|
|
128
|
+
self._routines[routine.name] = routine
|
|
129
|
+
|
|
130
|
+
def routine_by_name(self, name: str) -> SnakeRoutineInfo | None:
|
|
131
|
+
"""Returns the desired routine by name, or None if it is not declared."""
|
|
132
|
+
return self._routines.get(name)
|
|
133
|
+
|
|
134
|
+
def routines(self) -> tuple[SnakeRoutineInfo, ...]:
|
|
135
|
+
"""Lists the registered desired routines (autodetect's code-first source of truth)."""
|
|
136
|
+
return tuple(self._routines.values())
|
|
137
|
+
|
|
138
|
+
def register_trigger(self, trigger: SnakeTriggerInfo) -> None:
|
|
139
|
+
"""Registers (or replaces) a DESIRED trigger declared with `snake_trigger(...)`.
|
|
140
|
+
|
|
141
|
+
Keyed by (table, name): by name alone, a trigger of the same name on another table would
|
|
142
|
+
stomp on it.
|
|
143
|
+
"""
|
|
144
|
+
self._triggers[(trigger.table, trigger.name)] = trigger
|
|
145
|
+
|
|
146
|
+
def triggers(self) -> tuple[SnakeTriggerInfo, ...]:
|
|
147
|
+
"""Lists the registered desired triggers."""
|
|
148
|
+
return tuple(self._triggers.values())
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
registry = SnakeRegistry()
|
|
152
|
+
"""The default global registry that @snake_model populates."""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def registry_of(model: type) -> SnakeRegistry:
|
|
156
|
+
"""The registry where the model lives (the decorator put it there), or the global one.
|
|
157
|
+
|
|
158
|
+
It is what makes `@snake_model(registry=reg)` work: everything downstream —typed navigation,
|
|
159
|
+
the query, the session— has to resolve against THAT registry and not the global one, or a model
|
|
160
|
+
in its own registry is registered and unreachable.
|
|
161
|
+
|
|
162
|
+
It lives here rather than in `fields/` because it answers a REGISTRY question, and because two
|
|
163
|
+
other packages were already importing the private `_registry_of` across a package boundary,
|
|
164
|
+
which is the shape a helper takes just before it becomes public by accident.
|
|
165
|
+
"""
|
|
166
|
+
return getattr(model, "__snake_registry__", registry)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""SnakeORM's session layer: synchronous execution of queries."""
|
|
2
|
+
|
|
3
|
+
from snakeorm.session.asyncsession import AsyncSession as AsyncSession
|
|
4
|
+
from snakeorm.session.factory import snake_session as snake_session
|
|
5
|
+
from snakeorm.session.isolation import SnakeIsolation as SnakeIsolation
|
|
6
|
+
from snakeorm.session.retry import is_transient as is_transient
|
|
7
|
+
from snakeorm.session.retry import with_retry as with_retry
|
|
8
|
+
from snakeorm.session.session import SnakeSession as SnakeSession
|