xtr-orm 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xtr_orm/__init__.py +51 -0
- xtr_orm/bundle/__init__.py +11 -0
- xtr_orm/bundle/connection_config.py +250 -0
- xtr_orm/bundle/orm_bundle.py +346 -0
- xtr_orm/bundle/orm_config.py +101 -0
- xtr_orm/command/__init__.py +56 -0
- xtr_orm/command/connection_command.py +134 -0
- xtr_orm/command/connections.py +37 -0
- xtr_orm/command/database_create_command.py +53 -0
- xtr_orm/command/database_drop_command.py +65 -0
- xtr_orm/command/migrations_current_command.py +44 -0
- xtr_orm/command/migrations_diff_command.py +60 -0
- xtr_orm/command/migrations_dump_schema_command.py +56 -0
- xtr_orm/command/migrations_execute_command.py +86 -0
- xtr_orm/command/migrations_generate_command.py +47 -0
- xtr_orm/command/migrations_latest_command.py +44 -0
- xtr_orm/command/migrations_list_command.py +71 -0
- xtr_orm/command/migrations_migrate_command.py +108 -0
- xtr_orm/command/migrations_rollup_command.py +43 -0
- xtr_orm/command/migrations_status_command.py +66 -0
- xtr_orm/command/migrations_up_to_date_command.py +83 -0
- xtr_orm/command/migrations_version_command.py +89 -0
- xtr_orm/command/run_sql_command.py +77 -0
- xtr_orm/connection_registry.py +192 -0
- xtr_orm/database/__init__.py +7 -0
- xtr_orm/database/database_manager.py +196 -0
- xtr_orm/exception/__init__.py +19 -0
- xtr_orm/exception/invalid_argument_error.py +28 -0
- xtr_orm/exception/migration_error.py +28 -0
- xtr_orm/exception/orm_error.py +14 -0
- xtr_orm/exception/session_unavailable_error.py +29 -0
- xtr_orm/exception/unknown_connection_error.py +28 -0
- xtr_orm/exception/unsupported_database_error.py +29 -0
- xtr_orm/messenger/__init__.py +23 -0
- xtr_orm/messenger/close_connection_middleware.py +75 -0
- xtr_orm/messenger/open_transaction_logger_middleware.py +90 -0
- xtr_orm/messenger/transaction_middleware.py +104 -0
- xtr_orm/migrations/__init__.py +29 -0
- xtr_orm/migrations/_environment.py +188 -0
- xtr_orm/migrations/_history.py +178 -0
- xtr_orm/migrations/_revisions.py +198 -0
- xtr_orm/migrations/_schema.py +66 -0
- xtr_orm/migrations/available_migration.py +27 -0
- xtr_orm/migrations/direction.py +14 -0
- xtr_orm/migrations/executed_migration.py +31 -0
- xtr_orm/migrations/execution_result.py +26 -0
- xtr_orm/migrations/migration_plan.py +26 -0
- xtr_orm/migrations/migration_status.py +48 -0
- xtr_orm/migrations/migrations_config.py +162 -0
- xtr_orm/migrations/migrator.py +680 -0
- xtr_orm/migrations/template/script.py.mako +26 -0
- xtr_orm/py.typed +0 -0
- xtr_orm-1.5.0.dist-info/METADATA +487 -0
- xtr_orm-1.5.0.dist-info/RECORD +57 -0
- xtr_orm-1.5.0.dist-info/WHEEL +4 -0
- xtr_orm-1.5.0.dist-info/entry_points.txt +3 -0
- xtr_orm-1.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
"""Versioned schema changes for one database: read, planned, run and recorded."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from io import StringIO
|
|
6
|
+
from typing import TYPE_CHECKING, Final, TypeVar, final
|
|
7
|
+
|
|
8
|
+
from alembic.autogenerate import RevisionContext
|
|
9
|
+
from alembic.config import Config
|
|
10
|
+
from sqlalchemy import Column, MetaData, String, Table, delete
|
|
11
|
+
from xtr_logging_contracts import LoggerAware
|
|
12
|
+
|
|
13
|
+
from xtr_orm.exception import MigrationError
|
|
14
|
+
|
|
15
|
+
from ._environment import Environment
|
|
16
|
+
from ._history import HistoryRecorder, HistoryTable
|
|
17
|
+
from ._revisions import (
|
|
18
|
+
applied,
|
|
19
|
+
available,
|
|
20
|
+
known,
|
|
21
|
+
mark_step,
|
|
22
|
+
parents,
|
|
23
|
+
plan,
|
|
24
|
+
refusing,
|
|
25
|
+
resolve,
|
|
26
|
+
)
|
|
27
|
+
from ._revisions import migration as describe
|
|
28
|
+
from ._revisions import step as revision_step
|
|
29
|
+
from ._schema import creating, matching, tables_of
|
|
30
|
+
from .direction import Direction
|
|
31
|
+
from .executed_migration import ExecutedMigration
|
|
32
|
+
from .migration_plan import MigrationPlan
|
|
33
|
+
from .migration_status import MigrationStatus
|
|
34
|
+
from .migrations_config import MigrationsConfig
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
38
|
+
|
|
39
|
+
from alembic.operations.ops import MigrationScript
|
|
40
|
+
from alembic.runtime.migration import MigrationContext, RevisionStep
|
|
41
|
+
from alembic.script import Script, ScriptDirectory
|
|
42
|
+
from sqlalchemy.engine import Connection
|
|
43
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
44
|
+
|
|
45
|
+
from .available_migration import AvailableMigration
|
|
46
|
+
from .execution_result import ExecutionResult
|
|
47
|
+
|
|
48
|
+
__all__ = ["Migrator"]
|
|
49
|
+
|
|
50
|
+
_T = TypeVar("_T")
|
|
51
|
+
|
|
52
|
+
_VERSION_LENGTH: Final = 32
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@final
|
|
56
|
+
class Migrator(LoggerAware):
|
|
57
|
+
"""Reads, plans, runs and records one database's revisions.
|
|
58
|
+
|
|
59
|
+
Revisions are ordinary migration files in the configured directory. The
|
|
60
|
+
database keeps the revisions it is at in the version table, and a row per
|
|
61
|
+
revision applied in the history table; every change to one is made to the
|
|
62
|
+
other in the same transaction.
|
|
63
|
+
|
|
64
|
+
Every method opens its own connection from the engine and returns once it
|
|
65
|
+
is closed. What reads files only — :meth:`generate` — opens none.
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
migrator = Migrator(engine, MigrationsConfig(directory="migrations"), Base.metadata)
|
|
69
|
+
|
|
70
|
+
await migrator.migrate() # to the latest revisions
|
|
71
|
+
status = await migrator.status()
|
|
72
|
+
```
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
_engine: AsyncEngine
|
|
76
|
+
_config: MigrationsConfig
|
|
77
|
+
_metadata: MetaData | Sequence[MetaData] | None
|
|
78
|
+
_name: str
|
|
79
|
+
_environment: Environment
|
|
80
|
+
_history: HistoryTable
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
engine: AsyncEngine,
|
|
85
|
+
config: MigrationsConfig | None = None,
|
|
86
|
+
metadata: MetaData | Sequence[MetaData] | None = None,
|
|
87
|
+
*,
|
|
88
|
+
name: str = "default",
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Manage the revisions ``config`` describes on ``engine``'s database.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
engine: The database the revisions apply to.
|
|
94
|
+
config: Where the revisions are and how they are recorded.
|
|
95
|
+
metadata: The table definitions a diff compares the database
|
|
96
|
+
with; a diff is refused without them.
|
|
97
|
+
name: What reports call this database.
|
|
98
|
+
"""
|
|
99
|
+
self._engine = engine
|
|
100
|
+
self._config = config if config is not None else MigrationsConfig()
|
|
101
|
+
self._metadata = metadata
|
|
102
|
+
self._name = name
|
|
103
|
+
self._environment = Environment(self._config, metadata)
|
|
104
|
+
self._history = HistoryTable(self._config.history_table, self._config.version_table_schema)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def name(self) -> str:
|
|
108
|
+
"""What reports call this database."""
|
|
109
|
+
return self._name
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def engine(self) -> AsyncEngine:
|
|
113
|
+
"""The database the revisions apply to."""
|
|
114
|
+
return self._engine
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def config(self) -> MigrationsConfig:
|
|
118
|
+
"""Where the revisions are and how they are recorded."""
|
|
119
|
+
return self._config
|
|
120
|
+
|
|
121
|
+
async def status(self) -> MigrationStatus:
|
|
122
|
+
"""Read where the database stands against the revision files."""
|
|
123
|
+
return await self._connected(self._status)
|
|
124
|
+
|
|
125
|
+
async def plan(self, target: str = "latest") -> tuple[MigrationPlan, ...]:
|
|
126
|
+
"""Return the revisions :meth:`migrate` would run to reach ``target``, without running them.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
MigrationError: When the target is unknown or cannot be reached.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def work(connection: Connection) -> tuple[MigrationPlan, ...]:
|
|
133
|
+
directory = self._environment.directory()
|
|
134
|
+
heads = self._environment.context(connection).get_current_heads()
|
|
135
|
+
direction, steps = plan(directory, heads, target)
|
|
136
|
+
return tuple(
|
|
137
|
+
MigrationPlan(step.revision.revision, direction, (step.revision.doc or "").strip())
|
|
138
|
+
for step in steps
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return await self._connected(work)
|
|
142
|
+
|
|
143
|
+
async def migrate(self, target: str = "latest") -> tuple[ExecutionResult, ...]:
|
|
144
|
+
"""Run every revision between where the database is and ``target``.
|
|
145
|
+
|
|
146
|
+
``target`` is a revision — a full or partial id, or a label — a
|
|
147
|
+
relative one (``+1``, ``-2``, ``ae10+1``), or one of ``latest``,
|
|
148
|
+
``first`` (before every revision), ``next``, ``prev`` and ``current``.
|
|
149
|
+
A revision already applied is migrated down to; one that is not, up
|
|
150
|
+
to.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
MigrationError: When the target is unknown or cannot be reached.
|
|
154
|
+
"""
|
|
155
|
+
results = await self._connected(lambda connection: self._migrate(connection, target))
|
|
156
|
+
self._log_results(results)
|
|
157
|
+
return results
|
|
158
|
+
|
|
159
|
+
async def migrate_sql(self, target: str = "latest") -> str:
|
|
160
|
+
"""Return the SQL :meth:`migrate` would run, recording included, without running it.
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
MigrationError: When the target is unknown or cannot be reached.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def work(connection: Connection) -> str:
|
|
167
|
+
directory = self._environment.directory()
|
|
168
|
+
heads = self._environment.context(connection).get_current_heads()
|
|
169
|
+
_, steps = plan(directory, heads, target)
|
|
170
|
+
return self._sql(connection, directory, heads, steps)
|
|
171
|
+
|
|
172
|
+
return await self._connected(work)
|
|
173
|
+
|
|
174
|
+
async def execute(
|
|
175
|
+
self, versions: Sequence[str], direction: Direction = Direction.UP
|
|
176
|
+
) -> tuple[ExecutionResult, ...]:
|
|
177
|
+
"""Run exactly ``versions``, in order, each one way — and nothing before or after them.
|
|
178
|
+
|
|
179
|
+
Going up, a revision must not be applied and must follow only applied
|
|
180
|
+
ones; going down, it must be applied and followed by none that is.
|
|
181
|
+
Each version is checked against the ones before it, so a revision and
|
|
182
|
+
the one following it may be run together.
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
MigrationError: When a version is unknown, or out of order.
|
|
186
|
+
"""
|
|
187
|
+
results = await self._connected(
|
|
188
|
+
lambda connection: self._apply(connection, versions, direction, marking=False)
|
|
189
|
+
)
|
|
190
|
+
self._log_results(results)
|
|
191
|
+
return results
|
|
192
|
+
|
|
193
|
+
async def plan_for_versions(
|
|
194
|
+
self, versions: Sequence[str], direction: Direction = Direction.UP
|
|
195
|
+
) -> tuple[MigrationPlan, ...]:
|
|
196
|
+
"""Return what :meth:`execute` would run for ``versions``, without running it.
|
|
197
|
+
|
|
198
|
+
Each version is resolved to the revision it names — a partial id to
|
|
199
|
+
the full one — and checked as :meth:`execute` checks it.
|
|
200
|
+
|
|
201
|
+
Raises:
|
|
202
|
+
MigrationError: When a version is unknown, or out of order.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def work(connection: Connection) -> tuple[MigrationPlan, ...]:
|
|
206
|
+
directory = self._environment.directory()
|
|
207
|
+
heads = self._environment.context(connection).get_current_heads()
|
|
208
|
+
return tuple(
|
|
209
|
+
MigrationPlan(script.revision, direction, (script.doc or "").strip())
|
|
210
|
+
for script in self._checked(directory, heads, versions, direction)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
return await self._connected(work)
|
|
214
|
+
|
|
215
|
+
async def execute_sql(
|
|
216
|
+
self, versions: Sequence[str], direction: Direction = Direction.UP
|
|
217
|
+
) -> str:
|
|
218
|
+
"""Return the SQL :meth:`execute` would run, recording included, without running it.
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
MigrationError: When a version is unknown, or out of order.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
def work(connection: Connection) -> str:
|
|
225
|
+
directory = self._environment.directory()
|
|
226
|
+
heads = self._environment.context(connection).get_current_heads()
|
|
227
|
+
scripts = self._checked(directory, heads, versions, direction)
|
|
228
|
+
steps = [revision_step(directory, each, direction) for each in scripts]
|
|
229
|
+
return self._sql(connection, directory, heads, steps)
|
|
230
|
+
|
|
231
|
+
return await self._connected(work)
|
|
232
|
+
|
|
233
|
+
def generate(self, message: str | None = None) -> AvailableMigration:
|
|
234
|
+
"""Write an empty revision following the latest one, and return it.
|
|
235
|
+
|
|
236
|
+
Raises:
|
|
237
|
+
MigrationError: When there are several latest revisions to follow.
|
|
238
|
+
"""
|
|
239
|
+
directory = self._environment.directory()
|
|
240
|
+
context = RevisionContext(Config(), directory, _command_args(message, autogenerate=False))
|
|
241
|
+
written = self._written(context)
|
|
242
|
+
if written is None: # pragma: no cover — an empty revision is always written
|
|
243
|
+
raise MigrationError("No revision was written.")
|
|
244
|
+
return written
|
|
245
|
+
|
|
246
|
+
async def diff(
|
|
247
|
+
self,
|
|
248
|
+
message: str | None = None,
|
|
249
|
+
*,
|
|
250
|
+
allow_empty: bool = False,
|
|
251
|
+
from_empty_schema: bool = False,
|
|
252
|
+
) -> AvailableMigration | None:
|
|
253
|
+
"""Write a revision bringing the database to the table definitions, and return it.
|
|
254
|
+
|
|
255
|
+
The database is compared with the table definitions, and what differs
|
|
256
|
+
becomes the revision's upgrade. Nothing is written when nothing
|
|
257
|
+
differs, unless ``allow_empty``.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
message: The revision's message.
|
|
261
|
+
allow_empty: Write a revision even when nothing differs.
|
|
262
|
+
from_empty_schema: Write every table, as if the database were
|
|
263
|
+
empty.
|
|
264
|
+
|
|
265
|
+
Raises:
|
|
266
|
+
MigrationError: With no table definitions to compare with, or when
|
|
267
|
+
the database is not at the latest revisions.
|
|
268
|
+
"""
|
|
269
|
+
if self._metadata is None:
|
|
270
|
+
raise MigrationError(
|
|
271
|
+
f'The "{self._name}" connection has no table definitions to compare with.',
|
|
272
|
+
)
|
|
273
|
+
tables = tables_of(self._metadata) if from_empty_schema else None
|
|
274
|
+
|
|
275
|
+
def adjust(
|
|
276
|
+
context: MigrationContext, revision: object, directives: list[MigrationScript]
|
|
277
|
+
) -> None:
|
|
278
|
+
del context, revision
|
|
279
|
+
script = directives[0]
|
|
280
|
+
if tables is not None:
|
|
281
|
+
script.upgrade_ops, script.downgrade_ops = creating(tables)
|
|
282
|
+
if not allow_empty and script.upgrade_ops is not None and script.upgrade_ops.is_empty():
|
|
283
|
+
directives.clear()
|
|
284
|
+
|
|
285
|
+
def work(connection: Connection) -> AvailableMigration | None:
|
|
286
|
+
directory = self._environment.directory()
|
|
287
|
+
context = RevisionContext(
|
|
288
|
+
Config(),
|
|
289
|
+
directory,
|
|
290
|
+
_command_args(message, autogenerate=True),
|
|
291
|
+
process_revision_directives=adjust,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def steps(heads: tuple[str, ...], migration: MigrationContext) -> list[RevisionStep]:
|
|
295
|
+
if tables is None:
|
|
296
|
+
context.run_autogenerate(heads, migration)
|
|
297
|
+
else:
|
|
298
|
+
context.run_no_autogenerate(heads, migration)
|
|
299
|
+
return []
|
|
300
|
+
|
|
301
|
+
with refusing():
|
|
302
|
+
self._environment.run(
|
|
303
|
+
connection, directory, steps, revision_context=context, read_only=True
|
|
304
|
+
)
|
|
305
|
+
return self._written(context)
|
|
306
|
+
|
|
307
|
+
return await self._connected(work)
|
|
308
|
+
|
|
309
|
+
async def dump_schema(
|
|
310
|
+
self, message: str | None = None, *, table_filters: Sequence[str] = ()
|
|
311
|
+
) -> AvailableMigration:
|
|
312
|
+
"""Write a first revision creating every table the database has, and return it.
|
|
313
|
+
|
|
314
|
+
For a database that predates its revisions: the revision is written,
|
|
315
|
+
not applied — record it as applied with :meth:`rollup`.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
message: The revision's message.
|
|
319
|
+
table_filters: Regular expressions; only the tables whose name one
|
|
320
|
+
of them finds are written. Every table with none.
|
|
321
|
+
|
|
322
|
+
Raises:
|
|
323
|
+
MigrationError: When revisions exist already, or the database has
|
|
324
|
+
no table to write.
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
def work(connection: Connection) -> AvailableMigration:
|
|
328
|
+
directory = self._environment.directory()
|
|
329
|
+
if available(directory):
|
|
330
|
+
raise MigrationError(
|
|
331
|
+
f'Delete the previous revisions in "{self._config.directory}" '
|
|
332
|
+
"before dumping the schema.",
|
|
333
|
+
)
|
|
334
|
+
reflected = MetaData()
|
|
335
|
+
reflected.reflect(connection)
|
|
336
|
+
internal = {self._config.version_table, self._config.history_table}
|
|
337
|
+
tables = matching(
|
|
338
|
+
(each for each in reflected.sorted_tables if each.name not in internal),
|
|
339
|
+
table_filters,
|
|
340
|
+
)
|
|
341
|
+
if not tables:
|
|
342
|
+
raise MigrationError("The database schema does not contain any tables.")
|
|
343
|
+
|
|
344
|
+
def replace(
|
|
345
|
+
context: MigrationContext, revision: object, directives: list[MigrationScript]
|
|
346
|
+
) -> None:
|
|
347
|
+
del context, revision
|
|
348
|
+
directives[0].upgrade_ops, directives[0].downgrade_ops = creating(tables)
|
|
349
|
+
|
|
350
|
+
context = RevisionContext(
|
|
351
|
+
Config(),
|
|
352
|
+
directory,
|
|
353
|
+
_command_args(message or "dump the existing schema", autogenerate=False),
|
|
354
|
+
process_revision_directives=replace,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def steps(heads: tuple[str, ...], migration: MigrationContext) -> list[RevisionStep]:
|
|
358
|
+
context.run_no_autogenerate(heads, migration)
|
|
359
|
+
return []
|
|
360
|
+
|
|
361
|
+
with refusing():
|
|
362
|
+
self._environment.run(
|
|
363
|
+
connection, directory, steps, revision_context=context, read_only=True
|
|
364
|
+
)
|
|
365
|
+
written = self._written(context)
|
|
366
|
+
if written is None: # pragma: no cover — the operations above are never empty
|
|
367
|
+
raise MigrationError("No revision was written.")
|
|
368
|
+
return written
|
|
369
|
+
|
|
370
|
+
return await self._connected(work)
|
|
371
|
+
|
|
372
|
+
async def add_versions(self, versions: Sequence[str]) -> tuple[str, ...]:
|
|
373
|
+
"""Mark ``versions`` applied without running them, in order, and return them.
|
|
374
|
+
|
|
375
|
+
Each must not be applied and must follow only applied revisions, as
|
|
376
|
+
with :meth:`execute`.
|
|
377
|
+
|
|
378
|
+
Raises:
|
|
379
|
+
MigrationError: When a version is unknown, applied, or out of order.
|
|
380
|
+
"""
|
|
381
|
+
results = await self._connected(
|
|
382
|
+
lambda connection: self._apply(connection, versions, Direction.UP, marking=True)
|
|
383
|
+
)
|
|
384
|
+
return tuple(result.version for result in results)
|
|
385
|
+
|
|
386
|
+
async def add_all_versions(self) -> tuple[str, ...]:
|
|
387
|
+
"""Mark every revision not yet applied as applied, without running any; return them."""
|
|
388
|
+
|
|
389
|
+
def work(connection: Connection) -> tuple[ExecutionResult, ...]:
|
|
390
|
+
directory = self._environment.directory()
|
|
391
|
+
heads = self._environment.context(connection).get_current_heads()
|
|
392
|
+
done = applied(directory, heads)
|
|
393
|
+
pending = [each.revision for each in available(directory) if each.revision not in done]
|
|
394
|
+
return self._apply(connection, pending, Direction.UP, marking=True)
|
|
395
|
+
|
|
396
|
+
results = await self._connected(work)
|
|
397
|
+
return tuple(result.version for result in results)
|
|
398
|
+
|
|
399
|
+
async def delete_versions(self, versions: Sequence[str]) -> tuple[str, ...]:
|
|
400
|
+
"""Mark ``versions`` not applied without running them, in order, and return them.
|
|
401
|
+
|
|
402
|
+
A version with a file must be applied and followed by no applied
|
|
403
|
+
revision, as with :meth:`execute`. A version without one — a revision
|
|
404
|
+
applied whose file is gone — is simply forgotten.
|
|
405
|
+
|
|
406
|
+
Raises:
|
|
407
|
+
MigrationError: When a version is not applied, or out of order.
|
|
408
|
+
"""
|
|
409
|
+
return await self._connected(lambda connection: self._delete(connection, versions))
|
|
410
|
+
|
|
411
|
+
async def delete_all_versions(self) -> None:
|
|
412
|
+
"""Mark every revision not applied, without running any."""
|
|
413
|
+
|
|
414
|
+
def work(connection: Connection) -> None:
|
|
415
|
+
def steps(_: tuple[str, ...], migration: MigrationContext) -> list[RevisionStep]:
|
|
416
|
+
self._history.ensure(migration, connection)
|
|
417
|
+
self._history.clear(migration)
|
|
418
|
+
return []
|
|
419
|
+
|
|
420
|
+
self._environment.run(connection, self._environment.directory(), steps, purge=True)
|
|
421
|
+
|
|
422
|
+
await self._connected(work)
|
|
423
|
+
|
|
424
|
+
async def rollup(self) -> AvailableMigration:
|
|
425
|
+
"""Forget every applied revision, then mark the single remaining one applied.
|
|
426
|
+
|
|
427
|
+
For after squashing every revision into one: the database is left
|
|
428
|
+
exactly as it is, recorded as being at that one revision.
|
|
429
|
+
|
|
430
|
+
Raises:
|
|
431
|
+
MigrationError: When there is no revision, or more than one.
|
|
432
|
+
"""
|
|
433
|
+
|
|
434
|
+
def work(connection: Connection) -> AvailableMigration:
|
|
435
|
+
directory = self._environment.directory()
|
|
436
|
+
scripts = list(available(directory))
|
|
437
|
+
if not scripts:
|
|
438
|
+
raise MigrationError("No migrations found.")
|
|
439
|
+
if len(scripts) > 1:
|
|
440
|
+
raise MigrationError(
|
|
441
|
+
f"Too many migrations: {len(scripts)} found, a rollup needs exactly one.",
|
|
442
|
+
)
|
|
443
|
+
recorder = HistoryRecorder(self._history, marking=True)
|
|
444
|
+
|
|
445
|
+
def steps(_: tuple[str, ...], migration: MigrationContext) -> Iterator[RevisionStep]:
|
|
446
|
+
self._history.ensure(migration, connection)
|
|
447
|
+
self._history.clear(migration)
|
|
448
|
+
recorder.started()
|
|
449
|
+
yield mark_step(directory, scripts[0], Direction.UP)
|
|
450
|
+
|
|
451
|
+
self._environment.run(
|
|
452
|
+
connection, directory, steps, purge=True, on_version_apply=recorder
|
|
453
|
+
)
|
|
454
|
+
return describe(scripts[0])
|
|
455
|
+
|
|
456
|
+
return await self._connected(work)
|
|
457
|
+
|
|
458
|
+
async def _connected(self, work: Callable[[Connection], _T]) -> _T:
|
|
459
|
+
"""Run ``work`` on a connection of its own, committing what it did."""
|
|
460
|
+
async with self._engine.connect() as connection:
|
|
461
|
+
result = await connection.run_sync(work)
|
|
462
|
+
await connection.commit()
|
|
463
|
+
return result
|
|
464
|
+
|
|
465
|
+
def _status(self, connection: Connection) -> MigrationStatus:
|
|
466
|
+
directory = self._environment.directory()
|
|
467
|
+
heads = self._environment.context(connection).get_current_heads()
|
|
468
|
+
history = self._history.read(connection)
|
|
469
|
+
scripts = available(directory)
|
|
470
|
+
by_version = {each.revision: each for each in scripts}
|
|
471
|
+
# A head without a file hides what it followed; the history still names it.
|
|
472
|
+
done = applied(directory, heads) | {version for version in history if version in by_version}
|
|
473
|
+
migrations = tuple(describe(each) for each in scripts)
|
|
474
|
+
executed = [
|
|
475
|
+
ExecutedMigration(each.revision, *history.get(each.revision, (None, None)))
|
|
476
|
+
for each in scripts
|
|
477
|
+
if each.revision in done
|
|
478
|
+
]
|
|
479
|
+
missing = [head for head in heads if head not in by_version]
|
|
480
|
+
missing += [version for version in history if version not in by_version]
|
|
481
|
+
unavailable = tuple(
|
|
482
|
+
ExecutedMigration(version, *history.get(version, (None, None)))
|
|
483
|
+
for version in dict.fromkeys(missing)
|
|
484
|
+
)
|
|
485
|
+
previous = [
|
|
486
|
+
parent
|
|
487
|
+
for head in heads
|
|
488
|
+
if head in by_version
|
|
489
|
+
for parent in parents(directory, by_version[head])
|
|
490
|
+
]
|
|
491
|
+
new = tuple(each for each in migrations if each.version not in done)
|
|
492
|
+
runnable = tuple(
|
|
493
|
+
each.version
|
|
494
|
+
for each in new
|
|
495
|
+
if all(parent in done for parent in parents(directory, by_version[each.version]))
|
|
496
|
+
)
|
|
497
|
+
return MigrationStatus(
|
|
498
|
+
current=heads,
|
|
499
|
+
latest=tuple(each.version for each in migrations if each.is_head),
|
|
500
|
+
previous=tuple(dict.fromkeys(previous)),
|
|
501
|
+
next=runnable,
|
|
502
|
+
available=migrations,
|
|
503
|
+
executed=(*executed, *unavailable),
|
|
504
|
+
new=new,
|
|
505
|
+
executed_unavailable=unavailable,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
def _migrate(self, connection: Connection, target: str) -> tuple[ExecutionResult, ...]:
|
|
509
|
+
directory = self._environment.directory()
|
|
510
|
+
recorder = HistoryRecorder(self._history)
|
|
511
|
+
|
|
512
|
+
def steps(heads: tuple[str, ...], migration: MigrationContext) -> Iterator[RevisionStep]:
|
|
513
|
+
self._history.ensure(migration, connection)
|
|
514
|
+
_, planned = plan(directory, heads, target)
|
|
515
|
+
for step in planned:
|
|
516
|
+
recorder.started()
|
|
517
|
+
yield step
|
|
518
|
+
|
|
519
|
+
self._environment.run(connection, directory, steps, on_version_apply=recorder)
|
|
520
|
+
return recorder.results
|
|
521
|
+
|
|
522
|
+
def _apply(
|
|
523
|
+
self,
|
|
524
|
+
connection: Connection,
|
|
525
|
+
versions: Sequence[str],
|
|
526
|
+
direction: Direction,
|
|
527
|
+
*,
|
|
528
|
+
marking: bool,
|
|
529
|
+
) -> tuple[ExecutionResult, ...]:
|
|
530
|
+
directory = self._environment.directory()
|
|
531
|
+
recorder = HistoryRecorder(self._history, marking=marking)
|
|
532
|
+
|
|
533
|
+
def steps(heads: tuple[str, ...], migration: MigrationContext) -> Iterator[RevisionStep]:
|
|
534
|
+
scripts = self._checked(directory, heads, versions, direction)
|
|
535
|
+
self._history.ensure(migration, connection)
|
|
536
|
+
for each in scripts:
|
|
537
|
+
recorder.started()
|
|
538
|
+
yield (
|
|
539
|
+
mark_step(directory, each, direction)
|
|
540
|
+
if marking
|
|
541
|
+
else revision_step(directory, each, direction)
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
self._environment.run(connection, directory, steps, on_version_apply=recorder)
|
|
545
|
+
return recorder.results
|
|
546
|
+
|
|
547
|
+
def _delete(self, connection: Connection, versions: Sequence[str]) -> tuple[str, ...]:
|
|
548
|
+
directory = self._environment.directory()
|
|
549
|
+
heads = self._environment.context(connection).get_current_heads()
|
|
550
|
+
with_file = set(known(directory, versions))
|
|
551
|
+
recorded = self._history.read(connection)
|
|
552
|
+
orphans = [version for version in versions if version not in with_file]
|
|
553
|
+
for orphan in orphans:
|
|
554
|
+
if orphan not in heads and orphan not in recorded:
|
|
555
|
+
raise MigrationError(f'The version "{orphan}" is not applied.')
|
|
556
|
+
version_table = Table(
|
|
557
|
+
self._config.version_table,
|
|
558
|
+
MetaData(),
|
|
559
|
+
Column("version_num", String(_VERSION_LENGTH)),
|
|
560
|
+
schema=self._config.version_table_schema,
|
|
561
|
+
)
|
|
562
|
+
recorder = HistoryRecorder(self._history, marking=True)
|
|
563
|
+
|
|
564
|
+
def steps(current: tuple[str, ...], migration: MigrationContext) -> Iterator[RevisionStep]:
|
|
565
|
+
scripts = self._checked(
|
|
566
|
+
directory, current, [each for each in versions if each in with_file], Direction.DOWN
|
|
567
|
+
)
|
|
568
|
+
self._history.ensure(migration, connection)
|
|
569
|
+
for orphan in orphans:
|
|
570
|
+
migration.execute(
|
|
571
|
+
delete(version_table).where(version_table.c.version_num == orphan)
|
|
572
|
+
)
|
|
573
|
+
self._history.remove(migration, orphans)
|
|
574
|
+
for each in scripts:
|
|
575
|
+
yield mark_step(directory, each, Direction.DOWN)
|
|
576
|
+
|
|
577
|
+
self._environment.run(connection, directory, steps, on_version_apply=recorder)
|
|
578
|
+
done = {result.version for result in recorder.results}
|
|
579
|
+
return tuple(version for version in versions if version in done or version in orphans)
|
|
580
|
+
|
|
581
|
+
def _checked(
|
|
582
|
+
self,
|
|
583
|
+
directory: ScriptDirectory,
|
|
584
|
+
heads: tuple[str, ...],
|
|
585
|
+
versions: Sequence[str],
|
|
586
|
+
direction: Direction,
|
|
587
|
+
) -> list[Script]:
|
|
588
|
+
"""Return the revisions ``versions`` name, checking each may run ``direction`` in turn."""
|
|
589
|
+
done = applied(directory, heads)
|
|
590
|
+
found: list[Script] = []
|
|
591
|
+
for version in versions:
|
|
592
|
+
script = resolve(directory, version)
|
|
593
|
+
revision = script.revision
|
|
594
|
+
if direction is Direction.UP:
|
|
595
|
+
if revision in done:
|
|
596
|
+
raise MigrationError(f'The version "{revision}" is already applied.')
|
|
597
|
+
missing = [parent for parent in parents(directory, script) if parent not in done]
|
|
598
|
+
if missing:
|
|
599
|
+
raise MigrationError(
|
|
600
|
+
f'The version "{revision}" follows "{missing[0]}", which is not applied.',
|
|
601
|
+
)
|
|
602
|
+
done.add(revision)
|
|
603
|
+
else:
|
|
604
|
+
if revision not in done:
|
|
605
|
+
raise MigrationError(f'The version "{revision}" is not applied.')
|
|
606
|
+
followers = sorted(child for child in script.nextrev if child in done)
|
|
607
|
+
if followers:
|
|
608
|
+
raise MigrationError(
|
|
609
|
+
f'The version "{revision}" is followed by "{followers[0]}", '
|
|
610
|
+
"which is applied.",
|
|
611
|
+
)
|
|
612
|
+
done.discard(revision)
|
|
613
|
+
found.append(script)
|
|
614
|
+
return found
|
|
615
|
+
|
|
616
|
+
def _sql(
|
|
617
|
+
self,
|
|
618
|
+
connection: Connection,
|
|
619
|
+
directory: ScriptDirectory,
|
|
620
|
+
heads: tuple[str, ...],
|
|
621
|
+
steps: Sequence[RevisionStep],
|
|
622
|
+
) -> str:
|
|
623
|
+
"""Write the SQL of ``steps`` from ``heads``, as the runner would run it."""
|
|
624
|
+
output = StringIO()
|
|
625
|
+
# Nothing runs, so nothing is timed: the recorded duration is left empty.
|
|
626
|
+
recorder = HistoryRecorder(self._history, marking=True)
|
|
627
|
+
|
|
628
|
+
def planned(_: tuple[str, ...], migration: MigrationContext) -> Sequence[RevisionStep]:
|
|
629
|
+
self._history.ensure(migration, connection)
|
|
630
|
+
return steps
|
|
631
|
+
|
|
632
|
+
self._environment.run(
|
|
633
|
+
connection,
|
|
634
|
+
directory,
|
|
635
|
+
planned,
|
|
636
|
+
as_sql=True,
|
|
637
|
+
output=output,
|
|
638
|
+
starting_rev=heads,
|
|
639
|
+
on_version_apply=recorder,
|
|
640
|
+
)
|
|
641
|
+
return output.getvalue()
|
|
642
|
+
|
|
643
|
+
def _written(self, context: RevisionContext) -> AvailableMigration | None:
|
|
644
|
+
with refusing():
|
|
645
|
+
written = [each for each in context.generate_scripts() if each is not None]
|
|
646
|
+
if not written:
|
|
647
|
+
return None
|
|
648
|
+
migration = describe(written[0])
|
|
649
|
+
self.logger.info(
|
|
650
|
+
"Wrote the revision {version} to {path}",
|
|
651
|
+
{"connection": self._name, "version": migration.version, "path": migration.path},
|
|
652
|
+
)
|
|
653
|
+
return migration
|
|
654
|
+
|
|
655
|
+
def _log_results(self, results: Sequence[ExecutionResult]) -> None:
|
|
656
|
+
for result in results:
|
|
657
|
+
self.logger.info(
|
|
658
|
+
"Migrated {version} {direction} in {duration} s",
|
|
659
|
+
{
|
|
660
|
+
"connection": self._name,
|
|
661
|
+
"version": result.version,
|
|
662
|
+
"direction": result.direction.value,
|
|
663
|
+
"duration": round(result.duration, 3),
|
|
664
|
+
},
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _command_args(message: str | None, *, autogenerate: bool) -> dict[str, object]:
|
|
669
|
+
"""What a revision is written from: its message, and where it goes in the history."""
|
|
670
|
+
return {
|
|
671
|
+
"message": message,
|
|
672
|
+
"autogenerate": autogenerate,
|
|
673
|
+
"sql": False,
|
|
674
|
+
"head": "head",
|
|
675
|
+
"splice": False,
|
|
676
|
+
"branch_label": None,
|
|
677
|
+
"version_path": None,
|
|
678
|
+
"rev_id": None,
|
|
679
|
+
"depends_on": None,
|
|
680
|
+
}
|