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,77 @@
|
|
|
1
|
+
"""``orm:run-sql``: run a statement and show what it returned."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, cast, final
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import Row, text
|
|
8
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
9
|
+
from xtr_console import ConsoleStyle, ExitCode, as_command, escape
|
|
10
|
+
|
|
11
|
+
from .connection_command import ConnectionCommand
|
|
12
|
+
|
|
13
|
+
__all__ = ["RunSqlCommand"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@as_command("orm:run-sql")
|
|
17
|
+
@final
|
|
18
|
+
class RunSqlCommand(ConnectionCommand):
|
|
19
|
+
"""Executes arbitrary SQL directly from the command line."""
|
|
20
|
+
|
|
21
|
+
__slots__ = ()
|
|
22
|
+
|
|
23
|
+
async def __call__(
|
|
24
|
+
self,
|
|
25
|
+
io: ConsoleStyle,
|
|
26
|
+
sql: str,
|
|
27
|
+
*,
|
|
28
|
+
force_fetch: bool = False,
|
|
29
|
+
connection: str | None = None,
|
|
30
|
+
) -> int:
|
|
31
|
+
"""Run the statement, committed, and show its rows or how many it changed.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
io: Where the command writes.
|
|
35
|
+
sql: The statement to run.
|
|
36
|
+
force_fetch: Show the result as rows even when the statement
|
|
37
|
+
does not look like it returns any.
|
|
38
|
+
connection: The connection to run it on; the default one when
|
|
39
|
+
left out.
|
|
40
|
+
"""
|
|
41
|
+
engine = await self._engine(io, connection)
|
|
42
|
+
if engine is None:
|
|
43
|
+
return ExitCode.FAILURE
|
|
44
|
+
columns: list[str] = []
|
|
45
|
+
rows: list[list[str]] = []
|
|
46
|
+
affected = 0
|
|
47
|
+
try:
|
|
48
|
+
async with engine.begin() as opened:
|
|
49
|
+
result = await opened.execute(text(sql))
|
|
50
|
+
if result.returns_rows:
|
|
51
|
+
columns = [str(column) for column in result.keys()] # noqa: SIM118 — a result's keys are its columns, not a dict's
|
|
52
|
+
rows = [[_cell(value) for value in _values(row)] for row in result.all()]
|
|
53
|
+
else:
|
|
54
|
+
affected = result.rowcount
|
|
55
|
+
except SQLAlchemyError as error:
|
|
56
|
+
io.error(escape(str(error)))
|
|
57
|
+
return ExitCode.FAILURE
|
|
58
|
+
if not columns and not force_fetch:
|
|
59
|
+
# A driver that cannot tell — after a schema change, say — reports -1.
|
|
60
|
+
if affected < 0:
|
|
61
|
+
io.success("The statement was executed.")
|
|
62
|
+
else:
|
|
63
|
+
io.success(f"{affected} row{'' if affected == 1 else 's'} affected.")
|
|
64
|
+
return ExitCode.SUCCESS
|
|
65
|
+
if not rows:
|
|
66
|
+
io.success("The query yielded an empty result set.")
|
|
67
|
+
return ExitCode.SUCCESS
|
|
68
|
+
io.table([escape(column) for column in columns], rows)
|
|
69
|
+
return ExitCode.SUCCESS
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _values(row: Row[Any]) -> tuple[object, ...]: # pyright: ignore[reportExplicitAny] -- a row of a text statement has no known column types
|
|
73
|
+
return tuple(cast("tuple[object, ...]", row))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _cell(value: object) -> str:
|
|
77
|
+
return "NULL" if value is None else escape(str(value))
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Every configured connection by name, each piece built only when first asked for."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING, Final, TypeVar, cast, final
|
|
7
|
+
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
|
9
|
+
|
|
10
|
+
from xtr_orm.database import DatabaseManager
|
|
11
|
+
from xtr_orm.exception import (
|
|
12
|
+
InvalidArgumentError,
|
|
13
|
+
SessionUnavailableError,
|
|
14
|
+
UnknownConnectionError,
|
|
15
|
+
)
|
|
16
|
+
from xtr_orm.migrations import Migrator
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Awaitable, Callable
|
|
20
|
+
|
|
21
|
+
__all__ = ["DEFAULT_CONNECTION", "ConnectionRegistry"]
|
|
22
|
+
|
|
23
|
+
DEFAULT_CONNECTION: Final = "default"
|
|
24
|
+
"""The name of the connection used when none is named."""
|
|
25
|
+
|
|
26
|
+
_T = TypeVar("_T")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class _Connection:
|
|
31
|
+
engine: Callable[[], Awaitable[AsyncEngine]]
|
|
32
|
+
migrator: Callable[[], Awaitable[Migrator]]
|
|
33
|
+
database: Callable[[], Awaitable[DatabaseManager]]
|
|
34
|
+
session: Callable[[], Awaitable[AsyncSession]] | None
|
|
35
|
+
close: Callable[[], Awaitable[None]] | None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@final
|
|
39
|
+
class ConnectionRegistry:
|
|
40
|
+
"""Every connection by name: its engine, its migrator and its database manager.
|
|
41
|
+
|
|
42
|
+
Each is given as the object itself, or as an async function building it —
|
|
43
|
+
called only when that piece is first asked for, so a command touching one
|
|
44
|
+
connection opens nothing of another's.
|
|
45
|
+
|
|
46
|
+
A connection may also say how to reach the session of the current unit
|
|
47
|
+
of work — the one every handler of a message shares — and how to close
|
|
48
|
+
it, releasing every connection it holds; the message bus middleware use
|
|
49
|
+
both.
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
registry = ConnectionRegistry()
|
|
53
|
+
registry.register(
|
|
54
|
+
"default",
|
|
55
|
+
engine=engine,
|
|
56
|
+
migrator=Migrator(engine, MigrationsConfig(directory="migrations")),
|
|
57
|
+
database=DatabaseManager(url),
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
__slots__ = ("_connections", "_default")
|
|
63
|
+
|
|
64
|
+
_connections: dict[str, _Connection]
|
|
65
|
+
_default: str
|
|
66
|
+
|
|
67
|
+
def __init__(self, default: str = DEFAULT_CONNECTION) -> None:
|
|
68
|
+
"""Hold connections, answering for ``default`` when none is named."""
|
|
69
|
+
self._connections = {}
|
|
70
|
+
self._default = default
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def default(self) -> str:
|
|
74
|
+
"""The name of the connection used when none is named."""
|
|
75
|
+
return self._default
|
|
76
|
+
|
|
77
|
+
def register( # noqa: PLR0913 — one keyword per piece of a connection
|
|
78
|
+
self,
|
|
79
|
+
name: str,
|
|
80
|
+
*,
|
|
81
|
+
engine: AsyncEngine | Callable[[], Awaitable[AsyncEngine]],
|
|
82
|
+
migrator: Migrator | Callable[[], Awaitable[Migrator]],
|
|
83
|
+
database: DatabaseManager | Callable[[], Awaitable[DatabaseManager]],
|
|
84
|
+
session: Callable[[], Awaitable[AsyncSession]] | None = None,
|
|
85
|
+
close: Callable[[], Awaitable[None]] | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Add the connection ``name``, or replace it.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
name: What the connection is known by.
|
|
91
|
+
engine: Its engine, or what builds it.
|
|
92
|
+
migrator: Its migrator, or what builds it.
|
|
93
|
+
database: Its database manager, or what builds it.
|
|
94
|
+
session: What returns the session of the unit of work under way;
|
|
95
|
+
without it, :meth:`session` is refused.
|
|
96
|
+
close: What closes the connection; its engine is disposed when
|
|
97
|
+
left out.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
InvalidArgumentError: When ``name`` is empty.
|
|
101
|
+
"""
|
|
102
|
+
if not name:
|
|
103
|
+
raise InvalidArgumentError("A connection needs a non-empty name.")
|
|
104
|
+
self._connections[name] = _Connection(
|
|
105
|
+
engine=_provider(engine, AsyncEngine),
|
|
106
|
+
migrator=_provider(migrator, Migrator),
|
|
107
|
+
database=_provider(database, DatabaseManager),
|
|
108
|
+
session=session,
|
|
109
|
+
close=close,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def names(self) -> tuple[str, ...]:
|
|
113
|
+
"""Every connection's name, in the order registered."""
|
|
114
|
+
return tuple(self._connections)
|
|
115
|
+
|
|
116
|
+
def has(self, name: str | None = None) -> bool:
|
|
117
|
+
"""Whether the connection ``name`` — the default one when ``None`` — exists."""
|
|
118
|
+
return (name or self._default) in self._connections
|
|
119
|
+
|
|
120
|
+
async def engine(self, name: str | None = None) -> AsyncEngine:
|
|
121
|
+
"""Return the engine of the connection ``name``, the default one when ``None``.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
UnknownConnectionError: When no connection has that name.
|
|
125
|
+
"""
|
|
126
|
+
return await self._get(name).engine()
|
|
127
|
+
|
|
128
|
+
async def migrator(self, name: str | None = None) -> Migrator:
|
|
129
|
+
"""Return the migrator of the connection ``name``, the default one when ``None``.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
UnknownConnectionError: When no connection has that name.
|
|
133
|
+
"""
|
|
134
|
+
return await self._get(name).migrator()
|
|
135
|
+
|
|
136
|
+
async def database(self, name: str | None = None) -> DatabaseManager:
|
|
137
|
+
"""Return the database manager of the connection ``name``, the default one when ``None``.
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
UnknownConnectionError: When no connection has that name.
|
|
141
|
+
"""
|
|
142
|
+
return await self._get(name).database()
|
|
143
|
+
|
|
144
|
+
async def session(self, name: str | None = None) -> AsyncSession:
|
|
145
|
+
"""Return the session of the current unit of work on the connection ``name``.
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
UnknownConnectionError: When no connection has that name.
|
|
149
|
+
SessionUnavailableError: When the connection was registered
|
|
150
|
+
without a way to reach one.
|
|
151
|
+
"""
|
|
152
|
+
wanted = name or self._default
|
|
153
|
+
provide = self._get(name).session
|
|
154
|
+
if provide is None:
|
|
155
|
+
raise SessionUnavailableError(wanted, "it was registered without a session")
|
|
156
|
+
return await provide()
|
|
157
|
+
|
|
158
|
+
async def close(self, name: str | None = None) -> None:
|
|
159
|
+
"""Close the connection ``name``: release every database connection its pools hold.
|
|
160
|
+
|
|
161
|
+
The engine stays usable; its next use connects again.
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
UnknownConnectionError: When no connection has that name.
|
|
165
|
+
"""
|
|
166
|
+
connection = self._get(name)
|
|
167
|
+
if connection.close is not None:
|
|
168
|
+
await connection.close()
|
|
169
|
+
return
|
|
170
|
+
await (await connection.engine()).dispose()
|
|
171
|
+
|
|
172
|
+
def _get(self, name: str | None) -> _Connection:
|
|
173
|
+
wanted = name or self._default
|
|
174
|
+
found = self._connections.get(wanted)
|
|
175
|
+
if found is None:
|
|
176
|
+
raise UnknownConnectionError(wanted, self.names())
|
|
177
|
+
return found
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _provider(
|
|
181
|
+
given: _T | Callable[[], Awaitable[_T]], kind: type[_T]
|
|
182
|
+
) -> Callable[[], Awaitable[_T]]:
|
|
183
|
+
"""Return an async function giving ``given``: itself when it is one, else wrapping it."""
|
|
184
|
+
if isinstance(given, kind):
|
|
185
|
+
instance = given
|
|
186
|
+
|
|
187
|
+
async def provide() -> _T:
|
|
188
|
+
return instance
|
|
189
|
+
|
|
190
|
+
return provide
|
|
191
|
+
# Anything not of the built kind is the function building it, as the signature says.
|
|
192
|
+
return cast("Callable[[], Awaitable[_T]]", given)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Creating and dropping the database a connection URL names."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Final, final
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import URL, make_url, text
|
|
11
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
12
|
+
from sqlalchemy.pool import NullPool
|
|
13
|
+
|
|
14
|
+
from xtr_orm.exception import InvalidArgumentError, UnsupportedDatabaseError
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import AsyncGenerator, Mapping
|
|
18
|
+
|
|
19
|
+
from sqlalchemy.ext.asyncio import AsyncConnection
|
|
20
|
+
|
|
21
|
+
__all__ = ["DatabaseManager"]
|
|
22
|
+
|
|
23
|
+
_MEMORY: Final = frozenset({"", ":memory:"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class _Server:
|
|
28
|
+
"""How to reach a kind of server without its database, and ask it about one."""
|
|
29
|
+
|
|
30
|
+
maintenance_database: str | None
|
|
31
|
+
exists: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_SERVERS: Final[dict[str, _Server]] = {
|
|
35
|
+
"postgresql": _Server("postgres", "SELECT 1 FROM pg_database WHERE datname = :name"),
|
|
36
|
+
"mysql": _Server(None, "SELECT 1 FROM information_schema.schemata WHERE schema_name = :name"),
|
|
37
|
+
"mariadb": _Server(None, "SELECT 1 FROM information_schema.schemata WHERE schema_name = :name"),
|
|
38
|
+
"mssql": _Server("master", "SELECT 1 FROM sys.databases WHERE name = :name"),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@final
|
|
43
|
+
class DatabaseManager:
|
|
44
|
+
"""Creates and drops the database a connection URL names, on its server.
|
|
45
|
+
|
|
46
|
+
A database cannot be created from a connection to itself, so the server
|
|
47
|
+
is reached through its maintenance database — ``postgres`` on
|
|
48
|
+
PostgreSQL, ``master`` on SQL Server, none on MySQL and MariaDB — with
|
|
49
|
+
every statement committed as it runs. A SQLite database is its file.
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
manager = DatabaseManager("postgresql+asyncpg://app:secret@db/shop")
|
|
53
|
+
await manager.create(if_not_exists=True)
|
|
54
|
+
```
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
__slots__ = ("_connect_args", "_url")
|
|
58
|
+
|
|
59
|
+
_url: URL
|
|
60
|
+
_connect_args: Mapping[str, object]
|
|
61
|
+
|
|
62
|
+
def __init__(self, url: str | URL, *, connect_args: Mapping[str, object] | None = None) -> None:
|
|
63
|
+
"""Manage the database ``url`` names, connecting with ``connect_args``.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
InvalidArgumentError: When the URL names no database on a server.
|
|
67
|
+
"""
|
|
68
|
+
self._url = make_url(url)
|
|
69
|
+
self._connect_args = connect_args or {}
|
|
70
|
+
if self.backend != "sqlite" and not self._url.database:
|
|
71
|
+
raise InvalidArgumentError(
|
|
72
|
+
f"The URL {self.safe_url} names no database to create or drop.",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def backend(self) -> str:
|
|
77
|
+
"""The kind of server, such as ``"postgresql"`` or ``"sqlite"``."""
|
|
78
|
+
return self._url.get_backend_name()
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def database(self) -> str:
|
|
82
|
+
"""The database's name — for SQLite, its file."""
|
|
83
|
+
return self._url.database or ""
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def safe_url(self) -> str:
|
|
87
|
+
"""The URL with its password hidden, fit for a message."""
|
|
88
|
+
return self._url.render_as_string(hide_password=True)
|
|
89
|
+
|
|
90
|
+
async def exists(self) -> bool:
|
|
91
|
+
"""Whether the database exists.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
UnsupportedDatabaseError: For a server this library cannot ask.
|
|
95
|
+
"""
|
|
96
|
+
if self.backend == "sqlite":
|
|
97
|
+
path = self._sqlite_path()
|
|
98
|
+
return path is None or path.exists()
|
|
99
|
+
server = self._server("check")
|
|
100
|
+
async with self._maintenance() as connection:
|
|
101
|
+
found = await connection.execute(text(server.exists), {"name": self.database})
|
|
102
|
+
return found.first() is not None
|
|
103
|
+
|
|
104
|
+
async def create(self, *, if_not_exists: bool = False) -> bool:
|
|
105
|
+
"""Create the database; return whether it was created.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
if_not_exists: Leave an existing database alone and return
|
|
109
|
+
``False``, rather than let the server refuse.
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
UnsupportedDatabaseError: For a server this library cannot ask.
|
|
113
|
+
"""
|
|
114
|
+
if if_not_exists and await self.exists():
|
|
115
|
+
return False
|
|
116
|
+
if self.backend == "sqlite":
|
|
117
|
+
self._create_file()
|
|
118
|
+
return True
|
|
119
|
+
_ = self._server("create")
|
|
120
|
+
async with self._maintenance() as connection:
|
|
121
|
+
name = connection.dialect.identifier_preparer.quote(self.database)
|
|
122
|
+
_ = await connection.execute(text(f"CREATE DATABASE {name}"))
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
async def drop(self, *, if_exists: bool = False) -> bool:
|
|
126
|
+
"""Drop the database; return whether it was dropped.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
if_exists: Do nothing for a database that does not exist and
|
|
130
|
+
return ``False``, rather than let the server refuse.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
UnsupportedDatabaseError: For a server this library cannot ask.
|
|
134
|
+
"""
|
|
135
|
+
if if_exists and not await self.exists():
|
|
136
|
+
return False
|
|
137
|
+
if self.backend == "sqlite":
|
|
138
|
+
self._remove_file()
|
|
139
|
+
return True
|
|
140
|
+
_ = self._server("drop")
|
|
141
|
+
async with self._maintenance() as connection:
|
|
142
|
+
name = connection.dialect.identifier_preparer.quote(self.database)
|
|
143
|
+
_ = await connection.execute(text(f"DROP DATABASE {name}"))
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
def _server(self, operation: str) -> _Server:
|
|
147
|
+
server = _SERVERS.get(self.backend)
|
|
148
|
+
if server is None:
|
|
149
|
+
raise UnsupportedDatabaseError(self.backend, operation)
|
|
150
|
+
return server
|
|
151
|
+
|
|
152
|
+
@asynccontextmanager
|
|
153
|
+
async def _maintenance(self) -> AsyncGenerator[AsyncConnection]:
|
|
154
|
+
"""Connect to the server's maintenance database, committing every statement."""
|
|
155
|
+
server = _SERVERS[self.backend]
|
|
156
|
+
url = self._url
|
|
157
|
+
# ``set`` keeps a database it is given None for, so the URL is rebuilt.
|
|
158
|
+
maintenance = URL.create(
|
|
159
|
+
url.drivername,
|
|
160
|
+
username=url.username,
|
|
161
|
+
password=url.password,
|
|
162
|
+
host=url.host,
|
|
163
|
+
port=url.port,
|
|
164
|
+
database=server.maintenance_database,
|
|
165
|
+
query=url.query,
|
|
166
|
+
)
|
|
167
|
+
engine = create_async_engine(
|
|
168
|
+
maintenance,
|
|
169
|
+
poolclass=NullPool,
|
|
170
|
+
isolation_level="AUTOCOMMIT",
|
|
171
|
+
connect_args=dict(self._connect_args),
|
|
172
|
+
)
|
|
173
|
+
try:
|
|
174
|
+
async with engine.connect() as connection:
|
|
175
|
+
yield connection
|
|
176
|
+
finally:
|
|
177
|
+
await engine.dispose()
|
|
178
|
+
|
|
179
|
+
def _sqlite_path(self) -> Path | None:
|
|
180
|
+
"""Return the SQLite file, or ``None`` for a database kept in memory."""
|
|
181
|
+
if self.database in _MEMORY or self.database.startswith("file::memory:"):
|
|
182
|
+
return None
|
|
183
|
+
return Path(self.database.removeprefix("file:").split("?", 1)[0])
|
|
184
|
+
|
|
185
|
+
def _create_file(self) -> None:
|
|
186
|
+
"""Create the SQLite file, refusing one that exists; nothing for one in memory."""
|
|
187
|
+
path = self._sqlite_path()
|
|
188
|
+
if path is not None:
|
|
189
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
path.touch(exist_ok=False)
|
|
191
|
+
|
|
192
|
+
def _remove_file(self) -> None:
|
|
193
|
+
"""Remove the SQLite file; nothing for one in memory."""
|
|
194
|
+
path = self._sqlite_path()
|
|
195
|
+
if path is not None:
|
|
196
|
+
path.unlink()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Every error this library raises, all deriving from :class:`OrmError`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .invalid_argument_error import InvalidArgumentError
|
|
6
|
+
from .migration_error import MigrationError
|
|
7
|
+
from .orm_error import OrmError
|
|
8
|
+
from .session_unavailable_error import SessionUnavailableError
|
|
9
|
+
from .unknown_connection_error import UnknownConnectionError
|
|
10
|
+
from .unsupported_database_error import UnsupportedDatabaseError
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"InvalidArgumentError",
|
|
14
|
+
"MigrationError",
|
|
15
|
+
"OrmError",
|
|
16
|
+
"SessionUnavailableError",
|
|
17
|
+
"UnknownConnectionError",
|
|
18
|
+
"UnsupportedDatabaseError",
|
|
19
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""A configuration or a call was given something the library cannot work with."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .orm_error import OrmError
|
|
6
|
+
|
|
7
|
+
__all__ = ["InvalidArgumentError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InvalidArgumentError(OrmError, ValueError):
|
|
11
|
+
"""A configuration or a call was given something the library cannot work with.
|
|
12
|
+
|
|
13
|
+
Raised where the value is given — a connection without a name, a version
|
|
14
|
+
table without a name — rather than when a database is first reached.
|
|
15
|
+
|
|
16
|
+
Also a :class:`ValueError`, so code that already guards its configuration
|
|
17
|
+
with ``except ValueError`` keeps working without learning a new exception.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
reason: What is wrong with the value.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
reason: str
|
|
24
|
+
|
|
25
|
+
def __init__(self, reason: str) -> None:
|
|
26
|
+
"""Record what is wrong with the value."""
|
|
27
|
+
self.reason = reason
|
|
28
|
+
super().__init__(reason)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""A migration operation was refused, or its revisions could not be read."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .orm_error import OrmError
|
|
6
|
+
|
|
7
|
+
__all__ = ["MigrationError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MigrationError(OrmError):
|
|
11
|
+
"""A migration operation was refused, or its revisions could not be read.
|
|
12
|
+
|
|
13
|
+
Raised before anything is changed whenever it can be decided up front: a
|
|
14
|
+
version that no revision answers to, a revision run out of order, a diff
|
|
15
|
+
against a database that is behind its revisions. Nothing is written when
|
|
16
|
+
it is raised from a check; a failing revision rolls back what the
|
|
17
|
+
database lets it.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
reason: Why the operation was refused.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
reason: str
|
|
24
|
+
|
|
25
|
+
def __init__(self, reason: str) -> None:
|
|
26
|
+
"""Record why the operation was refused."""
|
|
27
|
+
self.reason = reason
|
|
28
|
+
super().__init__(reason)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""The root every error in this library derives from."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["OrmError"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OrmError(Exception):
|
|
9
|
+
"""Base class for every error raised by this library.
|
|
10
|
+
|
|
11
|
+
Catch this to handle anything the library can refuse; catch a subclass to
|
|
12
|
+
handle one cause. Every subclass carries the data a caller needs as typed
|
|
13
|
+
attributes and composes its own message from them.
|
|
14
|
+
"""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""The session of the current unit of work was asked for where there is none."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .orm_error import OrmError
|
|
6
|
+
|
|
7
|
+
__all__ = ["SessionUnavailableError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SessionUnavailableError(OrmError):
|
|
11
|
+
"""The session of the current unit of work was asked for where there is none.
|
|
12
|
+
|
|
13
|
+
A connection reaches the session everything in a unit of work shares —
|
|
14
|
+
a message's handlers, say — only inside one, and only when it was
|
|
15
|
+
registered with a way to.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
connection: The connection the session was asked of.
|
|
19
|
+
reason: Why there is none.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
connection: str
|
|
23
|
+
reason: str
|
|
24
|
+
|
|
25
|
+
def __init__(self, connection: str, reason: str) -> None:
|
|
26
|
+
"""Record which connection was asked, and why it has no session to give."""
|
|
27
|
+
self.connection = connection
|
|
28
|
+
self.reason = reason
|
|
29
|
+
super().__init__(f'The "{connection}" connection has no session here: {reason}.')
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""A connection was asked for by a name nothing registered."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .orm_error import OrmError
|
|
6
|
+
|
|
7
|
+
__all__ = ["UnknownConnectionError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UnknownConnectionError(OrmError, LookupError):
|
|
11
|
+
"""A connection was asked for by a name nothing registered.
|
|
12
|
+
|
|
13
|
+
Also a :class:`LookupError`, as a missing key in any other mapping is.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
name: The name asked for.
|
|
17
|
+
known: Every name that is registered.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
known: tuple[str, ...]
|
|
22
|
+
|
|
23
|
+
def __init__(self, name: str, known: tuple[str, ...]) -> None:
|
|
24
|
+
"""Record the name asked for and the ones that exist."""
|
|
25
|
+
self.name = name
|
|
26
|
+
self.known = known
|
|
27
|
+
names = ", ".join(f'"{each}"' for each in known) or "none"
|
|
28
|
+
super().__init__(f'Unknown connection "{name}"; the connections are: {names}.')
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""A database server was asked for something this library cannot do on it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .orm_error import OrmError
|
|
6
|
+
|
|
7
|
+
__all__ = ["UnsupportedDatabaseError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UnsupportedDatabaseError(OrmError):
|
|
11
|
+
"""A database server was asked for something this library cannot do on it.
|
|
12
|
+
|
|
13
|
+
Creating or dropping a database takes a statement of the server's own;
|
|
14
|
+
a server whose statement is not known here is refused rather than sent a
|
|
15
|
+
guess.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
backend: The kind of server the URL names, such as ``"oracle"``.
|
|
19
|
+
operation: What was asked, such as ``"create"``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
backend: str
|
|
23
|
+
operation: str
|
|
24
|
+
|
|
25
|
+
def __init__(self, backend: str, operation: str) -> None:
|
|
26
|
+
"""Record which server was asked for what."""
|
|
27
|
+
self.backend = backend
|
|
28
|
+
self.operation = operation
|
|
29
|
+
super().__init__(f'Cannot {operation} a database on "{backend}": it is not supported.')
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Message bus middleware for the connections: transactions, closing, and open transactions.
|
|
2
|
+
|
|
3
|
+
Importing this module declares them by name — ``orm_transaction``,
|
|
4
|
+
``orm_close_connection`` and ``orm_open_transaction_logger`` — for a bus
|
|
5
|
+
configuration to list. With a container, the orm bundle loads it when the
|
|
6
|
+
messenger bundle is active, and the messenger bundle registers each by that
|
|
7
|
+
name. Without one, build them with a :class:`~xtr_orm.ConnectionRegistry`
|
|
8
|
+
and put the instances in the configuration.
|
|
9
|
+
|
|
10
|
+
Needs the ``messenger`` extra: ``xtr-orm[messenger]``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .close_connection_middleware import CloseConnectionMiddleware
|
|
16
|
+
from .open_transaction_logger_middleware import OpenTransactionLoggerMiddleware
|
|
17
|
+
from .transaction_middleware import TransactionMiddleware
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"CloseConnectionMiddleware",
|
|
21
|
+
"OpenTransactionLoggerMiddleware",
|
|
22
|
+
"TransactionMiddleware",
|
|
23
|
+
]
|