xtr-orm 1.5.0__tar.gz → 2.0.0__tar.gz
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-1.5.0 → xtr_orm-2.0.0}/PKG-INFO +14 -9
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/README.md +8 -3
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/pyproject.toml +11 -11
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/pyproject.toml.orig +11 -11
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/bundle/connection_config.py +3 -1
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/bundle/orm_bundle.py +74 -46
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/connection_command.py +18 -3
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/database_create_command.py +1 -1
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/database_drop_command.py +1 -1
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_current_command.py +1 -1
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_diff_command.py +3 -3
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_dump_schema_command.py +2 -2
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_execute_command.py +3 -3
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_generate_command.py +2 -2
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_latest_command.py +1 -1
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_list_command.py +1 -1
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_migrate_command.py +6 -8
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_rollup_command.py +1 -1
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_status_command.py +1 -1
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_up_to_date_command.py +3 -8
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_version_command.py +4 -3
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/run_sql_command.py +1 -1
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/connection_registry.py +18 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/messenger/close_connection_middleware.py +3 -4
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/messenger/open_transaction_logger_middleware.py +20 -12
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/_environment.py +2 -1
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/executed_migration.py +3 -1
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/migrations_config.py +3 -1
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/migrator.py +19 -6
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/LICENSE +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/__init__.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/bundle/__init__.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/bundle/orm_config.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/__init__.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/connections.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/database/__init__.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/database/database_manager.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/exception/__init__.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/exception/invalid_argument_error.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/exception/migration_error.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/exception/orm_error.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/exception/session_unavailable_error.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/exception/unknown_connection_error.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/exception/unsupported_database_error.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/messenger/__init__.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/messenger/transaction_middleware.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/__init__.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/_history.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/_revisions.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/_schema.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/available_migration.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/direction.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/execution_result.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/migration_plan.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/migration_status.py +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/template/script.py.mako +0 -0
- {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: xtr-orm
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: Databases for async applications: engines, sessions and versioned schema migrations, configured once per connection.
|
|
5
5
|
Keywords: orm,database,migrations,sqlalchemy,alembic,asyncio
|
|
6
6
|
Author: Xterr
|
|
@@ -20,11 +20,11 @@ Requires-Dist: advanced-alchemy>=1.11
|
|
|
20
20
|
Requires-Dist: alembic>=1.20
|
|
21
21
|
Requires-Dist: sqlalchemy[asyncio]>=2.0.41
|
|
22
22
|
Requires-Dist: typing-extensions>=4.4
|
|
23
|
-
Requires-Dist: xtr-logging-contracts>=
|
|
24
|
-
Requires-Dist: xtr-console>=
|
|
25
|
-
Requires-Dist: xtr-dependency-injection>=
|
|
26
|
-
Requires-Dist: xtr-service-contracts>=
|
|
27
|
-
Requires-Dist: xtr-messenger>=
|
|
23
|
+
Requires-Dist: xtr-logging-contracts>=2.0,<3
|
|
24
|
+
Requires-Dist: xtr-console>=2.0,<3 ; extra == 'console'
|
|
25
|
+
Requires-Dist: xtr-dependency-injection>=2.0,<3 ; extra == 'di'
|
|
26
|
+
Requires-Dist: xtr-service-contracts>=2.0,<3 ; extra == 'di'
|
|
27
|
+
Requires-Dist: xtr-messenger>=2.0,<3 ; extra == 'messenger'
|
|
28
28
|
Requires-Dist: asyncmy>=0.2.9 ; extra == 'mysql'
|
|
29
29
|
Requires-Dist: asyncpg>=0.29 ; extra == 'postgres'
|
|
30
30
|
Requires-Dist: aiosqlite>=0.20 ; extra == 'sqlite'
|
|
@@ -152,7 +152,7 @@ the files, not its schema against your models; `diff` compares those.
|
|
|
152
152
|
|
|
153
153
|
| Field | Default | Meaning |
|
|
154
154
|
|---|---|---|
|
|
155
|
-
| `directory` | `"migrations"` | Where revision files are read and written |
|
|
155
|
+
| `directory` | `"migrations"` | Where revision files are read and written; a relative path is relative to the working directory — under a kernel, start it with `%kernel.project_dir%` |
|
|
156
156
|
| `version_table` / `history_table` | `"alembic_version"` / `"alembic_version_history"` | The two tables |
|
|
157
157
|
| `version_table_schema` | `None` | Their schema; the connection's default when `None` |
|
|
158
158
|
| `file_template` | `"{year}{month}{day}{hour}{minute}{second}_{rev}_{slug}"` | How a file is named; also `{epoch}` |
|
|
@@ -406,8 +406,8 @@ MessageBusConfig(
|
|
|
406
406
|
| Name | Class | Does | Arguments |
|
|
407
407
|
|---|---|---|---|
|
|
408
408
|
| `orm_transaction` | `TransactionMiddleware` | Runs every handler of a message in one transaction, committed once they all succeeded and rolled back otherwise; a failure drops the other handlers' handled stamps, so a retry runs them all again | `connection_name`, the default connection when left out |
|
|
409
|
-
| `orm_close_connection` | `CloseConnectionMiddleware` | Closes the connections once a worker handled a message it consumed, so an idle worker holds none — once the last, when it handles several at once | `connection_names`, every connection when left out |
|
|
410
|
-
| `orm_open_transaction_logger` | `OpenTransactionLoggerMiddleware` | Logs an error — to the application's logger when the logging bundle is active — when a handler began a transaction — `begin()`, `begin_nested()` — and left it open | `connection_names`, every connection when left out |
|
|
409
|
+
| `orm_close_connection` | `CloseConnectionMiddleware` | Closes the connections once a worker handled a message it consumed, so an idle worker holds none — once the last, when it handles several at once | `connection_names`, every connection in use — its engine built — when left out |
|
|
410
|
+
| `orm_open_transaction_logger` | `OpenTransactionLoggerMiddleware` | Logs an error — to the application's logger when the logging bundle is active — when a handler began a transaction — `begin()`, `begin_nested()` — and left it open, in a message that was handled | `connection_names`, every connection in use — its engine built — when left out |
|
|
411
411
|
|
|
412
412
|
- **One session per message.** The messenger bundle makes every message a unit of work, and a
|
|
413
413
|
scoped service — the session, a repository built on it — is the unit's: every handler of
|
|
@@ -463,6 +463,11 @@ Every error derives from `OrmError`.
|
|
|
463
463
|
|
|
464
464
|
## Known limitations
|
|
465
465
|
|
|
466
|
+
- **Migrations take no lock.** Two processes migrating one database at once — two deploys
|
|
467
|
+
starting together — both run the same revisions; run `orm:migrations:migrate` from one
|
|
468
|
+
place, before the application starts.
|
|
469
|
+
- **Applied times follow the database.** `executed_at` has a time zone on PostgreSQL and is
|
|
470
|
+
naive elsewhere: UTC on SQLite, the session's time zone on MySQL.
|
|
466
471
|
- **advanced-alchemy's model registry is process-wide.** Its `metadata_registry` maps a
|
|
467
472
|
`bind_key` to table definitions for the whole process, so two kernels in one process share
|
|
468
473
|
it. Give a connection its `metadata` explicitly to keep a kernel's diff to its own models.
|
|
@@ -113,7 +113,7 @@ the files, not its schema against your models; `diff` compares those.
|
|
|
113
113
|
|
|
114
114
|
| Field | Default | Meaning |
|
|
115
115
|
|---|---|---|
|
|
116
|
-
| `directory` | `"migrations"` | Where revision files are read and written |
|
|
116
|
+
| `directory` | `"migrations"` | Where revision files are read and written; a relative path is relative to the working directory — under a kernel, start it with `%kernel.project_dir%` |
|
|
117
117
|
| `version_table` / `history_table` | `"alembic_version"` / `"alembic_version_history"` | The two tables |
|
|
118
118
|
| `version_table_schema` | `None` | Their schema; the connection's default when `None` |
|
|
119
119
|
| `file_template` | `"{year}{month}{day}{hour}{minute}{second}_{rev}_{slug}"` | How a file is named; also `{epoch}` |
|
|
@@ -367,8 +367,8 @@ MessageBusConfig(
|
|
|
367
367
|
| Name | Class | Does | Arguments |
|
|
368
368
|
|---|---|---|---|
|
|
369
369
|
| `orm_transaction` | `TransactionMiddleware` | Runs every handler of a message in one transaction, committed once they all succeeded and rolled back otherwise; a failure drops the other handlers' handled stamps, so a retry runs them all again | `connection_name`, the default connection when left out |
|
|
370
|
-
| `orm_close_connection` | `CloseConnectionMiddleware` | Closes the connections once a worker handled a message it consumed, so an idle worker holds none — once the last, when it handles several at once | `connection_names`, every connection when left out |
|
|
371
|
-
| `orm_open_transaction_logger` | `OpenTransactionLoggerMiddleware` | Logs an error — to the application's logger when the logging bundle is active — when a handler began a transaction — `begin()`, `begin_nested()` — and left it open | `connection_names`, every connection when left out |
|
|
370
|
+
| `orm_close_connection` | `CloseConnectionMiddleware` | Closes the connections once a worker handled a message it consumed, so an idle worker holds none — once the last, when it handles several at once | `connection_names`, every connection in use — its engine built — when left out |
|
|
371
|
+
| `orm_open_transaction_logger` | `OpenTransactionLoggerMiddleware` | Logs an error — to the application's logger when the logging bundle is active — when a handler began a transaction — `begin()`, `begin_nested()` — and left it open, in a message that was handled | `connection_names`, every connection in use — its engine built — when left out |
|
|
372
372
|
|
|
373
373
|
- **One session per message.** The messenger bundle makes every message a unit of work, and a
|
|
374
374
|
scoped service — the session, a repository built on it — is the unit's: every handler of
|
|
@@ -424,6 +424,11 @@ Every error derives from `OrmError`.
|
|
|
424
424
|
|
|
425
425
|
## Known limitations
|
|
426
426
|
|
|
427
|
+
- **Migrations take no lock.** Two processes migrating one database at once — two deploys
|
|
428
|
+
starting together — both run the same revisions; run `orm:migrations:migrate` from one
|
|
429
|
+
place, before the application starts.
|
|
430
|
+
- **Applied times follow the database.** `executed_at` has a time zone on PostgreSQL and is
|
|
431
|
+
naive elsewhere: UTC on SQLite, the session's time zone on MySQL.
|
|
427
432
|
- **advanced-alchemy's model registry is process-wide.** Its `metadata_registry` maps a
|
|
428
433
|
`bind_key` to table definitions for the whole process, so two kernels in one process share
|
|
429
434
|
it. Give a connection its `metadata` explicitly to keep a kernel's diff to its own models.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xtr-orm"
|
|
3
|
-
version = "
|
|
3
|
+
version = "2.0.0"
|
|
4
4
|
description = "Databases for async applications: engines, sessions and versioned schema migrations, configured once per connection."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -30,7 +30,7 @@ dependencies = [
|
|
|
30
30
|
"alembic>=1.20",
|
|
31
31
|
"sqlalchemy[asyncio]>=2.0.41",
|
|
32
32
|
"typing-extensions>=4.4",
|
|
33
|
-
"xtr-logging-contracts>=
|
|
33
|
+
"xtr-logging-contracts>=2.0,<3",
|
|
34
34
|
]
|
|
35
35
|
|
|
36
36
|
[[project.authors]]
|
|
@@ -42,11 +42,11 @@ postgres = ["asyncpg>=0.29"]
|
|
|
42
42
|
sqlite = ["aiosqlite>=0.20"]
|
|
43
43
|
mysql = ["asyncmy>=0.2.9"]
|
|
44
44
|
di = [
|
|
45
|
-
"xtr-dependency-injection>=
|
|
46
|
-
"xtr-service-contracts>=
|
|
45
|
+
"xtr-dependency-injection>=2.0,<3",
|
|
46
|
+
"xtr-service-contracts>=2.0,<3",
|
|
47
47
|
]
|
|
48
|
-
console = ["xtr-console>=
|
|
49
|
-
messenger = ["xtr-messenger>=
|
|
48
|
+
console = ["xtr-console>=2.0,<3"]
|
|
49
|
+
messenger = ["xtr-messenger>=2.0,<3"]
|
|
50
50
|
|
|
51
51
|
[project.entry-points."xtr_dependency_injection.bundles"]
|
|
52
52
|
orm = "xtr_orm.bundle:OrmBundle"
|
|
@@ -60,11 +60,11 @@ dev = [
|
|
|
60
60
|
"anyio>=4.0",
|
|
61
61
|
"ty>=0.0.83",
|
|
62
62
|
"aiosqlite>=0.20",
|
|
63
|
-
"xtr-console>=
|
|
64
|
-
"xtr-dependency-injection>=
|
|
65
|
-
"xtr-logging>=
|
|
66
|
-
"xtr-messenger>=
|
|
67
|
-
"xtr-service-contracts>=
|
|
63
|
+
"xtr-console>=2.0,<3",
|
|
64
|
+
"xtr-dependency-injection>=2.0,<3",
|
|
65
|
+
"xtr-logging>=2.0,<3",
|
|
66
|
+
"xtr-messenger>=2.0,<3",
|
|
67
|
+
"xtr-service-contracts>=2.0,<3",
|
|
68
68
|
]
|
|
69
69
|
|
|
70
70
|
[build-system]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xtr-orm"
|
|
3
|
-
version = "
|
|
3
|
+
version = "2.0.0"
|
|
4
4
|
description = "Databases for async applications: engines, sessions and versioned schema migrations, configured once per connection."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -26,7 +26,7 @@ dependencies = [
|
|
|
26
26
|
"alembic>=1.20",
|
|
27
27
|
"sqlalchemy[asyncio]>=2.0.41",
|
|
28
28
|
"typing-extensions>=4.4",
|
|
29
|
-
"xtr-logging-contracts>=
|
|
29
|
+
"xtr-logging-contracts>=2.0,<3",
|
|
30
30
|
]
|
|
31
31
|
|
|
32
32
|
[project.optional-dependencies]
|
|
@@ -40,14 +40,14 @@ mysql = [
|
|
|
40
40
|
"asyncmy>=0.2.9",
|
|
41
41
|
]
|
|
42
42
|
di = [
|
|
43
|
-
"xtr-dependency-injection>=
|
|
44
|
-
"xtr-service-contracts>=
|
|
43
|
+
"xtr-dependency-injection>=2.0,<3",
|
|
44
|
+
"xtr-service-contracts>=2.0,<3",
|
|
45
45
|
]
|
|
46
46
|
console = [
|
|
47
|
-
"xtr-console>=
|
|
47
|
+
"xtr-console>=2.0,<3",
|
|
48
48
|
]
|
|
49
49
|
messenger = [
|
|
50
|
-
"xtr-messenger>=
|
|
50
|
+
"xtr-messenger>=2.0,<3",
|
|
51
51
|
]
|
|
52
52
|
|
|
53
53
|
# Advertises the bundle so debug:bundles can name it when installed but not listed.
|
|
@@ -65,11 +65,11 @@ dev = [
|
|
|
65
65
|
"ty>=0.0.83",
|
|
66
66
|
# Every test runs on SQLite files under the test's own directory: no server.
|
|
67
67
|
"aiosqlite>=0.20",
|
|
68
|
-
"xtr-console>=
|
|
69
|
-
"xtr-dependency-injection>=
|
|
70
|
-
"xtr-logging>=
|
|
71
|
-
"xtr-messenger>=
|
|
72
|
-
"xtr-service-contracts>=
|
|
68
|
+
"xtr-console>=2.0,<3",
|
|
69
|
+
"xtr-dependency-injection>=2.0,<3",
|
|
70
|
+
"xtr-logging>=2.0,<3",
|
|
71
|
+
"xtr-messenger>=2.0,<3",
|
|
72
|
+
"xtr-service-contracts>=2.0,<3",
|
|
73
73
|
]
|
|
74
74
|
|
|
75
75
|
[build-system]
|
|
@@ -118,7 +118,9 @@ class ConnectionConfig:
|
|
|
118
118
|
replicas={"replica1": env("DATABASE_REPLICA_URL")},
|
|
119
119
|
engine_options={"pool_size": 10, "pool_pre_ping": True},
|
|
120
120
|
session_options={"expire_on_commit": False},
|
|
121
|
-
migrations=MigrationsConfig(
|
|
121
|
+
migrations=MigrationsConfig(
|
|
122
|
+
directory="%kernel.project_dir%/migrations", render_as_batch=True
|
|
123
|
+
),
|
|
122
124
|
)
|
|
123
125
|
```
|
|
124
126
|
|
|
@@ -28,6 +28,8 @@ Nothing connects until a service is first used, and a URL given as
|
|
|
28
28
|
from __future__ import annotations
|
|
29
29
|
|
|
30
30
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
31
|
+
from functools import partial
|
|
32
|
+
from operator import contains
|
|
31
33
|
from typing import Final, TypeVar, cast, final
|
|
32
34
|
|
|
33
35
|
from advanced_alchemy.base import metadata_registry
|
|
@@ -88,7 +90,7 @@ class OrmBundle(Bundle[OrmConfig]):
|
|
|
88
90
|
if not bundle_active(builder, "logging"):
|
|
89
91
|
return
|
|
90
92
|
# Logging is an optional peer, importable only once it is active.
|
|
91
|
-
from xtr_logging.bundle import LoggingConfig # noqa: PLC0415
|
|
93
|
+
from xtr_logging.bundle import LoggingConfig # noqa: PLC0415 — optional peer
|
|
92
94
|
|
|
93
95
|
def add_orm_channel(config: LoggingConfig) -> LoggingConfig:
|
|
94
96
|
return config.with_channels(ORM_CHANNEL)
|
|
@@ -108,19 +110,21 @@ class OrmBundle(Bundle[OrmConfig]):
|
|
|
108
110
|
RoutingAsyncSessionMaker if spec.replicas else async_sessionmaker[AsyncSession]
|
|
109
111
|
)
|
|
110
112
|
factories = (
|
|
111
|
-
(_alchemy_factory(name), "alchemy"),
|
|
113
|
+
(_alchemy_factory(name, spec), "alchemy"),
|
|
112
114
|
(_engine_factory(name), "engine"),
|
|
113
115
|
(
|
|
114
116
|
_routing_maker_factory(name) if spec.replicas else _session_maker_factory(name),
|
|
115
117
|
"session_maker",
|
|
116
118
|
),
|
|
117
|
-
(_migrator_factory(name), "migrator"),
|
|
118
|
-
(_database_factory(
|
|
119
|
+
(_migrator_factory(name, spec), "migrator"),
|
|
120
|
+
(_database_factory(spec), "database"),
|
|
119
121
|
)
|
|
120
122
|
for factory, kind in factories:
|
|
121
123
|
_ = services.set(named_factory(factory, f"orm_{kind}_{name}"), qualifier=name)
|
|
122
124
|
_ = services.set(
|
|
123
|
-
named_factory(
|
|
125
|
+
named_factory(
|
|
126
|
+
_session_factory(name, routed=bool(spec.replicas)), f"orm_session_{name}"
|
|
127
|
+
),
|
|
124
128
|
qualifier=name,
|
|
125
129
|
lifetime="scoped",
|
|
126
130
|
)
|
|
@@ -128,7 +132,9 @@ class OrmBundle(Bundle[OrmConfig]):
|
|
|
128
132
|
for service in (*_PER_CONNECTION, maker_type):
|
|
129
133
|
services.alias(service, service, target_qualifier=name)
|
|
130
134
|
|
|
131
|
-
_ = services.set(
|
|
135
|
+
_ = services.set(_ConnectionsInUse)
|
|
136
|
+
routed = {name: bool(spec.replicas) for name, spec in config.connections.items()}
|
|
137
|
+
_ = services.set(_connection_registry_factory(config.default_connection, routed))
|
|
132
138
|
|
|
133
139
|
if bundle_active(builder, "console"):
|
|
134
140
|
services.load("xtr_orm.command")
|
|
@@ -136,19 +142,30 @@ class OrmBundle(Bundle[OrmConfig]):
|
|
|
136
142
|
services.load("xtr_orm.messenger")
|
|
137
143
|
|
|
138
144
|
|
|
145
|
+
@final
|
|
146
|
+
class _ConnectionsInUse:
|
|
147
|
+
"""The connections whose database layer — and so engines — the container has built."""
|
|
148
|
+
|
|
149
|
+
__slots__ = ("names",)
|
|
150
|
+
|
|
151
|
+
def __init__(self) -> None:
|
|
152
|
+
self.names: set[str] = set()
|
|
153
|
+
|
|
154
|
+
|
|
139
155
|
def _alchemy_factory(
|
|
140
|
-
name: str,
|
|
141
|
-
) -> Callable[[
|
|
156
|
+
name: str, connection: ConnectionConfig
|
|
157
|
+
) -> Callable[[_ConnectionsInUse], AsyncIterator[SQLAlchemyAsyncConfig]]:
|
|
142
158
|
"""Build the factory of the connection ``name``'s database layer configuration.
|
|
143
159
|
|
|
144
160
|
It owns every engine of the connection — the primary's, and each
|
|
145
|
-
replica's — and disposes them when the container closes.
|
|
146
|
-
|
|
147
|
-
|
|
161
|
+
replica's — and disposes them when the container closes. Like every
|
|
162
|
+
per-connection factory, it closes over its own connection's
|
|
163
|
+
configuration: the container resolves the environment placeholders of
|
|
164
|
+
that one alone, so a connection never reads another's variables.
|
|
148
165
|
"""
|
|
149
166
|
|
|
150
|
-
async def alchemy(
|
|
151
|
-
spec =
|
|
167
|
+
async def alchemy(in_use: _ConnectionsInUse) -> AsyncIterator[SQLAlchemyAsyncConfig]:
|
|
168
|
+
spec = connection.with_url_options()
|
|
152
169
|
# The options are forwarded by name, each checked against the target's
|
|
153
170
|
# fields by ConnectionConfig; their values are the application's.
|
|
154
171
|
engine = cast("Callable[..., EngineConfig]", EngineConfig)(**spec.engine_options)
|
|
@@ -169,6 +186,7 @@ def _alchemy_factory(
|
|
|
169
186
|
routing_maker = cast("RoutingAsyncSessionMaker", built.create_session_maker())
|
|
170
187
|
# One pool on the primary: the routing's, which migrations use too.
|
|
171
188
|
built.engine_instance = routing_maker.primary_engine
|
|
189
|
+
in_use.names.add(name)
|
|
172
190
|
try:
|
|
173
191
|
yield built
|
|
174
192
|
finally:
|
|
@@ -234,22 +252,19 @@ def _routing_maker_factory(
|
|
|
234
252
|
|
|
235
253
|
|
|
236
254
|
def _session_factory(
|
|
237
|
-
name: str,
|
|
238
|
-
) -> Callable[[
|
|
255
|
+
name: str, *, routed: bool
|
|
256
|
+
) -> Callable[[ContainerInterface], AsyncIterator[AsyncSession]]:
|
|
239
257
|
"""Build the factory of the connection ``name``'s session, closed when its unit ends.
|
|
240
258
|
|
|
241
259
|
Closing rolls back what was not committed: committing is the unit's job.
|
|
242
|
-
With replicas
|
|
243
|
-
it wrote: sticking to the primary after a write is one unit's
|
|
260
|
+
With replicas — ``routed`` — a unit starts reading from them whatever the
|
|
261
|
+
unit before it wrote: sticking to the primary after a write is one unit's
|
|
262
|
+
state, forgotten when the unit ends — not when another of its sessions
|
|
263
|
+
opens, which would send the reads after a write to a replica.
|
|
244
264
|
"""
|
|
245
265
|
|
|
246
|
-
async def session(
|
|
247
|
-
config: OrmConfig, container: ContainerInterface
|
|
248
|
-
) -> AsyncIterator[AsyncSession]:
|
|
249
|
-
routed = bool(config.connections[name].replicas)
|
|
266
|
+
async def session(container: ContainerInterface) -> AsyncIterator[AsyncSession]:
|
|
250
267
|
maker = (await container.get(SQLAlchemyAsyncConfig, name)).create_session_maker()
|
|
251
|
-
if routed:
|
|
252
|
-
_ = stick_to_primary_var.set(False)
|
|
253
268
|
try:
|
|
254
269
|
async with maker() as opened:
|
|
255
270
|
yield opened
|
|
@@ -260,11 +275,13 @@ def _session_factory(
|
|
|
260
275
|
return session
|
|
261
276
|
|
|
262
277
|
|
|
263
|
-
def _migrator_factory(
|
|
278
|
+
def _migrator_factory(
|
|
279
|
+
name: str, connection: ConnectionConfig
|
|
280
|
+
) -> Callable[[ContainerInterface], Awaitable[Migrator]]:
|
|
264
281
|
"""Build the factory of the connection ``name``'s migrator, logging to ``orm`` when it can."""
|
|
265
282
|
|
|
266
|
-
async def migrator(
|
|
267
|
-
spec =
|
|
283
|
+
async def migrator(container: ContainerInterface) -> Migrator:
|
|
284
|
+
spec = connection.with_url_options()
|
|
268
285
|
metadata = (
|
|
269
286
|
spec.metadata if spec.metadata is not None else metadata_registry.get(spec.bind_key)
|
|
270
287
|
)
|
|
@@ -282,11 +299,11 @@ def _migrator_factory(name: str) -> Callable[[OrmConfig, ContainerInterface], Aw
|
|
|
282
299
|
return migrator
|
|
283
300
|
|
|
284
301
|
|
|
285
|
-
def _database_factory(
|
|
286
|
-
"""Build the factory of
|
|
302
|
+
def _database_factory(connection: ConnectionConfig) -> Callable[[], DatabaseManager]:
|
|
303
|
+
"""Build the factory of ``connection``'s database manager."""
|
|
287
304
|
|
|
288
|
-
def database(
|
|
289
|
-
spec
|
|
305
|
+
def database() -> DatabaseManager:
|
|
306
|
+
spec = connection.with_url_options()
|
|
290
307
|
connect_args = spec.engine_options.get("connect_args")
|
|
291
308
|
return DatabaseManager(
|
|
292
309
|
spec.url,
|
|
@@ -296,23 +313,34 @@ def _database_factory(name: str) -> Callable[[OrmConfig], DatabaseManager]:
|
|
|
296
313
|
return database
|
|
297
314
|
|
|
298
315
|
|
|
299
|
-
def
|
|
300
|
-
|
|
316
|
+
def _connection_registry_factory(
|
|
317
|
+
default: str, routed: dict[str, bool]
|
|
318
|
+
) -> Callable[[ContainerInterface, _ConnectionsInUse], ConnectionRegistry]:
|
|
319
|
+
"""Build the factory of the registry of every connection — each ``routed`` or not, by name."""
|
|
320
|
+
|
|
321
|
+
def connection_registry(
|
|
322
|
+
container: ContainerInterface, in_use: _ConnectionsInUse
|
|
323
|
+
) -> ConnectionRegistry:
|
|
324
|
+
"""Name every connection for the commands and middleware, each piece built when needed.
|
|
325
|
+
|
|
326
|
+
A connection's session is the one of the unit of work under way,
|
|
327
|
+
closing it disposes every engine it has — its replicas' too — and it
|
|
328
|
+
is in use once the container built its engines.
|
|
329
|
+
"""
|
|
330
|
+
registry = ConnectionRegistry(default)
|
|
331
|
+
for name, replicated in routed.items():
|
|
332
|
+
registry.register(
|
|
333
|
+
name,
|
|
334
|
+
engine=_provider(container, AsyncEngine, name),
|
|
335
|
+
migrator=_provider(container, Migrator, name),
|
|
336
|
+
database=_provider(container, DatabaseManager, name),
|
|
337
|
+
session=_unit_session(name),
|
|
338
|
+
close=_closer(container, name, routed=replicated),
|
|
339
|
+
in_use=partial(contains, in_use.names, name),
|
|
340
|
+
)
|
|
341
|
+
return registry
|
|
301
342
|
|
|
302
|
-
|
|
303
|
-
closing it disposes every engine it has — its replicas' too.
|
|
304
|
-
"""
|
|
305
|
-
registry = ConnectionRegistry(config.default_connection)
|
|
306
|
-
for name, spec in config.connections.items():
|
|
307
|
-
registry.register(
|
|
308
|
-
name,
|
|
309
|
-
engine=_provider(container, AsyncEngine, name),
|
|
310
|
-
migrator=_provider(container, Migrator, name),
|
|
311
|
-
database=_provider(container, DatabaseManager, name),
|
|
312
|
-
session=_unit_session(name),
|
|
313
|
-
close=_closer(container, name, routed=bool(spec.replicas)),
|
|
314
|
-
)
|
|
315
|
-
return registry
|
|
343
|
+
return connection_registry
|
|
316
344
|
|
|
317
345
|
|
|
318
346
|
def _unit_session(name: str) -> Callable[[], Awaitable[AsyncSession]]:
|
|
@@ -95,13 +95,21 @@ def report_results(io: ConsoleStyle, results: Sequence[ExecutionResult]) -> None
|
|
|
95
95
|
io.text(f" {verb} {escape(result.version)} ({result.duration:.3f}s)")
|
|
96
96
|
|
|
97
97
|
|
|
98
|
-
def save_sql(io: ConsoleStyle, target: str, sql: str) ->
|
|
99
|
-
"""Write ``sql`` to ``target`` — a file, or a directory to put a dated file in.
|
|
98
|
+
def save_sql(io: ConsoleStyle, target: str, sql: str) -> bool:
|
|
99
|
+
"""Write ``sql`` to ``target`` — a file, or a directory to put a dated file in.
|
|
100
|
+
|
|
101
|
+
Returns whether it was written; when not, ``io`` says why.
|
|
102
|
+
"""
|
|
100
103
|
path = Path(target)
|
|
101
104
|
if path.is_dir():
|
|
102
105
|
path /= f"migration_{datetime.now().astimezone():%Y%m%d%H%M%S}.sql"
|
|
103
|
-
|
|
106
|
+
try:
|
|
107
|
+
_ = path.write_text(sql, encoding="utf-8")
|
|
108
|
+
except OSError as error:
|
|
109
|
+
io.error(f'Could not write the migration SQL to "{escape(str(path))}": {error.strerror}')
|
|
110
|
+
return False
|
|
104
111
|
io.success(f'Wrote the migration SQL to "{escape(str(path))}".')
|
|
112
|
+
return True
|
|
105
113
|
|
|
106
114
|
|
|
107
115
|
def report_written(
|
|
@@ -125,6 +133,13 @@ def report_count(io: ConsoleStyle, results: Sequence[ExecutionResult]) -> None:
|
|
|
125
133
|
io.success(f"{len(results)} migration{plural} executed in {total:.3f}s.")
|
|
126
134
|
|
|
127
135
|
|
|
136
|
+
def without_a_file(count: int) -> str:
|
|
137
|
+
"""Say how many applied revisions have no revision file."""
|
|
138
|
+
if count == 1:
|
|
139
|
+
return "1 applied revision has no revision file."
|
|
140
|
+
return f"{count} applied revisions have no revision file."
|
|
141
|
+
|
|
142
|
+
|
|
128
143
|
def format_version(version: str, description: str | None) -> str:
|
|
129
144
|
"""Render a version with its description, as the listing commands show it."""
|
|
130
145
|
return f"{version}{_described(description)}"
|
|
@@ -16,7 +16,7 @@ __all__ = ["MigrationsDiffCommand"]
|
|
|
16
16
|
@as_command("orm:migrations:diff")
|
|
17
17
|
@final
|
|
18
18
|
class MigrationsDiffCommand(ConnectionCommand):
|
|
19
|
-
"""
|
|
19
|
+
"""Writes a revision of what differs between the database and the table definitions."""
|
|
20
20
|
|
|
21
21
|
__slots__ = ()
|
|
22
22
|
|
|
@@ -54,7 +54,7 @@ class MigrationsDiffCommand(ConnectionCommand):
|
|
|
54
54
|
io.error(escape(error.reason))
|
|
55
55
|
return ExitCode.FAILURE
|
|
56
56
|
if written is None:
|
|
57
|
-
io.error("
|
|
57
|
+
io.error("Nothing differs between the database and the table definitions.")
|
|
58
58
|
return ExitCode.FAILURE
|
|
59
|
-
report_written(io, "
|
|
59
|
+
report_written(io, "Wrote the new revision to", written, connection)
|
|
60
60
|
return ExitCode.SUCCESS
|
|
@@ -16,7 +16,7 @@ __all__ = ["MigrationsDumpSchemaCommand"]
|
|
|
16
16
|
@as_command("orm:migrations:dump-schema")
|
|
17
17
|
@final
|
|
18
18
|
class MigrationsDumpSchemaCommand(ConnectionCommand):
|
|
19
|
-
"""
|
|
19
|
+
"""Writes a first revision creating every table an existing database has."""
|
|
20
20
|
|
|
21
21
|
__slots__ = ()
|
|
22
22
|
|
|
@@ -50,7 +50,7 @@ class MigrationsDumpSchemaCommand(ConnectionCommand):
|
|
|
50
50
|
except MigrationError as error:
|
|
51
51
|
io.error(escape(error.reason))
|
|
52
52
|
return ExitCode.FAILURE
|
|
53
|
-
report_written(io, "
|
|
53
|
+
report_written(io, "Wrote the schema to a new revision at", written, connection)
|
|
54
54
|
option = f" --connection {escape(connection)}" if connection else ""
|
|
55
55
|
io.text(f"To use this as a rollup migration you can use orm:migrations:rollup{option}")
|
|
56
56
|
return ExitCode.SUCCESS
|
|
@@ -24,7 +24,7 @@ __all__ = ["MigrationsExecuteCommand"]
|
|
|
24
24
|
@as_command("orm:migrations:execute")
|
|
25
25
|
@final
|
|
26
26
|
class MigrationsExecuteCommand(ConnectionCommand):
|
|
27
|
-
"""
|
|
27
|
+
"""Runs chosen revisions alone, each up or down."""
|
|
28
28
|
|
|
29
29
|
__slots__ = ()
|
|
30
30
|
|
|
@@ -67,8 +67,8 @@ class MigrationsExecuteCommand(ConnectionCommand):
|
|
|
67
67
|
return ExitCode.FAILURE
|
|
68
68
|
try:
|
|
69
69
|
if write_sql is not None:
|
|
70
|
-
save_sql(io, write_sql, await migrator.execute_sql(versions, direction))
|
|
71
|
-
return ExitCode.SUCCESS
|
|
70
|
+
saved = save_sql(io, write_sql, await migrator.execute_sql(versions, direction))
|
|
71
|
+
return ExitCode.SUCCESS if saved else ExitCode.FAILURE
|
|
72
72
|
planned = await migrator.plan_for_versions(versions, direction)
|
|
73
73
|
if dry_run:
|
|
74
74
|
report_plan(io, planned)
|
|
@@ -16,7 +16,7 @@ __all__ = ["MigrationsGenerateCommand"]
|
|
|
16
16
|
@as_command("orm:migrations:generate")
|
|
17
17
|
@final
|
|
18
18
|
class MigrationsGenerateCommand(ConnectionCommand):
|
|
19
|
-
"""
|
|
19
|
+
"""Writes an empty revision to fill in by hand."""
|
|
20
20
|
|
|
21
21
|
__slots__ = ()
|
|
22
22
|
|
|
@@ -43,5 +43,5 @@ class MigrationsGenerateCommand(ConnectionCommand):
|
|
|
43
43
|
except MigrationError as error:
|
|
44
44
|
io.error(escape(error.reason))
|
|
45
45
|
return ExitCode.FAILURE
|
|
46
|
-
report_written(io, "
|
|
46
|
+
report_written(io, "Wrote the new revision to", written, connection)
|
|
47
47
|
return ExitCode.SUCCESS
|
|
@@ -17,7 +17,7 @@ __all__ = ["MigrationsListCommand"]
|
|
|
17
17
|
@as_command("orm:migrations:list")
|
|
18
18
|
@final
|
|
19
19
|
class MigrationsListCommand(ConnectionCommand):
|
|
20
|
-
"""
|
|
20
|
+
"""Lists every revision and whether it is applied."""
|
|
21
21
|
|
|
22
22
|
__slots__ = ()
|
|
23
23
|
|
|
@@ -15,6 +15,7 @@ from .connection_command import (
|
|
|
15
15
|
report_plan,
|
|
16
16
|
report_results,
|
|
17
17
|
save_sql,
|
|
18
|
+
without_a_file,
|
|
18
19
|
)
|
|
19
20
|
|
|
20
21
|
__all__ = ["MigrationsMigrateCommand"]
|
|
@@ -25,7 +26,7 @@ _LATEST: Final = frozenset({"latest", "head", "heads"})
|
|
|
25
26
|
@as_command("orm:migrations:migrate")
|
|
26
27
|
@final
|
|
27
28
|
class MigrationsMigrateCommand(ConnectionCommand):
|
|
28
|
-
"""
|
|
29
|
+
"""Brings the database to a revision, up or down."""
|
|
29
30
|
|
|
30
31
|
__slots__ = ()
|
|
31
32
|
|
|
@@ -67,15 +68,12 @@ class MigrationsMigrateCommand(ConnectionCommand):
|
|
|
67
68
|
return ExitCode.SUCCESS
|
|
68
69
|
io.error(
|
|
69
70
|
f'The version "{escape(version)}" couldn\'t be reached, '
|
|
70
|
-
"there are no
|
|
71
|
+
"there are no revision files.",
|
|
71
72
|
)
|
|
72
73
|
return ExitCode.FAILURE
|
|
73
74
|
unregistered = len(status.executed_unavailable)
|
|
74
75
|
if unregistered:
|
|
75
|
-
io.warning(
|
|
76
|
-
f"You have {unregistered} previously executed migrations in the database "
|
|
77
|
-
"that are not registered migrations.",
|
|
78
|
-
)
|
|
76
|
+
io.warning(without_a_file(unregistered))
|
|
79
77
|
if not io.confirm("Are you sure you wish to continue?", default=True):
|
|
80
78
|
io.error("Migration cancelled!")
|
|
81
79
|
return ExitCode.FAILURE
|
|
@@ -90,8 +88,8 @@ class MigrationsMigrateCommand(ConnectionCommand):
|
|
|
90
88
|
io.success(message)
|
|
91
89
|
return ExitCode.SUCCESS
|
|
92
90
|
if write_sql is not None:
|
|
93
|
-
save_sql(io, write_sql, await migrator.migrate_sql(version))
|
|
94
|
-
return ExitCode.SUCCESS
|
|
91
|
+
saved = save_sql(io, write_sql, await migrator.migrate_sql(version))
|
|
92
|
+
return ExitCode.SUCCESS if saved else ExitCode.FAILURE
|
|
95
93
|
if dry_run:
|
|
96
94
|
report_plan(io, plans)
|
|
97
95
|
io.note("Dry run: nothing was executed.")
|
|
@@ -16,7 +16,7 @@ __all__ = ["MigrationsRollupCommand"]
|
|
|
16
16
|
@as_command("orm:migrations:rollup")
|
|
17
17
|
@final
|
|
18
18
|
class MigrationsRollupCommand(ConnectionCommand):
|
|
19
|
-
"""
|
|
19
|
+
"""Records the single remaining revision as all the database has applied."""
|
|
20
20
|
|
|
21
21
|
__slots__ = ()
|
|
22
22
|
|
|
@@ -16,7 +16,7 @@ __all__ = ["MigrationsStatusCommand"]
|
|
|
16
16
|
@as_command("orm:migrations:status")
|
|
17
17
|
@final
|
|
18
18
|
class MigrationsStatusCommand(ConnectionCommand):
|
|
19
|
-
"""
|
|
19
|
+
"""Shows where the database stands against its revisions."""
|
|
20
20
|
|
|
21
21
|
__slots__ = ()
|
|
22
22
|
|
|
@@ -8,7 +8,7 @@ from xtr_console import ConsoleStyle, ExitCode, Option, as_command, escape
|
|
|
8
8
|
|
|
9
9
|
from xtr_orm.exception import MigrationError
|
|
10
10
|
|
|
11
|
-
from .connection_command import ConnectionCommand
|
|
11
|
+
from .connection_command import ConnectionCommand, without_a_file
|
|
12
12
|
|
|
13
13
|
__all__ = ["MigrationsUpToDateCommand"]
|
|
14
14
|
|
|
@@ -19,7 +19,7 @@ _UNREGISTERED: Final = 2
|
|
|
19
19
|
@as_command("orm:migrations:up-to-date")
|
|
20
20
|
@final
|
|
21
21
|
class MigrationsUpToDateCommand(ConnectionCommand):
|
|
22
|
-
"""Tells
|
|
22
|
+
"""Tells whether every revision is applied."""
|
|
23
23
|
|
|
24
24
|
__slots__ = ()
|
|
25
25
|
|
|
@@ -62,12 +62,7 @@ class MigrationsUpToDateCommand(ConnectionCommand):
|
|
|
62
62
|
io.error(f"Out-of-date! {new} migration{are} available to execute.")
|
|
63
63
|
code = ExitCode.FAILURE
|
|
64
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
|
-
)
|
|
65
|
+
io.error(without_a_file(unregistered))
|
|
71
66
|
if fail_on_unregistered:
|
|
72
67
|
code = _UNREGISTERED
|
|
73
68
|
if list_migrations:
|
|
@@ -16,7 +16,7 @@ __all__ = ["MigrationsVersionCommand"]
|
|
|
16
16
|
@as_command("orm:migrations:version")
|
|
17
17
|
@final
|
|
18
18
|
class MigrationsVersionCommand(ConnectionCommand):
|
|
19
|
-
"""
|
|
19
|
+
"""Records revisions as applied or not, running nothing."""
|
|
20
20
|
|
|
21
21
|
__slots__ = ()
|
|
22
22
|
|
|
@@ -58,8 +58,9 @@ class MigrationsVersionCommand(ConnectionCommand):
|
|
|
58
58
|
if migrator is None:
|
|
59
59
|
return ExitCode.FAILURE
|
|
60
60
|
if not io.confirm(
|
|
61
|
-
"WARNING! You are about to
|
|
62
|
-
"version table
|
|
61
|
+
"WARNING! You are about to record revisions as applied or not without running them, "
|
|
62
|
+
"which can leave the version table out of step with the schema. Are you sure you "
|
|
63
|
+
"wish to continue?",
|
|
63
64
|
default=True,
|
|
64
65
|
):
|
|
65
66
|
io.error("Migration cancelled!")
|
|
@@ -33,6 +33,7 @@ class _Connection:
|
|
|
33
33
|
database: Callable[[], Awaitable[DatabaseManager]]
|
|
34
34
|
session: Callable[[], Awaitable[AsyncSession]] | None
|
|
35
35
|
close: Callable[[], Awaitable[None]] | None
|
|
36
|
+
in_use: Callable[[], bool] | None
|
|
36
37
|
|
|
37
38
|
|
|
38
39
|
@final
|
|
@@ -83,6 +84,7 @@ class ConnectionRegistry:
|
|
|
83
84
|
database: DatabaseManager | Callable[[], Awaitable[DatabaseManager]],
|
|
84
85
|
session: Callable[[], Awaitable[AsyncSession]] | None = None,
|
|
85
86
|
close: Callable[[], Awaitable[None]] | None = None,
|
|
87
|
+
in_use: Callable[[], bool] | None = None,
|
|
86
88
|
) -> None:
|
|
87
89
|
"""Add the connection ``name``, or replace it.
|
|
88
90
|
|
|
@@ -95,6 +97,8 @@ class ConnectionRegistry:
|
|
|
95
97
|
without it, :meth:`session` is refused.
|
|
96
98
|
close: What closes the connection; its engine is disposed when
|
|
97
99
|
left out.
|
|
100
|
+
in_use: Whether the connection is in use — its engine built;
|
|
101
|
+
without it, the connection always is. See :meth:`in_use`.
|
|
98
102
|
|
|
99
103
|
Raises:
|
|
100
104
|
InvalidArgumentError: When ``name`` is empty.
|
|
@@ -107,12 +111,26 @@ class ConnectionRegistry:
|
|
|
107
111
|
database=_provider(database, DatabaseManager),
|
|
108
112
|
session=session,
|
|
109
113
|
close=close,
|
|
114
|
+
in_use=in_use,
|
|
110
115
|
)
|
|
111
116
|
|
|
112
117
|
def names(self) -> tuple[str, ...]:
|
|
113
118
|
"""Every connection's name, in the order registered."""
|
|
114
119
|
return tuple(self._connections)
|
|
115
120
|
|
|
121
|
+
def in_use(self) -> tuple[str, ...]:
|
|
122
|
+
"""The names of the connections in use, in the order registered.
|
|
123
|
+
|
|
124
|
+
What the message bus middleware act on unless given names: a
|
|
125
|
+
connection nothing has used has no session to check and no pool to
|
|
126
|
+
close, and opening it to find out would read its configuration.
|
|
127
|
+
"""
|
|
128
|
+
return tuple(
|
|
129
|
+
name
|
|
130
|
+
for name, connection in self._connections.items()
|
|
131
|
+
if connection.in_use is None or connection.in_use()
|
|
132
|
+
)
|
|
133
|
+
|
|
116
134
|
def has(self, name: str | None = None) -> bool:
|
|
117
135
|
"""Whether the connection ``name`` — the default one when ``None`` — exists."""
|
|
118
136
|
return (name or self._default) in self._connections
|
|
@@ -41,7 +41,7 @@ class CloseConnectionMiddleware(MiddlewareInterface):
|
|
|
41
41
|
connections: ConnectionRegistry,
|
|
42
42
|
connection_names: str | Sequence[str] = (),
|
|
43
43
|
) -> None:
|
|
44
|
-
"""Close ``connection_names`` — every connection when none is given."""
|
|
44
|
+
"""Close ``connection_names`` — every connection in use when none is given."""
|
|
45
45
|
self._connections = connections
|
|
46
46
|
self._consumed = 0
|
|
47
47
|
self._connection_names = (
|
|
@@ -58,8 +58,7 @@ class CloseConnectionMiddleware(MiddlewareInterface):
|
|
|
58
58
|
Raises:
|
|
59
59
|
UnknownConnectionError: When a connection named is not registered.
|
|
60
60
|
"""
|
|
61
|
-
|
|
62
|
-
for name in names:
|
|
61
|
+
for name in self._connection_names:
|
|
63
62
|
if not self._connections.has(name):
|
|
64
63
|
raise UnknownConnectionError(name, self._connections.names())
|
|
65
64
|
if envelope.last(ReceivedStamp) is None:
|
|
@@ -71,5 +70,5 @@ class CloseConnectionMiddleware(MiddlewareInterface):
|
|
|
71
70
|
finally:
|
|
72
71
|
self._consumed -= 1
|
|
73
72
|
if self._consumed == 0:
|
|
74
|
-
for name in
|
|
73
|
+
for name in self._connection_names or self._connections.in_use():
|
|
75
74
|
await self._connections.close(name)
|
|
@@ -35,7 +35,8 @@ class OpenTransactionLoggerMiddleware(MiddlewareInterface):
|
|
|
35
35
|
|
|
36
36
|
A transaction counts when a handler began it — ``begin()`` or
|
|
37
37
|
``begin_nested()`` on the unit of work's session — not when the session
|
|
38
|
-
began one by itself for a query.
|
|
38
|
+
began one by itself for a query. Only a message that was handled is
|
|
39
|
+
checked: one that failed leaves its transaction to be rolled back.
|
|
39
40
|
"""
|
|
40
41
|
|
|
41
42
|
__slots__ = ("_connection_names", "_connections", "_logger")
|
|
@@ -46,7 +47,7 @@ class OpenTransactionLoggerMiddleware(MiddlewareInterface):
|
|
|
46
47
|
logger: LoggerInterface = _DISCARD,
|
|
47
48
|
connection_names: str | Sequence[str] = (),
|
|
48
49
|
) -> None:
|
|
49
|
-
"""Check ``connection_names`` — every
|
|
50
|
+
"""Check ``connection_names`` — every one in use when none is given — logging to ``logger``.
|
|
50
51
|
|
|
51
52
|
Under a container, ``logger`` is the application's logger when the
|
|
52
53
|
logging bundle is active; otherwise what is found goes nowhere.
|
|
@@ -62,20 +63,27 @@ class OpenTransactionLoggerMiddleware(MiddlewareInterface):
|
|
|
62
63
|
"""Run the rest of the chain, then log each connection left with a transaction open."""
|
|
63
64
|
if _handling.get():
|
|
64
65
|
return await stack.next().handle(envelope, stack)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
initial = {
|
|
67
|
+
name: _opened(await self._connections.session(name))
|
|
68
|
+
for name in self._connection_names or self._connections.in_use()
|
|
69
|
+
}
|
|
68
70
|
token = _handling.set(True)
|
|
69
71
|
try:
|
|
70
|
-
|
|
72
|
+
handled = await stack.next().handle(envelope, stack)
|
|
71
73
|
finally:
|
|
72
74
|
_handling.reset(token)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
# Read again: a connection the handlers put in use is checked too.
|
|
76
|
+
left = [
|
|
77
|
+
name
|
|
78
|
+
for name in self._connection_names or self._connections.in_use()
|
|
79
|
+
if _opened(await self._connections.session(name)) > initial.get(name, 0)
|
|
80
|
+
]
|
|
81
|
+
if left:
|
|
82
|
+
self._logger.error(
|
|
83
|
+
"A handler opened a transaction but did not close it.",
|
|
84
|
+
{"connections": left, "message": envelope.message},
|
|
85
|
+
)
|
|
86
|
+
return handled
|
|
79
87
|
|
|
80
88
|
|
|
81
89
|
def _opened(session: AsyncSession) -> int:
|
|
@@ -131,7 +131,8 @@ class Environment:
|
|
|
131
131
|
"include_name": _include_name(config.include_name, config.history_table),
|
|
132
132
|
"include_object": config.include_object,
|
|
133
133
|
"render_item": _render_item(config.render_item, config.user_module_prefix),
|
|
134
|
-
"process_revision_directives":
|
|
134
|
+
# Not "process_revision_directives": the migrator runs it inside its own,
|
|
135
|
+
# which the runner would call first, before the revision is complete.
|
|
135
136
|
"user_module_prefix": config.user_module_prefix,
|
|
136
137
|
"literal_binds": as_sql,
|
|
137
138
|
"output_buffer": output,
|
|
@@ -22,7 +22,9 @@ class ExecutedMigration:
|
|
|
22
22
|
|
|
23
23
|
Attributes:
|
|
24
24
|
version: The revision's identifier.
|
|
25
|
-
executed_at: When it was applied, by the database's clock
|
|
25
|
+
executed_at: When it was applied, by the database's clock — with a
|
|
26
|
+
time zone where the column keeps one (PostgreSQL), naive
|
|
27
|
+
elsewhere: UTC on SQLite, the session's time zone on MySQL.
|
|
26
28
|
execution_time: How long it took, in seconds.
|
|
27
29
|
"""
|
|
28
30
|
|
|
@@ -50,7 +50,9 @@ class MigrationsConfig:
|
|
|
50
50
|
|
|
51
51
|
Attributes:
|
|
52
52
|
directory: Where revision files are read from and written to; created
|
|
53
|
-
when the first one is written.
|
|
53
|
+
when the first one is written. A relative path is relative to the
|
|
54
|
+
working directory — under a kernel, start it with
|
|
55
|
+
``%kernel.project_dir%``.
|
|
54
56
|
version_table: The table holding the database's current revisions.
|
|
55
57
|
version_table_schema: The schema of both tables; the connection's
|
|
56
58
|
default when ``None``.
|
|
@@ -271,15 +271,21 @@ class Migrator(LoggerAware):
|
|
|
271
271
|
f'The "{self._name}" connection has no table definitions to compare with.',
|
|
272
272
|
)
|
|
273
273
|
tables = tables_of(self._metadata) if from_empty_schema else None
|
|
274
|
+
hook = self._config.process_revision_directives
|
|
274
275
|
|
|
275
276
|
def adjust(
|
|
276
277
|
context: MigrationContext, revision: object, directives: list[MigrationScript]
|
|
277
278
|
) -> None:
|
|
278
|
-
del context, revision
|
|
279
|
-
script = directives[0]
|
|
280
279
|
if tables is not None:
|
|
281
|
-
|
|
282
|
-
if
|
|
280
|
+
directives[0].upgrade_ops, directives[0].downgrade_ops = creating(tables)
|
|
281
|
+
if hook is not None:
|
|
282
|
+
hook(context, revision, directives)
|
|
283
|
+
if (
|
|
284
|
+
not allow_empty
|
|
285
|
+
and directives
|
|
286
|
+
and directives[0].upgrade_ops is not None
|
|
287
|
+
and directives[0].upgrade_ops.is_empty()
|
|
288
|
+
):
|
|
283
289
|
directives.clear()
|
|
284
290
|
|
|
285
291
|
def work(connection: Connection) -> AvailableMigration | None:
|
|
@@ -341,11 +347,14 @@ class Migrator(LoggerAware):
|
|
|
341
347
|
if not tables:
|
|
342
348
|
raise MigrationError("The database schema does not contain any tables.")
|
|
343
349
|
|
|
350
|
+
hook = self._config.process_revision_directives
|
|
351
|
+
|
|
344
352
|
def replace(
|
|
345
353
|
context: MigrationContext, revision: object, directives: list[MigrationScript]
|
|
346
354
|
) -> None:
|
|
347
|
-
del context, revision
|
|
348
355
|
directives[0].upgrade_ops, directives[0].downgrade_ops = creating(tables)
|
|
356
|
+
if hook is not None:
|
|
357
|
+
hook(context, revision, directives)
|
|
349
358
|
|
|
350
359
|
context = RevisionContext(
|
|
351
360
|
Config(),
|
|
@@ -603,7 +612,11 @@ class Migrator(LoggerAware):
|
|
|
603
612
|
else:
|
|
604
613
|
if revision not in done:
|
|
605
614
|
raise MigrationError(f'The version "{revision}" is not applied.')
|
|
606
|
-
followers = sorted(
|
|
615
|
+
followers = sorted(
|
|
616
|
+
each
|
|
617
|
+
for each in done
|
|
618
|
+
if revision in parents(directory, resolve(directory, each))
|
|
619
|
+
)
|
|
607
620
|
if followers:
|
|
608
621
|
raise MigrationError(
|
|
609
622
|
f'The version "{revision}" is followed by "{followers[0]}", '
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|