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,86 @@
|
|
|
1
|
+
"""``orm:migrations:execute``: run chosen revisions alone, up or down."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from xtr_console import ConsoleStyle, ExitCode, as_command, escape
|
|
8
|
+
|
|
9
|
+
from xtr_orm.exception import MigrationError
|
|
10
|
+
from xtr_orm.migrations import Direction
|
|
11
|
+
|
|
12
|
+
from .connection_command import (
|
|
13
|
+
ConnectionCommand,
|
|
14
|
+
confirm_changes,
|
|
15
|
+
report_count,
|
|
16
|
+
report_plan,
|
|
17
|
+
report_results,
|
|
18
|
+
save_sql,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = ["MigrationsExecuteCommand"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@as_command("orm:migrations:execute")
|
|
25
|
+
@final
|
|
26
|
+
class MigrationsExecuteCommand(ConnectionCommand):
|
|
27
|
+
"""Execute one or more migration versions up or down manually."""
|
|
28
|
+
|
|
29
|
+
__slots__ = ()
|
|
30
|
+
|
|
31
|
+
async def __call__( # noqa: PLR0911 — each way out says why it stopped
|
|
32
|
+
self,
|
|
33
|
+
io: ConsoleStyle,
|
|
34
|
+
*versions: str,
|
|
35
|
+
up: bool = False,
|
|
36
|
+
down: bool = False,
|
|
37
|
+
dry_run: bool = False,
|
|
38
|
+
write_sql: str | None = None,
|
|
39
|
+
connection: str | None = None,
|
|
40
|
+
) -> int:
|
|
41
|
+
"""Run exactly the versions, in order, each up (the default) or down, and nothing else.
|
|
42
|
+
|
|
43
|
+
Going up, a revision must not be applied and must follow only applied
|
|
44
|
+
ones; going down, it must be applied and followed by none that is.
|
|
45
|
+
Use orm:migrations:version to record a revision without running it.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
io: Where the command writes.
|
|
49
|
+
versions: The revisions to run, by full or partial id.
|
|
50
|
+
up: Run their upgrades.
|
|
51
|
+
down: Run their downgrades.
|
|
52
|
+
dry_run: List what would run, and run nothing.
|
|
53
|
+
write_sql: Write the SQL that would run to this file — or to a
|
|
54
|
+
dated file in this directory — and run nothing.
|
|
55
|
+
connection: The connection to run them on; the default one when
|
|
56
|
+
left out.
|
|
57
|
+
"""
|
|
58
|
+
if up and down:
|
|
59
|
+
io.error("Give --up or --down, not both.")
|
|
60
|
+
return ExitCode.INVALID
|
|
61
|
+
if not versions:
|
|
62
|
+
io.error("Name at least one version to execute.")
|
|
63
|
+
return ExitCode.INVALID
|
|
64
|
+
direction = Direction.DOWN if down else Direction.UP
|
|
65
|
+
migrator = await self._migrator(io, connection)
|
|
66
|
+
if migrator is None:
|
|
67
|
+
return ExitCode.FAILURE
|
|
68
|
+
try:
|
|
69
|
+
if write_sql is not None:
|
|
70
|
+
save_sql(io, write_sql, await migrator.execute_sql(versions, direction))
|
|
71
|
+
return ExitCode.SUCCESS
|
|
72
|
+
planned = await migrator.plan_for_versions(versions, direction)
|
|
73
|
+
if dry_run:
|
|
74
|
+
report_plan(io, planned)
|
|
75
|
+
io.note("Dry run: nothing was executed.")
|
|
76
|
+
return ExitCode.SUCCESS
|
|
77
|
+
if not confirm_changes(io, migrator):
|
|
78
|
+
io.error("Migration cancelled!")
|
|
79
|
+
return ExitCode.FAILURE
|
|
80
|
+
results = await migrator.execute(versions, direction)
|
|
81
|
+
except MigrationError as error:
|
|
82
|
+
io.error(escape(error.reason))
|
|
83
|
+
return ExitCode.FAILURE
|
|
84
|
+
report_results(io, results)
|
|
85
|
+
report_count(io, results)
|
|
86
|
+
return ExitCode.SUCCESS
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""``orm:migrations:generate``: write an empty revision."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from xtr_console import ConsoleStyle, ExitCode, as_command, escape
|
|
8
|
+
|
|
9
|
+
from xtr_orm.exception import MigrationError
|
|
10
|
+
|
|
11
|
+
from .connection_command import ConnectionCommand, report_written
|
|
12
|
+
|
|
13
|
+
__all__ = ["MigrationsGenerateCommand"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@as_command("orm:migrations:generate")
|
|
17
|
+
@final
|
|
18
|
+
class MigrationsGenerateCommand(ConnectionCommand):
|
|
19
|
+
"""Generate a blank migration class."""
|
|
20
|
+
|
|
21
|
+
__slots__ = ()
|
|
22
|
+
|
|
23
|
+
async def __call__(
|
|
24
|
+
self,
|
|
25
|
+
io: ConsoleStyle,
|
|
26
|
+
*,
|
|
27
|
+
message: str | None = None,
|
|
28
|
+
connection: str | None = None,
|
|
29
|
+
) -> int:
|
|
30
|
+
"""Write an empty revision following the latest one, to fill in by hand.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
io: Where the command writes.
|
|
34
|
+
message: What the revision does; it names the file too.
|
|
35
|
+
connection: The connection whose revisions to add to; the default
|
|
36
|
+
one when left out.
|
|
37
|
+
"""
|
|
38
|
+
migrator = await self._migrator(io, connection)
|
|
39
|
+
if migrator is None:
|
|
40
|
+
return ExitCode.FAILURE
|
|
41
|
+
try:
|
|
42
|
+
written = migrator.generate(message)
|
|
43
|
+
except MigrationError as error:
|
|
44
|
+
io.error(escape(error.reason))
|
|
45
|
+
return ExitCode.FAILURE
|
|
46
|
+
report_written(io, "Generated new migration class to", written, connection)
|
|
47
|
+
return ExitCode.SUCCESS
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""``orm:migrations:latest``: the revisions no other follows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from xtr_console import ConsoleStyle, ExitCode, as_command, escape
|
|
8
|
+
|
|
9
|
+
from xtr_orm.exception import MigrationError
|
|
10
|
+
|
|
11
|
+
from .connection_command import ConnectionCommand, format_version
|
|
12
|
+
|
|
13
|
+
__all__ = ["MigrationsLatestCommand"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@as_command("orm:migrations:latest")
|
|
17
|
+
@final
|
|
18
|
+
class MigrationsLatestCommand(ConnectionCommand):
|
|
19
|
+
"""Outputs the latest version."""
|
|
20
|
+
|
|
21
|
+
__slots__ = ()
|
|
22
|
+
|
|
23
|
+
async def __call__(self, io: ConsoleStyle, *, connection: str | None = None) -> int:
|
|
24
|
+
"""Print the revisions no other follows, one per line — base when there are none.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
io: Where the command writes.
|
|
28
|
+
connection: The connection whose revisions to read; the default one
|
|
29
|
+
when left out.
|
|
30
|
+
"""
|
|
31
|
+
migrator = await self._migrator(io, connection)
|
|
32
|
+
if migrator is None:
|
|
33
|
+
return ExitCode.FAILURE
|
|
34
|
+
try:
|
|
35
|
+
status = await migrator.status()
|
|
36
|
+
except MigrationError as error:
|
|
37
|
+
io.error(escape(error.reason))
|
|
38
|
+
return ExitCode.FAILURE
|
|
39
|
+
heads = [each for each in status.available if each.is_head]
|
|
40
|
+
if not heads:
|
|
41
|
+
io.text("base")
|
|
42
|
+
for head in heads:
|
|
43
|
+
io.text(escape(format_version(head.version, head.description)))
|
|
44
|
+
return ExitCode.SUCCESS
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""``orm:migrations:list``: every revision, and whether it is applied."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from xtr_console import ConsoleStyle, ExitCode, as_command, escape
|
|
8
|
+
|
|
9
|
+
from xtr_orm.exception import MigrationError
|
|
10
|
+
from xtr_orm.migrations import ExecutedMigration
|
|
11
|
+
|
|
12
|
+
from .connection_command import ConnectionCommand
|
|
13
|
+
|
|
14
|
+
__all__ = ["MigrationsListCommand"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@as_command("orm:migrations:list")
|
|
18
|
+
@final
|
|
19
|
+
class MigrationsListCommand(ConnectionCommand):
|
|
20
|
+
"""Display a list of all available migrations and their status."""
|
|
21
|
+
|
|
22
|
+
__slots__ = ()
|
|
23
|
+
|
|
24
|
+
async def __call__(self, io: ConsoleStyle, *, connection: str | None = None) -> int:
|
|
25
|
+
"""List every revision, earliest first, then the applied ones whose file is gone.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
io: Where the command writes.
|
|
29
|
+
connection: The connection to read; the default one when left out.
|
|
30
|
+
"""
|
|
31
|
+
migrator = await self._migrator(io, connection)
|
|
32
|
+
if migrator is None:
|
|
33
|
+
return ExitCode.FAILURE
|
|
34
|
+
try:
|
|
35
|
+
status = await migrator.status()
|
|
36
|
+
except MigrationError as error:
|
|
37
|
+
io.error(escape(error.reason))
|
|
38
|
+
return ExitCode.FAILURE
|
|
39
|
+
executed = {each.version: each for each in status.executed}
|
|
40
|
+
rows = [
|
|
41
|
+
_row(each.version, executed.get(each.version), each.description, available=True)
|
|
42
|
+
for each in status.available
|
|
43
|
+
]
|
|
44
|
+
rows += [
|
|
45
|
+
_row(each.version, each, "", available=False) for each in status.executed_unavailable
|
|
46
|
+
]
|
|
47
|
+
io.table(
|
|
48
|
+
["Migration Versions", "Status", "Migrated At", "Execution Time", "Description"],
|
|
49
|
+
rows,
|
|
50
|
+
)
|
|
51
|
+
return ExitCode.SUCCESS
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _row(
|
|
55
|
+
version: str, executed: ExecutedMigration | None, description: str, *, available: bool
|
|
56
|
+
) -> list[str]:
|
|
57
|
+
if executed is None:
|
|
58
|
+
status = "not migrated"
|
|
59
|
+
else:
|
|
60
|
+
status = "migrated" if available else "migrated, not available"
|
|
61
|
+
migrated_at = (
|
|
62
|
+
f"{executed.executed_at:%Y-%m-%d %H:%M:%S}"
|
|
63
|
+
if executed is not None and executed.executed_at is not None
|
|
64
|
+
else ""
|
|
65
|
+
)
|
|
66
|
+
duration = (
|
|
67
|
+
f"{executed.execution_time:.3f}s"
|
|
68
|
+
if executed is not None and executed.execution_time is not None
|
|
69
|
+
else ""
|
|
70
|
+
)
|
|
71
|
+
return [escape(version), status, migrated_at, duration, escape(description)]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""``orm:migrations:migrate``: bring the database to a revision, up or down."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final, final
|
|
6
|
+
|
|
7
|
+
from xtr_console import ConsoleStyle, ExitCode, as_command, escape
|
|
8
|
+
|
|
9
|
+
from xtr_orm.exception import MigrationError
|
|
10
|
+
|
|
11
|
+
from .connection_command import (
|
|
12
|
+
ConnectionCommand,
|
|
13
|
+
confirm_changes,
|
|
14
|
+
report_count,
|
|
15
|
+
report_plan,
|
|
16
|
+
report_results,
|
|
17
|
+
save_sql,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = ["MigrationsMigrateCommand"]
|
|
21
|
+
|
|
22
|
+
_LATEST: Final = frozenset({"latest", "head", "heads"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@as_command("orm:migrations:migrate")
|
|
26
|
+
@final
|
|
27
|
+
class MigrationsMigrateCommand(ConnectionCommand):
|
|
28
|
+
"""Execute a migration to a specified version or the latest available version."""
|
|
29
|
+
|
|
30
|
+
__slots__ = ()
|
|
31
|
+
|
|
32
|
+
async def __call__( # noqa: C901, PLR0911 — each way out says why it stopped
|
|
33
|
+
self,
|
|
34
|
+
io: ConsoleStyle,
|
|
35
|
+
version: str = "latest",
|
|
36
|
+
*,
|
|
37
|
+
dry_run: bool = False,
|
|
38
|
+
write_sql: str | None = None,
|
|
39
|
+
allow_no_migration: bool = False,
|
|
40
|
+
connection: str | None = None,
|
|
41
|
+
) -> int:
|
|
42
|
+
"""Run every revision between where the database is and the version.
|
|
43
|
+
|
|
44
|
+
The version is a revision — a full or partial id — a relative one
|
|
45
|
+
(+1, -2), or one of latest, first (before every
|
|
46
|
+
revision), next, prev and current. A revision already
|
|
47
|
+
applied is migrated down to; one that is not, up to.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
io: Where the command writes.
|
|
51
|
+
version: Where to bring the database.
|
|
52
|
+
dry_run: List what would run, and run nothing.
|
|
53
|
+
write_sql: Write the SQL that would run to this file — or to a
|
|
54
|
+
dated file in this directory — and run nothing.
|
|
55
|
+
allow_no_migration: Succeed when there are no revisions at all.
|
|
56
|
+
connection: The connection to migrate; the default one when left
|
|
57
|
+
out.
|
|
58
|
+
"""
|
|
59
|
+
migrator = await self._migrator(io, connection)
|
|
60
|
+
if migrator is None:
|
|
61
|
+
return ExitCode.FAILURE
|
|
62
|
+
try:
|
|
63
|
+
status = await migrator.status()
|
|
64
|
+
if not status.available:
|
|
65
|
+
if allow_no_migration:
|
|
66
|
+
io.warning("No migrations to execute.")
|
|
67
|
+
return ExitCode.SUCCESS
|
|
68
|
+
io.error(
|
|
69
|
+
f'The version "{escape(version)}" couldn\'t be reached, '
|
|
70
|
+
"there are no registered migrations.",
|
|
71
|
+
)
|
|
72
|
+
return ExitCode.FAILURE
|
|
73
|
+
unregistered = len(status.executed_unavailable)
|
|
74
|
+
if unregistered:
|
|
75
|
+
io.warning(
|
|
76
|
+
f"You have {unregistered} previously executed migrations in the database "
|
|
77
|
+
"that are not registered migrations.",
|
|
78
|
+
)
|
|
79
|
+
if not io.confirm("Are you sure you wish to continue?", default=True):
|
|
80
|
+
io.error("Migration cancelled!")
|
|
81
|
+
return ExitCode.FAILURE
|
|
82
|
+
plans = await migrator.plan(version)
|
|
83
|
+
if not plans:
|
|
84
|
+
where = ", ".join(status.current) or "base"
|
|
85
|
+
message = (
|
|
86
|
+
f'Already at the latest version ("{escape(where)}").'
|
|
87
|
+
if version in _LATEST
|
|
88
|
+
else f'Already at "{escape(version)}": no migrations to execute.'
|
|
89
|
+
)
|
|
90
|
+
io.success(message)
|
|
91
|
+
return ExitCode.SUCCESS
|
|
92
|
+
if write_sql is not None:
|
|
93
|
+
save_sql(io, write_sql, await migrator.migrate_sql(version))
|
|
94
|
+
return ExitCode.SUCCESS
|
|
95
|
+
if dry_run:
|
|
96
|
+
report_plan(io, plans)
|
|
97
|
+
io.note("Dry run: nothing was executed.")
|
|
98
|
+
return ExitCode.SUCCESS
|
|
99
|
+
if not confirm_changes(io, migrator):
|
|
100
|
+
io.error("Migration cancelled!")
|
|
101
|
+
return ExitCode.FAILURE
|
|
102
|
+
results = await migrator.migrate(version)
|
|
103
|
+
except MigrationError as error:
|
|
104
|
+
io.error(escape(error.reason))
|
|
105
|
+
return ExitCode.FAILURE
|
|
106
|
+
report_results(io, results)
|
|
107
|
+
report_count(io, results)
|
|
108
|
+
return ExitCode.SUCCESS
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""``orm:migrations:rollup``: record the single remaining revision as all there is."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from xtr_console import ConsoleStyle, ExitCode, as_command, escape
|
|
8
|
+
|
|
9
|
+
from xtr_orm.exception import MigrationError
|
|
10
|
+
|
|
11
|
+
from .connection_command import ConnectionCommand
|
|
12
|
+
|
|
13
|
+
__all__ = ["MigrationsRollupCommand"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@as_command("orm:migrations:rollup")
|
|
17
|
+
@final
|
|
18
|
+
class MigrationsRollupCommand(ConnectionCommand):
|
|
19
|
+
"""Rollup migrations by deleting all tracked versions and insert the one version that exists."""
|
|
20
|
+
|
|
21
|
+
__slots__ = ()
|
|
22
|
+
|
|
23
|
+
async def __call__(self, io: ConsoleStyle, *, connection: str | None = None) -> int:
|
|
24
|
+
"""Forget every applied revision, then record the single remaining one as applied.
|
|
25
|
+
|
|
26
|
+
For after squashing every revision into one: the database is left as
|
|
27
|
+
it is, recorded as being at that revision.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
io: Where the command writes.
|
|
31
|
+
connection: The connection to record on; the default one when left
|
|
32
|
+
out.
|
|
33
|
+
"""
|
|
34
|
+
migrator = await self._migrator(io, connection)
|
|
35
|
+
if migrator is None:
|
|
36
|
+
return ExitCode.FAILURE
|
|
37
|
+
try:
|
|
38
|
+
rolled = await migrator.rollup()
|
|
39
|
+
except MigrationError as error:
|
|
40
|
+
io.error(escape(error.reason))
|
|
41
|
+
return ExitCode.FAILURE
|
|
42
|
+
io.success(f'Rolled up migrations to version "{escape(rolled.version)}".')
|
|
43
|
+
return ExitCode.SUCCESS
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""``orm:migrations:status``: where the database stands, in one table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from xtr_console import ConsoleStyle, ExitCode, as_command, escape
|
|
8
|
+
|
|
9
|
+
from xtr_orm.exception import MigrationError
|
|
10
|
+
|
|
11
|
+
from .connection_command import ConnectionCommand
|
|
12
|
+
|
|
13
|
+
__all__ = ["MigrationsStatusCommand"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@as_command("orm:migrations:status")
|
|
17
|
+
@final
|
|
18
|
+
class MigrationsStatusCommand(ConnectionCommand):
|
|
19
|
+
"""View the status of a set of migrations."""
|
|
20
|
+
|
|
21
|
+
__slots__ = ()
|
|
22
|
+
|
|
23
|
+
async def __call__(self, io: ConsoleStyle, *, connection: str | None = None) -> int:
|
|
24
|
+
"""Show where the revisions and their tables are, and where the database stands.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
io: Where the command writes.
|
|
28
|
+
connection: The connection to read; the default one when left out.
|
|
29
|
+
"""
|
|
30
|
+
migrator = await self._migrator(io, connection)
|
|
31
|
+
if migrator is None:
|
|
32
|
+
return ExitCode.FAILURE
|
|
33
|
+
try:
|
|
34
|
+
status = await migrator.status()
|
|
35
|
+
except MigrationError as error:
|
|
36
|
+
io.error(escape(error.reason))
|
|
37
|
+
return ExitCode.FAILURE
|
|
38
|
+
config = migrator.config
|
|
39
|
+
url = migrator.engine.url
|
|
40
|
+
schema = config.version_table_schema
|
|
41
|
+
rows = [
|
|
42
|
+
["Storage", "Version table", _qualified(schema, config.version_table)],
|
|
43
|
+
["", "History table", _qualified(schema, config.history_table)],
|
|
44
|
+
["Database", "Connection", migrator.name],
|
|
45
|
+
["", "Driver", f"{url.get_backend_name()}+{url.get_driver_name()}"],
|
|
46
|
+
["", "Name", url.database or ""],
|
|
47
|
+
["Versions", "Previous", _versions(status.previous) if status.current else "none"],
|
|
48
|
+
["", "Current", _versions(status.current)],
|
|
49
|
+
["", "Next", ", ".join(status.next) or "none"],
|
|
50
|
+
["", "Latest", _versions(status.latest)],
|
|
51
|
+
["Migrations", "Executed", str(len(status.executed))],
|
|
52
|
+
["", "Executed Unavailable", str(len(status.executed_unavailable))],
|
|
53
|
+
["", "Available", str(len(status.available))],
|
|
54
|
+
["", "New", str(len(status.new))],
|
|
55
|
+
["Migrations Directory", "", config.directory],
|
|
56
|
+
]
|
|
57
|
+
io.table(["Configuration", "", ""], [[escape(cell) for cell in row] for row in rows])
|
|
58
|
+
return ExitCode.SUCCESS
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _versions(versions: tuple[str, ...]) -> str:
|
|
62
|
+
return ", ".join(versions) or "base"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _qualified(schema: str | None, table: str) -> str:
|
|
66
|
+
return f"{schema}.{table}" if schema else table
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""``orm:migrations:up-to-date``: whether every revision is applied."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Final, final
|
|
6
|
+
|
|
7
|
+
from xtr_console import ConsoleStyle, ExitCode, Option, as_command, escape
|
|
8
|
+
|
|
9
|
+
from xtr_orm.exception import MigrationError
|
|
10
|
+
|
|
11
|
+
from .connection_command import ConnectionCommand
|
|
12
|
+
|
|
13
|
+
__all__ = ["MigrationsUpToDateCommand"]
|
|
14
|
+
|
|
15
|
+
_UNREGISTERED: Final = 2
|
|
16
|
+
"""The exit code for applied revisions without a file, with --fail-on-unregistered."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@as_command("orm:migrations:up-to-date")
|
|
20
|
+
@final
|
|
21
|
+
class MigrationsUpToDateCommand(ConnectionCommand):
|
|
22
|
+
"""Tells you if your schema is up-to-date."""
|
|
23
|
+
|
|
24
|
+
__slots__ = ()
|
|
25
|
+
|
|
26
|
+
async def __call__(
|
|
27
|
+
self,
|
|
28
|
+
io: ConsoleStyle,
|
|
29
|
+
*,
|
|
30
|
+
fail_on_unregistered: Annotated[bool, Option(alias="-u")] = False,
|
|
31
|
+
list_migrations: Annotated[bool, Option(alias="-l")] = False,
|
|
32
|
+
connection: str | None = None,
|
|
33
|
+
) -> int:
|
|
34
|
+
"""Tell whether every revision is applied, exiting 1 when some are not.
|
|
35
|
+
|
|
36
|
+
Applied revisions whose file is gone are reported too, and exit 2 with
|
|
37
|
+
--fail-on-unregistered.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
io: Where the command writes.
|
|
41
|
+
fail_on_unregistered: Fail when applied revisions have no file.
|
|
42
|
+
list_migrations: List the revisions not applied, and the applied
|
|
43
|
+
ones without a file.
|
|
44
|
+
connection: The connection to read; the default one when left out.
|
|
45
|
+
"""
|
|
46
|
+
migrator = await self._migrator(io, connection)
|
|
47
|
+
if migrator is None:
|
|
48
|
+
return ExitCode.FAILURE
|
|
49
|
+
try:
|
|
50
|
+
status = await migrator.status()
|
|
51
|
+
except MigrationError as error:
|
|
52
|
+
io.error(escape(error.reason))
|
|
53
|
+
return ExitCode.FAILURE
|
|
54
|
+
new = len(status.new)
|
|
55
|
+
unregistered = len(status.executed_unavailable)
|
|
56
|
+
if not new and not unregistered:
|
|
57
|
+
io.success("Up-to-date! No migrations to execute.")
|
|
58
|
+
return ExitCode.SUCCESS
|
|
59
|
+
code: int = ExitCode.SUCCESS
|
|
60
|
+
if new:
|
|
61
|
+
are = " is" if new == 1 else "s are"
|
|
62
|
+
io.error(f"Out-of-date! {new} migration{are} available to execute.")
|
|
63
|
+
code = ExitCode.FAILURE
|
|
64
|
+
if unregistered:
|
|
65
|
+
io.error(
|
|
66
|
+
f"You have {unregistered} previously executed "
|
|
67
|
+
f"migration{'' if unregistered == 1 else 's'} in the database that "
|
|
68
|
+
f"{'is not a' if unregistered == 1 else 'are not'} registered "
|
|
69
|
+
f"migration{'' if unregistered == 1 else 's'}.",
|
|
70
|
+
)
|
|
71
|
+
if fail_on_unregistered:
|
|
72
|
+
code = _UNREGISTERED
|
|
73
|
+
if list_migrations:
|
|
74
|
+
rows = [
|
|
75
|
+
[escape(each.version), "not migrated", escape(each.description)]
|
|
76
|
+
for each in status.new
|
|
77
|
+
]
|
|
78
|
+
rows += [
|
|
79
|
+
[escape(each.version), "migrated, not available", ""]
|
|
80
|
+
for each in status.executed_unavailable
|
|
81
|
+
]
|
|
82
|
+
io.table(["Migration Versions", "Status", "Description"], rows)
|
|
83
|
+
return code
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""``orm:migrations:version``: record revisions as applied or not, without running them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, final
|
|
6
|
+
|
|
7
|
+
from xtr_console import ConsoleStyle, ExitCode, Option, as_command, escape
|
|
8
|
+
|
|
9
|
+
from xtr_orm.exception import MigrationError
|
|
10
|
+
|
|
11
|
+
from .connection_command import ConnectionCommand
|
|
12
|
+
|
|
13
|
+
__all__ = ["MigrationsVersionCommand"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@as_command("orm:migrations:version")
|
|
17
|
+
@final
|
|
18
|
+
class MigrationsVersionCommand(ConnectionCommand):
|
|
19
|
+
"""Manually add and delete migration versions from the version table."""
|
|
20
|
+
|
|
21
|
+
__slots__ = ()
|
|
22
|
+
|
|
23
|
+
async def __call__( # noqa: PLR0911 — each way out says why it stopped
|
|
24
|
+
self,
|
|
25
|
+
io: ConsoleStyle,
|
|
26
|
+
version: str | None = None,
|
|
27
|
+
*,
|
|
28
|
+
add: bool = False,
|
|
29
|
+
delete: bool = False,
|
|
30
|
+
all_versions: Annotated[bool, Option(name="--all")] = False,
|
|
31
|
+
connection: str | None = None,
|
|
32
|
+
) -> int:
|
|
33
|
+
"""Record the version as applied (--add) or not (--delete), running nothing.
|
|
34
|
+
|
|
35
|
+
Adding, the revision must follow only applied ones; deleting, no
|
|
36
|
+
applied revision may follow it. An applied revision whose file is gone
|
|
37
|
+
can always be deleted. With --all, every revision is added, or every
|
|
38
|
+
record deleted.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
io: Where the command writes.
|
|
42
|
+
version: The revision to record, by full or partial id.
|
|
43
|
+
add: Record it as applied.
|
|
44
|
+
delete: Record it as not applied.
|
|
45
|
+
all_versions: Every revision, instead of one.
|
|
46
|
+
connection: The connection to record on; the default one when left
|
|
47
|
+
out.
|
|
48
|
+
"""
|
|
49
|
+
if add == delete:
|
|
50
|
+
io.error(
|
|
51
|
+
"You must specify whether you want to --add or --delete the specified version."
|
|
52
|
+
)
|
|
53
|
+
return ExitCode.INVALID
|
|
54
|
+
if (version is None) == (not all_versions):
|
|
55
|
+
io.error("You must specify the version or use the --all argument, not both.")
|
|
56
|
+
return ExitCode.INVALID
|
|
57
|
+
migrator = await self._migrator(io, connection)
|
|
58
|
+
if migrator is None:
|
|
59
|
+
return ExitCode.FAILURE
|
|
60
|
+
if not io.confirm(
|
|
61
|
+
"WARNING! You are about to add, delete or synchronize migration versions from the "
|
|
62
|
+
"version table that could result in data lost. Are you sure you wish to continue?",
|
|
63
|
+
default=True,
|
|
64
|
+
):
|
|
65
|
+
io.error("Migration cancelled!")
|
|
66
|
+
return ExitCode.FAILURE
|
|
67
|
+
try:
|
|
68
|
+
if add:
|
|
69
|
+
done = await (
|
|
70
|
+
migrator.add_versions([version])
|
|
71
|
+
if version is not None
|
|
72
|
+
else migrator.add_all_versions()
|
|
73
|
+
)
|
|
74
|
+
elif version is not None:
|
|
75
|
+
done = await migrator.delete_versions([version])
|
|
76
|
+
else:
|
|
77
|
+
await migrator.delete_all_versions()
|
|
78
|
+
io.success("Every version was deleted from the version table.")
|
|
79
|
+
return ExitCode.SUCCESS
|
|
80
|
+
except MigrationError as error:
|
|
81
|
+
io.error(escape(error.reason))
|
|
82
|
+
return ExitCode.FAILURE
|
|
83
|
+
verb = "Added" if add else "Deleted"
|
|
84
|
+
for each in done:
|
|
85
|
+
io.text(f" {verb} {escape(each)}")
|
|
86
|
+
plural = "" if len(done) == 1 else "s"
|
|
87
|
+
where = "to" if add else "from"
|
|
88
|
+
io.success(f"{verb} {len(done)} version{plural} {where} the version table.")
|
|
89
|
+
return ExitCode.SUCCESS
|