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.
Files changed (198) hide show
  1. snake_orm-0.1.0b1.dist-info/METADATA +366 -0
  2. snake_orm-0.1.0b1.dist-info/RECORD +198 -0
  3. snake_orm-0.1.0b1.dist-info/WHEEL +4 -0
  4. snake_orm-0.1.0b1.dist-info/entry_points.txt +2 -0
  5. snake_orm-0.1.0b1.dist-info/licenses/LICENSE +21 -0
  6. snakeorm/__init__.py +214 -0
  7. snakeorm/advisor.py +109 -0
  8. snakeorm/cli/__init__.py +3 -0
  9. snakeorm/cli/app.py +992 -0
  10. snakeorm/cli/discovery.py +179 -0
  11. snakeorm/cli/hooks.py +86 -0
  12. snakeorm/compiler/__init__.py +3 -0
  13. snakeorm/compiler/compiler.py +330 -0
  14. snakeorm/connection.py +228 -0
  15. snakeorm/contrib/__init__.py +17 -0
  16. snakeorm/contrib/asgi.py +192 -0
  17. snakeorm/contrib/config.py +264 -0
  18. snakeorm/contrib/deliver.py +256 -0
  19. snakeorm/contrib/django.py +245 -0
  20. snakeorm/contrib/sidecar.py +44 -0
  21. snakeorm/contrib/wsgi.py +176 -0
  22. snakeorm/core/__init__.py +1 -0
  23. snakeorm/core/config.py +173 -0
  24. snakeorm/core/converters.py +172 -0
  25. snakeorm/core/exceptions.py +175 -0
  26. snakeorm/core/placement.py +15 -0
  27. snakeorm/core/sentinels.py +37 -0
  28. snakeorm/core/signals.py +114 -0
  29. snakeorm/debug/__init__.py +42 -0
  30. snakeorm/debug/assets/css/panel.css +616 -0
  31. snakeorm/debug/assets/css/snake_orm_app.css +293 -0
  32. snakeorm/debug/assets/img/icon.svg +5 -0
  33. snakeorm/debug/assets/js/history.js +425 -0
  34. snakeorm/debug/assets/js/language.js +283 -0
  35. snakeorm/debug/assets/js/panel.js +241 -0
  36. snakeorm/debug/assets/js/snake_orm_app.js +82 -0
  37. snakeorm/debug/assets/pages/cli.html +28 -0
  38. snakeorm/debug/assets/pages/config.html +34 -0
  39. snakeorm/debug/assets/pages/database_first.html +29 -0
  40. snakeorm/debug/assets/pages/help.html +21 -0
  41. snakeorm/debug/assets/pages/history.html +27 -0
  42. snakeorm/debug/capture.py +234 -0
  43. snakeorm/debug/channel.py +131 -0
  44. snakeorm/debug/collector.py +144 -0
  45. snakeorm/debug/config.py +149 -0
  46. snakeorm/debug/html.py +578 -0
  47. snakeorm/debug/matrix.py +60 -0
  48. snakeorm/debug/origin.py +42 -0
  49. snakeorm/debug/otel/__init__.py +68 -0
  50. snakeorm/debug/otel/context.py +43 -0
  51. snakeorm/debug/otel/exporter.py +277 -0
  52. snakeorm/debug/otel/payload.py +97 -0
  53. snakeorm/debug/otel/report.py +29 -0
  54. snakeorm/debug/otel/spans.py +393 -0
  55. snakeorm/debug/otel/summary.py +59 -0
  56. snakeorm/debug/record.py +54 -0
  57. snakeorm/debug/report.py +311 -0
  58. snakeorm/debug/testing.py +23 -0
  59. snakeorm/decorators/__init__.py +13 -0
  60. snakeorm/decorators/abstract.py +26 -0
  61. snakeorm/decorators/db_first.py +104 -0
  62. snakeorm/decorators/function.py +28 -0
  63. snakeorm/decorators/model.py +268 -0
  64. snakeorm/decorators/polymorphic.py +140 -0
  65. snakeorm/decorators/result.py +167 -0
  66. snakeorm/decorators/row.py +90 -0
  67. snakeorm/decorators/trigger.py +47 -0
  68. snakeorm/decorators/view.py +149 -0
  69. snakeorm/dialects/__init__.py +6 -0
  70. snakeorm/dialects/base.py +373 -0
  71. snakeorm/dialects/capabilities.py +571 -0
  72. snakeorm/dialects/literals.py +46 -0
  73. snakeorm/dialects/matrix.py +384 -0
  74. snakeorm/dialects/mysql.py +519 -0
  75. snakeorm/dialects/postgres.py +504 -0
  76. snakeorm/dialects/sqlite.py +417 -0
  77. snakeorm/drivers/__init__.py +18 -0
  78. snakeorm/drivers/asyncbase.py +73 -0
  79. snakeorm/drivers/asyncdecorators.py +244 -0
  80. snakeorm/drivers/asyncpool.py +228 -0
  81. snakeorm/drivers/asyncpsycopg.py +124 -0
  82. snakeorm/drivers/asyncpymysql.py +50 -0
  83. snakeorm/drivers/asyncsqlite.py +30 -0
  84. snakeorm/drivers/base.py +76 -0
  85. snakeorm/drivers/failures.py +181 -0
  86. snakeorm/drivers/logging.py +167 -0
  87. snakeorm/drivers/pool.py +352 -0
  88. snakeorm/drivers/psycopg.py +188 -0
  89. snakeorm/drivers/pymysql.py +176 -0
  90. snakeorm/drivers/savepoints.py +46 -0
  91. snakeorm/drivers/sqlite.py +170 -0
  92. snakeorm/drivers/threaded.py +143 -0
  93. snakeorm/drivers/timeout.py +87 -0
  94. snakeorm/dto/__init__.py +55 -0
  95. snakeorm/dto/read.py +231 -0
  96. snakeorm/dto/region.py +326 -0
  97. snakeorm/dto/resolve.py +354 -0
  98. snakeorm/dto/spec.py +201 -0
  99. snakeorm/expressions/__init__.py +88 -0
  100. snakeorm/expressions/conditional.py +178 -0
  101. snakeorm/expressions/expression.py +810 -0
  102. snakeorm/expressions/functions.py +71 -0
  103. snakeorm/expressions/keys.py +299 -0
  104. snakeorm/expressions/paths.py +43 -0
  105. snakeorm/expressions/scalar.py +293 -0
  106. snakeorm/expressions/window.py +248 -0
  107. snakeorm/fields/__init__.py +68 -0
  108. snakeorm/fields/check.py +79 -0
  109. snakeorm/fields/column.py +316 -0
  110. snakeorm/fields/enum.py +64 -0
  111. snakeorm/fields/index.py +46 -0
  112. snakeorm/fields/relationship.py +738 -0
  113. snakeorm/fields/typed.py +736 -0
  114. snakeorm/helpers/__init__.py +1 -0
  115. snakeorm/helpers/annotations.py +21 -0
  116. snakeorm/helpers/inheritance.py +41 -0
  117. snakeorm/helpers/pyliteral.py +52 -0
  118. snakeorm/helpers/pytype.py +50 -0
  119. snakeorm/introspection/__init__.py +13 -0
  120. snakeorm/introspection/base.py +30 -0
  121. snakeorm/introspection/drift.py +78 -0
  122. snakeorm/introspection/models.py +633 -0
  123. snakeorm/introspection/mysql.py +367 -0
  124. snakeorm/introspection/postgres.py +397 -0
  125. snakeorm/introspection/sqlite.py +217 -0
  126. snakeorm/introspection/unsupported.py +94 -0
  127. snakeorm/linker/__init__.py +3 -0
  128. snakeorm/linker/linker.py +521 -0
  129. snakeorm/metadata/__init__.py +41 -0
  130. snakeorm/metadata/check.py +37 -0
  131. snakeorm/metadata/column.py +201 -0
  132. snakeorm/metadata/enum_storage.py +33 -0
  133. snakeorm/metadata/fk_action.py +19 -0
  134. snakeorm/metadata/foreign_key.py +26 -0
  135. snakeorm/metadata/index.py +43 -0
  136. snakeorm/metadata/index_method.py +19 -0
  137. snakeorm/metadata/int_size.py +20 -0
  138. snakeorm/metadata/json_storage.py +21 -0
  139. snakeorm/metadata/polymorphic.py +30 -0
  140. snakeorm/metadata/primary_key.py +23 -0
  141. snakeorm/metadata/relationship.py +60 -0
  142. snakeorm/metadata/relationship_kind.py +43 -0
  143. snakeorm/metadata/routine.py +26 -0
  144. snakeorm/metadata/server_default.py +20 -0
  145. snakeorm/metadata/table.py +97 -0
  146. snakeorm/metadata/table_kind.py +23 -0
  147. snakeorm/metadata/trigger.py +62 -0
  148. snakeorm/metadata/type_params.py +348 -0
  149. snakeorm/migration/__init__.py +88 -0
  150. snakeorm/migration/asyncrunner.py +168 -0
  151. snakeorm/migration/autodetect.py +96 -0
  152. snakeorm/migration/ddl.py +1172 -0
  153. snakeorm/migration/diff.py +701 -0
  154. snakeorm/migration/loader.py +77 -0
  155. snakeorm/migration/operations.py +952 -0
  156. snakeorm/migration/planner.py +78 -0
  157. snakeorm/migration/realize.py +420 -0
  158. snakeorm/migration/renames.py +153 -0
  159. snakeorm/migration/render.py +1095 -0
  160. snakeorm/migration/runner.py +314 -0
  161. snakeorm/migration/squash.py +60 -0
  162. snakeorm/migration/state.py +107 -0
  163. snakeorm/model.py +162 -0
  164. snakeorm/py.typed +0 -0
  165. snakeorm/query/__init__.py +9 -0
  166. snakeorm/query/compound.py +288 -0
  167. snakeorm/query/join_kind.py +19 -0
  168. snakeorm/query/joined.py +143 -0
  169. snakeorm/query/query.py +1001 -0
  170. snakeorm/query/recursive.py +244 -0
  171. snakeorm/registry/__init__.py +5 -0
  172. snakeorm/registry/by_module.py +163 -0
  173. snakeorm/registry/registry.py +166 -0
  174. snakeorm/session/__init__.py +8 -0
  175. snakeorm/session/asyncsession.py +638 -0
  176. snakeorm/session/coercion.py +306 -0
  177. snakeorm/session/factory.py +29 -0
  178. snakeorm/session/guards.py +269 -0
  179. snakeorm/session/isolation.py +23 -0
  180. snakeorm/session/mapper.py +213 -0
  181. snakeorm/session/planning.py +815 -0
  182. snakeorm/session/retry.py +96 -0
  183. snakeorm/session/session.py +871 -0
  184. snakeorm/session/shared.py +355 -0
  185. snakeorm/sql/__init__.py +18 -0
  186. snakeorm/sql/adapt.py +104 -0
  187. snakeorm/sql/aggregate.py +232 -0
  188. snakeorm/sql/condition.py +359 -0
  189. snakeorm/sql/delete.py +46 -0
  190. snakeorm/sql/insert.py +149 -0
  191. snakeorm/sql/joins.py +158 -0
  192. snakeorm/sql/pk_subquery.py +54 -0
  193. snakeorm/sql/refs.py +19 -0
  194. snakeorm/sql/resolver.py +28 -0
  195. snakeorm/sql/select.py +175 -0
  196. snakeorm/sql/update.py +82 -0
  197. snakeorm/sql/value.py +552 -0
  198. 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