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,101 @@
|
|
|
1
|
+
"""Configuration for :class:`~xtr_orm.bundle.orm_bundle.OrmBundle`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from xtr_orm.connection_registry import DEFAULT_CONNECTION
|
|
10
|
+
from xtr_orm.exception import InvalidArgumentError
|
|
11
|
+
from xtr_orm.migrations import MigrationsConfig
|
|
12
|
+
|
|
13
|
+
from .connection_config import ConnectionConfig
|
|
14
|
+
|
|
15
|
+
__all__ = ["OrmConfig"]
|
|
16
|
+
|
|
17
|
+
_MIGRATIONS_DIRECTORY = "%kernel.project_dir%/migrations"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _one_connection() -> dict[str, ConnectionConfig]:
|
|
21
|
+
return {DEFAULT_CONNECTION: ConnectionConfig()}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class OrmConfig:
|
|
26
|
+
"""Which databases the application uses, each by name.
|
|
27
|
+
|
|
28
|
+
With no configuration there is one connection, ``default``, reading its
|
|
29
|
+
URL from ``DATABASE_URL`` when first used, with its revisions in
|
|
30
|
+
``migrations`` in the project directory.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
OrmConfig(
|
|
34
|
+
connections={
|
|
35
|
+
"default": ConnectionConfig(url=env("DATABASE_URL")),
|
|
36
|
+
"reports": ConnectionConfig(url=env("REPORTS_URL"), bind_key="reports"),
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
default_connection: The connection provided without a qualifier, and
|
|
43
|
+
used by every command not given ``--connection``.
|
|
44
|
+
connections: Every connection, by name — the default first once
|
|
45
|
+
built, each with its migrations settings decided.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
default_connection: str = DEFAULT_CONNECTION
|
|
49
|
+
connections: Mapping[str, ConnectionConfig] = field(default_factory=_one_connection)
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
"""Refuse no connection, an unnamed one, or a default that is not one of them.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
InvalidArgumentError: When the configuration cannot be used.
|
|
56
|
+
"""
|
|
57
|
+
if not self.connections:
|
|
58
|
+
raise InvalidArgumentError("Configure at least one connection.")
|
|
59
|
+
for name, connection in self.connections.items():
|
|
60
|
+
if not isinstance(name, str) or not name: # pyright: ignore[reportUnnecessaryIsInstance] -- configs are written by hand; the annotation is not enforced
|
|
61
|
+
raise InvalidArgumentError(f"A connection needs a non-empty name, got {name!r}.")
|
|
62
|
+
if not isinstance(connection, ConnectionConfig): # pyright: ignore[reportUnnecessaryIsInstance] -- as above
|
|
63
|
+
raise InvalidArgumentError(
|
|
64
|
+
f'The "{name}" connection must be a ConnectionConfig, got {connection!r}.',
|
|
65
|
+
)
|
|
66
|
+
if self.default_connection not in self.connections:
|
|
67
|
+
raise InvalidArgumentError(
|
|
68
|
+
f'The default connection "{self.default_connection}" is not configured.',
|
|
69
|
+
)
|
|
70
|
+
settled = _settled(self.default_connection, self.connections)
|
|
71
|
+
directories: dict[str, str] = {}
|
|
72
|
+
for name, connection in settled.items():
|
|
73
|
+
directory = connection.migrations.directory if connection.migrations else ""
|
|
74
|
+
if directory in directories:
|
|
75
|
+
raise InvalidArgumentError(
|
|
76
|
+
f'The "{name}" and "{directories[directory]}" connections keep their '
|
|
77
|
+
f'migrations in the same directory, "{directory}".',
|
|
78
|
+
)
|
|
79
|
+
directories[directory] = name
|
|
80
|
+
# Settled here, not when read, so the directory defaults take part in
|
|
81
|
+
# parameter resolution like any value the application wrote.
|
|
82
|
+
object.__setattr__(self, "connections", settled)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _settled(
|
|
86
|
+
default: str, connections: Mapping[str, ConnectionConfig]
|
|
87
|
+
) -> dict[str, ConnectionConfig]:
|
|
88
|
+
"""Return every connection, the default first, each with its migrations directory decided."""
|
|
89
|
+
ordered = [default, *(name for name in connections if name != default)]
|
|
90
|
+
settled: dict[str, ConnectionConfig] = {}
|
|
91
|
+
for name in ordered:
|
|
92
|
+
connection = connections[name]
|
|
93
|
+
if connection.migrations is None:
|
|
94
|
+
directory = (
|
|
95
|
+
_MIGRATIONS_DIRECTORY if name == default else f"{_MIGRATIONS_DIRECTORY}/{name}"
|
|
96
|
+
)
|
|
97
|
+
connection = dataclasses.replace(
|
|
98
|
+
connection, migrations=MigrationsConfig(directory=directory)
|
|
99
|
+
)
|
|
100
|
+
settled[name] = connection
|
|
101
|
+
return settled
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The ``orm:*`` console commands.
|
|
2
|
+
|
|
3
|
+
Importing this module declares them. With a container, the orm bundle loads
|
|
4
|
+
it when the console bundle is active, and every command acts on the
|
|
5
|
+
connections the bundle registered. Without one, give them connections with
|
|
6
|
+
:func:`use_connections`:
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from xtr_orm import ConnectionRegistry
|
|
10
|
+
from xtr_orm.command import use_connections
|
|
11
|
+
|
|
12
|
+
registry = ConnectionRegistry()
|
|
13
|
+
registry.register("default", engine=engine, migrator=migrator, database=database)
|
|
14
|
+
use_connections(registry)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Needs the ``console`` extra: ``xtr-orm[console]``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from .connections import use_connections
|
|
23
|
+
from .database_create_command import DatabaseCreateCommand
|
|
24
|
+
from .database_drop_command import DatabaseDropCommand
|
|
25
|
+
from .migrations_current_command import MigrationsCurrentCommand
|
|
26
|
+
from .migrations_diff_command import MigrationsDiffCommand
|
|
27
|
+
from .migrations_dump_schema_command import MigrationsDumpSchemaCommand
|
|
28
|
+
from .migrations_execute_command import MigrationsExecuteCommand
|
|
29
|
+
from .migrations_generate_command import MigrationsGenerateCommand
|
|
30
|
+
from .migrations_latest_command import MigrationsLatestCommand
|
|
31
|
+
from .migrations_list_command import MigrationsListCommand
|
|
32
|
+
from .migrations_migrate_command import MigrationsMigrateCommand
|
|
33
|
+
from .migrations_rollup_command import MigrationsRollupCommand
|
|
34
|
+
from .migrations_status_command import MigrationsStatusCommand
|
|
35
|
+
from .migrations_up_to_date_command import MigrationsUpToDateCommand
|
|
36
|
+
from .migrations_version_command import MigrationsVersionCommand
|
|
37
|
+
from .run_sql_command import RunSqlCommand
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"DatabaseCreateCommand",
|
|
41
|
+
"DatabaseDropCommand",
|
|
42
|
+
"MigrationsCurrentCommand",
|
|
43
|
+
"MigrationsDiffCommand",
|
|
44
|
+
"MigrationsDumpSchemaCommand",
|
|
45
|
+
"MigrationsExecuteCommand",
|
|
46
|
+
"MigrationsGenerateCommand",
|
|
47
|
+
"MigrationsLatestCommand",
|
|
48
|
+
"MigrationsListCommand",
|
|
49
|
+
"MigrationsMigrateCommand",
|
|
50
|
+
"MigrationsRollupCommand",
|
|
51
|
+
"MigrationsStatusCommand",
|
|
52
|
+
"MigrationsUpToDateCommand",
|
|
53
|
+
"MigrationsVersionCommand",
|
|
54
|
+
"RunSqlCommand",
|
|
55
|
+
"use_connections",
|
|
56
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""What every orm command shares: where the connections come from, and how it reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
8
|
+
|
|
9
|
+
from xtr_console import ConsoleStyle, escape
|
|
10
|
+
|
|
11
|
+
from xtr_orm.connection_registry import ConnectionRegistry
|
|
12
|
+
from xtr_orm.exception import UnknownConnectionError
|
|
13
|
+
|
|
14
|
+
from .connections import NO_CONNECTIONS, UNSET, resolve_connections
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
|
|
19
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
20
|
+
|
|
21
|
+
from xtr_orm.database import DatabaseManager
|
|
22
|
+
from xtr_orm.migrations import AvailableMigration, ExecutionResult, MigrationPlan, Migrator
|
|
23
|
+
|
|
24
|
+
__all__ = ["ConnectionCommand"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConnectionCommand:
|
|
28
|
+
"""An orm command, acting on the connections a container gives it, or ``use_connections()``.
|
|
29
|
+
|
|
30
|
+
A container builds each command with the connections its bundle
|
|
31
|
+
registered; without one, the console builds it bare, and it falls back to
|
|
32
|
+
the connections set with :func:`~xtr_orm.command.use_connections`.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
__slots__: ClassVar[tuple[str, ...]] = ("_connections",)
|
|
36
|
+
|
|
37
|
+
_connections: ConnectionRegistry
|
|
38
|
+
|
|
39
|
+
def __init__(self, connections: ConnectionRegistry = UNSET) -> None:
|
|
40
|
+
"""Act on ``connections``, or on those set with ``use_connections()`` when omitted."""
|
|
41
|
+
self._connections = connections
|
|
42
|
+
|
|
43
|
+
async def _migrator(self, io: ConsoleStyle, connection: str | None) -> Migrator | None:
|
|
44
|
+
"""Return the migrator of ``connection``, or say on ``io`` why there is none."""
|
|
45
|
+
registry = self._registry(io, connection)
|
|
46
|
+
return None if registry is None else await registry.migrator(connection)
|
|
47
|
+
|
|
48
|
+
async def _database(self, io: ConsoleStyle, connection: str | None) -> DatabaseManager | None:
|
|
49
|
+
"""Return the database manager of ``connection``, or say on ``io`` why there is none."""
|
|
50
|
+
registry = self._registry(io, connection)
|
|
51
|
+
return None if registry is None else await registry.database(connection)
|
|
52
|
+
|
|
53
|
+
async def _engine(self, io: ConsoleStyle, connection: str | None) -> AsyncEngine | None:
|
|
54
|
+
"""Return the engine of ``connection``, or say on ``io`` why there is none."""
|
|
55
|
+
registry = self._registry(io, connection)
|
|
56
|
+
return None if registry is None else await registry.engine(connection)
|
|
57
|
+
|
|
58
|
+
def _name(self, connection: str | None) -> str:
|
|
59
|
+
"""The name ``connection`` stands for: itself, or the default connection's."""
|
|
60
|
+
registry = resolve_connections(self._connections)
|
|
61
|
+
return connection or (registry.default if registry is not None else "default")
|
|
62
|
+
|
|
63
|
+
def _registry(self, io: ConsoleStyle, connection: str | None) -> ConnectionRegistry | None:
|
|
64
|
+
registry = resolve_connections(self._connections)
|
|
65
|
+
if registry is None:
|
|
66
|
+
io.error(NO_CONNECTIONS)
|
|
67
|
+
return None
|
|
68
|
+
if not registry.has(connection):
|
|
69
|
+
io.error(escape(str(UnknownConnectionError(self._name(connection), registry.names()))))
|
|
70
|
+
return None
|
|
71
|
+
return registry
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def confirm_changes(io: ConsoleStyle, migrator: Migrator) -> bool:
|
|
75
|
+
"""Ask before a migration changes the database; a run asking nothing goes ahead."""
|
|
76
|
+
return io.confirm(
|
|
77
|
+
"WARNING! You are about to execute a migration on the "
|
|
78
|
+
f'"{escape(migrator.name)}" connection that could result in schema changes and data '
|
|
79
|
+
"loss. Are you sure you wish to continue?",
|
|
80
|
+
default=True,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def report_plan(io: ConsoleStyle, plans: Sequence[MigrationPlan]) -> None:
|
|
85
|
+
"""List what a dry run would do."""
|
|
86
|
+
for plan in plans:
|
|
87
|
+
verb = "++ migrating" if plan.direction.value == "up" else "-- reverting"
|
|
88
|
+
io.text(f" {verb} {escape(format_version(plan.version, plan.description))}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def report_results(io: ConsoleStyle, results: Sequence[ExecutionResult]) -> None:
|
|
92
|
+
"""List what a migration did, each revision with the time it took."""
|
|
93
|
+
for result in results:
|
|
94
|
+
verb = "++ migrated" if result.direction.value == "up" else "-- reverted"
|
|
95
|
+
io.text(f" {verb} {escape(result.version)} ({result.duration:.3f}s)")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def save_sql(io: ConsoleStyle, target: str, sql: str) -> None:
|
|
99
|
+
"""Write ``sql`` to ``target`` — a file, or a directory to put a dated file in."""
|
|
100
|
+
path = Path(target)
|
|
101
|
+
if path.is_dir():
|
|
102
|
+
path /= f"migration_{datetime.now().astimezone():%Y%m%d%H%M%S}.sql"
|
|
103
|
+
_ = path.write_text(sql, encoding="utf-8")
|
|
104
|
+
io.success(f'Wrote the migration SQL to "{escape(str(path))}".')
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def report_written(
|
|
108
|
+
io: ConsoleStyle, what: str, written: AvailableMigration, connection: str | None
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Say where a new revision went, and how to run it alone and revert it."""
|
|
111
|
+
option = f" --connection {escape(connection)}" if connection else ""
|
|
112
|
+
version = escape(written.version)
|
|
113
|
+
io.success(f'{what} "{escape(written.path)}"')
|
|
114
|
+
io.text(
|
|
115
|
+
"To run just this migration for testing purposes, you can use "
|
|
116
|
+
f"orm:migrations:execute --up {version}{option}",
|
|
117
|
+
)
|
|
118
|
+
io.text(f"To revert the migration you can use orm:migrations:execute --down {version}{option}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def report_count(io: ConsoleStyle, results: Sequence[ExecutionResult]) -> None:
|
|
122
|
+
"""Close a run with how many revisions ran, and how long they took together."""
|
|
123
|
+
total = sum(result.duration for result in results)
|
|
124
|
+
plural = "" if len(results) == 1 else "s"
|
|
125
|
+
io.success(f"{len(results)} migration{plural} executed in {total:.3f}s.")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def format_version(version: str, description: str | None) -> str:
|
|
129
|
+
"""Render a version with its description, as the listing commands show it."""
|
|
130
|
+
return f"{version}{_described(description)}"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _described(description: str | None) -> str:
|
|
134
|
+
return f" - {description}" if description else ""
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Where the orm commands find the connections they act on."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import ClassVar, Final, final
|
|
6
|
+
|
|
7
|
+
from xtr_orm.connection_registry import ConnectionRegistry
|
|
8
|
+
|
|
9
|
+
__all__ = ["NO_CONNECTIONS", "UNSET", "resolve_connections", "use_connections"]
|
|
10
|
+
|
|
11
|
+
UNSET: Final = ConnectionRegistry()
|
|
12
|
+
"""The default of every command's ``connections`` parameter, meaning "no container gave one".
|
|
13
|
+
|
|
14
|
+
Typed as what a container fills the parameter with, so the engine still
|
|
15
|
+
matches it; ``ConnectionRegistry | None`` is a different type and never would be.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
NO_CONNECTIONS: Final = (
|
|
19
|
+
"No database connections: wire a container, or call xtr_orm.command.use_connections()."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@final
|
|
24
|
+
class _Process:
|
|
25
|
+
"""The connections commands use where no container supplies them."""
|
|
26
|
+
|
|
27
|
+
connections: ClassVar[ConnectionRegistry | None] = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def use_connections(connections: ConnectionRegistry | None) -> None:
|
|
31
|
+
"""Have every orm command act on ``connections`` wherever no container supplies them."""
|
|
32
|
+
_Process.connections = connections
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve_connections(given: ConnectionRegistry) -> ConnectionRegistry | None:
|
|
36
|
+
"""Return the connections a command was given, or those set with :func:`use_connections`."""
|
|
37
|
+
return given if given is not UNSET else _Process.connections
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""``orm:database:create``: create a connection's database on its server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
8
|
+
from xtr_console import ConsoleStyle, ExitCode, as_command, escape
|
|
9
|
+
|
|
10
|
+
from xtr_orm.exception import UnsupportedDatabaseError
|
|
11
|
+
|
|
12
|
+
from .connection_command import ConnectionCommand
|
|
13
|
+
|
|
14
|
+
__all__ = ["DatabaseCreateCommand"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@as_command("orm:database:create")
|
|
18
|
+
@final
|
|
19
|
+
class DatabaseCreateCommand(ConnectionCommand):
|
|
20
|
+
"""Creates the configured database."""
|
|
21
|
+
|
|
22
|
+
__slots__ = ()
|
|
23
|
+
|
|
24
|
+
async def __call__(
|
|
25
|
+
self,
|
|
26
|
+
io: ConsoleStyle,
|
|
27
|
+
*,
|
|
28
|
+
if_not_exists: bool = False,
|
|
29
|
+
connection: str | None = None,
|
|
30
|
+
) -> int:
|
|
31
|
+
"""Create the configured database.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
io: Where the command writes.
|
|
35
|
+
if_not_exists: Do nothing, successfully, when the database exists.
|
|
36
|
+
connection: The connection whose database to create; the default
|
|
37
|
+
one when left out.
|
|
38
|
+
"""
|
|
39
|
+
database = await self._database(io, connection)
|
|
40
|
+
if database is None:
|
|
41
|
+
return ExitCode.FAILURE
|
|
42
|
+
name = escape(database.database)
|
|
43
|
+
where = f'for connection named "{escape(self._name(connection))}"'
|
|
44
|
+
try:
|
|
45
|
+
created = await database.create(if_not_exists=if_not_exists)
|
|
46
|
+
except (SQLAlchemyError, OSError, UnsupportedDatabaseError) as error:
|
|
47
|
+
io.error(f'Could not create database "{name}" {where}: {escape(str(error))}')
|
|
48
|
+
return ExitCode.FAILURE
|
|
49
|
+
if not created:
|
|
50
|
+
io.text(f'Database "{name}" {where} already exists. Skipped.')
|
|
51
|
+
return ExitCode.SUCCESS
|
|
52
|
+
io.success(f'Created database "{name}" {where}.')
|
|
53
|
+
return ExitCode.SUCCESS
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""``orm:database:drop``: drop a connection's database from its server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, final
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
8
|
+
from xtr_console import ConsoleStyle, ExitCode, Option, as_command, escape
|
|
9
|
+
|
|
10
|
+
from xtr_orm.exception import UnsupportedDatabaseError
|
|
11
|
+
|
|
12
|
+
from .connection_command import ConnectionCommand
|
|
13
|
+
|
|
14
|
+
__all__ = ["DatabaseDropCommand"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@as_command("orm:database:drop")
|
|
18
|
+
@final
|
|
19
|
+
class DatabaseDropCommand(ConnectionCommand):
|
|
20
|
+
"""Drops the configured database."""
|
|
21
|
+
|
|
22
|
+
__slots__ = ()
|
|
23
|
+
|
|
24
|
+
async def __call__(
|
|
25
|
+
self,
|
|
26
|
+
io: ConsoleStyle,
|
|
27
|
+
*,
|
|
28
|
+
if_exists: bool = False,
|
|
29
|
+
force: Annotated[bool, Option(alias="-f")] = False,
|
|
30
|
+
connection: str | None = None,
|
|
31
|
+
) -> int:
|
|
32
|
+
"""Drop the configured database, and every table and row in it.
|
|
33
|
+
|
|
34
|
+
Refused without --force, which is the confirmation that all of it may
|
|
35
|
+
be lost.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
io: Where the command writes.
|
|
39
|
+
if_exists: Do nothing, successfully, when the database does not
|
|
40
|
+
exist.
|
|
41
|
+
force: Really drop it.
|
|
42
|
+
connection: The connection whose database to drop; the default one
|
|
43
|
+
when left out.
|
|
44
|
+
"""
|
|
45
|
+
database = await self._database(io, connection)
|
|
46
|
+
if database is None:
|
|
47
|
+
return ExitCode.FAILURE
|
|
48
|
+
name = escape(database.database)
|
|
49
|
+
where = f'for connection named "{escape(self._name(connection))}"'
|
|
50
|
+
if not force:
|
|
51
|
+
io.error(
|
|
52
|
+
f'This would drop the database "{name}" {where}, and all its data would be '
|
|
53
|
+
"lost. Run the command again with --force to drop it.",
|
|
54
|
+
)
|
|
55
|
+
return ExitCode.INVALID
|
|
56
|
+
try:
|
|
57
|
+
dropped = await database.drop(if_exists=if_exists)
|
|
58
|
+
except (SQLAlchemyError, OSError, UnsupportedDatabaseError) as error:
|
|
59
|
+
io.error(f'Could not drop database "{name}" {where}: {escape(str(error))}')
|
|
60
|
+
return ExitCode.FAILURE
|
|
61
|
+
if not dropped:
|
|
62
|
+
io.text(f'Database "{name}" {where} does not exist. Skipped.')
|
|
63
|
+
return ExitCode.SUCCESS
|
|
64
|
+
io.success(f'Dropped database "{name}" {where}.')
|
|
65
|
+
return ExitCode.SUCCESS
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""``orm:migrations:current``: the revisions the database is at."""
|
|
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__ = ["MigrationsCurrentCommand"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@as_command("orm:migrations:current")
|
|
17
|
+
@final
|
|
18
|
+
class MigrationsCurrentCommand(ConnectionCommand):
|
|
19
|
+
"""Outputs the current version."""
|
|
20
|
+
|
|
21
|
+
__slots__ = ()
|
|
22
|
+
|
|
23
|
+
async def __call__(self, io: ConsoleStyle, *, connection: str | None = None) -> int:
|
|
24
|
+
"""Print the revisions the database is at, one per line — base for none.
|
|
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
|
+
descriptions = {each.version: each.description for each in status.available}
|
|
39
|
+
if not status.current:
|
|
40
|
+
io.text("base")
|
|
41
|
+
for version in status.current:
|
|
42
|
+
description = descriptions.get(version, "(not available)")
|
|
43
|
+
io.text(escape(format_version(version, description)))
|
|
44
|
+
return ExitCode.SUCCESS
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""``orm:migrations:diff``: write a revision from what the table definitions add."""
|
|
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__ = ["MigrationsDiffCommand"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@as_command("orm:migrations:diff")
|
|
17
|
+
@final
|
|
18
|
+
class MigrationsDiffCommand(ConnectionCommand):
|
|
19
|
+
"""Generate a migration by comparing your current database to your mapping information."""
|
|
20
|
+
|
|
21
|
+
__slots__ = ()
|
|
22
|
+
|
|
23
|
+
async def __call__(
|
|
24
|
+
self,
|
|
25
|
+
io: ConsoleStyle,
|
|
26
|
+
*,
|
|
27
|
+
message: str | None = None,
|
|
28
|
+
allow_empty_diff: bool = False,
|
|
29
|
+
from_empty_schema: bool = False,
|
|
30
|
+
connection: str | None = None,
|
|
31
|
+
) -> int:
|
|
32
|
+
"""Compare the database with the table definitions, and write what differs as a revision.
|
|
33
|
+
|
|
34
|
+
The database must be at the latest revisions, so the comparison sees
|
|
35
|
+
what they leave out rather than what is not applied yet.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
io: Where the command writes.
|
|
39
|
+
message: What the revision does; it names the file too.
|
|
40
|
+
allow_empty_diff: Write a revision even when nothing differs.
|
|
41
|
+
from_empty_schema: Write every table, as if the database were
|
|
42
|
+
empty.
|
|
43
|
+
connection: The connection to compare; the default one when left
|
|
44
|
+
out.
|
|
45
|
+
"""
|
|
46
|
+
migrator = await self._migrator(io, connection)
|
|
47
|
+
if migrator is None:
|
|
48
|
+
return ExitCode.FAILURE
|
|
49
|
+
try:
|
|
50
|
+
written = await migrator.diff(
|
|
51
|
+
message, allow_empty=allow_empty_diff, from_empty_schema=from_empty_schema
|
|
52
|
+
)
|
|
53
|
+
except MigrationError as error:
|
|
54
|
+
io.error(escape(error.reason))
|
|
55
|
+
return ExitCode.FAILURE
|
|
56
|
+
if written is None:
|
|
57
|
+
io.error("No changes detected in your mapping information.")
|
|
58
|
+
return ExitCode.FAILURE
|
|
59
|
+
report_written(io, "Generated new migration class to", written, connection)
|
|
60
|
+
return ExitCode.SUCCESS
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""``orm:migrations:dump-schema``: write a first revision from an existing database."""
|
|
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__ = ["MigrationsDumpSchemaCommand"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@as_command("orm:migrations:dump-schema")
|
|
17
|
+
@final
|
|
18
|
+
class MigrationsDumpSchemaCommand(ConnectionCommand):
|
|
19
|
+
"""Dump the schema for your database to a migration."""
|
|
20
|
+
|
|
21
|
+
__slots__ = ()
|
|
22
|
+
|
|
23
|
+
async def __call__(
|
|
24
|
+
self,
|
|
25
|
+
io: ConsoleStyle,
|
|
26
|
+
*,
|
|
27
|
+
message: str | None = None,
|
|
28
|
+
filter_tables: list[str] | None = None,
|
|
29
|
+
connection: str | None = None,
|
|
30
|
+
) -> int:
|
|
31
|
+
"""Write a first revision creating every table the database has.
|
|
32
|
+
|
|
33
|
+
For a database that predates its revisions, so there must be none
|
|
34
|
+
yet. The revision is written, not applied: record it as applied with
|
|
35
|
+
orm:migrations:rollup.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
io: Where the command writes.
|
|
39
|
+
message: What the revision does; it names the file too.
|
|
40
|
+
filter_tables: A regular expression; only the tables whose name it
|
|
41
|
+
finds are dumped. Repeat for several.
|
|
42
|
+
connection: The connection whose database to dump; the default one
|
|
43
|
+
when left out.
|
|
44
|
+
"""
|
|
45
|
+
migrator = await self._migrator(io, connection)
|
|
46
|
+
if migrator is None:
|
|
47
|
+
return ExitCode.FAILURE
|
|
48
|
+
try:
|
|
49
|
+
written = await migrator.dump_schema(message, table_filters=filter_tables or ())
|
|
50
|
+
except MigrationError as error:
|
|
51
|
+
io.error(escape(error.reason))
|
|
52
|
+
return ExitCode.FAILURE
|
|
53
|
+
report_written(io, "Dumped your schema to a new migration class at", written, connection)
|
|
54
|
+
option = f" --connection {escape(connection)}" if connection else ""
|
|
55
|
+
io.text(f"To use this as a rollup migration you can use orm:migrations:rollup{option}")
|
|
56
|
+
return ExitCode.SUCCESS
|