test_sqlite_fk.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. """core/sqlite_fk.py applies the selected ON DELETE rules that SQLite leaves out.
  2. The app's SQLite database runs with PRAGMA foreign_keys off, so a delete left
  3. every ON DELETE CASCADE / SET NULL undone: rows pointing at the deleted row kept
  4. its id, and SQLite hands the highest freed id to the next row created.
  5. """
  6. import pytest
  7. from sqlalchemy import delete, event, select, text
  8. from backend.app.core import sqlite_fk
  9. from backend.app.core.database import load_all_models
  10. from backend.app.models.archive import PrintArchive
  11. from backend.app.models.library import LibraryFile
  12. from backend.app.models.notification import NotificationProvider
  13. from backend.app.models.print_log import PrintLogEntry
  14. from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
  15. from backend.app.models.printer import Printer
  16. # The SQLite stand-in for foreign key rules PostgreSQL applies itself.
  17. pytestmark = pytest.mark.sqlite_only
  18. async def _archive_with_dependents(db):
  19. printer = Printer(name="P", serial_number="S1", ip_address="192.0.2.1", access_code="x")
  20. library_file = LibraryFile(filename="f.3mf", file_path="l/f.3mf", file_type="3mf", file_size=1)
  21. db.add_all([printer, library_file])
  22. await db.flush()
  23. archive = PrintArchive(
  24. printer_id=printer.id, filename="a.3mf", file_path="a/a.3mf", file_size=1, status="completed"
  25. )
  26. db.add(archive)
  27. await db.flush()
  28. item = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, position=1)
  29. db.add(item)
  30. await db.flush()
  31. variant = PrintQueueVariant(queue_item_id=item.id, library_file_id=library_file.id, target_model="X1C")
  32. run = PrintLogEntry(status="completed", print_name="a", archive_id=archive.id, queue_item_id=item.id)
  33. db.add_all([variant, run])
  34. await db.commit()
  35. return archive.id, item.id, variant.id, run.id
  36. async def _exists(db, model, row_id) -> bool:
  37. return (await db.execute(select(model.id).where(model.id == row_id))).scalar_one_or_none() is not None
  38. async def _run(db, run_id):
  39. statement = select(PrintLogEntry).where(PrintLogEntry.id == run_id).execution_options(populate_existing=True)
  40. return (await db.execute(statement)).scalar_one()
  41. async def _assert_rules_applied(db, item_id, variant_id, run_id):
  42. assert not await _exists(db, PrintQueueItem, item_id) # CASCADE
  43. assert not await _exists(db, PrintQueueVariant, variant_id) # CASCADE of the CASCADE
  44. run = await _run(db, run_id)
  45. assert run.archive_id is None # SET NULL
  46. assert run.queue_item_id is None # SET NULL of the cascaded row
  47. @pytest.mark.asyncio
  48. async def test_an_orm_delete_applies_the_rules(db_session):
  49. archive_id, item_id, variant_id, run_id = await _archive_with_dependents(db_session)
  50. await db_session.delete(await db_session.get(PrintArchive, archive_id))
  51. await db_session.commit()
  52. await _assert_rules_applied(db_session, item_id, variant_id, run_id)
  53. @pytest.mark.asyncio
  54. async def test_a_bulk_delete_applies_the_rules(db_session):
  55. archive_id, item_id, variant_id, run_id = await _archive_with_dependents(db_session)
  56. await db_session.execute(delete(PrintArchive).where(PrintArchive.id == archive_id))
  57. await db_session.commit()
  58. await _assert_rules_applied(db_session, item_id, variant_id, run_id)
  59. @pytest.mark.asyncio
  60. async def test_a_bulk_delete_sees_changes_not_flushed_yet(db_session):
  61. """A queue item moved off the archive in the same transaction must survive."""
  62. archive_id, item_id, _, _ = await _archive_with_dependents(db_session)
  63. other = PrintArchive(filename="b.3mf", file_path="a/b.3mf", file_size=1, status="completed")
  64. db_session.add(other)
  65. await db_session.commit()
  66. item = await db_session.get(PrintQueueItem, item_id)
  67. item.archive_id = other.id
  68. await db_session.execute(delete(PrintArchive).where(PrintArchive.id == archive_id))
  69. await db_session.commit()
  70. assert await _exists(db_session, PrintQueueItem, item_id)
  71. @pytest.mark.asyncio
  72. async def test_references_not_on_the_list_are_left_alone(db_session):
  73. """Not every model rule is right: deleting a printer must not widen a
  74. printer-scoped notification provider (SET NULL means all printers)."""
  75. printer = Printer(name="P", serial_number="S2", ip_address="192.0.2.2", access_code="x")
  76. db_session.add(printer)
  77. await db_session.flush()
  78. provider = NotificationProvider(name="n", provider_type="webhook", config="{}", printer_id=printer.id)
  79. db_session.add(provider)
  80. await db_session.commit()
  81. printer_id, provider_id = printer.id, provider.id
  82. await db_session.execute(delete(Printer).where(Printer.id == printer_id))
  83. await db_session.commit()
  84. scoped = (
  85. await db_session.execute(select(NotificationProvider.printer_id).where(NotificationProvider.id == provider_id))
  86. ).scalar_one()
  87. assert scoped == printer_id
  88. @pytest.mark.asyncio
  89. async def test_many_orm_deletes_in_one_flush_cost_one_statement_per_rule(db_session):
  90. """Applied once per flush, not once per row: a folder of thousands of files
  91. or a big purge must not run the rules per row while holding the write lock."""
  92. archives = [PrintArchive(filename=f"{i}.3mf", file_path=f"a/{i}.3mf", file_size=1, status="x") for i in range(50)]
  93. db_session.add_all(archives)
  94. await db_session.commit()
  95. rule_statements: list[str] = []
  96. sync_engine = db_session.bind.sync_engine
  97. def count(conn, cursor, statement, *args):
  98. head = statement.lstrip().upper()
  99. if head.startswith(("UPDATE", "DELETE")) and not head.startswith("DELETE FROM PRINT_ARCHIVES"):
  100. rule_statements.append(statement)
  101. event.listen(sync_engine, "before_cursor_execute", count)
  102. try:
  103. for archive in archives:
  104. await db_session.delete(archive)
  105. await db_session.commit()
  106. finally:
  107. event.remove(sync_engine, "before_cursor_execute", count)
  108. # The rules of print_archives and of the rows they cascade to, once each.
  109. assert len(rule_statements) < 30, len(rule_statements)
  110. @pytest.mark.asyncio
  111. async def test_a_rollback_drops_ids_collected_for_a_flush_that_failed(db_session):
  112. """Ids collected by a flush that then failed must not be applied by the next one."""
  113. await db_session.execute(select(1)) # an open transaction, as during a failed flush
  114. db_session.sync_session.info[sqlite_fk._PENDING] = {"print_archives": [1]}
  115. await db_session.rollback()
  116. assert sqlite_fk._PENDING not in db_session.sync_session.info
  117. @pytest.mark.asyncio
  118. async def test_nothing_to_do_on_postgresql(db_session, monkeypatch):
  119. """PostgreSQL applies the rules itself."""
  120. archive_id, item_id, _, run_id = await _archive_with_dependents(db_session)
  121. monkeypatch.setattr(sqlite_fk, "_applies", lambda connection: False)
  122. await db_session.execute(delete(PrintArchive).where(PrintArchive.id == archive_id))
  123. await db_session.commit()
  124. assert await _exists(db_session, PrintQueueItem, item_id)
  125. assert (await _run(db_session, run_id)).archive_id == archive_id
  126. @pytest.mark.asyncio
  127. async def test_the_startup_sweep_repairs_rows_whose_parent_is_already_gone(db_session):
  128. archive_id, item_id, variant_id, run_id = await _archive_with_dependents(db_session)
  129. # Deleted the old way (raw SQL bypasses the hooks), leaving every dependent behind.
  130. await db_session.execute(text("DELETE FROM print_archives WHERE id = :id"), {"id": archive_id})
  131. await db_session.commit()
  132. await sqlite_fk.sweep_orphans(await db_session.connection())
  133. await db_session.commit()
  134. await _assert_rules_applied(db_session, item_id, variant_id, run_id)
  135. @pytest.mark.asyncio
  136. async def test_the_sweep_leaves_valid_references_and_unlisted_ones_alone(db_session):
  137. archive_id, item_id, variant_id, run_id = await _archive_with_dependents(db_session)
  138. provider = NotificationProvider(name="n", provider_type="webhook", config="{}", printer_id=987654)
  139. db_session.add(provider)
  140. await db_session.commit()
  141. provider_id = provider.id
  142. await sqlite_fk.sweep_orphans(await db_session.connection())
  143. await db_session.commit()
  144. assert await _exists(db_session, PrintQueueItem, item_id)
  145. assert await _exists(db_session, PrintQueueVariant, variant_id)
  146. assert (await _run(db_session, run_id)).archive_id == archive_id
  147. stays = (
  148. await db_session.execute(select(NotificationProvider.printer_id).where(NotificationProvider.id == provider_id))
  149. ).scalar_one()
  150. assert stays == 987654
  151. def test_every_listed_reference_is_a_model_foreign_key_with_a_rule():
  152. """A typo or a renamed column must not silently drop a rule."""
  153. metadata = load_all_models()
  154. with_rule = {
  155. (table.name, fk.parent.name)
  156. for table in metadata.sorted_tables
  157. for fk in table.foreign_keys
  158. if fk.ondelete in ("CASCADE", "SET NULL")
  159. }
  160. assert sqlite_fk.APPLIED - with_rule == set()
  161. assert len(sqlite_fk._applied_foreign_keys()) == len(sqlite_fk.APPLIED)
  162. def test_every_listed_set_null_column_can_hold_null():
  163. """A SET NULL rule on a NOT NULL column would make the delete itself fail."""
  164. not_nullable = {
  165. (table.name, fk.parent.name)
  166. for table in load_all_models().sorted_tables
  167. for fk in table.foreign_keys
  168. if (table.name, fk.parent.name) in sqlite_fk.APPLIED and fk.ondelete == "SET NULL" and not fk.parent.nullable
  169. }
  170. assert not_nullable == set()
  171. @pytest.mark.asyncio
  172. async def test_the_sweep_skips_a_set_null_its_column_still_refuses():
  173. """If a migration couldn't drop a NOT NULL, the sweep must not stop the app from starting."""
  174. from sqlalchemy.ext.asyncio import create_async_engine
  175. engine = create_async_engine("sqlite+aiosqlite:///:memory:")
  176. try:
  177. async with engine.begin() as conn:
  178. await conn.execute(text("CREATE TABLE users (id INTEGER PRIMARY KEY)"))
  179. await conn.execute(
  180. text(
  181. "CREATE TABLE wallet_transactions (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, "
  182. "cost_center_id INTEGER, created_by_user_id INTEGER, print_archive_id INTEGER, print_queue_id INTEGER)"
  183. )
  184. )
  185. await conn.execute(text("INSERT INTO wallet_transactions (id, user_id) VALUES (1, 7)"))
  186. await sqlite_fk.sweep_orphans(conn)
  187. assert (await conn.execute(text("SELECT user_id FROM wallet_transactions"))).scalar_one() == 7
  188. finally:
  189. await engine.dispose()