Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion framework/db/simple_module_db/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,27 @@ def build_module_metadata(modules: Sequence[ModuleBase] | None = None) -> MetaDa
return combined


def make_include_object(metadata: MetaData) -> IncludeObjectFn:
def make_include_object(
metadata: MetaData,
*,
ignore_unmodeled_fks: bool = True,
) -> IncludeObjectFn:
"""Return an Alembic ``include_object`` filter scoped to the module tables.

Call as ``context.configure(..., include_object=make_include_object(meta))``.
The filter accepts only table names present in ``metadata``, preventing
autogenerate from diffing — and potentially dropping — tables that exist
in the database but aren't owned by any installed module (e.g. a user
table added by the host developer outside the module system).

:param ignore_unmodeled_fks: When ``True`` (default), foreign-key
constraints that exist in the live DB but are absent from the SQLModel
metadata are skipped instead of being emitted as ``op.drop_constraint``
in the autogenerated migration. The framework's recommended pattern
declares cross-module FKs at the migration level only (no Python-level
relationship between modules), so those constraints look "unmodeled" to
Alembic on every autogen run and would otherwise be silently dropped.
Set to ``False`` to recover the prior, drop-on-sight behaviour.
"""
allowlist = {t.name for t in metadata.tables.values()}

Expand All @@ -86,6 +99,8 @@ def include_object(
) -> bool:
if type_ == "table":
return name in allowlist
if ignore_unmodeled_fks and type_ == "foreign_key_constraint" and compare_to is None:
return False
parent_table = getattr(object, "table", None)
if parent_table is not None:
return parent_table.name in allowlist
Expand Down
39 changes: 39 additions & 0 deletions framework/db/tests/test_migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,42 @@ async def test_include_object_allowlist_filters_unknown_tables(self):
"unrelated_host_table", MetaData(), Column("id", Integer, primary_key=True)
)
assert include(stranger, "unrelated_host_table", "table", False, None) is False

async def test_include_object_skips_unmodeled_cross_module_fks_by_default(self):
"""Cross-module FKs declared at the migration level only (no SQLModel
relationship) appear in the live DB but never in target metadata.
Alembic passes ``compare_to=None`` for live-only constraints and would
emit ``op.drop_constraint``; the default filter must drop those
constraint-level diffs to avoid destroying real FKs on every autogen.
"""
from simple_module_db.migrations import build_module_metadata, make_include_object
from sqlalchemy import Column, ForeignKeyConstraint, Integer, MetaData, Table

metadata = build_module_metadata()
include = make_include_object(metadata)
strict = make_include_object(metadata, ignore_unmodeled_fks=False)

known = next(iter(metadata.tables.values()))
scratch = MetaData()
local = Table(known.name, scratch, Column("id", Integer, primary_key=True))
fk = ForeignKeyConstraint([local.c.id], ["other_module.other.id"], name="fk_xmod")
local.append_constraint(fk)

assert include(fk, "fk_xmod", "foreign_key_constraint", True, None) is False
assert strict(fk, "fk_xmod", "foreign_key_constraint", True, None) is True

stranger_meta = MetaData()
stranger = Table(
"totally_unknown_table",
stranger_meta,
Column("id", Integer, primary_key=True),
Column("ref", Integer),
)
stranger_fk = ForeignKeyConstraint(
[stranger.c.ref], ["something.else.id"], name="fk_stranger"
)
stranger.append_constraint(stranger_fk)
assert (
include(stranger_fk, "fk_stranger", "foreign_key_constraint", False, stranger_fk)
is False
)
3 changes: 3 additions & 0 deletions framework/hosting/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ dependencies = [
"uvicorn[standard]>=0.34",
]

[project.scripts]
sm-host = "simple_module_hosting.host_cli:app"

[project.entry-points."simple_module_cli.cli_plugins"]
host = "simple_module_hosting.host_cli:app"

Expand Down
14 changes: 14 additions & 0 deletions framework/hosting/simple_module_hosting/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Module entry point: ``python -m simple_module_hosting`` invokes the host CLI.

Without this, ``python -m simple_module_hosting.host_cli`` would import the
module without running the Typer app — silently no-op'ing commands like
``gen-pages``. Provides the same Typer ``app`` callable that the
``simple_module_cli.cli_plugins`` entry point exposes as ``sm host``.
"""

from __future__ import annotations

from simple_module_hosting.host_cli import app

if __name__ == "__main__":
app()
4 changes: 4 additions & 0 deletions framework/hosting/simple_module_hosting/host_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,7 @@ def sync_js_deps(
return
result = subprocess.run(cmd, cwd=repo_root, check=False)
raise typer.Exit(code=result.returncode)


if __name__ == "__main__":
app()
22 changes: 22 additions & 0 deletions framework/hosting/tests/test_host_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

from __future__ import annotations

import subprocess
import sys
from pathlib import Path

import pytest
import typer
from simple_module_hosting.host_cli import app
from typer.testing import CliRunner
Expand All @@ -26,3 +29,22 @@ def test_gen_pages_errors_on_missing_client_app(tmp_path: Path) -> None:
result = runner.invoke(app, ["gen-pages", "--host-dir", str(tmp_path / "does-not-exist")])
assert result.exit_code != 0
assert "not found" in result.output.lower() or "not found" in (result.stderr or "").lower()


@pytest.mark.parametrize(
"module_target",
["simple_module_hosting", "simple_module_hosting.host_cli"],
)
def test_python_dash_m_invocation_runs_cli(module_target: str) -> None:
"""Both ``python -m simple_module_hosting`` and ``...host_cli`` must invoke
the Typer app — without ``__main__.py`` / a ``__name__ == "__main__"`` block
these silently no-op'd, breaking the documented ``gen-pages`` workflow.
"""
result = subprocess.run(
[sys.executable, "-m", module_target, "--help"],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
assert "gen-pages" in result.stdout
Loading