test_model_metadata.py 3.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
  1. """The schema has to be sortable, because backup and restore sort it.
  2. `metadata.sorted_tables` is asked for the order in three places: the backup
  3. export, the restore's import loop, and the loop that puts foreign keys back
  4. afterwards. Three nullable SET NULL links used to close a loop --
  5. print_archives.library_file_id -> library_files.folder_id ->
  6. library_folders.archive_id -> print_archives -- and SQLAlchemy answered a
  7. sort it could not make, with a warning on every backup and every restore:
  8. Cannot correctly sort tables; there are unresolvable cycles between
  9. tables "library_files, library_folders, print_archives" ... this warning
  10. may raise an error in a future release.
  11. Two things were wrong with living on that. The order it returns can place a
  12. child before its parent, which is what once imported library_files ahead of
  13. library_folders and killed a restore on a ForeignKeyViolation. And the
  14. sentence at the end is a promise: if it ever becomes an error, backup and
  15. restore break on the same upgrade.
  16. One edge of the loop is marked use_alter, which takes it out of the sort
  17. graph without taking the constraint out of the database.
  18. """
  19. from __future__ import annotations
  20. import importlib
  21. import pkgutil
  22. import warnings
  23. import pytest
  24. from sqlalchemy import create_engine, inspect
  25. from backend.app.core.database import Base
  26. def _all_models_imported() -> None:
  27. """Base.metadata is filled by imports, so a partial import means a partial
  28. schema -- and a cycle in a table nobody imported would not be found here."""
  29. import backend.app.models as models
  30. for module in pkgutil.iter_modules(models.__path__):
  31. importlib.import_module(f"backend.app.models.{module.name}")
  32. def test_the_schema_sorts_without_a_cycle_warning():
  33. _all_models_imported()
  34. with warnings.catch_warnings(record=True) as caught:
  35. warnings.simplefilter("always")
  36. assert Base.metadata.sorted_tables
  37. cycles = [str(w.message) for w in caught if "cycles" in str(w.message)]
  38. assert not cycles, (
  39. f"a new foreign key has closed a loop in the schema; backup and restore sort these tables: {cycles}"
  40. )
  41. def test_the_tables_that_used_to_cycle_sort_parents_first():
  42. """The property the warning took away. Order is what the restore's import
  43. loop follows, and a child ahead of its parent is a FK violation."""
  44. _all_models_imported()
  45. order = [t.name for t in Base.metadata.sorted_tables]
  46. assert order.index("library_folders") < order.index("library_files"), (
  47. "library_files.folder_id points at library_folders"
  48. )
  49. assert order.index("library_files") < order.index("print_archives"), (
  50. "print_archives.library_file_id points at library_files"
  51. )
  52. @pytest.mark.sqlite_only # SQLite table rebuild
  53. def test_the_altered_constraint_still_exists_on_sqlite():
  54. """use_alter asks for ALTER TABLE ADD CONSTRAINT, and SQLite has no such
  55. statement. It inlines the key into CREATE TABLE instead -- but if that ever
  56. stopped being true, deleting an archive would leave a dangling
  57. library_folders.archive_id rather than nulling it, silently."""
  58. _all_models_imported()
  59. engine = create_engine("sqlite://")
  60. Base.metadata.create_all(engine)
  61. keys = inspect(engine).get_foreign_keys("library_folders")
  62. archive_link = [k for k in keys if k["referred_table"] == "print_archives"]
  63. assert archive_link, f"library_folders lost its archive key: {keys}"
  64. assert archive_link[0]["constrained_columns"] == ["archive_id"]
  65. assert archive_link[0]["options"].get("ondelete") == "SET NULL"