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.
Files changed (57) hide show
  1. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/PKG-INFO +14 -9
  2. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/README.md +8 -3
  3. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/pyproject.toml +11 -11
  4. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/pyproject.toml.orig +11 -11
  5. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/bundle/connection_config.py +3 -1
  6. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/bundle/orm_bundle.py +74 -46
  7. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/connection_command.py +18 -3
  8. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/database_create_command.py +1 -1
  9. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/database_drop_command.py +1 -1
  10. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_current_command.py +1 -1
  11. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_diff_command.py +3 -3
  12. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_dump_schema_command.py +2 -2
  13. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_execute_command.py +3 -3
  14. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_generate_command.py +2 -2
  15. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_latest_command.py +1 -1
  16. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_list_command.py +1 -1
  17. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_migrate_command.py +6 -8
  18. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_rollup_command.py +1 -1
  19. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_status_command.py +1 -1
  20. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_up_to_date_command.py +3 -8
  21. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/migrations_version_command.py +4 -3
  22. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/run_sql_command.py +1 -1
  23. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/connection_registry.py +18 -0
  24. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/messenger/close_connection_middleware.py +3 -4
  25. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/messenger/open_transaction_logger_middleware.py +20 -12
  26. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/_environment.py +2 -1
  27. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/executed_migration.py +3 -1
  28. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/migrations_config.py +3 -1
  29. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/migrator.py +19 -6
  30. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/LICENSE +0 -0
  31. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/__init__.py +0 -0
  32. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/bundle/__init__.py +0 -0
  33. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/bundle/orm_config.py +0 -0
  34. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/__init__.py +0 -0
  35. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/command/connections.py +0 -0
  36. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/database/__init__.py +0 -0
  37. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/database/database_manager.py +0 -0
  38. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/exception/__init__.py +0 -0
  39. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/exception/invalid_argument_error.py +0 -0
  40. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/exception/migration_error.py +0 -0
  41. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/exception/orm_error.py +0 -0
  42. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/exception/session_unavailable_error.py +0 -0
  43. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/exception/unknown_connection_error.py +0 -0
  44. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/exception/unsupported_database_error.py +0 -0
  45. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/messenger/__init__.py +0 -0
  46. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/messenger/transaction_middleware.py +0 -0
  47. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/__init__.py +0 -0
  48. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/_history.py +0 -0
  49. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/_revisions.py +0 -0
  50. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/_schema.py +0 -0
  51. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/available_migration.py +0 -0
  52. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/direction.py +0 -0
  53. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/execution_result.py +0 -0
  54. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/migration_plan.py +0 -0
  55. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/migration_status.py +0 -0
  56. {xtr_orm-1.5.0 → xtr_orm-2.0.0}/src/xtr_orm/migrations/template/script.py.mako +0 -0
  57. {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: 1.5.0
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>=1.0,<2
24
- Requires-Dist: xtr-console>=1.0,<2 ; extra == 'console'
25
- Requires-Dist: xtr-dependency-injection>=1.0,<2 ; extra == 'di'
26
- Requires-Dist: xtr-service-contracts>=1.0,<2 ; extra == 'di'
27
- Requires-Dist: xtr-messenger>=1.0,<2 ; extra == '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 = "1.5.0"
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>=1.0,<2",
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>=1.0,<2",
46
- "xtr-service-contracts>=1.0,<2",
45
+ "xtr-dependency-injection>=2.0,<3",
46
+ "xtr-service-contracts>=2.0,<3",
47
47
  ]
48
- console = ["xtr-console>=1.0,<2"]
49
- messenger = ["xtr-messenger>=1.0,<2"]
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>=1.0,<2",
64
- "xtr-dependency-injection>=1.0,<2",
65
- "xtr-logging>=1.0,<2",
66
- "xtr-messenger>=1.0,<2",
67
- "xtr-service-contracts>=1.0,<2",
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 = "1.5.0"
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>=1.0,<2",
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>=1.0,<2",
44
- "xtr-service-contracts>=1.0,<2",
43
+ "xtr-dependency-injection>=2.0,<3",
44
+ "xtr-service-contracts>=2.0,<3",
45
45
  ]
46
46
  console = [
47
- "xtr-console>=1.0,<2",
47
+ "xtr-console>=2.0,<3",
48
48
  ]
49
49
  messenger = [
50
- "xtr-messenger>=1.0,<2",
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>=1.0,<2",
69
- "xtr-dependency-injection>=1.0,<2",
70
- "xtr-logging>=1.0,<2",
71
- "xtr-messenger>=1.0,<2",
72
- "xtr-service-contracts>=1.0,<2",
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(render_as_batch=True),
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(name), "database"),
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(_session_factory(name), f"orm_session_{name}"),
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(_connection_registry)
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[[OrmConfig], AsyncIterator[SQLAlchemyAsyncConfig]]:
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. It injects the
146
- configuration rather than closing over it: the container hands it a copy
147
- with every environment placeholder resolved.
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(config: OrmConfig) -> AsyncIterator[SQLAlchemyAsyncConfig]:
151
- spec = config.connections[name].with_url_options()
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[[OrmConfig, ContainerInterface], AsyncIterator[AsyncSession]]:
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, a unit starts reading from them whatever the unit before
243
- it wrote: sticking to the primary after a write is one unit's state.
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(name: str) -> Callable[[OrmConfig, ContainerInterface], Awaitable[Migrator]]:
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(config: OrmConfig, container: ContainerInterface) -> Migrator:
267
- spec = config.connections[name].with_url_options()
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(name: str) -> Callable[[OrmConfig], DatabaseManager]:
286
- """Build the factory of the connection ``name``'s database manager."""
302
+ def _database_factory(connection: ConnectionConfig) -> Callable[[], DatabaseManager]:
303
+ """Build the factory of ``connection``'s database manager."""
287
304
 
288
- def database(config: OrmConfig) -> DatabaseManager:
289
- spec: ConnectionConfig = config.connections[name].with_url_options()
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 _connection_registry(config: OrmConfig, container: ContainerInterface) -> ConnectionRegistry:
300
- """Name every connection for the commands and middleware, each piece built only when needed.
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
- A connection's session is the one of the unit of work under way, and
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) -> None:
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
- _ = path.write_text(sql, encoding="utf-8")
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)}"
@@ -17,7 +17,7 @@ __all__ = ["DatabaseCreateCommand"]
17
17
  @as_command("orm:database:create")
18
18
  @final
19
19
  class DatabaseCreateCommand(ConnectionCommand):
20
- """Creates the configured database."""
20
+ """Creates a connection's database on its server."""
21
21
 
22
22
  __slots__ = ()
23
23
 
@@ -17,7 +17,7 @@ __all__ = ["DatabaseDropCommand"]
17
17
  @as_command("orm:database:drop")
18
18
  @final
19
19
  class DatabaseDropCommand(ConnectionCommand):
20
- """Drops the configured database."""
20
+ """Drops a connection's database from its server."""
21
21
 
22
22
  __slots__ = ()
23
23
 
@@ -16,7 +16,7 @@ __all__ = ["MigrationsCurrentCommand"]
16
16
  @as_command("orm:migrations:current")
17
17
  @final
18
18
  class MigrationsCurrentCommand(ConnectionCommand):
19
- """Outputs the current version."""
19
+ """Prints the revisions the database is at."""
20
20
 
21
21
  __slots__ = ()
22
22
 
@@ -16,7 +16,7 @@ __all__ = ["MigrationsDiffCommand"]
16
16
  @as_command("orm:migrations:diff")
17
17
  @final
18
18
  class MigrationsDiffCommand(ConnectionCommand):
19
- """Generate a migration by comparing your current database to your mapping information."""
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("No changes detected in your mapping information.")
57
+ io.error("Nothing differs between the database and the table definitions.")
58
58
  return ExitCode.FAILURE
59
- report_written(io, "Generated new migration class to", written, connection)
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
- """Dump the schema for your database to a migration."""
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, "Dumped your schema to a new migration class at", written, connection)
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
- """Execute one or more migration versions up or down manually."""
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
- """Generate a blank migration class."""
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, "Generated new migration class to", written, connection)
46
+ report_written(io, "Wrote the new revision to", written, connection)
47
47
  return ExitCode.SUCCESS
@@ -16,7 +16,7 @@ __all__ = ["MigrationsLatestCommand"]
16
16
  @as_command("orm:migrations:latest")
17
17
  @final
18
18
  class MigrationsLatestCommand(ConnectionCommand):
19
- """Outputs the latest version."""
19
+ """Prints the revisions no other follows."""
20
20
 
21
21
  __slots__ = ()
22
22
 
@@ -17,7 +17,7 @@ __all__ = ["MigrationsListCommand"]
17
17
  @as_command("orm:migrations:list")
18
18
  @final
19
19
  class MigrationsListCommand(ConnectionCommand):
20
- """Display a list of all available migrations and their status."""
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
- """Execute a migration to a specified version or the latest available version."""
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 registered migrations.",
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
- """Rollup migrations by deleting all tracked versions and insert the one version that exists."""
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
- """View the status of a set of migrations."""
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 you if your schema is up-to-date."""
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
- """Manually add and delete migration versions from the version table."""
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 add, delete or synchronize migration versions from the "
62
- "version table that could result in data lost. Are you sure you wish to continue?",
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!")
@@ -16,7 +16,7 @@ __all__ = ["RunSqlCommand"]
16
16
  @as_command("orm:run-sql")
17
17
  @final
18
18
  class RunSqlCommand(ConnectionCommand):
19
- """Executes arbitrary SQL directly from the command line."""
19
+ """Runs a statement and shows what it returned."""
20
20
 
21
21
  __slots__ = ()
22
22
 
@@ -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
- names = self._connection_names or self._connections.names()
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 names:
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 connection when none is given — logging to ``logger``.
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
- names = self._connection_names or self._connections.names()
66
- sessions = {name: await self._connections.session(name) for name in names}
67
- initial = {name: _opened(session) for name, session in sessions.items()}
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
- return await stack.next().handle(envelope, stack)
72
+ handled = await stack.next().handle(envelope, stack)
71
73
  finally:
72
74
  _handling.reset(token)
73
- left = [name for name, session in sessions.items() if _opened(session) > initial[name]]
74
- if left:
75
- self._logger.error(
76
- "A handler opened a transaction but did not close it.",
77
- {"connections": left, "message": envelope.message},
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": config.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
- script.upgrade_ops, script.downgrade_ops = creating(tables)
282
- if not allow_empty and script.upgrade_ops is not None and script.upgrade_ops.is_empty():
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(child for child in script.nextrev if child in done)
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