xtr-orm 2.0.0__tar.gz → 3.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-2.0.0 → xtr_orm-3.0.0}/PKG-INFO +14 -10
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/README.md +8 -4
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/pyproject.toml +14 -11
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/pyproject.toml.orig +15 -11
- xtr_orm-3.0.0/src/xtr_orm/.agents/skills/xtr-orm/SKILL.md +282 -0
- xtr_orm-3.0.0/src/xtr_orm/.agents/skills/xtr-orm/references/commands.md +67 -0
- xtr_orm-3.0.0/src/xtr_orm/.agents/skills/xtr-orm/references/connections.md +97 -0
- xtr_orm-3.0.0/src/xtr_orm/.agents/skills/xtr-orm/references/messenger.md +95 -0
- xtr_orm-3.0.0/src/xtr_orm/.agents/skills/xtr-orm/references/migrations.md +71 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/bundle/orm_bundle.py +35 -26
- xtr_orm-3.0.0/src/xtr_orm/recipe/__init__.py +15 -0
- xtr_orm-3.0.0/src/xtr_orm/recipe/files/config/orm.py.tmpl +24 -0
- xtr_orm-3.0.0/src/xtr_orm/recipe/manifest.toml +32 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/LICENSE +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/__init__.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/bundle/__init__.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/bundle/connection_config.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/bundle/orm_config.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/__init__.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/connection_command.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/connections.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/database_create_command.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/database_drop_command.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_current_command.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_diff_command.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_dump_schema_command.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_execute_command.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_generate_command.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_latest_command.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_list_command.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_migrate_command.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_rollup_command.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_status_command.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_up_to_date_command.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/migrations_version_command.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/command/run_sql_command.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/connection_registry.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/database/__init__.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/database/database_manager.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/__init__.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/invalid_argument_error.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/migration_error.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/orm_error.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/session_unavailable_error.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/unknown_connection_error.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/exception/unsupported_database_error.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/messenger/__init__.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/messenger/close_connection_middleware.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/messenger/open_transaction_logger_middleware.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/messenger/transaction_middleware.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/__init__.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/_environment.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/_history.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/_revisions.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/_schema.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/available_migration.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/direction.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/executed_migration.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/execution_result.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/migration_plan.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/migration_status.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/migrations_config.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/migrator.py +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.0.0}/src/xtr_orm/migrations/template/script.py.mako +0 -0
- {xtr_orm-2.0.0 → xtr_orm-3.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: 3.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>=3.0,<4
|
|
24
|
+
Requires-Dist: xtr-console>=3.0,<4 ; extra == 'console'
|
|
25
|
+
Requires-Dist: xtr-dependency-injection>=3.0,<4 ; extra == 'di'
|
|
26
|
+
Requires-Dist: xtr-service-contracts>=3.0,<4 ; extra == 'di'
|
|
27
|
+
Requires-Dist: xtr-messenger>=3.0,<4 ; 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'
|
|
@@ -202,15 +202,19 @@ removing it undoes.
|
|
|
202
202
|
- **Install** — `uv add "xtr-orm[di,console,postgres]"`; `mysql` or `sqlite` instead of
|
|
203
203
|
`postgres` for those databases; drop `console` to go without the commands; add `messenger`
|
|
204
204
|
for the [message bus middleware](#message-bus-middleware).
|
|
205
|
+
- **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate*, *Configure* and
|
|
206
|
+
*Environment* steps below: it lists `OrmBundle`, writes a starting `<app>/config/orm.py` with the
|
|
207
|
+
`default` connection, and `DATABASE_URL` in `.env`. It prints the middleware step, which a recipe
|
|
208
|
+
cannot make for you.
|
|
205
209
|
- **Activate** — `OrmBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported from
|
|
206
210
|
`xtr_orm.bundle`.
|
|
207
211
|
- **Brings along** — the logging and console bundles, when those packages are installed.
|
|
208
212
|
With the messenger bundle active too, the [middleware](#message-bus-middleware) are
|
|
209
213
|
registered for a bus to list.
|
|
210
|
-
- **Configure** —
|
|
211
|
-
`DATABASE_URL`,
|
|
212
|
-
against the models advanced-alchemy registers.
|
|
213
|
-
|
|
214
|
+
- **Configure** — the recipe writes a starting `<app>/config/orm.py` with the `default`
|
|
215
|
+
connection reading `DATABASE_URL`, its revisions in `migrations` in the project directory and
|
|
216
|
+
diffs against the models advanced-alchemy registers. Add connections, replicas and table
|
|
217
|
+
definitions to that `@configure` function returning `OrmConfig` — see
|
|
214
218
|
[Kernel / bundle](#kernel--bundle).
|
|
215
219
|
- **Environment** — `DATABASE_URL`, read when a connection is first used — engine, session,
|
|
216
220
|
migrator or command — not when the application boots. Its query may carry options; see
|
|
@@ -163,15 +163,19 @@ removing it undoes.
|
|
|
163
163
|
- **Install** — `uv add "xtr-orm[di,console,postgres]"`; `mysql` or `sqlite` instead of
|
|
164
164
|
`postgres` for those databases; drop `console` to go without the commands; add `messenger`
|
|
165
165
|
for the [message bus middleware](#message-bus-middleware).
|
|
166
|
+
- **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate*, *Configure* and
|
|
167
|
+
*Environment* steps below: it lists `OrmBundle`, writes a starting `<app>/config/orm.py` with the
|
|
168
|
+
`default` connection, and `DATABASE_URL` in `.env`. It prints the middleware step, which a recipe
|
|
169
|
+
cannot make for you.
|
|
166
170
|
- **Activate** — `OrmBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported from
|
|
167
171
|
`xtr_orm.bundle`.
|
|
168
172
|
- **Brings along** — the logging and console bundles, when those packages are installed.
|
|
169
173
|
With the messenger bundle active too, the [middleware](#message-bus-middleware) are
|
|
170
174
|
registered for a bus to list.
|
|
171
|
-
- **Configure** —
|
|
172
|
-
`DATABASE_URL`,
|
|
173
|
-
against the models advanced-alchemy registers.
|
|
174
|
-
|
|
175
|
+
- **Configure** — the recipe writes a starting `<app>/config/orm.py` with the `default`
|
|
176
|
+
connection reading `DATABASE_URL`, its revisions in `migrations` in the project directory and
|
|
177
|
+
diffs against the models advanced-alchemy registers. Add connections, replicas and table
|
|
178
|
+
definitions to that `@configure` function returning `OrmConfig` — see
|
|
175
179
|
[Kernel / bundle](#kernel--bundle).
|
|
176
180
|
- **Environment** — `DATABASE_URL`, read when a connection is first used — engine, session,
|
|
177
181
|
migrator or command — not when the application boots. Its query may carry options; see
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xtr-orm"
|
|
3
|
-
version = "
|
|
3
|
+
version = "3.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>=3.0,<4",
|
|
34
34
|
]
|
|
35
35
|
|
|
36
36
|
[[project.authors]]
|
|
@@ -42,15 +42,18 @@ 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>=3.0,<4",
|
|
46
|
+
"xtr-service-contracts>=3.0,<4",
|
|
47
47
|
]
|
|
48
|
-
console = ["xtr-console>=
|
|
49
|
-
messenger = ["xtr-messenger>=
|
|
48
|
+
console = ["xtr-console>=3.0,<4"]
|
|
49
|
+
messenger = ["xtr-messenger>=3.0,<4"]
|
|
50
50
|
|
|
51
51
|
[project.entry-points."xtr_dependency_injection.bundles"]
|
|
52
52
|
orm = "xtr_orm.bundle:OrmBundle"
|
|
53
53
|
|
|
54
|
+
[project.entry-points.xtr_recipes]
|
|
55
|
+
orm = "xtr_orm.recipe"
|
|
56
|
+
|
|
54
57
|
[dependency-groups]
|
|
55
58
|
dev = [
|
|
56
59
|
"basedpyright>=1.21",
|
|
@@ -60,11 +63,11 @@ dev = [
|
|
|
60
63
|
"anyio>=4.0",
|
|
61
64
|
"ty>=0.0.83",
|
|
62
65
|
"aiosqlite>=0.20",
|
|
63
|
-
"xtr-console>=
|
|
64
|
-
"xtr-dependency-injection>=
|
|
65
|
-
"xtr-logging>=
|
|
66
|
-
"xtr-messenger>=
|
|
67
|
-
"xtr-service-contracts>=
|
|
66
|
+
"xtr-console>=3.0,<4",
|
|
67
|
+
"xtr-dependency-injection>=3.0,<4",
|
|
68
|
+
"xtr-logging>=3.0,<4",
|
|
69
|
+
"xtr-messenger>=3.0,<4",
|
|
70
|
+
"xtr-service-contracts>=3.0,<4",
|
|
68
71
|
]
|
|
69
72
|
|
|
70
73
|
[build-system]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xtr-orm"
|
|
3
|
-
version = "
|
|
3
|
+
version = "3.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>=3.0,<4",
|
|
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>=3.0,<4",
|
|
44
|
+
"xtr-service-contracts>=3.0,<4",
|
|
45
45
|
]
|
|
46
46
|
console = [
|
|
47
|
-
"xtr-console>=
|
|
47
|
+
"xtr-console>=3.0,<4",
|
|
48
48
|
]
|
|
49
49
|
messenger = [
|
|
50
|
-
"xtr-messenger>=
|
|
50
|
+
"xtr-messenger>=3.0,<4",
|
|
51
51
|
]
|
|
52
52
|
|
|
53
53
|
# Advertises the bundle so debug:bundles can name it when installed but not listed.
|
|
@@ -55,6 +55,10 @@ messenger = [
|
|
|
55
55
|
[project.entry-points."xtr_dependency_injection.bundles"]
|
|
56
56
|
orm = "xtr_orm.bundle:OrmBundle"
|
|
57
57
|
|
|
58
|
+
# Advertises the recipe — what *Use in an application* takes — for recipes:sync.
|
|
59
|
+
[project.entry-points."xtr_recipes"]
|
|
60
|
+
orm = "xtr_orm.recipe"
|
|
61
|
+
|
|
58
62
|
[dependency-groups]
|
|
59
63
|
dev = [
|
|
60
64
|
"basedpyright>=1.21",
|
|
@@ -65,11 +69,11 @@ dev = [
|
|
|
65
69
|
"ty>=0.0.83",
|
|
66
70
|
# Every test runs on SQLite files under the test's own directory: no server.
|
|
67
71
|
"aiosqlite>=0.20",
|
|
68
|
-
"xtr-console>=
|
|
69
|
-
"xtr-dependency-injection>=
|
|
70
|
-
"xtr-logging>=
|
|
71
|
-
"xtr-messenger>=
|
|
72
|
-
"xtr-service-contracts>=
|
|
72
|
+
"xtr-console>=3.0,<4",
|
|
73
|
+
"xtr-dependency-injection>=3.0,<4",
|
|
74
|
+
"xtr-logging>=3.0,<4",
|
|
75
|
+
"xtr-messenger>=3.0,<4",
|
|
76
|
+
"xtr-service-contracts>=3.0,<4",
|
|
73
77
|
]
|
|
74
78
|
|
|
75
79
|
[build-system]
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: xtr-orm
|
|
3
|
+
description: How to configure database connections, inject async sessions and run versioned schema migrations with xtr-orm. Use when application code needs an AsyncEngine, an AsyncSession, a session per request or per message, read replicas, a DATABASE_URL, a new migration revision, a schema diff against models, the orm:migrations:* or orm:database:* console commands, one transaction per message on a bus, or when adding OrmBundle to an application on xtr-dependency-injection.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# xtr-orm
|
|
7
|
+
|
|
8
|
+
A connection is configuration. You name each database once — URL, replicas, engine and session
|
|
9
|
+
options, where its revisions live — and the bundle hands out the engine, the session maker, the
|
|
10
|
+
session of the current unit of work, a `Migrator` and a `DatabaseManager`, each qualified by the
|
|
11
|
+
connection's name. Everything is async; there is no sync counterpart.
|
|
12
|
+
|
|
13
|
+
## Quick reference
|
|
14
|
+
|
|
15
|
+
- Inject `Injected[AsyncSession]` for the default connection;
|
|
16
|
+
`Annotated[AsyncSession, Target("reports")]` for another. Never build a session yourself.
|
|
17
|
+
- Name databases in `<app>/config/orm.py` with a `@configure` function returning `OrmConfig`.
|
|
18
|
+
- Set `DATABASE_URL` with an async driver: `postgresql+asyncpg://`, `mysql+asyncmy://`,
|
|
19
|
+
`sqlite+aiosqlite:///`. Engine and session options may ride on its query.
|
|
20
|
+
- Write a revision from your models: `orm:migrations:diff --message "add invoices"`. Apply:
|
|
21
|
+
`orm:migrations:migrate`. Read it: `orm:migrations:status`.
|
|
22
|
+
- Without a container: `Migrator(engine, MigrationsConfig(directory="migrations"), Base.metadata)`.
|
|
23
|
+
- One transaction per message: list `orm_transaction` in the bus's `middleware`; handlers never
|
|
24
|
+
commit.
|
|
25
|
+
- On SQLite, set `MigrationsConfig(render_as_batch=True)` or table alterations cannot be written.
|
|
26
|
+
|
|
27
|
+
## Inject a session
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from typing import Annotated
|
|
31
|
+
|
|
32
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
|
33
|
+
from xtr_dependency_injection import Injected, Target, as_service
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@as_service
|
|
37
|
+
class InvoiceRepository:
|
|
38
|
+
def __init__(self, session: Injected[AsyncSession]) -> None:
|
|
39
|
+
self._session = session
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def report(
|
|
43
|
+
session: Annotated[AsyncSession, Target("reports")],
|
|
44
|
+
engine: Annotated[AsyncEngine, Target("reports")],
|
|
45
|
+
) -> None: ...
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Per connection, qualified by name — and unqualified for the default one:
|
|
49
|
+
|
|
50
|
+
| Service | What it is |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `AsyncSession` | The session of the current unit of work: a request, a message, a command. Closed, and rolled back when not committed, as the unit ends |
|
|
53
|
+
| `AsyncEngine` | The engine on the primary database. Disposed when the container closes |
|
|
54
|
+
| `async_sessionmaker[AsyncSession]` | The session maker; `RoutingAsyncSessionMaker` when the connection has replicas |
|
|
55
|
+
| `SQLAlchemyAsyncConfig` | advanced-alchemy's configuration, for its repositories and services |
|
|
56
|
+
| `Migrator` / `DatabaseManager` | The connection's revisions / its database, both on the primary |
|
|
57
|
+
| `ConnectionRegistry` | Every connection by name, unqualified — what the commands use |
|
|
58
|
+
|
|
59
|
+
A session is scoped to the unit of work: asking for one outside any raises
|
|
60
|
+
`SessionUnavailableError`. Do not cache a session on a singleton.
|
|
61
|
+
|
|
62
|
+
## Migrations
|
|
63
|
+
|
|
64
|
+
`Migrator` is the whole surface. Every method opens its own connection; `generate` reads files
|
|
65
|
+
only.
|
|
66
|
+
|
|
67
|
+
| Call | Does |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `await migrator.status()` | `current`, `latest`, `previous`, `next`, `available`, `executed`, `new`, `executed_unavailable`, `is_up_to_date` |
|
|
70
|
+
| `await migrator.migrate(target)` | Runs every revision to `target`; returns `ExecutionResult`s. `plan(target)` plans it, `migrate_sql(target)` writes the SQL |
|
|
71
|
+
| `await migrator.execute(versions, Direction.UP)` | Exactly those revisions, nothing before or after. `plan_for_versions(versions, direction)` and `execute_sql(...)` alongside |
|
|
72
|
+
| `migrator.generate("message")` | Writes an empty revision after the latest (not awaited) |
|
|
73
|
+
| `await migrator.diff("message")` | Writes what the table definitions add; takes `allow_empty=`, `from_empty_schema=` |
|
|
74
|
+
| `await migrator.dump_schema("message", table_filters=...)` | A first revision creating every table an existing database has |
|
|
75
|
+
| `await migrator.add_versions(v)` / `add_all_versions()` | Records revisions as applied, running nothing |
|
|
76
|
+
| `await migrator.delete_versions(v)` / `delete_all_versions()` | Records them as not applied |
|
|
77
|
+
| `await migrator.rollup()` | Forgets every applied revision, then records the single remaining one |
|
|
78
|
+
|
|
79
|
+
A **target** is a revision id (full or partial), a relative one (`+1`, `-2`, `ae10+1`), or
|
|
80
|
+
`latest`, `first`, `next`, `prev`, `current`. A revision already applied is migrated *down* to.
|
|
81
|
+
|
|
82
|
+
Two tables, in one schema: `alembic_version` holds where the database **is** and decides what a
|
|
83
|
+
migration runs; `alembic_version_history` holds a row per revision ever applied, with
|
|
84
|
+
`executed_at` and `execution_time` in milliseconds.
|
|
85
|
+
|
|
86
|
+
`execute` is refused before touching the database unless the version table stays truthful: going
|
|
87
|
+
up a revision must not be applied and must follow only applied ones; going down it must be applied
|
|
88
|
+
and followed by none that is. **Up to date** compares the database's versions with the revision
|
|
89
|
+
files, not its schema with your models; `diff` does the latter.
|
|
90
|
+
|
|
91
|
+
Without a container:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
95
|
+
from xtr_orm import MigrationsConfig, Migrator
|
|
96
|
+
|
|
97
|
+
from app.models import Base
|
|
98
|
+
|
|
99
|
+
engine = create_async_engine("sqlite+aiosqlite:///shop.db")
|
|
100
|
+
migrator = Migrator(
|
|
101
|
+
engine,
|
|
102
|
+
MigrationsConfig(directory="migrations", render_as_batch=True),
|
|
103
|
+
Base.metadata,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
revision = await migrator.diff("add invoices") # AvailableMigration, or None with nothing to do
|
|
107
|
+
await migrator.migrate()
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`MigrationsConfig` fields you reach for most: `directory` (relative to the working directory —
|
|
111
|
+
under a kernel start it with `%kernel.project_dir%`), `render_as_batch` (SQLite),
|
|
112
|
+
`compare_type` / `compare_server_default` (off by default), `version_table` / `history_table`,
|
|
113
|
+
`file_template`, `post_write_hooks` (a formatter), `transaction_per_migration`,
|
|
114
|
+
`template_directory`, `user_module_prefix`. See [references/migrations.md](references/migrations.md)
|
|
115
|
+
for every field and for writing your own `script.py.mako`.
|
|
116
|
+
|
|
117
|
+
## Console commands
|
|
118
|
+
|
|
119
|
+
Needs the `console` extra. Every command takes `--connection NAME`, defaulting to
|
|
120
|
+
`OrmConfig.default_connection`. The ones that change the database ask first; `-n` answers yes.
|
|
121
|
+
|
|
122
|
+
| Command | Does |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `orm:migrations:diff [--message M]` | Writes what the models add; the database must be up to date first |
|
|
125
|
+
| `orm:migrations:migrate [VERSION]` | Runs every revision to `VERSION`, `latest` by default |
|
|
126
|
+
| `orm:migrations:status` / `:list` / `:current` / `:latest` | Where the database stands |
|
|
127
|
+
| `orm:migrations:up-to-date` | Exits non-zero when revisions are not applied — for a deploy check |
|
|
128
|
+
| `orm:database:create` / `:drop --force` | The database itself |
|
|
129
|
+
| `orm:run-sql SQL` | Runs one statement, committed |
|
|
130
|
+
|
|
131
|
+
Nine more, and every flag: [references/commands.md](references/commands.md).
|
|
132
|
+
|
|
133
|
+
Deploy order: `orm:migrations:migrate` from **one** place, before the application starts —
|
|
134
|
+
migrations take no lock.
|
|
135
|
+
|
|
136
|
+
Without a container, hand the commands their connections once:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from xtr_orm import ConnectionRegistry, DatabaseManager
|
|
140
|
+
from xtr_orm.command import use_connections
|
|
141
|
+
|
|
142
|
+
registry = ConnectionRegistry()
|
|
143
|
+
registry.register("default", engine=engine, migrator=migrator, database=DatabaseManager(url))
|
|
144
|
+
use_connections(registry)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Use in an application
|
|
148
|
+
|
|
149
|
+
`uv run xtr-recipes recipes:sync` applies the recipe shipped with this package: it lists `OrmBundle`,
|
|
150
|
+
writes a starting `config/orm.py` with the `default` connection, and `DATABASE_URL` in `.env`. That
|
|
151
|
+
is the steps below a recipe can do; the middleware it prints for you to add.
|
|
152
|
+
|
|
153
|
+
1. **Install** — `uv add "xtr-orm[di,console,postgres]"`; `mysql` or `sqlite` instead of
|
|
154
|
+
`postgres`; drop `console` to go without the commands; add `messenger` for the bus middleware.
|
|
155
|
+
2. **Activate** — `OrmBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`
|
|
156
|
+
(`from xtr_orm.bundle import OrmBundle`).
|
|
157
|
+
3. **Brings along** — the logging and console bundles when those packages are installed, each
|
|
158
|
+
optional. With the logging bundle active an `orm` channel is added and every migrator logs the
|
|
159
|
+
revisions it runs there.
|
|
160
|
+
4. **Configure** — optional. With no configuration there is one connection, `default`, reading
|
|
161
|
+
`DATABASE_URL`, revisions in `migrations` in the project directory, diffing against the models
|
|
162
|
+
advanced-alchemy registers.
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
# <app>/config/orm.py
|
|
166
|
+
from xtr_dependency_injection import configure, env
|
|
167
|
+
from xtr_orm.bundle import ConnectionConfig, MigrationsConfig, OrmConfig
|
|
168
|
+
|
|
169
|
+
from app.models import Base
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@configure
|
|
173
|
+
def orm() -> OrmConfig:
|
|
174
|
+
return OrmConfig(
|
|
175
|
+
connections={
|
|
176
|
+
"default": ConnectionConfig(
|
|
177
|
+
url=env("DATABASE_URL"),
|
|
178
|
+
replicas={"replica1": env("DATABASE_REPLICA_URL")},
|
|
179
|
+
engine_options={"pool_size": 10, "pool_pre_ping": True},
|
|
180
|
+
session_options={"expire_on_commit": False},
|
|
181
|
+
metadata=Base.metadata,
|
|
182
|
+
migrations=MigrationsConfig(directory="%kernel.project_dir%/migrations"),
|
|
183
|
+
),
|
|
184
|
+
"reports": ConnectionConfig(url=env("REPORTS_URL"), bind_key="reports"),
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
5. **Environment** — `DATABASE_URL`, read when a connection is first used (engine, session,
|
|
190
|
+
migrator or command), not at boot. Its query may carry options — see
|
|
191
|
+
[references/connections.md](references/connections.md).
|
|
192
|
+
6. **Use** — inject `AsyncSession`; run `orm:migrations:*` from the console.
|
|
193
|
+
7. **Ignore** — nothing: commit `migrations/`.
|
|
194
|
+
8. **Check** — `debug:bundles` shows `orm` as `listed` and `active`, and
|
|
195
|
+
`orm:migrations:status` reaches the database.
|
|
196
|
+
9. **Remove** — take the `orm_*` entries out of every bus's `middleware`, drop the `BUNDLES`
|
|
197
|
+
entry, delete `<app>/config/orm.py`, then `uv remove xtr-orm`.
|
|
198
|
+
|
|
199
|
+
`OrmConfig` has two fields: `default_connection` (`"default"`) and `connections`
|
|
200
|
+
(`{"default": ConnectionConfig()}`). Two connections may not keep their revisions in one
|
|
201
|
+
directory; a non-default connection defaults to `%kernel.project_dir%/migrations/<name>`. Every
|
|
202
|
+
`ConnectionConfig` field, replicas and URL options: [references/connections.md](references/connections.md).
|
|
203
|
+
|
|
204
|
+
## One transaction per message
|
|
205
|
+
|
|
206
|
+
With the `messenger` extra and both bundles active, three middleware are registered by name.
|
|
207
|
+
Nothing is added to a bus by itself — the bus lists what it wants, and order matters.
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
# <app>/config/messenger.py
|
|
211
|
+
MessageBusConfig(
|
|
212
|
+
middleware=[
|
|
213
|
+
"orm_close_connection", # outermost: closes after the transaction ended
|
|
214
|
+
"orm_transaction",
|
|
215
|
+
{"orm_transaction": {"connection_name": "reports"}},
|
|
216
|
+
"orm_open_transaction_logger", # inside orm_transaction
|
|
217
|
+
],
|
|
218
|
+
)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
| Name | Class | Arguments |
|
|
222
|
+
|---|---|---|
|
|
223
|
+
| `orm_transaction` | `TransactionMiddleware` | `connection_name`, the default connection when left out |
|
|
224
|
+
| `orm_close_connection` | `CloseConnectionMiddleware` | `connection_names`, every connection in use when left out |
|
|
225
|
+
| `orm_open_transaction_logger` | `OpenTransactionLoggerMiddleware` | `connection_names`, as above |
|
|
226
|
+
|
|
227
|
+
A handler asks for `Injected[AsyncSession]` like any service and adds rows; it never commits.
|
|
228
|
+
|
|
229
|
+
- Every handler of a message shares one session per connection. A message dispatched while
|
|
230
|
+
another is handled joins its unit of work, in a savepoint of its own.
|
|
231
|
+
- A follow-up dispatched to a transport goes out at once unless stamped:
|
|
232
|
+
`await bus.dispatch(SendReceipt(order.id), DispatchAfterCurrentBusStamp())` sends it only after
|
|
233
|
+
the commit, and never after a rollback.
|
|
234
|
+
- Only a message a worker consumed closes connections afterwards; `sync://` closes nothing.
|
|
235
|
+
- Find a dropped connection with the engine option `pool_pre_ping`, not a middleware.
|
|
236
|
+
|
|
237
|
+
More, including building the middleware without a container:
|
|
238
|
+
[references/messenger.md](references/messenger.md).
|
|
239
|
+
|
|
240
|
+
## Creating and dropping a database
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
from xtr_orm import DatabaseManager
|
|
244
|
+
|
|
245
|
+
database = DatabaseManager("postgresql+asyncpg://app:secret@db/shop")
|
|
246
|
+
await database.exists()
|
|
247
|
+
await database.create(if_not_exists=True) # True when it created one
|
|
248
|
+
await database.drop(if_exists=True)
|
|
249
|
+
database.backend, database.database, database.safe_url # properties
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
The server is reached through its maintenance database — `postgres`, `master`, none on MySQL and
|
|
253
|
+
MariaDB — carrying every connection parameter of the URL but the database name. A SQLite database
|
|
254
|
+
is its file. Any other server raises `UnsupportedDatabaseError`.
|
|
255
|
+
|
|
256
|
+
## Errors
|
|
257
|
+
|
|
258
|
+
Every error derives from `OrmError`.
|
|
259
|
+
|
|
260
|
+
| Error | Raised when |
|
|
261
|
+
|---|---|
|
|
262
|
+
| `InvalidArgumentError` | A configuration cannot be used: an empty name, an unknown engine/session option, a URL option that does not read. Also a `ValueError` |
|
|
263
|
+
| `MigrationError` | A migration is refused or revisions cannot be read: an unknown or ambiguous version, a revision out of order, a diff of a database behind its revisions |
|
|
264
|
+
| `UnknownConnectionError` | A connection asked for by a name nothing registered. Also a `LookupError` |
|
|
265
|
+
| `UnsupportedDatabaseError` | A database created or dropped on a server whose statements are not known |
|
|
266
|
+
| `SessionUnavailableError` | A session asked for outside a unit of work, or of a connection registered without one |
|
|
267
|
+
|
|
268
|
+
## Do not
|
|
269
|
+
|
|
270
|
+
- Do not use a sync driver. `postgresql://` fails; write `postgresql+asyncpg://`.
|
|
271
|
+
- Do not commit in a handler under `orm_transaction`, and do not wrap one in
|
|
272
|
+
`async with session.begin()` — the transaction is already open and it is refused.
|
|
273
|
+
- Do not build engines or sessions by hand in application code, and do not hold a session past its
|
|
274
|
+
unit of work or store one on a singleton.
|
|
275
|
+
- Do not write an `env.py` or an `alembic.ini`; the package runs the revisions itself.
|
|
276
|
+
- Do not expect `up-to-date` to notice a model change — run `diff` — and do not run `diff` against
|
|
277
|
+
a database behind its revisions; migrate first.
|
|
278
|
+
- Do not leave `render_as_batch` off on SQLite.
|
|
279
|
+
- Do not run `orm:migrations:migrate` from several processes at once; it takes no lock.
|
|
280
|
+
- Do not rely on advanced-alchemy's process-wide model registry when two kernels share one
|
|
281
|
+
process: give each connection its `metadata`.
|
|
282
|
+
- Do not expect a timezone on `executed_at` outside PostgreSQL; it is naive elsewhere.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# The `orm:*` console commands
|
|
2
|
+
|
|
3
|
+
Needs the `console` extra: `uv add "xtr-orm[console]"`. With a container, the bundle declares them
|
|
4
|
+
when the console bundle is active, and each acts on the connections the bundle registered.
|
|
5
|
+
|
|
6
|
+
Every command takes `--connection NAME`; the default connection is used without it. The ones that
|
|
7
|
+
change the database ask first, and `-n` answers yes. `--message` names the revision and its file.
|
|
8
|
+
|
|
9
|
+
## The database
|
|
10
|
+
|
|
11
|
+
| Command | Flags |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `orm:database:create` | `--if-not-exists` — do nothing, successfully, when it exists |
|
|
14
|
+
| `orm:database:drop` | `--force` / `-f` — required, the confirmation that everything may be lost; `--if-exists` |
|
|
15
|
+
| `orm:run-sql SQL` | `--force-fetch` — show rows even when the statement does not look like it returns any. The statement is committed |
|
|
16
|
+
|
|
17
|
+
## Reading where the database stands
|
|
18
|
+
|
|
19
|
+
| Command | Shows |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `orm:migrations:current` | The revisions the version table holds; `base` for none |
|
|
22
|
+
| `orm:migrations:latest` | The revisions no other follows |
|
|
23
|
+
| `orm:migrations:list` | Every revision with its status, when it ran and how long it took |
|
|
24
|
+
| `orm:migrations:status` | The two tables, the database, previous / current / next / latest, and the counts |
|
|
25
|
+
| `orm:migrations:up-to-date` | Exits 1 with revisions not applied. `--fail-on-unregistered` / `-u` exits 2 for applied revisions whose file is gone; `--list-migrations` / `-l` names them |
|
|
26
|
+
|
|
27
|
+
`up-to-date` is the deploy check: it compares the database's versions with the revision files, not
|
|
28
|
+
its schema with your models.
|
|
29
|
+
|
|
30
|
+
## Running revisions
|
|
31
|
+
|
|
32
|
+
| Command | Flags |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `orm:migrations:migrate [VERSION]` | `VERSION` defaults to `latest`; also a full or partial id, a relative one (`+1`, `-2`), `first`, `next`, `prev`, `current`. `--dry-run` reports the plan; `--write-sql PATH` writes the SQL instead of running it; `--allow-no-migration` succeeds when there is nothing to run |
|
|
35
|
+
| `orm:migrations:execute VERSION...` | `--up` (the default) or `--down`; `--dry-run`; `--write-sql PATH`. Runs exactly those revisions and nothing before or after. Going up each must not be applied and must follow only applied ones; going down each must be applied and be followed by none that is |
|
|
36
|
+
|
|
37
|
+
`--write-sql` writes to the file named, or to a dated file in the directory named.
|
|
38
|
+
|
|
39
|
+
## Writing revisions
|
|
40
|
+
|
|
41
|
+
| Command | Flags |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `orm:migrations:generate` | `--message M`. An empty revision after the latest, to fill in by hand |
|
|
44
|
+
| `orm:migrations:diff` | `--message M`; `--allow-empty-diff` writes a revision even when nothing differs; `--from-empty-schema` writes every table as if the database were empty. The database must be at the latest revisions first |
|
|
45
|
+
| `orm:migrations:dump-schema` | `--message M`; `--filter-tables RE`, repeatable, dumps only the tables whose name the expression finds. For a database that predates its revisions, so there must be none yet. Written, not applied — follow with `orm:migrations:rollup` |
|
|
46
|
+
|
|
47
|
+
## Recording without running
|
|
48
|
+
|
|
49
|
+
| Command | Flags |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `orm:migrations:version [VERSION]` | Exactly one of `--add` / `--delete`; `--all` for every revision instead of one. Adding, the revision must follow only applied ones; deleting, no applied revision may follow it. An applied revision whose file is gone can always be deleted |
|
|
52
|
+
| `orm:migrations:rollup` | Forgets every applied revision, then records the single remaining one as all there is |
|
|
53
|
+
|
|
54
|
+
## Without a container
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from xtr_orm import ConnectionRegistry, DatabaseManager
|
|
58
|
+
from xtr_orm.command import use_connections
|
|
59
|
+
|
|
60
|
+
registry = ConnectionRegistry()
|
|
61
|
+
registry.register("default", engine=engine, migrator=migrator, database=DatabaseManager(url))
|
|
62
|
+
use_connections(registry)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The command classes are exported from `xtr_orm.command` — `MigrationsMigrateCommand`,
|
|
66
|
+
`MigrationsDiffCommand`, `DatabaseCreateCommand` and the rest — for a console application to
|
|
67
|
+
register itself.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# ConnectionConfig, URL options and read replicas
|
|
2
|
+
|
|
3
|
+
`from xtr_orm.bundle import ConnectionConfig`. A frozen dataclass, buildable with no arguments.
|
|
4
|
+
|
|
5
|
+
| Field | Default | Meaning |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `url` | `"%env(DATABASE_URL)%"` | The database URL, with an async driver. Its query may carry options — see below |
|
|
8
|
+
| `engine_options` | `{}` | `echo`, `pool_size`, `max_overflow`, `pool_pre_ping`, `pool_recycle`, `isolation_level`, `connect_args`, `execution_options`, … |
|
|
9
|
+
| `session_options` | `{}` | `expire_on_commit`, `autoflush`, `info`, … |
|
|
10
|
+
| `alchemy_options` | `{}` | Any other option of advanced-alchemy's configuration, such as `enable_touch_updated_timestamp_listener` |
|
|
11
|
+
| `replicas` | `{}` | Read replicas, each a URL by name |
|
|
12
|
+
| `keep_replica` | `False` | Go back to the replicas once a session commits |
|
|
13
|
+
| `bind_key` | `None` | Which table definitions belong to this database when models declare several |
|
|
14
|
+
| `metadata` | `None` | The table definitions a diff compares the database with; those registered for `bind_key` when `None` |
|
|
15
|
+
| `migrations` | `None` | A `MigrationsConfig`; `%kernel.project_dir%/migrations` for the default connection, `.../migrations/<name>` for any other, when `None` |
|
|
16
|
+
|
|
17
|
+
An unknown key in `engine_options`, `session_options` or `alchemy_options` raises
|
|
18
|
+
`InvalidArgumentError` at configuration time, naming the options that exist. Options the bundle
|
|
19
|
+
sets itself — `connection_string`, `engine_config`, `session_config`, `bind_key`, `metadata`,
|
|
20
|
+
`engine_instance`, `session_maker`, `alembic_config`, `create_engine_callable`,
|
|
21
|
+
`session_maker_class`, `routing_config` — are refused in `alchemy_options`.
|
|
22
|
+
|
|
23
|
+
An empty `url`, an empty replica name or an empty replica URL is refused too. The default
|
|
24
|
+
`"%env(DATABASE_URL)%"` is a placeholder resolved when the connection is first used, so a missing
|
|
25
|
+
variable fails that use, not the boot.
|
|
26
|
+
|
|
27
|
+
## Options in the URL
|
|
28
|
+
|
|
29
|
+
One environment variable can configure a whole connection. An engine option, a session option or
|
|
30
|
+
`keep_replica` is taken out of the query and merged over what the configuration set — **the URL
|
|
31
|
+
wins**. Every other query parameter stays on the URL for the driver.
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
DATABASE_URL="postgresql+asyncpg://app:secret@db/shop?pool_size=20&expire_on_commit=false&ssl=require"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
| Read from the URL | Options |
|
|
38
|
+
|---|---|
|
|
39
|
+
| Engine | `echo`, `echo_pool`, `enable_from_linting`, `hide_parameters`, `insertmanyvalues_page_size`, `isolation_level`, `label_length`, `logging_name`, `max_identifier_length`, `max_overflow`, `paramstyle`, `pool_logging_name`, `pool_pre_ping`, `pool_recycle`, `pool_size`, `pool_timeout`, `pool_use_lifo`, `query_cache_size`, `use_insertmanyvalues` |
|
|
40
|
+
| Session | `autobegin`, `autoflush`, `expire_on_commit`, `join_transaction_mode`, `twophase` |
|
|
41
|
+
| Connection | `keep_replica` |
|
|
42
|
+
|
|
43
|
+
Flags read `1`/`true`/`yes`/`on` and `0`/`false`/`no`/`off`; numbers are numbers; `echo` and
|
|
44
|
+
`echo_pool` also take `debug`. A value that does not read raises `InvalidArgumentError` naming the
|
|
45
|
+
option, failing the service that needed the URL. Only the connection's own URL is read this way,
|
|
46
|
+
never a replica's.
|
|
47
|
+
|
|
48
|
+
## Read replicas
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
ConnectionConfig(
|
|
52
|
+
url=env("DATABASE_URL"),
|
|
53
|
+
replicas={"replica1": env("DATABASE_REPLICA_URL"), "replica2": env("DATABASE_REPLICA2_URL")},
|
|
54
|
+
keep_replica=False,
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- A session reads from a replica picked at random.
|
|
59
|
+
- It goes to the primary for a write, for a transaction's statements, and for every read after
|
|
60
|
+
them, so it sees what it wrote.
|
|
61
|
+
- It stays on the primary for the rest of its unit of work, unless `keep_replica=True` sends it
|
|
62
|
+
back to the replicas once it commits.
|
|
63
|
+
- Each unit of work starts on the replicas, whatever the one before wrote.
|
|
64
|
+
- Migrations and the `orm:database:*` commands always use the primary.
|
|
65
|
+
- The injected session maker is a `RoutingAsyncSessionMaker` instead of an `async_sessionmaker`.
|
|
66
|
+
- Send one block elsewhere with `primary_context()` / `replica_context()` from
|
|
67
|
+
`advanced_alchemy.routing`.
|
|
68
|
+
|
|
69
|
+
## Without a container
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
73
|
+
from xtr_orm import ConnectionRegistry, DatabaseManager, MigrationsConfig, Migrator
|
|
74
|
+
|
|
75
|
+
url = "sqlite+aiosqlite:///shop.db"
|
|
76
|
+
engine = create_async_engine(url)
|
|
77
|
+
|
|
78
|
+
registry = ConnectionRegistry() # ConnectionRegistry(default="primary") renames the default
|
|
79
|
+
registry.register(
|
|
80
|
+
"default",
|
|
81
|
+
engine=engine,
|
|
82
|
+
migrator=Migrator(engine, MigrationsConfig(directory="migrations", render_as_batch=True)),
|
|
83
|
+
database=DatabaseManager(url),
|
|
84
|
+
session=None, # an async callable returning the unit of work's session; the middleware need it
|
|
85
|
+
close=None, # an async callable; the engine is disposed when left out
|
|
86
|
+
in_use=None, # a callable; the connection always counts as in use when left out
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
await registry.engine() # the default connection's
|
|
90
|
+
await registry.migrator("reports")
|
|
91
|
+
registry.names(), registry.in_use(), registry.has("reports"), registry.default
|
|
92
|
+
await registry.close()
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Each of `engine`, `migrator` and `database` takes the object itself or an async function building
|
|
96
|
+
it, called only when that piece is first asked for. `DEFAULT_CONNECTION` (`"default"`) is exported
|
|
97
|
+
from `xtr_orm`.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Message bus middleware
|
|
2
|
+
|
|
3
|
+
Needs the `messenger` extra: `uv add "xtr-orm[messenger]"`. With both bundles active, importing
|
|
4
|
+
`xtr_orm.messenger` is done for you and each middleware is registered under its name for a bus
|
|
5
|
+
configuration to list.
|
|
6
|
+
|
|
7
|
+
| Name | Class | Does | Arguments |
|
|
8
|
+
|---|---|---|---|
|
|
9
|
+
| `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 |
|
|
10
|
+
| `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 when left out |
|
|
11
|
+
| `orm_open_transaction_logger` | `OpenTransactionLoggerMiddleware` | Logs an error when a handler began a transaction (`begin()`, `begin_nested()`) and left it open in a message that was handled | `connection_names`, as above |
|
|
12
|
+
|
|
13
|
+
"In use" means a connection whose engine has been built; opening one to find out would read its
|
|
14
|
+
configuration, so an untouched connection is left alone.
|
|
15
|
+
|
|
16
|
+
## Order
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
# <app>/config/messenger.py
|
|
20
|
+
MessageBusConfig(
|
|
21
|
+
middleware=[
|
|
22
|
+
"orm_close_connection",
|
|
23
|
+
"orm_transaction",
|
|
24
|
+
{"orm_transaction": {"connection_name": "reports"}},
|
|
25
|
+
"orm_open_transaction_logger",
|
|
26
|
+
],
|
|
27
|
+
)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- `orm_close_connection` outermost, so it closes after the transaction ended.
|
|
31
|
+
- `orm_open_transaction_logger` inside `orm_transaction`, so it looks before the commit closes
|
|
32
|
+
everything.
|
|
33
|
+
- A second `orm_transaction` with another `connection_name` puts that connection in a transaction
|
|
34
|
+
too.
|
|
35
|
+
|
|
36
|
+
## Handlers
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
# app/billing/handlers.py
|
|
40
|
+
@as_message_handler(IssueInvoice)
|
|
41
|
+
async def issue(message: IssueInvoice, session: Injected[AsyncSession]) -> None:
|
|
42
|
+
session.add(Invoice(order_id=message.order_id)) # no commit
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@as_message_handler(IssueInvoice)
|
|
46
|
+
class NotifyAccounting:
|
|
47
|
+
# A scoped repository on the same session: one transaction for both handlers.
|
|
48
|
+
async def __call__(
|
|
49
|
+
self, message: IssueInvoice, invoices: Injected[InvoiceRepository]
|
|
50
|
+
) -> None: ...
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- **One session per message.** Every handler of the message, and every scoped service built on the
|
|
54
|
+
session, gets the same one, released once the message is done with.
|
|
55
|
+
- **A nested dispatch joins the unit of work** and its transaction, in a savepoint of its own: when
|
|
56
|
+
it fails only its handlers' work is rolled back, and the handler that dispatched it decides
|
|
57
|
+
whether the rest goes on.
|
|
58
|
+
- **Handlers do not commit.** One that does ends the transaction early; one using
|
|
59
|
+
`async with session.begin()` is refused, the transaction being open already.
|
|
60
|
+
- **Follow-ups wait for the commit only when stamped.** A message dispatched to a transport is
|
|
61
|
+
sent at once, before the commit and whether or not it comes:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
await bus.dispatch(SendReceipt(order.id), DispatchAfterCurrentBusStamp())
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
With that stamp it goes out only once the message being handled was committed, and never after a
|
|
68
|
+
rollback.
|
|
69
|
+
- **Only messages a worker consumed** — one carrying a `ReceivedStamp` on arrival — close
|
|
70
|
+
connections afterwards. A message handled during its dispatch, `sync://` included, closes
|
|
71
|
+
nothing. A worker handling several at once closes once the last is done.
|
|
72
|
+
- **A dropped connection** is found by the engine option `pool_pre_ping` as a connection is taken
|
|
73
|
+
from the pool (`?pool_pre_ping=true` on a worker's URL), so there is no middleware for it.
|
|
74
|
+
|
|
75
|
+
## Without a container
|
|
76
|
+
|
|
77
|
+
Build the middleware yourself and put the instances in the bus configuration. They need a
|
|
78
|
+
`ConnectionRegistry` whose connections were registered with a `session` callable — what returns the
|
|
79
|
+
session the handlers use — or `registry.session()` raises `SessionUnavailableError`.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from xtr_orm.messenger import (
|
|
83
|
+
CloseConnectionMiddleware,
|
|
84
|
+
OpenTransactionLoggerMiddleware,
|
|
85
|
+
TransactionMiddleware,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
middleware = [
|
|
89
|
+
CloseConnectionMiddleware(registry),
|
|
90
|
+
TransactionMiddleware(registry),
|
|
91
|
+
OpenTransactionLoggerMiddleware(registry),
|
|
92
|
+
]
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`connection_names` takes one name or a sequence of them.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# MigrationsConfig and revision templates
|
|
2
|
+
|
|
3
|
+
A frozen dataclass; every field has a default, so `MigrationsConfig()` is valid.
|
|
4
|
+
Import it from `xtr_orm` or from `xtr_orm.bundle`.
|
|
5
|
+
|
|
6
|
+
| Field | Default | Meaning |
|
|
7
|
+
|---|---|---|
|
|
8
|
+
| `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%` |
|
|
9
|
+
| `version_table` | `"alembic_version"` | Holds the revisions the database is at |
|
|
10
|
+
| `history_table` | `"alembic_version_history"` | A row per revision ever applied: `version`, `executed_at`, `execution_time` in milliseconds |
|
|
11
|
+
| `version_table_schema` | `None` | The schema of both; the connection's default when `None` |
|
|
12
|
+
| `file_template` | `"{year}{month}{day}{hour}{minute}{second}_{rev}_{slug}"` | How a file is named; `{epoch}` is available too |
|
|
13
|
+
| `truncate_slug_length` | `40` | The longest message part of a file name |
|
|
14
|
+
| `timezone` | `None` | The zone a revision's date is written in |
|
|
15
|
+
| `template_directory` | `None` | A directory holding a `script.py.mako` to render new revisions from |
|
|
16
|
+
| `recursive_version_locations` | `False` | Read subdirectories of `directory` too |
|
|
17
|
+
| `post_write_hooks` | `()` | Programs run on each file written, such as a formatter |
|
|
18
|
+
| `transaction_per_migration` | `False` | Commit after each revision rather than once |
|
|
19
|
+
| `transactional_ddl` | `None` | Run schema changes inside the transaction |
|
|
20
|
+
| `compare_type` | `False` | Compare column types in a diff |
|
|
21
|
+
| `compare_server_default` | `False` | Compare server defaults in a diff |
|
|
22
|
+
| `render_as_batch` | `False` | Write table changes as batches. **SQLite needs this** |
|
|
23
|
+
| `include_schemas` | `False` | Read other schemas in a diff |
|
|
24
|
+
| `include_name` / `include_object` | `None` | Callables deciding what a diff reads |
|
|
25
|
+
| `render_item` | `None` | Callable deciding how an item is written |
|
|
26
|
+
| `process_revision_directives` | `None` | Callable adjusting a diff before it is written |
|
|
27
|
+
| `user_module_prefix` | `None` | The prefix custom column types are written with |
|
|
28
|
+
| `context_options` | `{}` | Any other migration context option, passed through |
|
|
29
|
+
|
|
30
|
+
## Custom column types in a diff
|
|
31
|
+
|
|
32
|
+
A custom type is written under its own module's name, and the revision imports that module:
|
|
33
|
+
`advanced_alchemy.types.guid.GUID(length=16)` plus `import advanced_alchemy.types.guid`. Set
|
|
34
|
+
`user_module_prefix` or `render_item` to write it otherwise.
|
|
35
|
+
|
|
36
|
+
`compare_type` is off by default on purpose: a type the database stores as another — a UUID kept
|
|
37
|
+
as bytes on SQLite — would otherwise differ in every diff.
|
|
38
|
+
|
|
39
|
+
## Your own revision template
|
|
40
|
+
|
|
41
|
+
New revisions render from `script.py.mako`: the package's own, or the one in
|
|
42
|
+
`template_directory`. Nothing else is read from that directory.
|
|
43
|
+
|
|
44
|
+
The template receives `message`, `up_revision`, `down_revision`, `branch_labels`, `depends_on`,
|
|
45
|
+
`create_date`, `imports`, `upgrades`, `downgrades`, and the `comma` filter
|
|
46
|
+
(`${down_revision | comma,n}`). A template written for a plain migration-runner project works as
|
|
47
|
+
it is.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
MigrationsConfig(
|
|
51
|
+
template_directory="%kernel.project_dir%/resources/migrations",
|
|
52
|
+
user_module_prefix="sa.", # for a template aliasing sa.GUID = GUID
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A template calling `op.get_context().autocommit_block()` commits the revision's schema changes
|
|
57
|
+
before the version and history tables are written, and gives up the transaction a failing
|
|
58
|
+
revision would roll back. Set `transaction_per_migration=True` alongside it, and keep it to the
|
|
59
|
+
revisions that need it (`CREATE INDEX CONCURRENTLY`).
|
|
60
|
+
|
|
61
|
+
## Result objects
|
|
62
|
+
|
|
63
|
+
| Type | Fields |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `MigrationPlan` | `version`, `direction: Direction`, `description` |
|
|
66
|
+
| `ExecutionResult` | `version`, `direction: Direction`, `duration` in seconds |
|
|
67
|
+
| `AvailableMigration` | `version`, `description`, `down_versions`, `path`, `is_head` |
|
|
68
|
+
| `ExecutedMigration` | the recorded row: its version and when it ran |
|
|
69
|
+
| `MigrationStatus` | `current`, `latest`, `previous`, `next`, `available`, `executed`, `new`, `executed_unavailable`, `is_up_to_date` |
|
|
70
|
+
|
|
71
|
+
`Direction` is an `Enum` with `Direction.UP` and `Direction.DOWN`.
|
|
@@ -105,25 +105,30 @@ class OrmBundle(Bundle[OrmConfig]):
|
|
|
105
105
|
builder: ContainerBuilder,
|
|
106
106
|
) -> None:
|
|
107
107
|
"""Register every connection's services under its name, the registry, the commands."""
|
|
108
|
-
for name,
|
|
108
|
+
for name, connection in config.connections.items():
|
|
109
109
|
maker_type: type = (
|
|
110
|
-
RoutingAsyncSessionMaker
|
|
110
|
+
RoutingAsyncSessionMaker
|
|
111
|
+
if connection.replicas
|
|
112
|
+
else async_sessionmaker[AsyncSession]
|
|
111
113
|
)
|
|
112
114
|
factories = (
|
|
113
|
-
(_alchemy_factory(name,
|
|
115
|
+
(_alchemy_factory(name, connection), "alchemy"),
|
|
114
116
|
(_engine_factory(name), "engine"),
|
|
115
117
|
(
|
|
116
|
-
_routing_maker_factory(name)
|
|
118
|
+
_routing_maker_factory(name)
|
|
119
|
+
if connection.replicas
|
|
120
|
+
else _session_maker_factory(name),
|
|
117
121
|
"session_maker",
|
|
118
122
|
),
|
|
119
|
-
(_migrator_factory(name,
|
|
120
|
-
(_database_factory(
|
|
123
|
+
(_migrator_factory(name, connection), "migrator"),
|
|
124
|
+
(_database_factory(connection), "database"),
|
|
121
125
|
)
|
|
122
126
|
for factory, kind in factories:
|
|
123
127
|
_ = services.set(named_factory(factory, f"orm_{kind}_{name}"), qualifier=name)
|
|
124
128
|
_ = services.set(
|
|
125
129
|
named_factory(
|
|
126
|
-
_session_factory(name, routed=bool(
|
|
130
|
+
_session_factory(name, routed=bool(connection.replicas)),
|
|
131
|
+
f"orm_session_{name}",
|
|
127
132
|
),
|
|
128
133
|
qualifier=name,
|
|
129
134
|
lifetime="scoped",
|
|
@@ -133,7 +138,9 @@ class OrmBundle(Bundle[OrmConfig]):
|
|
|
133
138
|
services.alias(service, service, target_qualifier=name)
|
|
134
139
|
|
|
135
140
|
_ = services.set(_ConnectionsInUse)
|
|
136
|
-
routed = {
|
|
141
|
+
routed = {
|
|
142
|
+
name: bool(connection.replicas) for name, connection in config.connections.items()
|
|
143
|
+
}
|
|
137
144
|
_ = services.set(_connection_registry_factory(config.default_connection, routed))
|
|
138
145
|
|
|
139
146
|
if bundle_active(builder, "console"):
|
|
@@ -165,24 +172,24 @@ def _alchemy_factory(
|
|
|
165
172
|
"""
|
|
166
173
|
|
|
167
174
|
async def alchemy(in_use: _ConnectionsInUse) -> AsyncIterator[SQLAlchemyAsyncConfig]:
|
|
168
|
-
|
|
175
|
+
resolved = connection.with_url_options()
|
|
169
176
|
# The options are forwarded by name, each checked against the target's
|
|
170
177
|
# fields by ConnectionConfig; their values are the application's.
|
|
171
|
-
engine = cast("Callable[..., EngineConfig]", EngineConfig)(**
|
|
178
|
+
engine = cast("Callable[..., EngineConfig]", EngineConfig)(**resolved.engine_options)
|
|
172
179
|
session = cast("Callable[..., AsyncSessionConfig]", AsyncSessionConfig)(
|
|
173
|
-
**
|
|
180
|
+
**resolved.session_options
|
|
174
181
|
)
|
|
175
182
|
built = cast("Callable[..., SQLAlchemyAsyncConfig]", SQLAlchemyAsyncConfig)(
|
|
176
183
|
# With replicas, the primary is given through the routing instead.
|
|
177
|
-
connection_string=None if
|
|
178
|
-
routing_config=_routing(
|
|
184
|
+
connection_string=None if resolved.replicas else resolved.url,
|
|
185
|
+
routing_config=_routing(resolved) if resolved.replicas else None,
|
|
179
186
|
engine_config=engine,
|
|
180
187
|
session_config=session,
|
|
181
|
-
bind_key=
|
|
182
|
-
**
|
|
188
|
+
bind_key=resolved.bind_key,
|
|
189
|
+
**resolved.alchemy_options,
|
|
183
190
|
)
|
|
184
191
|
routing_maker: RoutingAsyncSessionMaker | None = None
|
|
185
|
-
if
|
|
192
|
+
if resolved.replicas:
|
|
186
193
|
routing_maker = cast("RoutingAsyncSessionMaker", built.create_session_maker())
|
|
187
194
|
# One pool on the primary: the routing's, which migrations use too.
|
|
188
195
|
built.engine_instance = routing_maker.primary_engine
|
|
@@ -198,7 +205,7 @@ def _alchemy_factory(
|
|
|
198
205
|
return alchemy
|
|
199
206
|
|
|
200
207
|
|
|
201
|
-
def _routing(
|
|
208
|
+
def _routing(connection: ConnectionConfig) -> RoutingConfig:
|
|
202
209
|
"""Map the connection's replicas onto the database layer's read/write routing.
|
|
203
210
|
|
|
204
211
|
Reads go to a replica picked at random; a write, a transaction and every
|
|
@@ -207,14 +214,14 @@ def _routing(spec: ConnectionConfig) -> RoutingConfig:
|
|
|
207
214
|
built.
|
|
208
215
|
"""
|
|
209
216
|
return RoutingConfig(
|
|
210
|
-
primary_connection_string=
|
|
217
|
+
primary_connection_string=connection.url,
|
|
211
218
|
read_replicas=[
|
|
212
219
|
ReplicaEngine(connection_string=url, name=replica)
|
|
213
|
-
for replica, url in
|
|
220
|
+
for replica, url in connection.replicas.items()
|
|
214
221
|
],
|
|
215
222
|
routing_strategy=RoutingStrategy.RANDOM,
|
|
216
223
|
sticky_after_write=True,
|
|
217
|
-
reset_stickiness_on_commit=
|
|
224
|
+
reset_stickiness_on_commit=connection.keep_replica,
|
|
218
225
|
)
|
|
219
226
|
|
|
220
227
|
|
|
@@ -281,13 +288,15 @@ def _migrator_factory(
|
|
|
281
288
|
"""Build the factory of the connection ``name``'s migrator, logging to ``orm`` when it can."""
|
|
282
289
|
|
|
283
290
|
async def migrator(container: ContainerInterface) -> Migrator:
|
|
284
|
-
|
|
291
|
+
resolved = connection.with_url_options()
|
|
285
292
|
metadata = (
|
|
286
|
-
|
|
293
|
+
resolved.metadata
|
|
294
|
+
if resolved.metadata is not None
|
|
295
|
+
else metadata_registry.get(resolved.bind_key)
|
|
287
296
|
)
|
|
288
297
|
built = Migrator(
|
|
289
298
|
await container.get(AsyncEngine, name),
|
|
290
|
-
|
|
299
|
+
resolved.migrations or MigrationsConfig(),
|
|
291
300
|
metadata,
|
|
292
301
|
name=name,
|
|
293
302
|
)
|
|
@@ -303,10 +312,10 @@ def _database_factory(connection: ConnectionConfig) -> Callable[[], DatabaseMana
|
|
|
303
312
|
"""Build the factory of ``connection``'s database manager."""
|
|
304
313
|
|
|
305
314
|
def database() -> DatabaseManager:
|
|
306
|
-
|
|
307
|
-
connect_args =
|
|
315
|
+
resolved = connection.with_url_options()
|
|
316
|
+
connect_args = resolved.engine_options.get("connect_args")
|
|
308
317
|
return DatabaseManager(
|
|
309
|
-
|
|
318
|
+
resolved.url,
|
|
310
319
|
connect_args=connect_args if isinstance(connect_args, dict) else None, # pyright: ignore[reportUnknownArgumentType] -- an option mapping, read as given
|
|
311
320
|
)
|
|
312
321
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""This directory holds the package's recipe: its manifest and its templates.
|
|
2
|
+
|
|
3
|
+
The recipe is what *Use in an application* in the README lists, written as
|
|
4
|
+
data: the bundle to list, the environment variable to declare, and the steps
|
|
5
|
+
only a person can take. An application applies it with
|
|
6
|
+
``xtr-recipes recipes:sync``, which finds this module through the
|
|
7
|
+
``xtr_recipes`` entry point.
|
|
8
|
+
|
|
9
|
+
Nothing is importable from here. The manifest is read as a file, so a recipe
|
|
10
|
+
is the same from a wheel as from a working tree.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""The database: one connection, its URL read from ``DATABASE_URL``.
|
|
2
|
+
|
|
3
|
+
The URL is read when a connection is first used — an engine, a session, a migrator or a
|
|
4
|
+
command — not when the application boots, and its query may carry engine and session
|
|
5
|
+
options. Revisions live in ``migrations/`` in the project directory. Add replicas, more
|
|
6
|
+
connections or the metadata of your own models here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from xtr_dependency_injection import configure, env
|
|
12
|
+
from xtr_orm.bundle import ConnectionConfig, OrmConfig
|
|
13
|
+
|
|
14
|
+
__all__ = ["orm"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@configure
|
|
18
|
+
def orm() -> OrmConfig:
|
|
19
|
+
"""The ``default`` connection, on ``DATABASE_URL``."""
|
|
20
|
+
return OrmConfig(
|
|
21
|
+
connections={
|
|
22
|
+
"default": ConnectionConfig(url=env("DATABASE_URL")),
|
|
23
|
+
},
|
|
24
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# The recipe for xtr-orm: what *Use in an application* in the README takes, as
|
|
2
|
+
# data. Keep the two in step — the README is the source.
|
|
3
|
+
|
|
4
|
+
[bundles]
|
|
5
|
+
# "<module>:<class>" = the environments it is listed with, exactly as in BUNDLES.
|
|
6
|
+
"xtr_orm.bundle:OrmBundle" = { all = true }
|
|
7
|
+
|
|
8
|
+
[files]
|
|
9
|
+
# Destination under the application package = template under this recipe.
|
|
10
|
+
# The default connection on DATABASE_URL, written out so the application has
|
|
11
|
+
# one place to add replicas, connections and its models' metadata.
|
|
12
|
+
"config/orm.py" = "files/config/orm.py.tmpl"
|
|
13
|
+
|
|
14
|
+
[env]
|
|
15
|
+
# Read when a connection is first used — engine, session, migrator or command —
|
|
16
|
+
# not when the application boots. A local PostgreSQL to start from, for the
|
|
17
|
+
# postgres extra the README installs; change it, or put the real one in
|
|
18
|
+
# .env.local.
|
|
19
|
+
DATABASE_URL = "postgresql+asyncpg://app:change-me@127.0.0.1:5432/app"
|
|
20
|
+
|
|
21
|
+
[gitignore]
|
|
22
|
+
# Nothing: migrations/ is committed.
|
|
23
|
+
lines = []
|
|
24
|
+
|
|
25
|
+
[notes]
|
|
26
|
+
steps = [
|
|
27
|
+
"With the messenger bundle active, list the orm_* middleware in every bus's middleware: every message is then a unit of work.",
|
|
28
|
+
]
|
|
29
|
+
check = [
|
|
30
|
+
"<script> debug:bundles — orm is listed and active",
|
|
31
|
+
"<script> orm:migrations:status — it reaches the database",
|
|
32
|
+
]
|
|
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
|
|
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
|