test_model_metadata.py 3.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
  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. from sqlalchemy import create_engine, inspect
  24. from backend.app.core.database import Base
  25. def _all_models_imported() -> None:
  26. """Base.metadata is filled by imports, so a partial import means a partial
  27. schema -- and a cycle in a table nobody imported would not be found here."""
  28. import backend.app.models as models
  29. for module in pkgutil.iter_modules(models.__path__):
  30. importlib.import_module(f"backend.app.models.{module.name}")
  31. def test_the_schema_sorts_without_a_cycle_warning():
  32. _all_models_imported()
  33. with warnings.catch_warnings(record=True) as caught:
  34. warnings.simplefilter("always")
  35. assert Base.metadata.sorted_tables
  36. cycles = [str(w.message) for w in caught if "cycles" in str(w.message)]
  37. assert not cycles, (
  38. f"a new foreign key has closed a loop in the schema; backup and restore sort these tables: {cycles}"
  39. )
  40. def test_the_tables_that_used_to_cycle_sort_parents_first():
  41. """The property the warning took away. Order is what the restore's import
  42. loop follows, and a child ahead of its parent is a FK violation."""
  43. _all_models_imported()
  44. order = [t.name for t in Base.metadata.sorted_tables]
  45. assert order.index("library_folders") < order.index("library_files"), (
  46. "library_files.folder_id points at library_folders"
  47. )
  48. assert order.index("library_files") < order.index("print_archives"), (
  49. "print_archives.library_file_id points at library_files"
  50. )
  51. def test_the_altered_constraint_still_exists_on_sqlite():
  52. """use_alter asks for ALTER TABLE ADD CONSTRAINT, and SQLite has no such
  53. statement. It inlines the key into CREATE TABLE instead -- but if that ever
  54. stopped being true, deleting an archive would leave a dangling
  55. library_folders.archive_id rather than nulling it, silently."""
  56. _all_models_imported()
  57. engine = create_engine("sqlite://")
  58. Base.metadata.create_all(engine)
  59. keys = inspect(engine).get_foreign_keys("library_folders")
  60. archive_link = [k for k in keys if k["referred_table"] == "print_archives"]
  61. assert archive_link, f"library_folders lost its archive key: {keys}"
  62. assert archive_link[0]["constrained_columns"] == ["archive_id"]
  63. assert archive_link[0]["options"].get("ondelete") == "SET NULL"