sqlite_fk.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. """Selected ON DELETE rules of the foreign keys, applied by hand on SQLite.
  2. The SQLite database runs with ``PRAGMA foreign_keys`` off: turning it on breaks
  3. the migrations' table rebuilds (measured: every test errors with "no such table:
  4. main.print_archives"). So a delete there applies none of the models' ON DELETE
  5. CASCADE / SET NULL rules. Rows pointing at the deleted row keep its id, and
  6. SQLite hands the highest freed id to the next row created, which then inherits
  7. them: a queue item prints another archive, a project shows another project's
  8. files.
  9. Only the references in APPLIED are handled: those where a dangling id does harm
  10. and the model's rule is what the app wants. Not every model rule is (deleting a
  11. printer would make a printer-scoped notification provider global, and delete
  12. finished queue history), so a reference is added here deliberately, never by
  13. default.
  14. Hooks apply them at every ORM delete, so no delete path needs to remember:
  15. - ``after_delete`` on every mapped class collects the ids of ``db.delete(obj)``,
  16. and ``after_flush`` applies the rules once per flush, one statement per rule;
  17. - ``do_orm_execute`` applies them before ``db.execute(delete(Model)...)``.
  18. Rule statements go straight to the connection and don't trigger the hooks again.
  19. ``sweep_orphans`` repairs what earlier deletes left behind; init_db runs it at
  20. every start. Nothing here does anything on PostgreSQL, which enforces the rules
  21. itself.
  22. """
  23. import logging
  24. from functools import cache
  25. from typing import Any
  26. from sqlalchemy import Column, Connection, Delete, ForeignKey, Table, delete, event, exists, select, text, update
  27. from sqlalchemy.orm import Mapper, ORMExecuteState, Session
  28. from backend.app.core.database import Base
  29. logger = logging.getLogger(__name__)
  30. # (child table, child column) whose ON DELETE rule is applied on SQLite.
  31. APPLIED: frozenset[tuple[str, str]] = frozenset(
  32. {
  33. # print_archives
  34. ("active_print_spoolman", "archive_id"),
  35. ("archive_aliases", "archive_id"),
  36. ("budget_reservations", "print_archive_id"),
  37. ("library_folders", "archive_id"),
  38. ("pending_uploads", "archived_id"),
  39. ("pipeline_runs", "source_archive_id"),
  40. ("print_batches", "archive_id"),
  41. ("print_log_entries", "archive_id"),
  42. ("print_queue", "archive_id"),
  43. ("project_bom_items", "archive_id"),
  44. ("spool_usage_history", "archive_id"),
  45. # spool
  46. ("spool_usage_history", "spool_id"),
  47. ("telegram_pending_verdicts", "archive_id"),
  48. ("wallet_transactions", "print_archive_id"),
  49. # library_files. print_queue.library_file_id is CASCADE in the model, but
  50. # library deletes run release_queue_references first, which keeps the rows
  51. # (finished plates count towards a batch); the rule then finds nothing.
  52. ("library_file_tags", "file_id"),
  53. ("pipeline_runs", "sliced_library_file_id"),
  54. ("pipeline_runs", "source_library_file_id"),
  55. ("print_archives", "library_file_id"),
  56. ("print_batches", "library_file_id"),
  57. ("print_queue", "library_file_id"),
  58. ("print_queue_variants", "library_file_id"),
  59. # library_folders: a virtual printer's uploads would go into the next
  60. # folder given the id, maybe someone's private one.
  61. ("virtual_printers", "library_folder_id"),
  62. # virtual_printers: an approved upload would go to the folder of the
  63. # next virtual printer given the id.
  64. ("pending_uploads", "virtual_printer_id"),
  65. # print_queue
  66. ("pipeline_jobs", "queue_entry_id"),
  67. ("print_log_entries", "queue_item_id"),
  68. ("print_queue_variants", "queue_item_id"),
  69. ("wallet_transactions", "print_queue_id"),
  70. # print_batches
  71. ("print_batch_plates", "batch_id"),
  72. ("print_queue", "batch_id"),
  73. # projects
  74. ("library_files", "project_id"),
  75. ("library_folders", "project_id"),
  76. ("pending_uploads", "project_id"),
  77. ("print_archives", "project_id"),
  78. ("print_batches", "project_id"),
  79. ("print_queue", "project_id"),
  80. ("project_bom_items", "project_id"),
  81. # printers: what the next printer given the id would otherwise take
  82. # over. Only these: providers, plugs and virtual printers go with the
  83. # printer in its delete route, and the rest is decided separately. The
  84. # print being tracked would be restored for a new printer that is already
  85. # printing, charging its filament to the deleted printer's spools.
  86. ("active_print_sessions", "printer_id"),
  87. ("active_print_spoolman", "printer_id"),
  88. ("pipeline_jobs", "assigned_printer_id"),
  89. ("slicer_pipelines", "target_printer_id"),
  90. # A deleted printer's spools would stay "assigned" to it (SA-1).
  91. ("spool_assignment", "printer_id"),
  92. # A printer given a deleted one's id would never be told about the
  93. # faults that printer was notified about (#3486).
  94. ("hms_notified_faults", "printer_id"),
  95. # A profile default's K profile would select a calibration slot on
  96. # the next printer given the id (#1326).
  97. ("filament_profile_default_k_profile", "printer_id"),
  98. # filament_profile_default: the next default given the id would take
  99. # over its presets and K profiles (#1326).
  100. ("filament_profile_default_k_profile", "profile_default_id"),
  101. ("filament_profile_default_preset", "profile_default_id"),
  102. # external_links: the next link given the id would show the count an
  103. # app reported for the deleted one.
  104. ("external_link_badges", "link_id"),
  105. # smart_plugs: a printer delete now deletes its plugs; their energy
  106. # history would pass to the next plug given the id.
  107. ("smart_plug_energy_snapshots", "plug_id"),
  108. # users: everything a user owns goes with them or loses its owner, or a
  109. # new user given the freed id inherits it (wallet, cost centres, private
  110. # folders). Without an owner, folders and private cost centres are left
  111. # to admins, not opened to everyone.
  112. ("announcement_reads", "user_id"),
  113. ("api_keys", "user_id"),
  114. ("connected_app_grants", "user_id"),
  115. ("connected_apps", "created_by_id"),
  116. ("cost_center_members", "user_id"),
  117. ("cost_centers", "owner_user_id"),
  118. ("file_variant_groups", "created_by_id"),
  119. ("library_files", "created_by_id"),
  120. ("library_folders", "created_by_id"),
  121. ("long_lived_tokens", "user_id"),
  122. ("pending_uploads", "uploaded_by_id"),
  123. ("pipeline_runs", "created_by"),
  124. ("print_archives", "created_by_id"),
  125. ("print_batches", "created_by_id"),
  126. ("print_log_entries", "created_by_id"),
  127. ("print_queue", "created_by_id"),
  128. ("scheduled_dryings", "created_by_id"),
  129. ("slicer_pipelines", "created_by"),
  130. ("sponsor_toast_state", "user_id"),
  131. ("user_email_preferences", "user_id"),
  132. ("user_groups", "user_id"),
  133. ("user_oidc_links", "user_id"),
  134. ("user_otp_codes", "user_id"),
  135. ("user_totp", "user_id"),
  136. ("user_wallets", "user_id"),
  137. ("wallet_transactions", "created_by_user_id"),
  138. ("wallet_transactions", "user_id"),
  139. }
  140. )
  141. _PENDING = "bambuddy_sqlite_fk_pending"
  142. _TABLES = "bambuddy_sqlite_fk_tables"
  143. # Keeps any one statement's IN list small.
  144. _CHUNK = 500
  145. _MAX_DEPTH = 32
  146. def _applies(connection: Connection) -> bool:
  147. """Only SQLite: PostgreSQL enforces the rules itself."""
  148. return connection.dialect.name == "sqlite"
  149. def _existing_tables(connection: Connection) -> set[str]:
  150. """Tables the database has (cached on the connection); a model without one has no rows to fix."""
  151. tables = connection.info.get(_TABLES)
  152. if tables is None:
  153. result = connection.execute(text("SELECT name FROM sqlite_master WHERE type = 'table'"))
  154. tables = connection.info[_TABLES] = {row[0] for row in result}
  155. return tables
  156. def _single_primary_key(table: Table) -> Column | None:
  157. columns = list(table.primary_key.columns)
  158. return columns[0] if len(columns) == 1 else None
  159. @cache
  160. def _applied_foreign_keys() -> tuple[ForeignKey, ...]:
  161. """The model foreign keys of APPLIED references to a single-column primary key."""
  162. from backend.app.core.database import load_all_models
  163. found = []
  164. for table in load_all_models().sorted_tables:
  165. for fk in table.foreign_keys:
  166. if (
  167. (table.name, fk.parent.name) in APPLIED
  168. and fk.ondelete in ("CASCADE", "SET NULL")
  169. and isinstance(fk.column.table, Table)
  170. and fk.column is _single_primary_key(fk.column.table)
  171. ):
  172. found.append(fk)
  173. return tuple(found)
  174. @cache
  175. def _rules(parent_table: str) -> tuple[tuple[Table, Column, str], ...]:
  176. """(child table, child column, ON DELETE action) of the APPLIED references to *parent_table*."""
  177. return tuple(
  178. (fk.parent.table, fk.parent, str(fk.ondelete))
  179. for fk in _applied_foreign_keys()
  180. if fk.column.table.name == parent_table and isinstance(fk.parent.table, Table)
  181. )
  182. def _apply(connection: Connection, parent_table: str, ids: Any, depth: int = 0) -> None:
  183. """Apply the rules for rows of *parent_table* whose primary key is in *ids* (a list or a select)."""
  184. rules = _rules(parent_table)
  185. if not rules or depth > _MAX_DEPTH:
  186. return
  187. if isinstance(ids, list):
  188. for start in range(0, len(ids), _CHUNK):
  189. _apply_chunk(connection, rules, ids[start : start + _CHUNK], depth)
  190. else:
  191. _apply_chunk(connection, rules, ids, depth)
  192. def _apply_chunk(connection: Connection, rules: tuple, ids: Any, depth: int) -> None:
  193. tables = _existing_tables(connection)
  194. for child, column, action in rules:
  195. if child.name not in tables:
  196. continue
  197. if action == "SET NULL":
  198. connection.execute(update(child).where(column.in_(ids)).values({column.name: None}))
  199. continue
  200. child_key = _single_primary_key(child)
  201. if child_key is not None:
  202. # By depth, not by table: a table that refers to itself recurses too.
  203. _apply(connection, child.name, select(child_key).where(column.in_(ids)), depth + 1)
  204. connection.execute(delete(child).where(column.in_(ids)))
  205. @event.listens_for(Base, "after_delete", propagate=True)
  206. def _collect_orm_delete(mapper: Mapper, connection: Connection, target: Any) -> None:
  207. table = mapper.local_table
  208. session = Session.object_session(target)
  209. if session is None or not isinstance(table, Table) or not _rules(table.name) or not _applies(connection):
  210. return
  211. pending = session.info.setdefault(_PENDING, {})
  212. pending.setdefault(table.name, []).append(mapper.primary_key_from_instance(target)[0])
  213. @event.listens_for(Session, "after_flush")
  214. def _apply_orm_deletes(session: Session, flush_context: Any) -> None:
  215. pending = session.info.pop(_PENDING, None)
  216. if not pending:
  217. return
  218. connection = session.connection()
  219. # The parent rows are already gone; with foreign keys off that doesn't stop
  220. # the rules from finding their dependents.
  221. for table, ids in pending.items():
  222. _apply(connection, table, ids)
  223. @event.listens_for(Session, "after_soft_rollback")
  224. def _forget_pending(session: Session, previous_transaction: Any) -> None:
  225. # A flush that failed after collecting must not leave ids for the next one.
  226. session.info.pop(_PENDING, None)
  227. @event.listens_for(Session, "do_orm_execute")
  228. def _before_bulk_delete(state: ORMExecuteState) -> None:
  229. statement = state.statement
  230. if not state.is_delete or not isinstance(statement, Delete):
  231. return
  232. table = statement.table
  233. if not isinstance(table, Table) or not _rules(table.name):
  234. return
  235. key = _single_primary_key(table)
  236. if key is None:
  237. return
  238. connection = state.session.connection()
  239. if not _applies(connection):
  240. return
  241. # The session would flush pending changes only after this hook; flush now so
  242. # the rules see them (a queue item just moved off the archive must not go).
  243. if state.session.autoflush:
  244. state.session.flush()
  245. where = statement.whereclause
  246. _apply(connection, table.name, select(key).where(where) if where is not None else select(key))
  247. def _still_not_null(connection: Connection, column: Column) -> bool:
  248. """Whether the column is NOT NULL in the database, whatever the model says."""
  249. rows = connection.execute(text(f'PRAGMA table_info("{column.table.name}")'))
  250. return any(row[1] == column.name and row[3] for row in rows)
  251. async def sweep_orphans(conn: Any) -> None:
  252. """Repair APPLIED references to rows that no longer exist (SQLite only).
  253. A SET NULL reference to a missing row is cleared and a CASCADE row is
  254. deleted, which is what the rule would have done at the delete. Repeated
  255. until a pass changes nothing, since a deleted row can leave its own
  256. dependents behind.
  257. """
  258. def run(sync: Any) -> None:
  259. connection = sync.connection() if isinstance(sync, Session) else sync
  260. if not _applies(connection):
  261. return
  262. tables = _existing_tables(connection)
  263. rules = [
  264. fk for fk in _applied_foreign_keys() if fk.parent.table.name in tables and fk.column.table.name in tables
  265. ]
  266. refused = {fk for fk in rules if fk.ondelete == "SET NULL" and _still_not_null(connection, fk.parent)}
  267. for fk in refused:
  268. # A migration that would have dropped it failed (and said so); clearing
  269. # the column would fail too, and take the start of the app with it.
  270. logger.warning("Not clearing %s.%s: the column is still NOT NULL", fk.parent.table.name, fk.parent.name)
  271. rules = [fk for fk in rules if fk not in refused]
  272. for _ in range(10):
  273. changed = 0
  274. for fk in rules:
  275. child, column, referenced = fk.parent.table, fk.parent, fk.column
  276. missing = column.is_not(None) & ~exists(
  277. select(1).select_from(referenced.table).where(referenced == column)
  278. )
  279. if fk.ondelete == "SET NULL":
  280. result = connection.execute(update(child).where(missing).values({column.name: None}))
  281. else:
  282. result = connection.execute(delete(child).where(missing))
  283. if result.rowcount:
  284. changed += result.rowcount
  285. logger.info(
  286. "%s %d %s row(s) whose %s no longer exists",
  287. "Cleared" if fk.ondelete == "SET NULL" else "Deleted",
  288. result.rowcount,
  289. child.name,
  290. referenced.table.name,
  291. )
  292. if not changed:
  293. return
  294. await conn.run_sync(run)