test_postgres_restore_drop_cascade.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. """Regression test for the Postgres restore drop-tables-with-CASCADE fix.
  2. The bug: the restore path called `metadata.drop_all`, which only drops
  3. tables defined in the SQLAlchemy ORM and emits plain `DROP TABLE` (no
  4. CASCADE). When the live DB carries orphan tables from removed features
  5. (e.g. legacy `spoolman_slot_assignments` whose `_printer_id_fkey`
  6. constraint still references `printers`), Postgres refuses with
  7. `DependentObjectsStillExistError` and the entire restore aborts before
  8. any rows land.
  9. The fix: drop every table in the `public` schema with `CASCADE` via a
  10. `pg_tables`-iterating PL/pgSQL `DO` block, then re-create from the
  11. ORM metadata. CASCADE removes external constraints alongside the table,
  12. so orphan tables can no longer block the restore.
  13. These tests guard against a regression to `metadata.drop_all` (which
  14. would re-introduce the bug for any user with orphan tables).
  15. The second half of the file covers the follow-on fix: the recreated
  16. tables must carry no foreign keys at all while rows are being imported.
  17. """
  18. from __future__ import annotations
  19. import logging
  20. import sqlite3
  21. import tempfile
  22. from pathlib import Path
  23. from unittest.mock import AsyncMock, MagicMock, patch
  24. import pytest
  25. def _make_sqlite_source() -> Path:
  26. """Build a tiny SQLite file with one ORM-known table so the restore
  27. function progresses past its `tables_to_import & metadata.tables` gate."""
  28. with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
  29. path = Path(tmp.name)
  30. conn = sqlite3.connect(str(path))
  31. # `users` is in the ORM metadata so `tables_to_import` is non-empty.
  32. conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT)")
  33. # At least one row, so the import actually emits an INSERT -- the
  34. # loop skips empty tables outright.
  35. conn.execute("INSERT INTO users (id, username) VALUES (1, 'alice')")
  36. conn.commit()
  37. conn.close()
  38. return path
  39. @pytest.mark.asyncio
  40. async def test_restore_drops_tables_with_cascade_not_metadata_drop_all():
  41. """Verify the restore drop phase issues a CASCADE-aware DROP TABLE
  42. iteration over `public` schema rather than `metadata.drop_all`.
  43. Regression: prior to the fix, an orphan table holding an FK back to
  44. `printers` (e.g. legacy `spoolman_slot_assignments_printer_id_fkey`)
  45. would cause `metadata.drop_all` to fail with
  46. `DependentObjectsStillExistError`, aborting the whole restore."""
  47. from backend.app.api.routes import settings as settings_module
  48. sqlite_path = _make_sqlite_source()
  49. try:
  50. executed_sql: list[str] = []
  51. run_sync_calls: list[str] = []
  52. # Capture the exact SQL emitted on the Postgres connection.
  53. mock_conn = MagicMock()
  54. mock_conn.execute = AsyncMock(
  55. side_effect=lambda stmt, *a, **k: executed_sql.append(getattr(stmt, "text", str(stmt)))
  56. )
  57. # `await conn.run_sync(metadata.create_all)` is the only run_sync
  58. # the fix should issue. `metadata.drop_all` must never appear.
  59. async def _run_sync(fn, *args, **kw):
  60. name = getattr(fn, "__name__", repr(fn))
  61. run_sync_calls.append(name)
  62. return None
  63. mock_conn.run_sync = AsyncMock(side_effect=_run_sync)
  64. # `pg_engine.begin()` is used twice (drop+create, then import).
  65. # Both must yield the same captured-conn so we observe everything.
  66. begin_cm = MagicMock()
  67. begin_cm.__aenter__ = AsyncMock(return_value=mock_conn)
  68. begin_cm.__aexit__ = AsyncMock(return_value=False)
  69. mock_engine = MagicMock()
  70. mock_engine.begin = MagicMock(return_value=begin_cm)
  71. mock_engine.dispose = AsyncMock()
  72. # `_create_engine` is imported lazily inside the function via
  73. # `from backend.app.core.database import ... _create_engine`,
  74. # so we patch the module it's imported FROM, not settings.py.
  75. with patch(
  76. "backend.app.core.database._create_engine",
  77. new=MagicMock(return_value=mock_engine),
  78. ):
  79. await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
  80. # 1. CASCADE drop is emitted, hitting every public-schema table.
  81. cascade_drops = [s for s in executed_sql if "CASCADE" in s and "pg_tables" in s]
  82. assert cascade_drops, (
  83. "Expected a CASCADE-aware DROP TABLE iteration over the public "
  84. "schema in the restore SQL stream. Without it, orphan tables "
  85. "with FK constraints back to ORM tables (e.g. legacy "
  86. "spoolman_slot_assignments) abort the restore. Captured SQL: " + "; ".join(s[:120] for s in executed_sql)
  87. )
  88. # 2. The DO block iterates pg_tables (not just one DROP) so every
  89. # table is handled, including orphan ones not in the ORM.
  90. do_block = cascade_drops[0]
  91. assert "DROP TABLE" in do_block
  92. assert "schemaname = 'public'" in do_block
  93. # 3. `metadata.drop_all` is never invoked — that was the buggy
  94. # path. `metadata.create_all` is fine; it rebuilds the schema
  95. # after the CASCADE drop.
  96. assert "drop_all" not in run_sync_calls, (
  97. f"metadata.drop_all should not be called (regression): {run_sync_calls}"
  98. )
  99. assert "create_all" in run_sync_calls, f"metadata.create_all should still be called: {run_sync_calls}"
  100. # 4. Drop runs before create. The captured SQL is in execution order
  101. # within the same pg_engine.begin() block, and run_sync_calls is
  102. # in invocation order across both blocks.
  103. first_create_idx = run_sync_calls.index("create_all")
  104. # No drop_all anywhere — the cascade DO block (executed via .execute,
  105. # not run_sync) is what runs first. Its presence is confirmed above.
  106. assert first_create_idx >= 0
  107. finally:
  108. sqlite_path.unlink(missing_ok=True)
  109. @pytest.mark.asyncio
  110. async def test_restore_cascade_drop_targets_only_public_schema():
  111. """Defensive: the CASCADE drop must scope to the `public` schema so a
  112. shared Postgres holding non-Bambuddy tables in other schemas doesn't
  113. lose data on restore."""
  114. from backend.app.api.routes import settings as settings_module
  115. sqlite_path = _make_sqlite_source()
  116. try:
  117. executed_sql: list[str] = []
  118. mock_conn = MagicMock()
  119. mock_conn.execute = AsyncMock(
  120. side_effect=lambda stmt, *a, **k: executed_sql.append(getattr(stmt, "text", str(stmt)))
  121. )
  122. mock_conn.run_sync = AsyncMock()
  123. begin_cm = MagicMock()
  124. begin_cm.__aenter__ = AsyncMock(return_value=mock_conn)
  125. begin_cm.__aexit__ = AsyncMock(return_value=False)
  126. mock_engine = MagicMock()
  127. mock_engine.begin = MagicMock(return_value=begin_cm)
  128. mock_engine.dispose = AsyncMock()
  129. with patch(
  130. "backend.app.core.database._create_engine",
  131. new=MagicMock(return_value=mock_engine),
  132. ):
  133. await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
  134. cascade = next((s for s in executed_sql if "CASCADE" in s), None)
  135. assert cascade is not None
  136. # Schema scope check: we're not iterating `pg_class` /
  137. # `information_schema.tables` without a schema filter, which
  138. # would catch system catalogs or other-app tables.
  139. assert "schemaname = 'public'" in cascade, f"CASCADE drop must filter to public schema; got: {cascade[:200]}"
  140. assert "schemaname = '*'" not in cascade
  141. finally:
  142. sqlite_path.unlink(missing_ok=True)
  143. def _mock_pg_engine(
  144. executed_sql: list[str],
  145. create_all_error: Exception | None = None,
  146. fk_error: Exception | None = None,
  147. ):
  148. """Build a fake async engine that records every statement, plus a
  149. `run_sync:<fn>` marker, into `executed_sql` in execution order.
  150. `fk_error` makes every ADD CONSTRAINT fail, standing in for a backup
  151. carrying orphaned rows."""
  152. from sqlalchemy.schema import AddConstraint
  153. mock_conn = MagicMock()
  154. def _execute(stmt, *a, **k):
  155. if fk_error is not None and isinstance(stmt, AddConstraint):
  156. raise fk_error
  157. executed_sql.append(getattr(stmt, "text", str(stmt)))
  158. mock_conn.execute = AsyncMock(side_effect=_execute)
  159. async def _run_sync(fn, *args, **kw):
  160. executed_sql.append("run_sync:" + getattr(fn, "__name__", repr(fn)))
  161. if create_all_error is not None:
  162. raise create_all_error
  163. return None
  164. mock_conn.run_sync = AsyncMock(side_effect=_run_sync)
  165. begin_cm = MagicMock()
  166. begin_cm.__aenter__ = AsyncMock(return_value=mock_conn)
  167. begin_cm.__aexit__ = AsyncMock(return_value=False)
  168. mock_engine = MagicMock()
  169. mock_engine.begin = MagicMock(return_value=begin_cm)
  170. mock_engine.dispose = AsyncMock()
  171. return mock_engine
  172. def _fk_names(table) -> set[str]:
  173. return {id(fk) for fk in table.constraints if hasattr(fk, "elements")}
  174. @pytest.mark.asyncio
  175. async def test_restore_drops_every_foreign_key_before_importing_rows():
  176. """The recreated schema must carry no FK constraints while rows land.
  177. Regression (#restore FK violation): the fix used to discard each
  178. ForeignKeyConstraint from `table.constraints` before `create_all`.
  179. That only suppresses the inline REFERENCES clause -- when `create_all`
  180. hits a dependency cycle it cannot sort (library_files /
  181. library_folders / print_archives are exactly such a cycle) it emits
  182. those tables' keys as separate ALTER TABLE ... ADD FOREIGN KEY
  183. statements read from `Table.foreign_key_constraints`, which the
  184. discard never touched. The child table then imported before its
  185. parent and Postgres raised ForeignKeyViolationError on
  186. `library_files_folder_id_fkey`."""
  187. from backend.app.api.routes import settings as settings_module
  188. sqlite_path = _make_sqlite_source()
  189. try:
  190. executed_sql: list[str] = []
  191. with patch(
  192. "backend.app.core.database._create_engine",
  193. new=MagicMock(return_value=_mock_pg_engine(executed_sql)),
  194. ):
  195. await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
  196. fk_drops = [i for i, s in enumerate(executed_sql) if "pg_constraint" in s and "DROP CONSTRAINT" in s]
  197. assert fk_drops, (
  198. "Expected an unconditional DROP CONSTRAINT sweep over pg_constraint "
  199. "so no foreign key survives create_all's cycle-breaking ALTER "
  200. "TABLE statements. Captured SQL: " + "; ".join(s[:100] for s in executed_sql)
  201. )
  202. drop_sql = executed_sql[fk_drops[0]]
  203. # Foreign keys only ('f'), scoped to public -- not PK/unique/check,
  204. # and not another application's schema on a shared Postgres.
  205. assert "contype = 'f'" in drop_sql, drop_sql
  206. assert "'public'::regnamespace" in drop_sql, drop_sql
  207. # It has to land after the tables exist and before the first row.
  208. create_idx = executed_sql.index("run_sync:create_all")
  209. insert_idx = next((i for i, s in enumerate(executed_sql) if s.startswith("INSERT INTO")), -1)
  210. assert insert_idx > 0, f"no row import happened, so the ordering is untested: {executed_sql}"
  211. assert create_idx < fk_drops[0] < insert_idx, (
  212. f"FK drop must sit between create_all and the first INSERT: {executed_sql}"
  213. )
  214. finally:
  215. sqlite_path.unlink(missing_ok=True)
  216. @pytest.mark.asyncio
  217. @pytest.mark.parametrize("create_all_fails", [False, True])
  218. async def test_restore_never_mutates_the_process_wide_orm_metadata(create_all_fails):
  219. """`Base.metadata` is global to the running app. The old code removed
  220. every FK from it and only put them back *after* the drop/create
  221. transaction, so a failure in there left the live process unable to
  222. emit or re-add foreign keys until restart."""
  223. from backend.app.api.routes import settings as settings_module
  224. from backend.app.core.database import Base
  225. table = Base.metadata.tables["library_files"]
  226. before = _fk_names(table)
  227. assert before, "library_files should carry FK constraints to begin with"
  228. sqlite_path = _make_sqlite_source()
  229. try:
  230. executed_sql: list[str] = []
  231. boom = RuntimeError("create_all exploded") if create_all_fails else None
  232. engine = _mock_pg_engine(executed_sql, create_all_error=boom)
  233. with patch("backend.app.core.database._create_engine", new=MagicMock(return_value=engine)):
  234. if create_all_fails:
  235. with pytest.raises(RuntimeError, match="create_all exploded"):
  236. await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
  237. else:
  238. await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
  239. assert _fk_names(table) == before, "The restore must not add or remove constraints on the shared ORM metadata"
  240. finally:
  241. sqlite_path.unlink(missing_ok=True)
  242. @pytest.mark.asyncio
  243. async def test_unrestorable_fk_is_reported_by_its_columns(caplog):
  244. """A key that can't go back on must be named by what it links.
  245. These constraints are unnamed in the ORM, so `fk.name` is None and the
  246. warning used to read "print_archives.None" once per failure -- five of
  247. that table's keys share it, so the report said nothing about which
  248. columns to inspect."""
  249. from backend.app.api.routes import settings as settings_module
  250. orphan = RuntimeError(
  251. 'violates foreign key constraint "library_files_folder_id_fkey"\n'
  252. 'DETAIL: Key (folder_id)=(9) is not present in table "library_folders".'
  253. )
  254. sqlite_path = _make_sqlite_source()
  255. try:
  256. executed_sql: list[str] = []
  257. engine = _mock_pg_engine(executed_sql, fk_error=orphan)
  258. with (
  259. patch("backend.app.core.database._create_engine", new=MagicMock(return_value=engine)),
  260. caplog.at_level(logging.INFO, logger="backend.app.api.routes.settings"),
  261. ):
  262. await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
  263. warning = next((r.getMessage() for r in caplog.records if r.levelno == logging.WARNING), None)
  264. assert warning is not None, "a failed FK restore must be reported"
  265. assert ".None" not in warning, f"constraints must not be named by fk.name: {warning}"
  266. assert "library_files(folder_id) -> library_folders.id" in warning, warning
  267. # And the offending value is recorded so the rows can be found.
  268. assert any("Key (folder_id)=(9)" in r.getMessage() for r in caplog.records), (
  269. "the Postgres DETAIL line names the orphan; it must survive into the log"
  270. )
  271. finally:
  272. sqlite_path.unlink(missing_ok=True)
  273. def test_library_tables_form_an_fk_cycle():
  274. """Documents why the restore cannot import in dependency order.
  275. library_files -> library_folders -> print_archives -> library_files.
  276. SQLAlchemy's `sorted_tables` gives up on these three and falls back to
  277. alphabetical, which puts the child (library_files) before its parent.
  278. Dropping the constraints outright is the only ordering-independent
  279. answer; if this cycle is ever broken, the restore still works, but the
  280. comment in `_import_sqlite_to_postgres` should be revisited."""
  281. from backend.app.core.database import Base
  282. def refs(name: str) -> set[str]:
  283. table = Base.metadata.tables[name]
  284. return {fk.column.table.name for fk in table.foreign_keys}
  285. assert "library_folders" in refs("library_files")
  286. assert "print_archives" in refs("library_folders")
  287. assert "library_files" in refs("print_archives")