simple-module-db 0.0.2__tar.gz → 0.0.4__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.
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/.gitignore +4 -0
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/PKG-INFO +2 -2
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/pyproject.toml +2 -2
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/simple_module_db/base.py +28 -6
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/simple_module_db/migrations.py +55 -12
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/simple_module_db/mixins.py +29 -5
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/simple_module_db/session.py +9 -4
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/tests/test_db_logging.py +0 -46
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/tests/test_migrations.py +41 -2
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/LICENSE +0 -0
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/README.md +0 -0
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/simple_module_db/__init__.py +0 -0
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/simple_module_db/deps.py +0 -0
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/simple_module_db/listeners.py +0 -0
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/simple_module_db/provider.py +0 -0
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/simple_module_db/py.typed +0 -0
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/tests/_models.py +0 -0
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/tests/conftest.py +0 -0
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/tests/test_base.py +0 -0
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/tests/test_multi_tenancy.py +0 -0
- {simple_module_db-0.0.2 → simple_module_db-0.0.4}/tests/test_session.py +0 -0
|
@@ -36,6 +36,10 @@ uploads/
|
|
|
36
36
|
# Vite
|
|
37
37
|
host/static/dist/
|
|
38
38
|
|
|
39
|
+
# VitePress (docs)
|
|
40
|
+
docs/.vitepress/cache/
|
|
41
|
+
docs/.vitepress/dist/
|
|
42
|
+
|
|
39
43
|
# Auto-generated frontend module manifest (regenerated by the host at boot
|
|
40
44
|
# or via `make gen-pages`).
|
|
41
45
|
host/client_app/modules.manifest.json
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simple_module_db
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
4
4
|
Summary: Per-module SQLModel Base, async session, standard mixins (Audit, SoftDelete, MultiTenant, Versioned) for simple_module
|
|
5
5
|
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
6
|
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
@@ -24,7 +24,7 @@ Requires-Python: >=3.12
|
|
|
24
24
|
Requires-Dist: aiosqlite>=0.20
|
|
25
25
|
Requires-Dist: alembic>=1.14
|
|
26
26
|
Requires-Dist: asyncpg>=0.30
|
|
27
|
-
Requires-Dist: simple-module-core==0.0.
|
|
27
|
+
Requires-Dist: simple-module-core==0.0.4
|
|
28
28
|
Requires-Dist: sqlalchemy[asyncio]>=2.0
|
|
29
29
|
Requires-Dist: sqlmodel>=0.0.22
|
|
30
30
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_db"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.4"
|
|
4
4
|
description = "Per-module SQLModel Base, async session, standard mixins (Audit, SoftDelete, MultiTenant, Versioned) for simple_module"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -24,7 +24,7 @@ dependencies = [
|
|
|
24
24
|
"aiosqlite>=0.20",
|
|
25
25
|
"alembic>=1.14",
|
|
26
26
|
"asyncpg>=0.30",
|
|
27
|
-
"simple_module_core==0.0.
|
|
27
|
+
"simple_module_core==0.0.4",
|
|
28
28
|
"sqlalchemy[asyncio]>=2.0",
|
|
29
29
|
"sqlmodel>=0.0.22",
|
|
30
30
|
]
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
6
|
|
|
7
|
+
from simple_module_core.dotenv import env_bool
|
|
7
8
|
from sqlalchemy import MetaData
|
|
8
9
|
from sqlmodel import SQLModel
|
|
9
10
|
|
|
@@ -38,14 +39,35 @@ def _register_base(base: type[SQLModel]) -> None:
|
|
|
38
39
|
all_module_bases.append(base)
|
|
39
40
|
|
|
40
41
|
|
|
41
|
-
def
|
|
42
|
-
"""Resolve the
|
|
42
|
+
def _default_schema_policy() -> DatabaseProvider:
|
|
43
|
+
"""Resolve the schema layout to register module tables under.
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
The :class:`DatabaseProvider` enum doubles as a *schema-layout*
|
|
46
|
+
selector here — ``POSTGRESQL`` means "give every module its own
|
|
47
|
+
schema (``orders.<table>``)", ``SQLITE`` means "shared public schema,
|
|
48
|
+
name-prefixed tables (``orders_<table>``)". The conflation is
|
|
49
|
+
deliberate so existing call sites keep working, but conceptually this
|
|
50
|
+
is "schema policy", not "what DB are we connecting to": you can run
|
|
51
|
+
Postgres with ``SM_SCHEMA_PER_MODULE=false`` to keep a flat layout.
|
|
52
|
+
|
|
53
|
+
Resolution order:
|
|
54
|
+
1. ``SM_SCHEMA_PER_MODULE`` (authoritative when set, decoupled from URL).
|
|
55
|
+
2. ``SM_DATABASE_URL`` (legacy fallback so deployments that haven't
|
|
56
|
+
migrated to the explicit knob keep working).
|
|
57
|
+
3. ``SQLITE`` (shared schema, the safe default).
|
|
46
58
|
"""
|
|
59
|
+
explicit = os.environ.get("SM_SCHEMA_PER_MODULE")
|
|
60
|
+
if explicit is not None:
|
|
61
|
+
return (
|
|
62
|
+
DatabaseProvider.POSTGRESQL
|
|
63
|
+
if env_bool("SM_SCHEMA_PER_MODULE")
|
|
64
|
+
else DatabaseProvider.SQLITE
|
|
65
|
+
)
|
|
66
|
+
|
|
47
67
|
url = os.environ.get("SM_DATABASE_URL", "")
|
|
48
|
-
|
|
68
|
+
if url:
|
|
69
|
+
return detect_provider(url)
|
|
70
|
+
return DatabaseProvider.SQLITE
|
|
49
71
|
|
|
50
72
|
|
|
51
73
|
def create_module_base(
|
|
@@ -67,7 +89,7 @@ def create_module_base(
|
|
|
67
89
|
concrete table classes declare ``table=True`` and inherit from it.
|
|
68
90
|
"""
|
|
69
91
|
if provider is None:
|
|
70
|
-
provider =
|
|
92
|
+
provider = _default_schema_policy()
|
|
71
93
|
|
|
72
94
|
cache_key = f"{module_name}:{provider}"
|
|
73
95
|
if cache_key in _base_cache:
|
|
@@ -17,8 +17,10 @@ from __future__ import annotations
|
|
|
17
17
|
import importlib
|
|
18
18
|
import logging
|
|
19
19
|
from collections.abc import Callable, Sequence
|
|
20
|
+
from enum import StrEnum
|
|
20
21
|
from typing import Literal
|
|
21
22
|
|
|
23
|
+
import sqlalchemy as sa
|
|
22
24
|
from simple_module_core import ModuleBase
|
|
23
25
|
from simple_module_core.discovery import discover_modules, get_module_package_name
|
|
24
26
|
from sqlalchemy import MetaData
|
|
@@ -65,7 +67,11 @@ def build_module_metadata(modules: Sequence[ModuleBase] | None = None) -> MetaDa
|
|
|
65
67
|
return combined
|
|
66
68
|
|
|
67
69
|
|
|
68
|
-
def make_include_object(
|
|
70
|
+
def make_include_object(
|
|
71
|
+
metadata: MetaData,
|
|
72
|
+
*,
|
|
73
|
+
ignore_unmodeled_fks: bool = True,
|
|
74
|
+
) -> IncludeObjectFn:
|
|
69
75
|
"""Return an Alembic ``include_object`` filter scoped to the module tables.
|
|
70
76
|
|
|
71
77
|
Call as ``context.configure(..., include_object=make_include_object(meta))``.
|
|
@@ -73,6 +79,15 @@ def make_include_object(metadata: MetaData) -> IncludeObjectFn:
|
|
|
73
79
|
autogenerate from diffing — and potentially dropping — tables that exist
|
|
74
80
|
in the database but aren't owned by any installed module (e.g. a user
|
|
75
81
|
table added by the host developer outside the module system).
|
|
82
|
+
|
|
83
|
+
:param ignore_unmodeled_fks: When ``True`` (default), foreign-key
|
|
84
|
+
constraints that exist in the live DB but are absent from the SQLModel
|
|
85
|
+
metadata are skipped instead of being emitted as ``op.drop_constraint``
|
|
86
|
+
in the autogenerated migration. The framework's recommended pattern
|
|
87
|
+
declares cross-module FKs at the migration level only (no Python-level
|
|
88
|
+
relationship between modules), so those constraints look "unmodeled" to
|
|
89
|
+
Alembic on every autogen run and would otherwise be silently dropped.
|
|
90
|
+
Set to ``False`` to recover the prior, drop-on-sight behaviour.
|
|
76
91
|
"""
|
|
77
92
|
allowlist = {t.name for t in metadata.tables.values()}
|
|
78
93
|
|
|
@@ -85,6 +100,8 @@ def make_include_object(metadata: MetaData) -> IncludeObjectFn:
|
|
|
85
100
|
) -> bool:
|
|
86
101
|
if type_ == "table":
|
|
87
102
|
return name in allowlist
|
|
103
|
+
if ignore_unmodeled_fks and type_ == "foreign_key_constraint" and compare_to is None:
|
|
104
|
+
return False
|
|
88
105
|
parent_table = getattr(object, "table", None)
|
|
89
106
|
if parent_table is not None:
|
|
90
107
|
return parent_table.name in allowlist
|
|
@@ -94,19 +111,45 @@ def make_include_object(metadata: MetaData) -> IncludeObjectFn:
|
|
|
94
111
|
|
|
95
112
|
|
|
96
113
|
def render_item(type_, obj, autogen_context):
|
|
97
|
-
"""Alembic ``render_item`` callback
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
114
|
+
"""Alembic ``render_item`` callback for SQLModel + extension types.
|
|
115
|
+
|
|
116
|
+
* Collapses SQLModel's ``AutoString`` to ``sa.String`` so migrations don't
|
|
117
|
+
need to ``import sqlmodel``.
|
|
118
|
+
* Renders Python ``StrEnum`` columns with ``values_callable`` so the
|
|
119
|
+
Postgres enum labels match the lowercase ``StrEnum`` values rather than
|
|
120
|
+
SQLAlchemy's default of using uppercase attribute names. This means raw
|
|
121
|
+
SQL like ``WHERE status = 'ready'`` actually works against the live DB.
|
|
122
|
+
* Adds the necessary imports for ``fastapi_users_db_sqlalchemy.generics``
|
|
123
|
+
and ``geoalchemy2`` types (rendered by their own classes elsewhere) so
|
|
124
|
+
the generated migration is importable.
|
|
104
125
|
|
|
105
126
|
Pass to :func:`alembic.context.configure` as ``render_item=render_item``.
|
|
106
127
|
"""
|
|
107
|
-
if type_
|
|
128
|
+
if type_ != "type":
|
|
129
|
+
return False
|
|
130
|
+
cls_name = type(obj).__name__
|
|
131
|
+
cls_module = type(obj).__module__ or ""
|
|
132
|
+
imports = autogen_context.imports if autogen_context is not None else None
|
|
133
|
+
|
|
134
|
+
if cls_name == "AutoString":
|
|
108
135
|
length = getattr(obj, "length", None)
|
|
109
|
-
if length is not None
|
|
110
|
-
|
|
111
|
-
|
|
136
|
+
return f"sa.String(length={length})" if length is not None else "sa.String()"
|
|
137
|
+
|
|
138
|
+
if imports is not None and cls_module.startswith("fastapi_users_db_sqlalchemy"):
|
|
139
|
+
imports.add("import fastapi_users_db_sqlalchemy.generics")
|
|
140
|
+
if imports is not None and cls_module.startswith("geoalchemy2"):
|
|
141
|
+
imports.add("import geoalchemy2")
|
|
142
|
+
|
|
143
|
+
# Match by isinstance, not class-name string — a user-defined ``Enum``
|
|
144
|
+
# class elsewhere in the project would otherwise be matched here and
|
|
145
|
+
# render with ``values_callable`` against an unrelated ``enum_class``.
|
|
146
|
+
if isinstance(obj, sa.Enum):
|
|
147
|
+
python_type = getattr(obj, "enum_class", None)
|
|
148
|
+
if isinstance(python_type, type) and issubclass(python_type, StrEnum):
|
|
149
|
+
name = getattr(obj, "name", None) or python_type.__name__.lower()
|
|
150
|
+
members = ", ".join(repr(m.value) for m in python_type)
|
|
151
|
+
return (
|
|
152
|
+
f"sa.Enum({members}, name={name!r}, values_callable=lambda e: [m.value for m in e])"
|
|
153
|
+
)
|
|
154
|
+
|
|
112
155
|
return False # let alembic use its default rendering
|
|
@@ -8,20 +8,30 @@ SQLModel constructs a fresh ``Column`` per concrete subclass; sharing a single
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
-
from datetime import datetime
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
12
|
|
|
13
13
|
from sqlalchemy import DateTime, func
|
|
14
14
|
from sqlmodel import Field, SQLModel
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def _utcnow() -> datetime:
|
|
18
|
+
"""Timezone-aware UTC ``now`` for the Python-side default of ``created_at``."""
|
|
19
|
+
return datetime.now(UTC)
|
|
20
|
+
|
|
21
|
+
|
|
17
22
|
class AuditMixin(SQLModel):
|
|
18
23
|
"""Adds created_at, updated_at, created_by, updated_by fields.
|
|
19
24
|
|
|
20
|
-
``created_at``
|
|
21
|
-
|
|
25
|
+
``created_at`` is populated both Python-side (``default_factory``) and
|
|
26
|
+
server-side (``server_default``) so freshly-instantiated instances can
|
|
27
|
+
be serialized before flush — without the Python default, accessing
|
|
28
|
+
``obj.created_at`` raised ``AttributeError`` until the row hit the DB.
|
|
29
|
+
``updated_at`` is populated by the audit listener in
|
|
30
|
+
:mod:`simple_module_db.listeners`.
|
|
22
31
|
"""
|
|
23
32
|
|
|
24
33
|
created_at: datetime = Field(
|
|
34
|
+
default_factory=_utcnow,
|
|
25
35
|
sa_type=DateTime(timezone=True),
|
|
26
36
|
sa_column_kwargs={"server_default": func.now()},
|
|
27
37
|
)
|
|
@@ -51,9 +61,23 @@ class SoftDeleteMixin(SQLModel):
|
|
|
51
61
|
|
|
52
62
|
|
|
53
63
|
class MultiTenantMixin(SQLModel):
|
|
54
|
-
"""Adds a tenant_id column for data isolation in multi-tenant apps.
|
|
64
|
+
"""Adds a tenant_id column for data isolation in multi-tenant apps.
|
|
65
|
+
|
|
66
|
+
The Python-side type is optional so callers can construct an instance
|
|
67
|
+
inside a request scope without explicitly threading the tenant through —
|
|
68
|
+
the ``_before_flush_listener`` in :mod:`simple_module_db.listeners`
|
|
69
|
+
populates it from the ``current_tenant_id`` contextvar before the row
|
|
70
|
+
reaches the DB. The column itself is non-nullable, so a row inserted
|
|
71
|
+
outside any tenant context fails loudly at the DB rather than silently
|
|
72
|
+
leaking across tenants.
|
|
73
|
+
"""
|
|
55
74
|
|
|
56
|
-
tenant_id: str = Field(
|
|
75
|
+
tenant_id: str | None = Field(
|
|
76
|
+
default=None,
|
|
77
|
+
max_length=50,
|
|
78
|
+
index=True,
|
|
79
|
+
sa_column_kwargs={"nullable": False},
|
|
80
|
+
)
|
|
57
81
|
|
|
58
82
|
|
|
59
83
|
class VersionedMixin(SQLModel):
|
|
@@ -33,12 +33,15 @@ def init_db(
|
|
|
33
33
|
max_overflow: int = 20,
|
|
34
34
|
pool_pre_ping: bool = True,
|
|
35
35
|
pool_recycle: int = 1800,
|
|
36
|
+
poolclass: type | None = None,
|
|
36
37
|
) -> DatabaseState:
|
|
37
38
|
"""Create an async engine and session factory.
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
Pool tuning only applies to server-side providers (Postgres) — SQLite
|
|
41
|
+
rejects ``pool_size``/etc. Pass ``poolclass=NullPool`` from test
|
|
42
|
+
fixtures running against asyncpg/Postgres so pytest-asyncio's per-test
|
|
43
|
+
event loops don't outlive pooled connections; the pool-tuning kwargs
|
|
44
|
+
are ignored in that case.
|
|
42
45
|
|
|
43
46
|
Returns a ``DatabaseState`` that should be stored on ``app.state.db``.
|
|
44
47
|
"""
|
|
@@ -46,9 +49,11 @@ def init_db(
|
|
|
46
49
|
|
|
47
50
|
connect_args: dict = {}
|
|
48
51
|
engine_kwargs: dict = {"echo": echo, "connect_args": connect_args}
|
|
52
|
+
if poolclass is not None:
|
|
53
|
+
engine_kwargs["poolclass"] = poolclass
|
|
49
54
|
if provider == DatabaseProvider.SQLITE:
|
|
50
55
|
connect_args["check_same_thread"] = False
|
|
51
|
-
|
|
56
|
+
elif poolclass is None:
|
|
52
57
|
engine_kwargs.update(
|
|
53
58
|
pool_size=pool_size,
|
|
54
59
|
max_overflow=max_overflow,
|
|
@@ -4,12 +4,10 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import contextlib
|
|
6
6
|
import logging
|
|
7
|
-
from decimal import Decimal
|
|
8
7
|
from unittest.mock import MagicMock
|
|
9
8
|
|
|
10
9
|
from _models import _TenantBase, _TenantItem
|
|
11
10
|
from simple_module_db.deps import get_db
|
|
12
|
-
from sqlalchemy.ext.asyncio import AsyncSession
|
|
13
11
|
|
|
14
12
|
|
|
15
13
|
async def _drive_get_db(db_state, populate=None):
|
|
@@ -82,47 +80,3 @@ class TestGetDbLogging:
|
|
|
82
80
|
read_only = [r for r in records if r.message == "db.session.read_only"]
|
|
83
81
|
assert len(read_only) == 1
|
|
84
82
|
assert read_only[0].operation == "read_only_rollback" # type: ignore[attr-defined]
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
class TestEntityListenerLogging:
|
|
88
|
-
async def test_create_logs_entity_created(self, db_session: AsyncSession, caplog):
|
|
89
|
-
"""Inserting a new entity should log db.entity.created."""
|
|
90
|
-
from products.models import Product
|
|
91
|
-
|
|
92
|
-
with caplog.at_level(logging.INFO, logger="simple_module.db"):
|
|
93
|
-
product = Product(name="Widget", price=Decimal("9.99"))
|
|
94
|
-
db_session.add(product)
|
|
95
|
-
await db_session.flush()
|
|
96
|
-
|
|
97
|
-
created_msgs = [
|
|
98
|
-
r
|
|
99
|
-
for r in caplog.records
|
|
100
|
-
if r.name == "simple_module.db" and r.message == "db.entity.created"
|
|
101
|
-
]
|
|
102
|
-
assert len(created_msgs) == 1
|
|
103
|
-
assert created_msgs[0].entity == "Product" # type: ignore[attr-defined]
|
|
104
|
-
assert created_msgs[0].operation == "create" # type: ignore[attr-defined]
|
|
105
|
-
|
|
106
|
-
async def test_update_logs_entity_updated(self, db_session: AsyncSession, caplog):
|
|
107
|
-
"""Modifying an entity should log db.entity.updated."""
|
|
108
|
-
from products.models import Product
|
|
109
|
-
|
|
110
|
-
product = Product(name="Widget", price=Decimal("9.99"))
|
|
111
|
-
db_session.add(product)
|
|
112
|
-
await db_session.flush()
|
|
113
|
-
|
|
114
|
-
caplog.clear()
|
|
115
|
-
|
|
116
|
-
product.name = "Updated Widget"
|
|
117
|
-
with caplog.at_level(logging.INFO, logger="simple_module.db"):
|
|
118
|
-
await db_session.flush()
|
|
119
|
-
|
|
120
|
-
updated_msgs = [
|
|
121
|
-
r
|
|
122
|
-
for r in caplog.records
|
|
123
|
-
if r.name == "simple_module.db" and r.message == "db.entity.updated"
|
|
124
|
-
]
|
|
125
|
-
assert len(updated_msgs) == 1
|
|
126
|
-
assert updated_msgs[0].entity == "Product" # type: ignore[attr-defined]
|
|
127
|
-
assert updated_msgs[0].operation == "update" # type: ignore[attr-defined]
|
|
128
|
-
assert updated_msgs[0].entity_id is not None # type: ignore[attr-defined]
|
|
@@ -17,10 +17,10 @@ class TestMigrationsHelper:
|
|
|
17
17
|
metadata = build_module_metadata()
|
|
18
18
|
table_names = set(metadata.tables.keys())
|
|
19
19
|
|
|
20
|
-
#
|
|
20
|
+
# Users ships models and must contribute at least one table.
|
|
21
21
|
# (Dashboard is event-driven with no models; Auth's tables are
|
|
22
22
|
# currently not part of this workspace's ORM surface.)
|
|
23
|
-
assert any("
|
|
23
|
+
assert any("user" in name.lower() for name in table_names)
|
|
24
24
|
assert len(table_names) >= 1
|
|
25
25
|
|
|
26
26
|
async def test_combined_metadata_only_returns_module_tables(self):
|
|
@@ -54,3 +54,42 @@ class TestMigrationsHelper:
|
|
|
54
54
|
"unrelated_host_table", MetaData(), Column("id", Integer, primary_key=True)
|
|
55
55
|
)
|
|
56
56
|
assert include(stranger, "unrelated_host_table", "table", False, None) is False
|
|
57
|
+
|
|
58
|
+
async def test_include_object_skips_unmodeled_cross_module_fks_by_default(self):
|
|
59
|
+
"""Cross-module FKs declared at the migration level only (no SQLModel
|
|
60
|
+
relationship) appear in the live DB but never in target metadata.
|
|
61
|
+
Alembic passes ``compare_to=None`` for live-only constraints and would
|
|
62
|
+
emit ``op.drop_constraint``; the default filter must drop those
|
|
63
|
+
constraint-level diffs to avoid destroying real FKs on every autogen.
|
|
64
|
+
"""
|
|
65
|
+
from simple_module_db.migrations import build_module_metadata, make_include_object
|
|
66
|
+
from sqlalchemy import Column, ForeignKeyConstraint, Integer, MetaData, Table
|
|
67
|
+
|
|
68
|
+
metadata = build_module_metadata()
|
|
69
|
+
include = make_include_object(metadata)
|
|
70
|
+
strict = make_include_object(metadata, ignore_unmodeled_fks=False)
|
|
71
|
+
|
|
72
|
+
known = next(iter(metadata.tables.values()))
|
|
73
|
+
scratch = MetaData()
|
|
74
|
+
local = Table(known.name, scratch, Column("id", Integer, primary_key=True))
|
|
75
|
+
fk = ForeignKeyConstraint([local.c.id], ["other_module.other.id"], name="fk_xmod")
|
|
76
|
+
local.append_constraint(fk)
|
|
77
|
+
|
|
78
|
+
assert include(fk, "fk_xmod", "foreign_key_constraint", True, None) is False
|
|
79
|
+
assert strict(fk, "fk_xmod", "foreign_key_constraint", True, None) is True
|
|
80
|
+
|
|
81
|
+
stranger_meta = MetaData()
|
|
82
|
+
stranger = Table(
|
|
83
|
+
"totally_unknown_table",
|
|
84
|
+
stranger_meta,
|
|
85
|
+
Column("id", Integer, primary_key=True),
|
|
86
|
+
Column("ref", Integer),
|
|
87
|
+
)
|
|
88
|
+
stranger_fk = ForeignKeyConstraint(
|
|
89
|
+
[stranger.c.ref], ["something.else.id"], name="fk_stranger"
|
|
90
|
+
)
|
|
91
|
+
stranger.append_constraint(stranger_fk)
|
|
92
|
+
assert (
|
|
93
|
+
include(stranger_fk, "fk_stranger", "foreign_key_constraint", False, stranger_fk)
|
|
94
|
+
is False
|
|
95
|
+
)
|
|
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
|