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,314 @@
|
|
|
1
|
+
"""Migration runtime: applies and reverts migrations, with tracking in the database.
|
|
2
|
+
|
|
3
|
+
The order comes from the files' linear numbering (there is no `down_revision`). The applied versions
|
|
4
|
+
are stored in `snake_migrations`, which makes `apply` IDEMPOTENT. Every migration is ATOMIC if the
|
|
5
|
+
engine has transactional DDL; if it does not, on error it reports how many operations were applied.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from snakeorm.core.placement import DEFAULT_SCHEMA
|
|
14
|
+
from snakeorm.dialects import SnakeDialect
|
|
15
|
+
from snakeorm.drivers import SnakeDriver
|
|
16
|
+
from snakeorm.core.exceptions import SnakeMigrationError
|
|
17
|
+
from snakeorm.metadata import SnakeStrParams
|
|
18
|
+
from snakeorm.migration.operations import (
|
|
19
|
+
RebuildTable,
|
|
20
|
+
SnakeDataOperation,
|
|
21
|
+
SnakeMigrationOperation,
|
|
22
|
+
SnakeOperation,
|
|
23
|
+
)
|
|
24
|
+
from snakeorm.migration.realize import realize
|
|
25
|
+
from snakeorm.session import SnakeSession
|
|
26
|
+
from snakeorm.sql.refs import qualified
|
|
27
|
+
|
|
28
|
+
_TRACKING_TABLE = "snake_migrations"
|
|
29
|
+
_SCHEMA = DEFAULT_SCHEMA
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Migration:
|
|
34
|
+
"""A versioned unit of operations (schema and/or data). The order comes from its number."""
|
|
35
|
+
|
|
36
|
+
version: str
|
|
37
|
+
operations: tuple[SnakeMigrationOperation, ...]
|
|
38
|
+
replaces: tuple[str, ...] = ()
|
|
39
|
+
"""Versions this migration REPLACES (a squash). Empty in a normal migration.
|
|
40
|
+
|
|
41
|
+
It allows a history to be collapsed without breaking the databases where the originals were
|
|
42
|
+
already applied: there the squash is marked applied without being executed. See `apply`.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def squash_already_done(migration: Migration, already: set[str]) -> bool:
|
|
47
|
+
"""Is this squash's work already done by the migrations it replaces?
|
|
48
|
+
|
|
49
|
+
- ALL applied -> yes. The DB is already in that state; running it would repeat a `CREATE TABLE`.
|
|
50
|
+
- NONE applied -> no. Fresh install: it runs.
|
|
51
|
+
- SOME yes and others no -> it STOPS. Running it would repeat what is done and marking it would
|
|
52
|
+
skip what is missing; both corrupt, so it is flagged and the human decides.
|
|
53
|
+
|
|
54
|
+
A loose function (not a method) so both runners share THE SAME answer: a duplicated criterion
|
|
55
|
+
that drifts decides differently in silence.
|
|
56
|
+
"""
|
|
57
|
+
done = [version for version in migration.replaces if version in already]
|
|
58
|
+
if not done:
|
|
59
|
+
return False
|
|
60
|
+
if len(done) == len(migration.replaces):
|
|
61
|
+
return True
|
|
62
|
+
missing = [version for version in migration.replaces if version not in already]
|
|
63
|
+
raise SnakeMigrationError(
|
|
64
|
+
f"The squash '{migration.version}' replaces a history that was applied HALF-WAY: "
|
|
65
|
+
f"{sorted(done)} are already there and {sorted(missing)} are missing. It can neither "
|
|
66
|
+
f"be run (it would repeat what is done) nor be marked as applied (it would skip what is "
|
|
67
|
+
f"missing). Apply the missing ones first with the original history and try again."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def explain_rebuild_failure(
|
|
72
|
+
plan: Sequence[SnakeMigrationOperation], dialect: SnakeDialect, error: Exception
|
|
73
|
+
) -> SnakeMigrationError | None:
|
|
74
|
+
"""Turns a rebuild's integrity failure into a sentence, or `None` if that is not what happened.
|
|
75
|
+
|
|
76
|
+
A loose function, like `squash_already_done` and `tracking_table_ddl`, and for the same reason:
|
|
77
|
+
the two runners must not be able to answer this differently.
|
|
78
|
+
|
|
79
|
+
WHAT IT IS EXPLAINING, measured on SQLite 3.50. A rebuild drops the old table halfway through,
|
|
80
|
+
and that orphans every row of every OTHER table whose foreign key names it. The keys are
|
|
81
|
+
deferred, so nothing fails there — but SQLite counts deferred violations instead of rechecking
|
|
82
|
+
them, and putting the table back three statements later does not bring the counter down. So the
|
|
83
|
+
COMMIT refuses with four words, `PRAGMA foreign_key_check` says the database is clean, and the
|
|
84
|
+
person reading the deploy log has no way in.
|
|
85
|
+
|
|
86
|
+
It does not repair anything: the migration has already rolled back, which is correct. What it
|
|
87
|
+
adds is the name of the table, the reason the check disagrees, and the one instrument that lifts
|
|
88
|
+
the limit — `PRAGMA foreign_keys = OFF` on a connection with NO transaction open, which is where
|
|
89
|
+
that pragma stops being a no-op.
|
|
90
|
+
|
|
91
|
+
IT ANSWERS A SECOND FAILURE, and the two do not look alike. A rebuild finishes with a `RENAME`,
|
|
92
|
+
and on this engine a rename REPARSES the whole schema — so a view standing over the table makes
|
|
93
|
+
that statement fail with `error in view <v>: no such table: main.<t>`, naming a table the reader
|
|
94
|
+
can see with their own eyes and a view nobody touched. The generator emits the rebuild bare on
|
|
95
|
+
purpose, because which tables a view reads is not something the metadata says, so this turns the
|
|
96
|
+
engine's sentence into the two operations the migration was missing.
|
|
97
|
+
"""
|
|
98
|
+
if dialect.syntax.defer_constraints_statement is None:
|
|
99
|
+
return None
|
|
100
|
+
rebuilt = [
|
|
101
|
+
operation.after.name
|
|
102
|
+
for operation in plan
|
|
103
|
+
if isinstance(operation, RebuildTable)
|
|
104
|
+
]
|
|
105
|
+
if not rebuilt:
|
|
106
|
+
return None
|
|
107
|
+
message = str(error).lower()
|
|
108
|
+
if "error in view" in message:
|
|
109
|
+
return SnakeMigrationError(
|
|
110
|
+
f"The rebuild of {', '.join(sorted(set(rebuilt)))} could not be applied: this engine "
|
|
111
|
+
f"has to DROP the table to change a constraint on it and RENAME the replacement into "
|
|
112
|
+
f"place, and a RENAME reparses the WHOLE schema — every view in it. A view whose SELECT "
|
|
113
|
+
f"names the table is unparseable at that instant, so the RENAME is what failed, not the "
|
|
114
|
+
f"drop, and the engine's own words ({error}) name a table that does exist. Nothing was "
|
|
115
|
+
f"applied: the migration rolled back whole. Surround the rebuild with the two "
|
|
116
|
+
f"operations that say it out loud — a DropView of that view BEFORE it and a CreateView "
|
|
117
|
+
f"of the same view AFTER it — and the rename has nothing left to reparse. The "
|
|
118
|
+
f"generator does not write them for you, and cannot: nothing in the metadata says "
|
|
119
|
+
f"which TABLES a view reads (a `sql=` view is raw text, and `depends_on` only names "
|
|
120
|
+
f"other views), so which view has to come down is a decision only you can make."
|
|
121
|
+
)
|
|
122
|
+
if "foreign key" not in message:
|
|
123
|
+
return None
|
|
124
|
+
return SnakeMigrationError(
|
|
125
|
+
f"The rebuild of {', '.join(sorted(set(rebuilt)))} could not be committed: this engine has "
|
|
126
|
+
f"to DROP the table to change a constraint on it, and a foreign key from another table "
|
|
127
|
+
f"still names it. The keys were deferred, so the drop went through and the COMMIT is what "
|
|
128
|
+
f"refused — and `PRAGMA foreign_key_check` will tell you the database is clean, because "
|
|
129
|
+
f"deferred violations are COUNTED at the moment they happen and not re-checked at the end. "
|
|
130
|
+
f"Nothing was applied: the migration rolled back whole. To let it through, the foreign keys "
|
|
131
|
+
f"have to be off, and `PRAGMA foreign_keys = OFF` is a no-op inside a transaction — it has "
|
|
132
|
+
f"to be issued on the connection BEFORE the migration opens one."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
_VERSION_MAX_LENGTH = 255
|
|
137
|
+
"""Length of the tracking table's `version` column.
|
|
138
|
+
|
|
139
|
+
Not a random number: it is the classic one that fits a MySQL index under any collation, and this
|
|
140
|
+
column IS the table's primary key. A migration name does not come anywhere close to it.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def tracking_table_ddl(dialect: SnakeDialect) -> str:
|
|
145
|
+
"""The `CREATE TABLE IF NOT EXISTS` for `snake_migrations`, for either runner.
|
|
146
|
+
|
|
147
|
+
A loose function, like `squash_already_done`, and for the same reason made concrete: the two
|
|
148
|
+
runners must not be able to answer this differently. They already did. The synchronous one
|
|
149
|
+
learned the hard way that a hard-wired `TEXT` makes MySQL reject the entire table — "BLOB/TEXT
|
|
150
|
+
column used in key specification without a key length", error 1170, because this column IS the
|
|
151
|
+
primary key — and asked the dialect for the type instead, with the reason written down. The
|
|
152
|
+
async runner was written later, copied the shape rather than the lesson, and carried the bug on
|
|
153
|
+
the FIRST statement of its `apply()`: on MySQL the async migration system never started.
|
|
154
|
+
|
|
155
|
+
Nothing caught it. The parity test between the runners compared method NAMES, and both are
|
|
156
|
+
called `ensure_tracking_table`.
|
|
157
|
+
|
|
158
|
+
With a declared length each engine emits its own: `VARCHAR(255)` on Postgres and MySQL, `TEXT`
|
|
159
|
+
on SQLite, which does not distinguish.
|
|
160
|
+
"""
|
|
161
|
+
table = qualified(_SCHEMA, _TRACKING_TABLE, dialect)
|
|
162
|
+
version = dialect.quote_ident("version")
|
|
163
|
+
applied_at = dialect.quote_ident("applied_at")
|
|
164
|
+
version_type = dialect.map_type(
|
|
165
|
+
str, params=SnakeStrParams(max_length=_VERSION_MAX_LENGTH)
|
|
166
|
+
)
|
|
167
|
+
return (
|
|
168
|
+
f"CREATE TABLE IF NOT EXISTS {table} "
|
|
169
|
+
f"({version} {version_type} NOT NULL, "
|
|
170
|
+
f"{applied_at} TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, "
|
|
171
|
+
f"PRIMARY KEY ({version}))"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class MigrationRunner:
|
|
176
|
+
"""Applies/reverts migrations and records the applied ones in `snake_migrations`."""
|
|
177
|
+
|
|
178
|
+
def __init__(self, driver: SnakeDriver, dialect: SnakeDialect) -> None:
|
|
179
|
+
self._driver = driver
|
|
180
|
+
self._dialect = dialect
|
|
181
|
+
# A session over the SAME driver/dialect: the `RunPython`s run the ORM inside the
|
|
182
|
+
# migration's transaction, atomic alongside the schema operations.
|
|
183
|
+
self._session = SnakeSession(driver, dialect)
|
|
184
|
+
|
|
185
|
+
def ensure_tracking_table(self) -> None:
|
|
186
|
+
"""Creates the tracking table if it does not exist (idempotent).
|
|
187
|
+
|
|
188
|
+
The DDL itself is `tracking_table_ddl`, shared with the async runner. It used to live here,
|
|
189
|
+
and the async one carried its own copy with the MySQL bug this one had already fixed — which
|
|
190
|
+
is the reason it is a loose function now and not a method on each.
|
|
191
|
+
"""
|
|
192
|
+
self._driver.execute(tracking_table_ddl(self._dialect), ())
|
|
193
|
+
self._driver.commit()
|
|
194
|
+
|
|
195
|
+
def applied_versions(self) -> set[str]:
|
|
196
|
+
"""Returns the set of versions already applied."""
|
|
197
|
+
version = self._dialect.quote_ident("version")
|
|
198
|
+
rows = self._driver.fetch_all(f"SELECT {version} FROM {self._table_ref()}", ())
|
|
199
|
+
return {str(row[0]) for row in rows}
|
|
200
|
+
|
|
201
|
+
def apply(self, migrations: list[Migration]) -> list[str]:
|
|
202
|
+
"""Applies the pending migrations in order. Returns the versions applied just now.
|
|
203
|
+
|
|
204
|
+
Every migration is ATOMIC: its operations and the version record go together. With
|
|
205
|
+
transactional DDL a failure halfway does a `rollback()`; without it, how many were applied is
|
|
206
|
+
reported.
|
|
207
|
+
"""
|
|
208
|
+
self.ensure_tracking_table()
|
|
209
|
+
already = self.applied_versions()
|
|
210
|
+
replaced = {
|
|
211
|
+
version for migration in migrations for version in migration.replaces
|
|
212
|
+
}
|
|
213
|
+
newly: list[str] = []
|
|
214
|
+
for migration in migrations:
|
|
215
|
+
if migration.version in already:
|
|
216
|
+
continue
|
|
217
|
+
# A migration replaced by a squash in this batch is not applied: the squash does it.
|
|
218
|
+
if migration.version in replaced:
|
|
219
|
+
continue
|
|
220
|
+
if migration.replaces and squash_already_done(migration, already):
|
|
221
|
+
self._record(migration.version)
|
|
222
|
+
self._driver.commit()
|
|
223
|
+
newly.append(migration.version)
|
|
224
|
+
continue
|
|
225
|
+
if self._dialect.supports_transactional_ddl:
|
|
226
|
+
self._apply_atomic(migration)
|
|
227
|
+
else:
|
|
228
|
+
self._apply_stepwise(migration)
|
|
229
|
+
newly.append(migration.version)
|
|
230
|
+
return newly
|
|
231
|
+
|
|
232
|
+
def _apply_atomic(self, migration: Migration) -> None:
|
|
233
|
+
"""Applies a migration wrapped in the engine's transaction: all or nothing."""
|
|
234
|
+
plan = realize(migration.operations, self._dialect)
|
|
235
|
+
try:
|
|
236
|
+
for operation in plan:
|
|
237
|
+
self._forward(operation)
|
|
238
|
+
self._record(migration.version)
|
|
239
|
+
self._driver.commit()
|
|
240
|
+
except Exception as error:
|
|
241
|
+
self._driver.rollback() # undoes the operations already emitted for THIS migration
|
|
242
|
+
explained = explain_rebuild_failure(plan, self._dialect, error)
|
|
243
|
+
if explained is not None:
|
|
244
|
+
raise explained from error
|
|
245
|
+
raise
|
|
246
|
+
|
|
247
|
+
def _apply_stepwise(self, migration: Migration) -> None:
|
|
248
|
+
"""Applies operation by operation (engine without transactional DDL); on error, reports how many."""
|
|
249
|
+
applied = 0
|
|
250
|
+
plan = realize(migration.operations, self._dialect)
|
|
251
|
+
try:
|
|
252
|
+
for operation in plan:
|
|
253
|
+
self._forward(operation)
|
|
254
|
+
applied += 1
|
|
255
|
+
self._record(migration.version)
|
|
256
|
+
self._driver.commit()
|
|
257
|
+
except Exception as error:
|
|
258
|
+
raise SnakeMigrationError(
|
|
259
|
+
f"Migration '{migration.version}' failed after applying {applied} of "
|
|
260
|
+
f"{len(plan)} operation(s): {error}. The engine does not support "
|
|
261
|
+
f"transactional DDL, so the database was left half-way: review it and "
|
|
262
|
+
f"fix it by hand."
|
|
263
|
+
) from error
|
|
264
|
+
|
|
265
|
+
def rollback(self, migration: Migration) -> None:
|
|
266
|
+
"""Reverts a migration (operations in reverse order) and deletes its record."""
|
|
267
|
+
for operation in reversed(realize(migration.operations, self._dialect)):
|
|
268
|
+
self._backward(operation)
|
|
269
|
+
self._unrecord(migration.version)
|
|
270
|
+
self._driver.commit()
|
|
271
|
+
|
|
272
|
+
def _forward(self, operation: SnakeMigrationOperation) -> None:
|
|
273
|
+
"""Runs an operation forwards, dispatching by STRUCTURE.
|
|
274
|
+
|
|
275
|
+
Data (`SnakeDataOperation`) has `run` and runs with the session; schema (`SnakeOperation`) has
|
|
276
|
+
`up_sql` and executes its DDL. Both in the same transaction (a mixed migration is still
|
|
277
|
+
all-or-nothing).
|
|
278
|
+
"""
|
|
279
|
+
if isinstance(operation, SnakeDataOperation):
|
|
280
|
+
operation.run(self._session)
|
|
281
|
+
elif isinstance(operation, SnakeOperation):
|
|
282
|
+
for sql in operation.up_sql(self._dialect):
|
|
283
|
+
self._driver.execute(sql, ())
|
|
284
|
+
|
|
285
|
+
def _backward(self, operation: SnakeMigrationOperation) -> None:
|
|
286
|
+
"""Reverts an operation: the DATA one runs its `unrun`; the SCHEMA one emits its reverse DDL."""
|
|
287
|
+
if isinstance(operation, SnakeDataOperation):
|
|
288
|
+
operation.unrun(self._session)
|
|
289
|
+
elif isinstance(operation, SnakeOperation):
|
|
290
|
+
for sql in operation.down_sql(self._dialect):
|
|
291
|
+
self._driver.execute(sql, ())
|
|
292
|
+
|
|
293
|
+
def _table_ref(self) -> str:
|
|
294
|
+
"""Reference to the tracking table, qualified ONLY if the engine has schemas.
|
|
295
|
+
|
|
296
|
+
Qualifying `"public"."snake_migrations"` by hand broke on SQLite (`unknown database
|
|
297
|
+
"public"`); `qualified()` exists for exactly this.
|
|
298
|
+
"""
|
|
299
|
+
return qualified(_SCHEMA, _TRACKING_TABLE, self._dialect)
|
|
300
|
+
|
|
301
|
+
def _record(self, version: str) -> None:
|
|
302
|
+
column = self._dialect.quote_ident("version")
|
|
303
|
+
self._driver.execute(
|
|
304
|
+
f"INSERT INTO {self._table_ref()} ({column}) "
|
|
305
|
+
f"VALUES ({self._dialect.placeholder(1)})",
|
|
306
|
+
(version,),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
def _unrecord(self, version: str) -> None:
|
|
310
|
+
column = self._dialect.quote_ident("version")
|
|
311
|
+
self._driver.execute(
|
|
312
|
+
f"DELETE FROM {self._table_ref()} WHERE {column} = {self._dialect.placeholder(1)}",
|
|
313
|
+
(version,),
|
|
314
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Collapsing N migrations into a single one, declaring which ones it replaces.
|
|
2
|
+
|
|
3
|
+
The collapse is a replay up to the final state. The delicate part is the DB where the originals have
|
|
4
|
+
ALREADY been applied: there the squash must not run (its `CREATE TABLE` would die against tables
|
|
5
|
+
that exist), hence the `replaces` the runner uses to decide (see `MigrationRunner.apply`). It is a
|
|
6
|
+
DESKTOP operation (it does not touch the DB), and that is why it cannot merge DATA operations.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
from snakeorm.core.exceptions import SnakeMigrationError
|
|
14
|
+
from snakeorm.migration.autodetect import replay
|
|
15
|
+
from snakeorm.migration.diff import diff_routines, diff_schema, diff_triggers
|
|
16
|
+
from snakeorm.migration.operations import RunPython, RunSQL, SnakeOperation
|
|
17
|
+
from snakeorm.migration.runner import Migration
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def squash(migrations: Sequence[Migration], *, version: str) -> Migration:
|
|
21
|
+
"""Collapses `migrations` into ONE producing the same final state.
|
|
22
|
+
|
|
23
|
+
It emits the state they lead to, not the steps (a column added and then dropped does not show
|
|
24
|
+
up). Crossing a DATA operation (`RunPython`, `RunSQL`) is rejected: its `apply_to_state` is a
|
|
25
|
+
no-op, so merging them would demand EXECUTING them, and the collapse does not touch the DB
|
|
26
|
+
(losing them would silently leave data unmigrated).
|
|
27
|
+
"""
|
|
28
|
+
if not migrations:
|
|
29
|
+
raise SnakeMigrationError("A squash needs at least one migration to collapse.")
|
|
30
|
+
|
|
31
|
+
for migration in migrations:
|
|
32
|
+
for operation in migration.operations:
|
|
33
|
+
if isinstance(operation, (RunPython, RunSQL)):
|
|
34
|
+
raise SnakeMigrationError(
|
|
35
|
+
f"Migration '{migration.version}' contains a DATA operation "
|
|
36
|
+
f"({type(operation).__name__}), which cannot be collapsed: mutating rows "
|
|
37
|
+
f"demands running it, and a squash does not touch the database. Collapse the "
|
|
38
|
+
f"stretch that reaches UP TO it and leave the rest of the history as it is."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# The replayed final state, diffed against nothing: that IS the squash. EVERY collection the
|
|
42
|
+
# state holds has to be re-emitted, and in `autodetect`'s order — tables first, then the
|
|
43
|
+
# routines and triggers that may reference them. Anything not re-emitted here is dropped from
|
|
44
|
+
# the collapsed history in silence, and a fresh install comes up without it.
|
|
45
|
+
#
|
|
46
|
+
# That is not hypothetical: it happened to the routines, was fixed by adding their line, and the
|
|
47
|
+
# triggers stayed missing for exactly as long, because the test written that day knew only about
|
|
48
|
+
# routines. The two callers are compared in `test_squash.py` now, so the next collection added
|
|
49
|
+
# to `SchemaState` cannot be forgotten the same way.
|
|
50
|
+
state = replay(migrations)
|
|
51
|
+
operations: list[SnakeOperation] = [
|
|
52
|
+
*diff_schema([], state.tables()),
|
|
53
|
+
*diff_routines([], state.routines()),
|
|
54
|
+
*diff_triggers([], state.triggers()),
|
|
55
|
+
]
|
|
56
|
+
return Migration(
|
|
57
|
+
version=version,
|
|
58
|
+
operations=tuple(operations),
|
|
59
|
+
replaces=tuple(migration.version for migration in migrations),
|
|
60
|
+
)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""SchemaState: the schema's mutable state, rebuilt by replaying migrations.
|
|
2
|
+
|
|
3
|
+
It is the equivalent of Django's `ProjectState`. Every operation knows how to mutate it
|
|
4
|
+
(`apply_to_state`), just as it knows how to emit its SQL. Autogen replays the migrations'
|
|
5
|
+
operations onto an empty SchemaState to obtain the "previous state", and diffs that against the
|
|
6
|
+
current metadata.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
|
|
13
|
+
from snakeorm.core.placement import DEFAULT_SCHEMA
|
|
14
|
+
from snakeorm.metadata import SnakeRoutineInfo, SnakeTableInfo, SnakeTriggerInfo
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SchemaState:
|
|
18
|
+
"""A mutable collection of tables (and routines), indexed by name."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
tables: Iterable[SnakeTableInfo] = (),
|
|
23
|
+
routines: Iterable[SnakeRoutineInfo] = (),
|
|
24
|
+
triggers: Iterable[SnakeTriggerInfo] = (),
|
|
25
|
+
) -> None:
|
|
26
|
+
self._tables: dict[str, SnakeTableInfo] = {
|
|
27
|
+
table.name: table for table in tables
|
|
28
|
+
}
|
|
29
|
+
self._routines: dict[str, SnakeRoutineInfo] = {
|
|
30
|
+
routine.name: routine for routine in routines
|
|
31
|
+
}
|
|
32
|
+
# A trigger's key is (table, name): in Postgres the name is not unique, two tables can
|
|
33
|
+
# each have one of the same name, and indexing by name alone would overwrite one silently.
|
|
34
|
+
self._triggers: dict[tuple[str, str], SnakeTriggerInfo] = {
|
|
35
|
+
(trigger.table, trigger.name): trigger for trigger in triggers
|
|
36
|
+
}
|
|
37
|
+
# `public` always exists in Postgres: nobody creates it and nobody should drop it.
|
|
38
|
+
self._schemas: set[str] = {DEFAULT_SCHEMA}
|
|
39
|
+
|
|
40
|
+
def add_table(self, table: SnakeTableInfo) -> None:
|
|
41
|
+
"""Adds (or replaces) a table in the state."""
|
|
42
|
+
self._tables[table.name] = table
|
|
43
|
+
|
|
44
|
+
def remove_table(self, name: str) -> None:
|
|
45
|
+
"""Removes a table from the state AND the triggers that hung off it.
|
|
46
|
+
|
|
47
|
+
A trigger belongs to its table on all three engines: a `DROP TABLE` takes it along, and
|
|
48
|
+
nothing is left to drop afterwards. The state used to keep it, and it was the only thing in
|
|
49
|
+
the system still believing in it — `squash`, which re-emits everything the state holds, put
|
|
50
|
+
a `CreateTrigger` in the collapsed migration for a table that migration never creates, and a
|
|
51
|
+
fresh install died on that statement.
|
|
52
|
+
|
|
53
|
+
A rename goes through here too, and there the trigger does NOT disappear: it follows the
|
|
54
|
+
table. `RenameTable.apply_to_state` puts it back under the new name, for the same reason it
|
|
55
|
+
already re-points the foreign keys.
|
|
56
|
+
"""
|
|
57
|
+
self._tables.pop(name, None)
|
|
58
|
+
for key in [key for key in self._triggers if key[0] == name]:
|
|
59
|
+
del self._triggers[key]
|
|
60
|
+
|
|
61
|
+
def get_table(self, name: str) -> SnakeTableInfo | None:
|
|
62
|
+
"""Returns the table by name, or None if it is not in the state."""
|
|
63
|
+
return self._tables.get(name)
|
|
64
|
+
|
|
65
|
+
def tables(self) -> tuple[SnakeTableInfo, ...]:
|
|
66
|
+
"""The tables of the current state."""
|
|
67
|
+
return tuple(self._tables.values())
|
|
68
|
+
|
|
69
|
+
def add_schema(self, schema: str) -> None:
|
|
70
|
+
"""Records a schema as existing."""
|
|
71
|
+
self._schemas.add(schema)
|
|
72
|
+
|
|
73
|
+
def remove_schema(self, schema: str) -> None:
|
|
74
|
+
"""Removes a schema from the state (idempotent if it was not there)."""
|
|
75
|
+
self._schemas.discard(schema)
|
|
76
|
+
|
|
77
|
+
def schemas(self) -> frozenset[str]:
|
|
78
|
+
"""Schemas the state takes to exist."""
|
|
79
|
+
return frozenset(self._schemas)
|
|
80
|
+
|
|
81
|
+
def add_routine(self, routine: SnakeRoutineInfo) -> None:
|
|
82
|
+
"""Adds (or replaces) a routine (function/procedure) in the state."""
|
|
83
|
+
self._routines[routine.name] = routine
|
|
84
|
+
|
|
85
|
+
def remove_routine(self, name: str) -> None:
|
|
86
|
+
"""Removes a routine from the state (idempotent if it is not there)."""
|
|
87
|
+
self._routines.pop(name, None)
|
|
88
|
+
|
|
89
|
+
def get_routine(self, name: str) -> SnakeRoutineInfo | None:
|
|
90
|
+
"""Returns the routine by name, or None if it is not in the state."""
|
|
91
|
+
return self._routines.get(name)
|
|
92
|
+
|
|
93
|
+
def routines(self) -> tuple[SnakeRoutineInfo, ...]:
|
|
94
|
+
"""The routines of the current state."""
|
|
95
|
+
return tuple(self._routines.values())
|
|
96
|
+
|
|
97
|
+
def add_trigger(self, trigger: SnakeTriggerInfo) -> None:
|
|
98
|
+
"""Adds (or replaces) a trigger. The key is (table, name), not the name alone."""
|
|
99
|
+
self._triggers[(trigger.table, trigger.name)] = trigger
|
|
100
|
+
|
|
101
|
+
def remove_trigger(self, table: str, name: str) -> None:
|
|
102
|
+
"""Removes a trigger from the state (idempotent if it is not there)."""
|
|
103
|
+
self._triggers.pop((table, name), None)
|
|
104
|
+
|
|
105
|
+
def triggers(self) -> tuple[SnakeTriggerInfo, ...]:
|
|
106
|
+
"""The triggers of the current state."""
|
|
107
|
+
return tuple(self._triggers.values())
|
snakeorm/model.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Base class of the SnakeORM models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import ClassVar, dataclass_transform
|
|
7
|
+
|
|
8
|
+
from snakeorm.core.exceptions import SnakeAggregateNotLoaded
|
|
9
|
+
from snakeorm.fields import (
|
|
10
|
+
SnakeIndex,
|
|
11
|
+
snake_auto,
|
|
12
|
+
snake_column,
|
|
13
|
+
snake_datetime,
|
|
14
|
+
snake_datetimetz,
|
|
15
|
+
snake_float,
|
|
16
|
+
snake_time,
|
|
17
|
+
snake_timetz,
|
|
18
|
+
snake_decimal,
|
|
19
|
+
snake_discriminator,
|
|
20
|
+
snake_enum,
|
|
21
|
+
snake_int,
|
|
22
|
+
snake_json,
|
|
23
|
+
snake_str,
|
|
24
|
+
snake_to_many,
|
|
25
|
+
snake_to_many_through,
|
|
26
|
+
snake_to_one,
|
|
27
|
+
)
|
|
28
|
+
from snakeorm.metadata import SnakeCheckInfo
|
|
29
|
+
|
|
30
|
+
# Key (with a trailing double underscore: it does NOT get name-mangled) holding, per instance, the
|
|
31
|
+
# dict of aggregates `session.annotate(...)` fills in. Absent = the instance was not annotated.
|
|
32
|
+
_AGGREGATES_KEY = "__snake_aggregates__"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _AggregateNamespace:
|
|
36
|
+
"""EMERGENCY EXIT for reading annotated aggregates by dynamic name.
|
|
37
|
+
|
|
38
|
+
`obj.aggregate.<name>` returns `object` (NEVER `Any`): the name is dynamic, so the checker cannot
|
|
39
|
+
type it and FORCES a `cast()` at the point of use. No IntelliSense and no typo-safety: it is the
|
|
40
|
+
hatch, not the road. The genuinely typed road is `@snake_result` + `session.annotate(...)`, which
|
|
41
|
+
hands you a class with genuinely typed fields.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
__slots__ = ("_values",)
|
|
45
|
+
|
|
46
|
+
def __init__(self, values: Mapping[str, object] | None) -> None:
|
|
47
|
+
self._values = values
|
|
48
|
+
|
|
49
|
+
def __getattr__(self, name: str) -> object:
|
|
50
|
+
"""Returns the aggregate's annotated value, or explains why it is not available."""
|
|
51
|
+
values = self._values
|
|
52
|
+
if values is None:
|
|
53
|
+
raise SnakeAggregateNotLoaded(
|
|
54
|
+
f"This instance was not annotated: '{name}' only exists after "
|
|
55
|
+
f"session.annotate(query, Result, {name}=<aggregate>). The typed path is "
|
|
56
|
+
f"@snake_result; this is the emergency exit (returns object, demands a cast)."
|
|
57
|
+
)
|
|
58
|
+
if name not in values:
|
|
59
|
+
available = ", ".join(sorted(values)) or "(none)"
|
|
60
|
+
raise SnakeAggregateNotLoaded(
|
|
61
|
+
f"There is no '{name}' aggregate on this instance. Annotations available: "
|
|
62
|
+
f"{available}. Ask for it with session.annotate(query, Result, {name}=<aggregate>)."
|
|
63
|
+
)
|
|
64
|
+
return values[name]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# The tuple is LITERAL: PEP 681 does not accept a constant here (see SNAKE_FIELD_SPECIFIERS in
|
|
68
|
+
# `fields/__init__.py`, the canonical reference, which a test ties this literal to).
|
|
69
|
+
@dataclass_transform(
|
|
70
|
+
kw_only_default=True,
|
|
71
|
+
field_specifiers=(
|
|
72
|
+
snake_column,
|
|
73
|
+
snake_auto,
|
|
74
|
+
snake_enum,
|
|
75
|
+
snake_int,
|
|
76
|
+
snake_str,
|
|
77
|
+
snake_decimal,
|
|
78
|
+
snake_datetime,
|
|
79
|
+
snake_datetimetz,
|
|
80
|
+
snake_float,
|
|
81
|
+
snake_time,
|
|
82
|
+
snake_timetz,
|
|
83
|
+
snake_json,
|
|
84
|
+
snake_to_one,
|
|
85
|
+
snake_to_many,
|
|
86
|
+
snake_to_many_through,
|
|
87
|
+
snake_discriminator,
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
class SnakeModel:
|
|
91
|
+
"""Model base: provides the table config at class level, TYPED.
|
|
92
|
+
|
|
93
|
+
The settings that reference columns or are documentation (table comment,
|
|
94
|
+
indexes, checks) are declared here and NOT in the decorator. They are annotated
|
|
95
|
+
as `ClassVar` so `dataclass_transform` does not mistake them for model columns.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
SnakeComment: ClassVar[str | None] = None
|
|
99
|
+
SnakeIndexes: ClassVar[list[SnakeIndex]] = []
|
|
100
|
+
# Domain rules the DB enforces. Declared with `snake_check(User.age >= 18)`: the condition is
|
|
101
|
+
# the SAME one `.filter()` uses, so the checker validates it and renaming a column breaks the
|
|
102
|
+
# rule at type-check time, not in production.
|
|
103
|
+
SnakeChecks: ClassVar[list[SnakeCheckInfo]] = []
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def aggregate(self) -> _AggregateNamespace:
|
|
107
|
+
"""EMERGENCY EXIT to the annotated aggregates by dynamic name (`object`, demands a cast).
|
|
108
|
+
|
|
109
|
+
Returns a per-instance namespace whose `__getattr__` gives `object` (NEVER `Any`): the checker
|
|
110
|
+
forces a `cast()` on you and gives you no IntelliSense. The typed road is `@snake_result`. If
|
|
111
|
+
the instance was not annotated, or the name does not exist, `SnakeAggregateNotLoaded` is raised.
|
|
112
|
+
"""
|
|
113
|
+
values: Mapping[str, object] | None = getattr(self, _AGGREGATES_KEY, None)
|
|
114
|
+
return _AggregateNamespace(values)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# The tuple is LITERAL: PEP 681 does not accept a constant here (see SNAKE_FIELD_SPECIFIERS in
|
|
118
|
+
# `fields/__init__.py`, the canonical reference, which a test ties this literal to).
|
|
119
|
+
@dataclass_transform(
|
|
120
|
+
kw_only_default=True,
|
|
121
|
+
field_specifiers=(
|
|
122
|
+
snake_column,
|
|
123
|
+
snake_auto,
|
|
124
|
+
snake_enum,
|
|
125
|
+
snake_int,
|
|
126
|
+
snake_str,
|
|
127
|
+
snake_decimal,
|
|
128
|
+
snake_datetime,
|
|
129
|
+
snake_datetimetz,
|
|
130
|
+
snake_float,
|
|
131
|
+
snake_time,
|
|
132
|
+
snake_timetz,
|
|
133
|
+
snake_json,
|
|
134
|
+
snake_to_one,
|
|
135
|
+
snake_to_many,
|
|
136
|
+
snake_to_many_through,
|
|
137
|
+
snake_discriminator,
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
class SnakeView:
|
|
141
|
+
"""VIEW base: like `SnakeModel`, but it marks the model as READ-ONLY.
|
|
142
|
+
|
|
143
|
+
A database view (`@snake_view`) is queried and NAVIGATED exactly like a model, but it is NOT
|
|
144
|
+
written: `session.add/update/delete/...` do not accept it. The lock is one of TYPES — those
|
|
145
|
+
methods ask for a `SnakeModel`, and a `SnakeView` does NOT inherit from `SnakeModel`, so it does
|
|
146
|
+
not fit — backed by a reinforcing runtime guard in the session.
|
|
147
|
+
|
|
148
|
+
It carries its own `@dataclass_transform` (independent of `SnakeModel`'s) because it is also
|
|
149
|
+
INSTANTIATED: the user does not build it by hand, but the session does when HYDRATING each row of
|
|
150
|
+
the view. That way its generated `__init__` ends up typed just like a model's.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
SnakeComment: ClassVar[str | None] = None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def attach_aggregates(instance: object, values: Mapping[str, object]) -> None:
|
|
157
|
+
"""Stores on the instance the dict of aggregates the `aggregate` escape hatch exposes.
|
|
158
|
+
|
|
159
|
+
`session.annotate(...)` calls it after hydrating the base row. It uses `object.__setattr__`
|
|
160
|
+
because models keep their state per attribute (there are no `__slots__`), as descriptors do.
|
|
161
|
+
"""
|
|
162
|
+
object.__setattr__(instance, _AGGREGATES_KEY, dict(values))
|
snakeorm/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""SnakeORM's query layer: the SnakeQuery builder and the explicit JOIN SnakeJoinedQuery."""
|
|
2
|
+
|
|
3
|
+
from snakeorm.query.compound import SnakeCompound as SnakeCompound
|
|
4
|
+
from snakeorm.query.compound import SnakeCompoundBranch as SnakeCompoundBranch
|
|
5
|
+
from snakeorm.query.compound import SnakeSetOp as SnakeSetOp
|
|
6
|
+
from snakeorm.query.join_kind import SnakeJoin as SnakeJoin
|
|
7
|
+
from snakeorm.query.joined import SnakeJoinedQuery as SnakeJoinedQuery
|
|
8
|
+
from snakeorm.query.query import SnakeQuery as SnakeQuery
|
|
9
|
+
from snakeorm.query.recursive import SnakeRecursive as SnakeRecursive
|