pgsqlasync2fast-fastapi 0.4.0__tar.gz → 0.5.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.
- {pgsqlasync2fast_fastapi-0.4.0/src/pgsqlasync2fast_fastapi.egg-info → pgsqlasync2fast_fastapi-0.5.0}/PKG-INFO +2 -1
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/pyproject.toml +6 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/__init__.py +21 -0
- pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi/__version__.py +1 -0
- pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi/migrations/__init__.py +49 -0
- pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi/migrations/env.py +120 -0
- pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi/migrations/registry.py +70 -0
- pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi/migrations/runner.py +163 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/seeder.py +59 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/skills/SKILL.md +8 -2
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi.egg-info}/PKG-INFO +2 -1
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi.egg-info/SOURCES.txt +7 -1
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi.egg-info/requires.txt +1 -0
- pgsqlasync2fast_fastapi-0.5.0/tests/test_migration_chains.py +411 -0
- pgsqlasync2fast_fastapi-0.5.0/tests/test_sync_sequence.py +201 -0
- pgsqlasync2fast_fastapi-0.4.0/src/pgsqlasync2fast_fastapi/__version__.py +0 -1
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/LICENSE +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/MANIFEST.in +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/README.md +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/docs/.env.example +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/docs/seeder-format.md +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/basic_usage.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/database_creation.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/fastapi_integration.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/multi_database.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/dev/categories.json +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/dev/permissions.json +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/dev/roles.json +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/manifest.json +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/prod/categories.json +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/prod/permissions.json +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/prod/roles.json +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/setup.cfg +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/connection.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/database.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/dependencies.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/settings.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi.egg-info/dependency_links.txt +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi.egg-info/top_level.txt +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/tests/test_completo.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/tests/test_insert_if_missing.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/tests/test_seeder.py +0 -0
- {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/tests/test_seeder_override.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pgsqlasync2fast-fastapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Simple and fast PostgreSQL async module for FastAPI with multi-database support
|
|
5
5
|
Author-email: Angel Daniel Sanchez Castillo <angeldaniel.sanchezcastillo@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -50,6 +50,7 @@ Requires-Dist: sqlalchemy>=2.0.0
|
|
|
50
50
|
Requires-Dist: sqlmodel>=0.0.22
|
|
51
51
|
Requires-Dist: asyncpg>=0.29.0
|
|
52
52
|
Requires-Dist: greenlet>=3.0.0
|
|
53
|
+
Requires-Dist: alembic==1.20.0
|
|
53
54
|
Dynamic: license-file
|
|
54
55
|
|
|
55
56
|
# pgsqlasync2fast-fastapi
|
|
@@ -34,6 +34,9 @@ dependencies = [
|
|
|
34
34
|
"sqlmodel>=0.0.22",
|
|
35
35
|
"asyncpg>=0.29.0",
|
|
36
36
|
"greenlet>=3.0.0",
|
|
37
|
+
# Exact pin per the alembic-2fast migration change: deliberate re-pins
|
|
38
|
+
# require a version bump and a full test run. Never use a range here.
|
|
39
|
+
"alembic==1.20.0",
|
|
37
40
|
]
|
|
38
41
|
|
|
39
42
|
[dependency-groups]
|
|
@@ -72,6 +75,9 @@ version = {attr = "pgsqlasync2fast_fastapi.__version__.__version__"}
|
|
|
72
75
|
[tool.setuptools.package-data]
|
|
73
76
|
pgsqlasync2fast_fastapi = [
|
|
74
77
|
"skills/*.md",
|
|
78
|
+
# Shared Alembic env implementation + any future chain data shipped as
|
|
79
|
+
# wheel package data (resolved from the installed release, never a checkout).
|
|
80
|
+
"migrations/**/*.py",
|
|
75
81
|
]
|
|
76
82
|
|
|
77
83
|
[tool.pytest.ini_options]
|
|
@@ -52,6 +52,19 @@ from pgsqlasync2fast_fastapi.seeder import (
|
|
|
52
52
|
seed_all,
|
|
53
53
|
# Shared idempotent insert-if-missing primitive
|
|
54
54
|
insert_if_missing,
|
|
55
|
+
# Shared PostgreSQL sequence re-sync primitive
|
|
56
|
+
sync_table_sequence,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Re-export migration-specific exports
|
|
60
|
+
from pgsqlasync2fast_fastapi.migrations import (
|
|
61
|
+
# Chain spec + runner
|
|
62
|
+
ChainSpec,
|
|
63
|
+
run_migrations,
|
|
64
|
+
# Lane-keyed chain registry
|
|
65
|
+
register_chain,
|
|
66
|
+
get_lane_chains,
|
|
67
|
+
clear_chain_registry,
|
|
55
68
|
)
|
|
56
69
|
|
|
57
70
|
__all__ = [
|
|
@@ -90,4 +103,12 @@ __all__ = [
|
|
|
90
103
|
"seed_all",
|
|
91
104
|
# Shared idempotent insert-if-missing primitive
|
|
92
105
|
"insert_if_missing",
|
|
106
|
+
# Shared PostgreSQL sequence re-sync primitive
|
|
107
|
+
"sync_table_sequence",
|
|
108
|
+
# Migration infrastructure (alembic-2fast)
|
|
109
|
+
"ChainSpec",
|
|
110
|
+
"run_migrations",
|
|
111
|
+
"register_chain",
|
|
112
|
+
"get_lane_chains",
|
|
113
|
+
"clear_chain_registry",
|
|
93
114
|
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.0"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pgsqlasync2fast_fastapi.migrations - shared Alembic infrastructure for the
|
|
3
|
+
2fast family.
|
|
4
|
+
|
|
5
|
+
This package ships the shared pieces of the ``alembic-2fast`` migration
|
|
6
|
+
overhaul (Metal ERP change):
|
|
7
|
+
|
|
8
|
+
- ``env.py`` - shared async env implementation (official Alembic async recipe:
|
|
9
|
+
``async_engine_from_config`` + ``NullPool`` + ``dispose()`` per run). Each
|
|
10
|
+
consumer chain dir ships a thin ``env.py`` shim that imports
|
|
11
|
+
``run_migrations_offline`` / ``run_migrations_online`` from here.
|
|
12
|
+
- ``registry.py`` - lane-keyed chain registry (``register_chain`` /
|
|
13
|
+
``get_lane_chains``), same idiom as ``register_seeder``.
|
|
14
|
+
- ``runner.py`` - ``ChainSpec`` and ``run_migrations(connection_name, chains)``:
|
|
15
|
+
reads the URL from manager config only (never ``DatabaseManager._engines``)
|
|
16
|
+
and upgrades each chain to head in declaration order.
|
|
17
|
+
|
|
18
|
+
Consumers resolve chains from the INSTALLED release via alembic-native
|
|
19
|
+
``pkg:path`` resource strings (``coerce_resource_to_filename``), never from a
|
|
20
|
+
repository checkout.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from .env import (
|
|
24
|
+
do_run_migrations,
|
|
25
|
+
run_async_migrations,
|
|
26
|
+
run_migrations_offline,
|
|
27
|
+
run_migrations_online,
|
|
28
|
+
)
|
|
29
|
+
from .registry import (
|
|
30
|
+
clear_chain_registry,
|
|
31
|
+
get_lane_chains,
|
|
32
|
+
register_chain,
|
|
33
|
+
)
|
|
34
|
+
from .runner import ChainSpec, run_migrations
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
# env impl
|
|
38
|
+
"do_run_migrations",
|
|
39
|
+
"run_async_migrations",
|
|
40
|
+
"run_migrations_offline",
|
|
41
|
+
"run_migrations_online",
|
|
42
|
+
# registry
|
|
43
|
+
"clear_chain_registry",
|
|
44
|
+
"get_lane_chains",
|
|
45
|
+
"register_chain",
|
|
46
|
+
# runner
|
|
47
|
+
"ChainSpec",
|
|
48
|
+
"run_migrations",
|
|
49
|
+
]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared Alembic async env implementation (design D1 of the ``alembic-2fast``
|
|
3
|
+
change).
|
|
4
|
+
|
|
5
|
+
Consumer packages ship a ~12-line ``env.py`` shim inside each chain directory
|
|
6
|
+
that imports ``run_migrations_offline`` / ``run_migrations_online`` from this
|
|
7
|
+
module and dispatches on ``context.is_offline_mode()``. Alembic's
|
|
8
|
+
``env_py_location`` is fixed at ``<script_location>/env.py``, so the shared
|
|
9
|
+
logic lives here and the per-chain shims stay thin.
|
|
10
|
+
|
|
11
|
+
Configuration contract (set programmatically by ``runner._chain_config``):
|
|
12
|
+
|
|
13
|
+
- main option ``script_location`` - ``"<package>:<chain_path>"`` (installed
|
|
14
|
+
release resolution via ``coerce_resource_to_filename``).
|
|
15
|
+
- main option ``sqlalchemy.url`` - the connection URL (config only).
|
|
16
|
+
- main option ``version_table`` - the chain's version table. Alembic 1.20.0
|
|
17
|
+
does NOT transport a ``version_table`` main option into ``MigrationContext``
|
|
18
|
+
(it is read from configure ``opts`` only), so it is passed EXPLICITLY to
|
|
19
|
+
``context.configure(version_table=...)`` below (design D1).
|
|
20
|
+
- attribute ``target_metadata`` - chain-owned ``MetaData`` for autogenerate.
|
|
21
|
+
- attributes ``include_object`` / ``include_name`` - ownership filters from
|
|
22
|
+
``runner._owner_filter`` (design D9).
|
|
23
|
+
|
|
24
|
+
Async pattern (official recipe): a fresh engine per run via
|
|
25
|
+
``async_engine_from_config`` with ``NullPool``, disposed after the run. The
|
|
26
|
+
runner executes each chain via ``asyncio.to_thread(command.upgrade, ...)``, so
|
|
27
|
+
every run owns its own event loop in its own worker thread.
|
|
28
|
+
|
|
29
|
+
IMPORTANT (shared-module reuse): this module is imported ONCE per process
|
|
30
|
+
while Alembic re-executes each chain's ``env.py`` shim on every run. Nothing
|
|
31
|
+
here may bind ``context.config`` at module level - every function resolves
|
|
32
|
+
``context.config`` lazily at call time so each run sees its own ``Config``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import asyncio
|
|
38
|
+
from typing import TYPE_CHECKING, Any
|
|
39
|
+
|
|
40
|
+
from alembic import context
|
|
41
|
+
from sqlalchemy import pool
|
|
42
|
+
from sqlalchemy.engine import Connection
|
|
43
|
+
from sqlalchemy.ext.asyncio import async_engine_from_config
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from alembic.config import Config
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"do_run_migrations",
|
|
50
|
+
"run_async_migrations",
|
|
51
|
+
"run_migrations_offline",
|
|
52
|
+
"run_migrations_online",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _shared_configure_kwargs(config: "Config") -> dict[str, Any]:
|
|
57
|
+
"""Options shared by the offline and online ``configure`` calls."""
|
|
58
|
+
kwargs: dict[str, Any] = {
|
|
59
|
+
"target_metadata": config.attributes.get("target_metadata"),
|
|
60
|
+
"include_object": config.attributes.get("include_object"),
|
|
61
|
+
"include_name": config.attributes.get("include_name"),
|
|
62
|
+
}
|
|
63
|
+
version_table = config.get_main_option("version_table")
|
|
64
|
+
if version_table:
|
|
65
|
+
# Deliberate explicit pass-through: alembic 1.20.0 reads version_table
|
|
66
|
+
# from the MigrationContext opts only, never from main options.
|
|
67
|
+
kwargs["version_table"] = version_table
|
|
68
|
+
return kwargs
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def do_run_migrations(connection: Connection) -> None:
|
|
72
|
+
"""Configure ``context`` for an online run against ``connection``."""
|
|
73
|
+
config = context.config
|
|
74
|
+
context.configure(
|
|
75
|
+
connection=connection,
|
|
76
|
+
**_shared_configure_kwargs(config),
|
|
77
|
+
)
|
|
78
|
+
with context.begin_transaction():
|
|
79
|
+
context.run_migrations()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def run_migrations_offline() -> None:
|
|
83
|
+
"""Render the chain's SQL without a database connection (offline mode)."""
|
|
84
|
+
config = context.config
|
|
85
|
+
kwargs = _shared_configure_kwargs(config)
|
|
86
|
+
kwargs.update(
|
|
87
|
+
{
|
|
88
|
+
"url": config.get_main_option("sqlalchemy.url"),
|
|
89
|
+
"literal_binds": True,
|
|
90
|
+
"dialect_opts": {"paramstyle": "named"},
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
context.configure(**kwargs)
|
|
94
|
+
with context.begin_transaction():
|
|
95
|
+
context.run_migrations()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def run_async_migrations() -> None:
|
|
99
|
+
"""Fresh NullPool async engine, upgraded, disposed - all per run."""
|
|
100
|
+
config = context.config
|
|
101
|
+
connectable = async_engine_from_config(
|
|
102
|
+
config.get_section(config.config_ini_section, {}),
|
|
103
|
+
prefix="sqlalchemy.",
|
|
104
|
+
poolclass=pool.NullPool,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
async with connectable.connect() as connection:
|
|
108
|
+
await connection.run_sync(do_run_migrations)
|
|
109
|
+
|
|
110
|
+
await connectable.dispose()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def run_migrations_online() -> None:
|
|
114
|
+
"""Entry point called by chain shims in online mode.
|
|
115
|
+
|
|
116
|
+
Runs in the worker thread that ``runner.run_migrations`` spawned via
|
|
117
|
+
``asyncio.to_thread``, so no event loop is running here and
|
|
118
|
+
``asyncio.run`` owns a fresh loop (and therefore the engine) per run.
|
|
119
|
+
"""
|
|
120
|
+
asyncio.run(run_async_migrations())
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lane-keyed chain registry (design D6 of the ``alembic-2fast`` change).
|
|
3
|
+
|
|
4
|
+
Mirrors the ``register_seeder`` idiom: consumers register their chain specs at
|
|
5
|
+
import time, the app composes lanes explicitly, and the runner executes them
|
|
6
|
+
in registration order per lane.
|
|
7
|
+
|
|
8
|
+
- AUTH lane orders ``oauth`` -> ``permissions-global`` -> ``tenant2fast-auth``
|
|
9
|
+
(FK-proven); registration order within a lane is preserved.
|
|
10
|
+
- TENANT lane chains (``tenant2fast-rbac``, ``app``) are order-independent;
|
|
11
|
+
callers may still declare an explicit preferred order.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .runner import ChainSpec
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"clear_chain_registry",
|
|
23
|
+
"get_lane_chains",
|
|
24
|
+
"register_chain",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
_CHAIN_REGISTRY: dict[str, list["ChainSpec"]] = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def register_chain(lane: str, chain: "ChainSpec") -> None:
|
|
31
|
+
"""
|
|
32
|
+
Register a chain spec under a lane.
|
|
33
|
+
|
|
34
|
+
Re-registering the same ``(package, script_path)`` key updates that entry
|
|
35
|
+
in place (idempotent, position preserved); a distinct chain appends.
|
|
36
|
+
Order of registration is the order ``get_lane_chains`` returns, which is
|
|
37
|
+
the execution order for ordering-sensitive lanes.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
lane: Lane name, e.g. ``"auth"`` or ``"tenant"``.
|
|
41
|
+
chain: The chain spec to register.
|
|
42
|
+
"""
|
|
43
|
+
chains = _CHAIN_REGISTRY.setdefault(lane, [])
|
|
44
|
+
for index, existing in enumerate(chains):
|
|
45
|
+
if (
|
|
46
|
+
existing.package == chain.package
|
|
47
|
+
and existing.script_path == chain.script_path
|
|
48
|
+
):
|
|
49
|
+
chains[index] = chain
|
|
50
|
+
return
|
|
51
|
+
chains.append(chain)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_lane_chains(lane: str) -> list["ChainSpec"]:
|
|
55
|
+
"""
|
|
56
|
+
Return a defensive copy of the chains registered for a lane.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
lane: Lane name, e.g. ``"auth"`` or ``"tenant"``.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
List of ``ChainSpec`` objects in registration order (empty if none).
|
|
63
|
+
"""
|
|
64
|
+
return list(_CHAIN_REGISTRY.get(lane, []))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def clear_chain_registry() -> None:
|
|
68
|
+
"""Clear all registered chains. Useful for testing."""
|
|
69
|
+
global _CHAIN_REGISTRY
|
|
70
|
+
_CHAIN_REGISTRY = {}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async migration runner for package-owned Alembic chains (design D3 of the
|
|
3
|
+
``alembic-2fast`` change).
|
|
4
|
+
|
|
5
|
+
``run_migrations(connection_name, chains)`` upgrades each chain to ``head``
|
|
6
|
+
in declaration order. Key properties:
|
|
7
|
+
|
|
8
|
+
- URL resolution is CONFIG-ONLY: ``manager.config.get_connection_url(name)``.
|
|
9
|
+
``DatabaseManager._engines`` (pooled app engines) is never read or reused -
|
|
10
|
+
each chain run gets a fresh ``NullPool`` async engine inside env.py that is
|
|
11
|
+
disposed after the run.
|
|
12
|
+
- Each chain runs in a worker thread via ``asyncio.to_thread``, so env.py's
|
|
13
|
+
``asyncio.run`` owns a fresh event loop (and engine) per run - it can never
|
|
14
|
+
collide with the app's lifespan loop.
|
|
15
|
+
- ``script_location`` is an alembic-native ``"<package>:<chain_path>"``
|
|
16
|
+
resource string: Alembic resolves it from the INSTALLED release via
|
|
17
|
+
``coerce_resource_to_filename`` (importlib.resources), never from a
|
|
18
|
+
checkout.
|
|
19
|
+
- Ownership filtering (design D9): ``include_object`` / ``include_name``
|
|
20
|
+
attributes admit only tables (and their child objects) listed in
|
|
21
|
+
``owned_tables``, so autogenerate on a shared ``MetaData`` never proposes
|
|
22
|
+
foreign tables.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Any, Callable, Sequence
|
|
30
|
+
|
|
31
|
+
from alembic import command
|
|
32
|
+
from alembic.config import Config
|
|
33
|
+
from sqlalchemy import MetaData
|
|
34
|
+
|
|
35
|
+
from ..connection import get_manager
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"ChainSpec",
|
|
39
|
+
"run_migrations",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
#: Object types whose ownership derives from their parent table (type names
|
|
43
|
+
#: verified against alembic 1.20.0 ``NameFilterType``).
|
|
44
|
+
_OWNED_PARENT_TYPES = frozenset(
|
|
45
|
+
{
|
|
46
|
+
"index",
|
|
47
|
+
"unique_constraint",
|
|
48
|
+
"foreign_key_constraint",
|
|
49
|
+
"check_constraint",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class ChainSpec:
|
|
56
|
+
"""
|
|
57
|
+
Description of one package-owned Alembic chain shipped inside a wheel.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
package: Import name of the wheel's package, e.g. ``"oauth2fast_fastapi"``.
|
|
61
|
+
script_path: Chain directory inside the package, e.g. ``"migrations/oauth"``.
|
|
62
|
+
version_table: Name of the chain's version table, e.g.
|
|
63
|
+
``"alembic_version_oauth"`` (per-chain, never the default).
|
|
64
|
+
target_metadata: Chain-owned ``MetaData`` for autogenerate comparison.
|
|
65
|
+
owned_tables: Table names this chain owns; the autogenerate ownership
|
|
66
|
+
filter admits only these (design D9).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
package: str
|
|
70
|
+
script_path: str
|
|
71
|
+
version_table: str
|
|
72
|
+
target_metadata: MetaData
|
|
73
|
+
owned_tables: frozenset[str]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _owner_filter(owned_tables: frozenset[str]) -> Callable[..., bool]:
|
|
77
|
+
"""
|
|
78
|
+
Build an ownership filter usable for BOTH alembic include hooks (D9).
|
|
79
|
+
|
|
80
|
+
Alembic calls ``include_object`` with
|
|
81
|
+
``(object_, name, type_, reflected, compare_to)`` and ``include_name``
|
|
82
|
+
with ``(name, type_, parent_names)``; the returned callable dispatches on
|
|
83
|
+
the argument count:
|
|
84
|
+
|
|
85
|
+
- ``type_ == "table"`` -> the table's own name must be owned.
|
|
86
|
+
- index / unique / FK / check constraint -> the object's *parent table*
|
|
87
|
+
must be owned (no cross-lane objects are proposed).
|
|
88
|
+
- anything else -> admitted.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
owned_tables: Table names owned by the chain.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
A callable assigning to ``cfg.attributes["include_object"]`` and
|
|
95
|
+
``cfg.attributes["include_name"]``.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def _filter(*args: Any) -> bool:
|
|
99
|
+
if len(args) == 3: # include_name(name, type_, parent_names)
|
|
100
|
+
name, type_, parent_names = args
|
|
101
|
+
parent_table = None
|
|
102
|
+
else: # include_object(object_, name, type_, reflected, compare_to)
|
|
103
|
+
object_, name, type_, _, _ = args
|
|
104
|
+
parent_table = getattr(object_, "table", None)
|
|
105
|
+
|
|
106
|
+
if type_ == "table":
|
|
107
|
+
return name in owned_tables
|
|
108
|
+
if type_ in _OWNED_PARENT_TYPES:
|
|
109
|
+
if parent_table is not None:
|
|
110
|
+
return parent_table.name in owned_tables
|
|
111
|
+
return parent_names.get("table_name") in owned_tables
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
return _filter
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _chain_config(chain: ChainSpec, url: str) -> Config:
|
|
118
|
+
"""
|
|
119
|
+
Build the programmatic Alembic ``Config`` for one chain run (no ini file).
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
chain: The chain spec to configure.
|
|
123
|
+
url: The connection URL (already resolved from manager config).
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
A ``Config`` whose main options and attributes drive the shared env.
|
|
127
|
+
"""
|
|
128
|
+
cfg = Config()
|
|
129
|
+
cfg.set_main_option(
|
|
130
|
+
"script_location", f"{chain.package}:{chain.script_path}"
|
|
131
|
+
)
|
|
132
|
+
cfg.set_main_option("sqlalchemy.url", url)
|
|
133
|
+
cfg.set_main_option("version_table", chain.version_table)
|
|
134
|
+
cfg.attributes["target_metadata"] = chain.target_metadata
|
|
135
|
+
cfg.attributes["include_object"] = _owner_filter(chain.owned_tables)
|
|
136
|
+
cfg.attributes["include_name"] = _owner_filter(chain.owned_tables)
|
|
137
|
+
return cfg
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def run_migrations(
|
|
141
|
+
connection_name: str, chains: Sequence[ChainSpec]
|
|
142
|
+
) -> None:
|
|
143
|
+
"""
|
|
144
|
+
Upgrade every chain to head for a named connection, in declared order.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
connection_name: Named connection from the manager config (e.g.
|
|
148
|
+
``"auth"``, ``"tenant_1"``).
|
|
149
|
+
chains: Chain specs to apply, in FK-proven order.
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
ValueError: If the connection name is not configured.
|
|
153
|
+
Any exception raised by Alembic's ``command.upgrade`` (fail-fast per
|
|
154
|
+
chain; the caller decides lane error policy).
|
|
155
|
+
"""
|
|
156
|
+
manager = get_manager()
|
|
157
|
+
# Config only - pooled app engines under manager._engines are never used.
|
|
158
|
+
url = manager.config.get_connection_url(connection_name)
|
|
159
|
+
|
|
160
|
+
for chain in chains:
|
|
161
|
+
await asyncio.to_thread(
|
|
162
|
+
command.upgrade, _chain_config(chain, url), "head"
|
|
163
|
+
)
|
|
@@ -593,6 +593,60 @@ def _resolve_load_order(manifest: dict[str, Any]) -> list[str]:
|
|
|
593
593
|
# ============================================================================
|
|
594
594
|
|
|
595
595
|
|
|
596
|
+
async def sync_table_sequence(
|
|
597
|
+
session: Any,
|
|
598
|
+
model_class: type,
|
|
599
|
+
) -> None:
|
|
600
|
+
"""
|
|
601
|
+
Re-sync a PostgreSQL table identity sequence to its current MAX(id).
|
|
602
|
+
|
|
603
|
+
The seeder inserts rows with explicit ``id`` values straight from the
|
|
604
|
+
manifest JSON. PostgreSQL's plain ``serial``/``sequence``-backed columns do
|
|
605
|
+
NOT advance the sequence when a row is inserted with an explicit id, so a
|
|
606
|
+
later sequence-backed insert (e.g. ``insert_if_missing``) can generate an
|
|
607
|
+
id that collides with an existing explicit-id row (UniqueViolation).
|
|
608
|
+
|
|
609
|
+
This issues one ``setval`` to ``MAX(id)`` after the explicit-id inserts so
|
|
610
|
+
the next auto-generated id continues past the seeded rows. It only runs
|
|
611
|
+
against PostgreSQL — SQLite assigns ``INTEGER PRIMARY KEY`` as max+1
|
|
612
|
+
automatically and has no incompatible sequence catalog, so it is a no-op
|
|
613
|
+
there.
|
|
614
|
+
|
|
615
|
+
Args:
|
|
616
|
+
session: SQLModel AsyncSession bound to the target DB.
|
|
617
|
+
model_class: The SQLModel/SQLAlchemy table class to re-sync.
|
|
618
|
+
"""
|
|
619
|
+
from sqlalchemy import text
|
|
620
|
+
|
|
621
|
+
try:
|
|
622
|
+
bind = getattr(session, "bind", None)
|
|
623
|
+
if bind is None:
|
|
624
|
+
return
|
|
625
|
+
dialect = getattr(bind.dialect, "name", None)
|
|
626
|
+
if dialect != "postgresql":
|
|
627
|
+
return
|
|
628
|
+
|
|
629
|
+
table = model_class.__tablename__
|
|
630
|
+
seq = f"{table}_id_seq"
|
|
631
|
+
async with bind.begin() as conn:
|
|
632
|
+
max_id = await conn.scalar(
|
|
633
|
+
text(f'SELECT COALESCE(MAX(id), 0) FROM "{table}"')
|
|
634
|
+
)
|
|
635
|
+
if max_id:
|
|
636
|
+
await conn.execute(
|
|
637
|
+
text("SELECT setval(:seq, :val)"),
|
|
638
|
+
{"seq": seq, "val": max_id},
|
|
639
|
+
)
|
|
640
|
+
logger.info(
|
|
641
|
+
f"Synced sequence '{seq}' to MAX(id)={max_id} "
|
|
642
|
+
f"(table '{table}')"
|
|
643
|
+
)
|
|
644
|
+
except Exception as exc: # pragma: no cover - defensive, non-fatal
|
|
645
|
+
logger.warning(
|
|
646
|
+
f"Could not re-sync sequence for '{model_class.__name__}': {exc}"
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
|
|
596
650
|
async def _seed_table_idempotent(
|
|
597
651
|
session: Any,
|
|
598
652
|
table_name: str,
|
|
@@ -658,6 +712,9 @@ async def _seed_table_idempotent(
|
|
|
658
712
|
logger.error(f"Failed to insert row in table '{table_name}': {e}")
|
|
659
713
|
raise
|
|
660
714
|
|
|
715
|
+
if model_class is not None:
|
|
716
|
+
await sync_table_sequence(session, model_class)
|
|
717
|
+
|
|
661
718
|
return rows_inserted, rows_skipped
|
|
662
719
|
|
|
663
720
|
|
|
@@ -727,6 +784,8 @@ async def _seed_table_idempotent_generic(
|
|
|
727
784
|
logger.error(f"Failed to insert row in table '{table_name}': {e}")
|
|
728
785
|
raise
|
|
729
786
|
|
|
787
|
+
await sync_table_sequence(session, model_class)
|
|
788
|
+
|
|
730
789
|
return rows_inserted, rows_skipped
|
|
731
790
|
|
|
732
791
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pgsqlasync2fast-fastapi
|
|
3
|
-
description: "Trigger: working on or with pgsqlasync2fast-fastapi. Multi-database async engine manager for FastAPI: DB_CONNECTIONS config, get_db_session deps, seeder orchestrator, tenant engines. Prevails over the 2fast-handbook base skill for this package."
|
|
3
|
+
description: "Trigger: working on or with pgsqlasync2fast-fastapi. Multi-database async engine manager for FastAPI: DB_CONNECTIONS config, get_db_session deps, seeder orchestrator, tenant engines, shared Alembic migration runner. Prevails over the 2fast-handbook base skill for this package."
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
6
|
author: AngelDanielSanchezCastillo
|
|
7
|
-
version: "2.
|
|
7
|
+
version: "2.2"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
## Purpose
|
|
@@ -33,6 +33,12 @@ engines/session factories keyed by connection name (`default`, `auth`,
|
|
|
33
33
|
reuse this instead of hand-rolling per package (RBAC standardization D2). No
|
|
34
34
|
commit — caller owns the transaction boundary. Also re-exported from the top-level
|
|
35
35
|
`pgsqlasync2fast_fastapi` package.
|
|
36
|
+
- Migration infra (`pgsqlasync2fast_fastapi.migrations`, alembic-2fast change):
|
|
37
|
+
- `ChainSpec(package, script_path, version_table, target_metadata, owned_tables)` — frozen spec of one wheel-shipped chain.
|
|
38
|
+
- `register_chain(lane, chain)` / `get_lane_chains(lane)` / `clear_chain_registry()` — lane-keyed registry, same idiom as `register_seeder` (same-key re-registration updates in place; registration order = execution order).
|
|
39
|
+
- `run_migrations(connection_name, chains)` — async runner per the OFFICIAL alembic recipe: URL config-only (`manager.config.get_connection_url`), each chain upgraded to `head` via `asyncio.to_thread`; shared `migrations/env.py` creates a fresh `async_engine_from_config` + `NullPool` engine and disposes it per run. **NEVER touches `DatabaseManager._engines`** (pooled app engines).
|
|
40
|
+
- env contract: `script_location` = `"<pkg>:<chain_path>"` resource string resolved from the INSTALLED release (alembic `coerce_resource_to_filename`), never a checkout; `version_table` read via `config.get_main_option` and passed EXPLICITLY to `context.configure(version_table=...)` (alembic 1.20.0 does not auto-transport it); include_object/include_name ownership filters admit only `owned_tables` (+ their indexes/constraints).
|
|
41
|
+
- `alembic==1.20.0` pinned EXACTLY in runtime deps (deliberate re-pin rule).
|
|
36
42
|
|
|
37
43
|
## Architecture
|
|
38
44
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pgsqlasync2fast-fastapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Simple and fast PostgreSQL async module for FastAPI with multi-database support
|
|
5
5
|
Author-email: Angel Daniel Sanchez Castillo <angeldaniel.sanchezcastillo@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -50,6 +50,7 @@ Requires-Dist: sqlalchemy>=2.0.0
|
|
|
50
50
|
Requires-Dist: sqlmodel>=0.0.22
|
|
51
51
|
Requires-Dist: asyncpg>=0.29.0
|
|
52
52
|
Requires-Dist: greenlet>=3.0.0
|
|
53
|
+
Requires-Dist: alembic==1.20.0
|
|
53
54
|
Dynamic: license-file
|
|
54
55
|
|
|
55
56
|
# pgsqlasync2fast-fastapi
|
|
@@ -27,8 +27,14 @@ src/pgsqlasync2fast_fastapi.egg-info/SOURCES.txt
|
|
|
27
27
|
src/pgsqlasync2fast_fastapi.egg-info/dependency_links.txt
|
|
28
28
|
src/pgsqlasync2fast_fastapi.egg-info/requires.txt
|
|
29
29
|
src/pgsqlasync2fast_fastapi.egg-info/top_level.txt
|
|
30
|
+
src/pgsqlasync2fast_fastapi/migrations/__init__.py
|
|
31
|
+
src/pgsqlasync2fast_fastapi/migrations/env.py
|
|
32
|
+
src/pgsqlasync2fast_fastapi/migrations/registry.py
|
|
33
|
+
src/pgsqlasync2fast_fastapi/migrations/runner.py
|
|
30
34
|
src/pgsqlasync2fast_fastapi/skills/SKILL.md
|
|
31
35
|
tests/test_completo.py
|
|
32
36
|
tests/test_insert_if_missing.py
|
|
37
|
+
tests/test_migration_chains.py
|
|
33
38
|
tests/test_seeder.py
|
|
34
|
-
tests/test_seeder_override.py
|
|
39
|
+
tests/test_seeder_override.py
|
|
40
|
+
tests/test_sync_sequence.py
|