ltree.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. """
  2. Migration support for ltree-based hierarchical models.
  3. This module holds the schema-level machinery that backs `netbox.models.ltree`:
  4. the PostgreSQL trigger function / trigger SQL and the `InstallLtreeTriggers`
  5. migration operation that installs them. It is kept separate from the model layer
  6. (`netbox.models.ltree`) so that migrations depend only on this DB-level code and
  7. not on the model definitions.
  8. The paths maintained by these triggers are never computed or mutated from Python;
  9. the model layer only reads `path`/`sort_path` back from the database.
  10. Trigger DDL and search_path
  11. ---------------------------
  12. Everything this module emits is replayed verbatim by `pg_restore`, which runs with
  13. `search_path` set to the empty string and schema-qualifies every name it can (a
  14. CVE-2018-1058 hardening). Unqualified names in the SQL below therefore have to
  15. resolve without help from the path, and the two halves of a trigger differ in when
  16. that resolution happens:
  17. * A trigger's WHEN clause is resolved at CREATE TRIGGER time. `IS DISTINCT FROM`
  18. (like `=`, `<`, ...) is grammar which expands to the operand type's operator, and
  19. there is no syntax to schema-qualify it. An extension type whose operators live
  20. outside `pg_catalog` — `ltree` installs into `public` — makes that CREATE TRIGGER
  21. unrestorable, and because `psql` does not stop on error by default the restore
  22. appears to succeed with the trigger silently missing (#23130).
  23. * A trigger FUNCTION's body survives only because pg_dump emits
  24. `SET check_function_bodies = false`, which suppresses the validation that would
  25. otherwise reject the unqualified `ltree` declarations below at CREATE FUNCTION
  26. time. Under the default `check_function_bodies = on` they fail with
  27. `type "ltree" does not exist`. So the bodies are not inherently path-independent;
  28. they are exempted by the restore's own configuration. Anything replaying this DDL
  29. outside a pg_dump context must either put the extension's schema on the path or
  30. set that GUC itself.
  31. Rule: keep WHEN-clause operand types inside `pg_catalog`. Cast an
  32. extension-typed column with `::text` (ltree's text I/O is byte-canonical, so
  33. `::text` equality is exactly ltree equality).
  34. `RestoreUnderRestrictedSearchPathTests` in `utilities/tests/test_ltree.py` enforces
  35. this by creating the generated DDL with the extension's schema off the search_path.
  36. """
  37. from django.db import migrations
  38. __all__ = (
  39. 'InstallLtreeTriggers',
  40. 'ReinstallLtreeTriggers',
  41. 'ltree_trigger_sql',
  42. )
  43. # Path label is the row's PK zero-padded to 19 chars (max bigint width) so that
  44. # lexicographic ordering of ltree labels matches numeric PK ordering across digit
  45. # boundaries (e.g. "0...09" sorts before "0...10").
  46. # Per-tree advisory locking (see _lock_tree_roots_sql / LtreeModel "Concurrency"):
  47. # every child insert / move / reparent of a node takes a transaction-level
  48. # advisory lock keyed on the root(s) of the tree(s) it touches, BEFORE reading the
  49. # parent path. A concurrent reparent of an ancestor takes the same key, so the two
  50. # serialize and the AFTER cascade can never miss a row inserted concurrently;
  51. # writes in different trees use different keys and run in parallel. Inserting a new
  52. # root (parent_id IS NULL) takes no lock at all -- it is a race-free singleton tree
  53. # -- so a bulk import of many top-level objects does not accumulate one lock per
  54. # root and cannot exhaust the shared lock table.
  55. _LOCK_TREE_ROOTS_SQL = '''
  56. -- A brand-new root (INSERT with parent_id IS NULL) starts its own singleton
  57. -- tree that no concurrent transaction can yet see (MVCC: the uncommitted row
  58. -- is invisible) or reference (no other transaction has its PK). For such a
  59. -- row this BEFORE function reads no other row (the parent lookup below is
  60. -- gated on parent_id) and the AFTER cascade fires only on UPDATE, so the
  61. -- insert touches solely its own NEW row -- there is nothing to serialize
  62. -- against, and the advisory lock it would otherwise take can never contend.
  63. -- Skipping it here is what stops a bulk import of many top-level objects from
  64. -- taking one xact-lock per root and exhausting the shared lock table
  65. -- (sized by max_locks_per_transaction). Every other case still locks below:
  66. -- a child insert, any reparent, and a reparent-to-root (TG_OP = UPDATE, where
  67. -- the existing row has a real subtree the AFTER cascade must rewrite).
  68. IF NOT (TG_OP = 'INSERT' AND NEW.parent_id IS NULL) THEN
  69. -- Destination tree root: the parent's root label, or this row's own label
  70. -- when it is (or becomes) a root. The CASE guards against a parent whose path
  71. -- is the empty ltree '' (reachable only via a trigger-bypassing raw write):
  72. -- subltree('', 0, 1) would raise 'invalid positions', so fall back to the
  73. -- child's own label as the lock key rather than aborting the insert/move.
  74. IF NEW.parent_id IS NOT NULL THEN
  75. EXECUTE format(
  76. 'SELECT CASE WHEN nlevel(path) > 0 THEN subltree(path, 0, 1)::text END'
  77. ' FROM %%I WHERE id = $1',
  78. TG_TABLE_NAME
  79. ) INTO dest_root USING NEW.parent_id;
  80. END IF;
  81. dest_root := COALESCE(dest_root, lpad(NEW.id::text, 19, '0'));
  82. -- Source tree root (moves only): this row's current root, which the AFTER
  83. -- cascade will rewrite.
  84. IF TG_OP = 'UPDATE' AND OLD.path IS NOT NULL AND nlevel(OLD.path) > 0 THEN
  85. old_root := subltree(OLD.path, 0, 1)::text;
  86. END IF;
  87. key_dest := hashtextextended(TG_TABLE_NAME || ':' || dest_root, 0);
  88. IF old_root IS NOT NULL AND old_root <> dest_root THEN
  89. -- Cross-tree move: lock both roots, ascending, to avoid deadlock between
  90. -- two concurrent moves that touch the same pair.
  91. key_old := hashtextextended(TG_TABLE_NAME || ':' || old_root, 0);
  92. PERFORM pg_advisory_xact_lock(LEAST(key_dest, key_old));
  93. PERFORM pg_advisory_xact_lock(GREATEST(key_dest, key_old));
  94. ELSE
  95. PERFORM pg_advisory_xact_lock(key_dest);
  96. END IF;
  97. END IF;
  98. '''
  99. _COMPUTE_PATH_ONLY_FN = '''
  100. CREATE OR REPLACE FUNCTION "{table}_ltree_compute_path_fn"() RETURNS TRIGGER AS $$
  101. DECLARE
  102. parent_path ltree;
  103. dest_root text;
  104. old_root text;
  105. key_dest bigint;
  106. key_old bigint;
  107. BEGIN
  108. ''' + _LOCK_TREE_ROOTS_SQL + '''
  109. IF NEW.parent_id IS NOT NULL THEN
  110. EXECUTE format('SELECT path FROM %%I WHERE id = $1', TG_TABLE_NAME)
  111. INTO parent_path USING NEW.parent_id;
  112. -- Cycle guard. The Python LtreeModel.save() also rejects cyclic moves,
  113. -- but a QuerySet.update() / bulk_update() bypasses save() entirely, so
  114. -- catch the case here as a last line of defense. A cycle exists iff
  115. -- this row's own label appears anywhere in parent_path (the row would
  116. -- become its own ancestor). Match the label as any segment via lquery.
  117. IF parent_path ~ ('*.' || lpad(NEW.id::text, 19, '0') || '.*')::lquery
  118. OR parent_path = lpad(NEW.id::text, 19, '0')::ltree THEN
  119. RAISE EXCEPTION 'cycle detected: %% cannot be its own ancestor', TG_TABLE_NAME
  120. USING ERRCODE = 'check_violation';
  121. END IF;
  122. NEW.path := parent_path || lpad(NEW.id::text, 19, '0')::ltree;
  123. ELSE
  124. NEW.path := lpad(NEW.id::text, 19, '0')::ltree;
  125. END IF;
  126. RETURN NEW;
  127. END
  128. $$ LANGUAGE plpgsql;
  129. '''
  130. _CASCADE_PATH_ONLY_FN = '''
  131. CREATE OR REPLACE FUNCTION "{table}_ltree_cascade_path_fn"() RETURNS TRIGGER AS $$
  132. BEGIN
  133. -- `nlevel($2) > 0` guards against an empty OLD.path ('', reachable only via a
  134. -- trigger-bypassing raw write): `path <@ ''` is true for EVERY row, so without
  135. -- this the cascade would rewrite the entire table on one reparent.
  136. EXECUTE format(
  137. 'UPDATE %%I SET path = $1 || subpath(path, nlevel($2))'
  138. ' WHERE nlevel($2) > 0 AND path <@ $2 AND id != $3',
  139. TG_TABLE_NAME
  140. ) USING NEW.path, OLD.path, NEW.id;
  141. RETURN NULL;
  142. END
  143. $$ LANGUAGE plpgsql;
  144. '''
  145. # For models with order_insertion_by=(name,) — maintain a second text column
  146. # `sort_path` whose value is the chain of ancestor names joined by chr(9) (TAB).
  147. # TAB sorts strictly below any printable character under both the default text
  148. # collation and the ICU `natural_sort` collation (which is `und-u-kn-true`).
  149. # ICU collations with default variable weighting treat U+0001..U+0008 as
  150. # variable-ignorable, so a chr(1) separator under natural_sort would interleave
  151. # children with unrelated roots; TAB is given a primary weight and orders
  152. # deterministically. ORDER BY sort_path then gives MPTT-equivalent
  153. # tree-flatten ordering with siblings in name (collation) order.
  154. #
  155. # The BEFORE trigger fires on INSERT, parent_id changes, and name changes, so
  156. # a rename updates the row's own sort_path; the AFTER trigger then cascades
  157. # the new sort_path into descendants. (django-mptt's `order_insertion_by`
  158. # stops at the renamed node and leaves descendants stale until a manual
  159. # rebuild — NetBox auto-cascades because operators expect renames to flow
  160. # through. `rebuild_sort_paths()` is still available for bulk repair.)
  161. _COMPUTE_PATH_AND_SORT_FN = '''
  162. CREATE OR REPLACE FUNCTION "{table}_ltree_compute_path_fn"() RETURNS TRIGGER AS $$
  163. DECLARE
  164. parent_path ltree;
  165. parent_sort_path text;
  166. dest_root text;
  167. old_root text;
  168. key_dest bigint;
  169. key_old bigint;
  170. BEGIN
  171. ''' + _LOCK_TREE_ROOTS_SQL + '''
  172. -- sort_path joins ancestor names with chr(9) (TAB); a literal tab in a name
  173. -- would inject a spurious separator and corrupt sibling ordering for the node
  174. -- and its descendants. LtreeModel.clean() rejects this for forms/serializers;
  175. -- this is the backstop for bulk_create / scripts / raw writes that bypass clean().
  176. IF position(chr(9) in COALESCE(NEW."{name_col}", '')) > 0 THEN
  177. RAISE EXCEPTION 'name contains a tab character, which is not allowed'
  178. USING ERRCODE = 'check_violation';
  179. END IF;
  180. IF NEW.parent_id IS NOT NULL THEN
  181. EXECUTE format('SELECT path, sort_path FROM %%I WHERE id = $1', TG_TABLE_NAME)
  182. INTO parent_path, parent_sort_path USING NEW.parent_id;
  183. -- Cycle guard. See _COMPUTE_PATH_ONLY_FN for the rationale; this catches
  184. -- raw UPDATE / bulk_update paths that bypass LtreeModel.save().
  185. IF parent_path ~ ('*.' || lpad(NEW.id::text, 19, '0') || '.*')::lquery
  186. OR parent_path = lpad(NEW.id::text, 19, '0')::ltree THEN
  187. RAISE EXCEPTION 'cycle detected: %% cannot be its own ancestor', TG_TABLE_NAME
  188. USING ERRCODE = 'check_violation';
  189. END IF;
  190. NEW.path := parent_path || lpad(NEW.id::text, 19, '0')::ltree;
  191. NEW.sort_path := parent_sort_path || chr(9) || NEW."{name_col}";
  192. ELSE
  193. NEW.path := lpad(NEW.id::text, 19, '0')::ltree;
  194. NEW.sort_path := NEW."{name_col}";
  195. END IF;
  196. RETURN NEW;
  197. END
  198. $$ LANGUAGE plpgsql;
  199. '''
  200. _CASCADE_PATH_AND_SORT_FN = """
  201. CREATE OR REPLACE FUNCTION "{table}_ltree_cascade_path_fn"() RETURNS TRIGGER AS $$
  202. BEGIN
  203. -- COALESCE guards against a NULL sort_path slipping in via a raw write that
  204. -- bypassed the BEFORE trigger: without it, length(NULL)/substring(... FROM NULL)
  205. -- would cascade NULL to every descendant's sort_path in one shot.
  206. -- `nlevel($2) > 0` guards against an empty OLD.path ('', reachable only via a
  207. -- trigger-bypassing raw write): `path <@ ''` is true for EVERY row, so without
  208. -- this the cascade would rewrite the entire table on one reparent.
  209. EXECUTE format(
  210. 'UPDATE %%I SET '
  211. ' path = $1 || subpath(path, nlevel($2)), '
  212. ' sort_path = COALESCE($4, '''') || substring(COALESCE(sort_path, '''') FROM length(COALESCE($5, '''')) + 1) '
  213. 'WHERE nlevel($2) > 0 AND path <@ $2 AND id != $3',
  214. TG_TABLE_NAME
  215. ) USING NEW.path, OLD.path, NEW.id, NEW.sort_path, OLD.sort_path;
  216. RETURN NULL;
  217. END
  218. $$ LANGUAGE plpgsql;
  219. """
  220. _BEFORE_TRIGGER_PATH_ONLY = '''
  221. CREATE TRIGGER "{table}_ltree_compute_path"
  222. BEFORE INSERT OR UPDATE OF parent_id ON "{table}"
  223. FOR EACH ROW EXECUTE FUNCTION "{table}_ltree_compute_path_fn"();
  224. '''
  225. # For path+sort tables, also fire on UPDATE OF {name_col} so that renaming a
  226. # node recomputes its sort_path. The cascade trigger then propagates the new
  227. # sort_path to descendants.
  228. _BEFORE_TRIGGER_PATH_AND_SORT = '''
  229. CREATE TRIGGER "{table}_ltree_compute_path"
  230. BEFORE INSERT OR UPDATE OF parent_id, "{name_col}" ON "{table}"
  231. FOR EACH ROW EXECUTE FUNCTION "{table}_ltree_compute_path_fn"();
  232. '''
  233. # AFTER trigger fires on the columns that operators / Django write directly
  234. # (parent_id and the name column) — NOT on path or sort_path. The cascade
  235. # function rewrites path/sort_path on descendants in a single statement, and
  236. # because that statement does not touch parent_id or {name_col}, the AFTER
  237. # trigger does not re-fire on those descendant rows. This prevents the
  238. # quadratic re-cascade that would otherwise occur for any deep subtree.
  239. #
  240. # `path` is compared as text rather than as ltree. `IS DISTINCT FROM` is SQL
  241. # grammar, not an operator: it has no schema-qualification syntax, and it expands
  242. # to the operand type's `=` operator, which is resolved from search_path at
  243. # CREATE TRIGGER time. `ltree =` lives in whichever schema the extension was
  244. # installed into (normally `public`), so a CREATE TRIGGER replayed by pg_restore
  245. # — which runs with `search_path` set to the empty string and schema-qualifies
  246. # every name it can — cannot resolve it and fails with "operator does not exist:
  247. # public.ltree = public.ltree", silently dropping the cascade trigger from the
  248. # restored database (#23130). `text =` is in `pg_catalog`, which is always on the
  249. # effective path, so the cast makes the DDL search_path-independent.
  250. #
  251. # The comparison is equivalent: ltree's text I/O is byte-preserving (parse_ltree
  252. # and deparse_ltree copy label bytes with memcpy, and ltree_eq is a memcmp over
  253. # those same bytes), so two ltree values are equal iff their text renderings are
  254. # — see contrib/ltree/ltree_io.c and ltree_op.c. PostgreSQL publishes no explicit
  255. # guarantee of this; it is a property of the implementation, which cannot change
  256. # without breaking ltree's on-disk format and every existing ltree index.
  257. #
  258. # `sort_path` needs no cast: it is already a text column.
  259. #
  260. # See also the module docstring ("Trigger DDL and search_path") and
  261. # RestoreUnderRestrictedSearchPathTests in utilities/tests/test_ltree.py, which
  262. # enforces this by creating the generated DDL with the extension's schema off the
  263. # search_path.
  264. _AFTER_TRIGGER_PATH_ONLY = '''
  265. CREATE TRIGGER "{table}_ltree_cascade_path"
  266. AFTER UPDATE OF parent_id ON "{table}"
  267. FOR EACH ROW WHEN (OLD.path::text IS DISTINCT FROM NEW.path::text)
  268. EXECUTE FUNCTION "{table}_ltree_cascade_path_fn"();
  269. '''
  270. _AFTER_TRIGGER_PATH_AND_SORT = '''
  271. CREATE TRIGGER "{table}_ltree_cascade_path"
  272. AFTER UPDATE OF parent_id, "{name_col}" ON "{table}"
  273. FOR EACH ROW WHEN (
  274. OLD.path::text IS DISTINCT FROM NEW.path::text
  275. OR OLD.sort_path IS DISTINCT FROM NEW.sort_path
  276. )
  277. EXECUTE FUNCTION "{table}_ltree_cascade_path_fn"();
  278. '''
  279. def ltree_trigger_sql(table, name_column=None):
  280. """
  281. Return the DDL statements which install ltree path-maintenance triggers on `table`.
  282. Two functions and two triggers, in dependency order. If `name_column` is given, the
  283. table is expected to carry a `sort_path` column and gets the variants which maintain
  284. it alongside `path`.
  285. The triggers are dropped before being created, so re-running this SQL converges
  286. instead of failing: a plain `CREATE TRIGGER` raises 42710 when the trigger already
  287. exists, which a re-run, a partially-applied migration, or a later migration
  288. reinstalling a corrected definition (#23130) would all hit. The functions already use
  289. CREATE OR REPLACE. This mirrors `utilities.migration.InstallDenormalizationTrigger`.
  290. `InstallLtreeTriggers` executes exactly this SQL, so tests can assert against the
  291. statements migrations really run rather than a copy which can drift.
  292. """
  293. if name_column:
  294. function_sql = (
  295. _COMPUTE_PATH_AND_SORT_FN.format(table=table, name_col=name_column),
  296. _CASCADE_PATH_AND_SORT_FN.format(table=table),
  297. )
  298. trigger_sql = (
  299. _BEFORE_TRIGGER_PATH_AND_SORT.format(table=table, name_col=name_column),
  300. _AFTER_TRIGGER_PATH_AND_SORT.format(table=table, name_col=name_column),
  301. )
  302. else:
  303. function_sql = (
  304. _COMPUTE_PATH_ONLY_FN.format(table=table),
  305. _CASCADE_PATH_ONLY_FN.format(table=table),
  306. )
  307. trigger_sql = (
  308. _BEFORE_TRIGGER_PATH_ONLY.format(table=table),
  309. _AFTER_TRIGGER_PATH_ONLY.format(table=table),
  310. )
  311. return (
  312. *function_sql,
  313. f'DROP TRIGGER IF EXISTS "{table}_ltree_cascade_path" ON "{table}";',
  314. f'DROP TRIGGER IF EXISTS "{table}_ltree_compute_path" ON "{table}";',
  315. *trigger_sql,
  316. )
  317. class InstallLtreeTriggers(migrations.operations.base.Operation):
  318. """
  319. Install per-table ltree path-maintenance triggers.
  320. Two row-level triggers are installed on each target table:
  321. BEFORE INSERT OR UPDATE OF parent_id -> compute NEW.path (and sort_path if applicable)
  322. AFTER UPDATE OF parent_id -> cascade path/sort_path change to descendants
  323. If `name_column` is provided, the model is expected to have a `sort_path`
  324. text column whose value will be maintained as a chr(9)-separated chain of
  325. ancestor names. This implements MPTT's `order_insertion_by=(name,)`
  326. semantics: insert, reparent, and rename all honor the current value of
  327. `name_column`, with renames cascaded into descendants' sort_paths.
  328. Applying this operation is idempotent (see `ltree_trigger_sql`), so it can be
  329. re-run to reinstall a corrected trigger definition on a table which already has
  330. one.
  331. """
  332. reversible = True
  333. def __init__(self, table_name, name_column=None):
  334. self.table_name = table_name
  335. self.name_column = name_column
  336. def state_forwards(self, app_label, state):
  337. pass
  338. def database_forwards(self, app_label, schema_editor, from_state, to_state):
  339. for sql in ltree_trigger_sql(self.table_name, self.name_column):
  340. schema_editor.execute(sql)
  341. def database_backwards(self, app_label, schema_editor, from_state, to_state):
  342. t = self.table_name
  343. schema_editor.execute(f'DROP TRIGGER IF EXISTS "{t}_ltree_cascade_path" ON "{t}";')
  344. schema_editor.execute(f'DROP TRIGGER IF EXISTS "{t}_ltree_compute_path" ON "{t}";')
  345. schema_editor.execute(f'DROP FUNCTION IF EXISTS "{t}_ltree_cascade_path_fn"();')
  346. schema_editor.execute(f'DROP FUNCTION IF EXISTS "{t}_ltree_compute_path_fn"();')
  347. def describe(self):
  348. return f"Install ltree path triggers on {self.table_name}"
  349. class ReinstallLtreeTriggers(InstallLtreeTriggers):
  350. """
  351. Reinstall a table's ltree path-maintenance triggers, replacing an earlier definition.
  352. Identical to `InstallLtreeTriggers` going forwards, but a no-op in reverse. The
  353. parent operation's reverse drops both triggers and both functions, which is right
  354. when reversing the migration that first installed them and wrong when reversing one
  355. that merely corrected them: it would leave the table with no path maintenance at all
  356. — a state no release ever shipped — and every subsequent INSERT failing on `path`'s
  357. NOT NULL constraint. The triggers this replaces are recreated by reversing back to
  358. the migration which installed them, so there is nothing for this operation to undo.
  359. """
  360. def database_backwards(self, app_label, schema_editor, from_state, to_state):
  361. pass
  362. def describe(self):
  363. return f"Reinstall ltree path triggers on {self.table_name}"