AshPostgres.MigrationGenerator.OperationDeps (ash_postgres v2.12.0)

Copy Markdown View Source

Computes the dependency graph used to order migration operations.

Each operation may provide facts (things that become true once it runs) and require facts (things that must already be true before it can run). AshPostgres.MigrationGenerator.MigrationGenerator.toposort_operations/1 turns these into a dependency graph and topologically sorts it.

There is deliberately no symmetric "late tier" counterpart to early_tier?/1 (a global "runs after everything else" barrier). Adding one creates a real cycle: anything that requires a same-table fact regardless of provider (e.g. AddCustomStatement requiring its own table's structure to be ready) would need that "late" op to run first, while the "late" op's barrier would need it to run last — contradictory. Give each operation type the ordering it needs via requires/1 instead.

Requiring a fact waits on every operation that provides it, not just one — see toposort_operations/1's provides_index. That's what makes :table_structure_ready/:table_finalized work as catch-alls: many operation types provide them, so an op that requires one (e.g. AddCustomStatement's own-table or after_tables requirement) transparently waits for all of that table's work, without needing to enumerate every attribute/index/constraint by hand.

Requiring a fact that nothing in the current batch provides is vacuously satisfied — it adds no dependency edge at all, rather than blocking or raising. This is intentional: e.g. reference_requirements/1 requires a referenced table/column to be :column_ready, but if that table already exists from an earlier migration (so nothing in this batch provides the fact), the requirement is trivially met and doesn't hold anything up.

A generated migration's down isn't independently derived — it's built by walking the same operation order in reverse and rendering each operation's down/1 (see MigrationGenerator.build_up_and_down/1). So an ordering that's harmless for up can still be wrong: if RenameAttribute isn't required to run after a RemoveCustomIndex on the same table, down ends up recreating that index (RemoveCustomIndex.down) before undoing the rename, referencing a column name that doesn't exist yet at that point. Postgres itself doesn't need indexes/constraints removed before a rename or a column drop — it tracks index predicates, expression indexes, and same-table constraints internally by column position and updates or drops them automatically (verified directly: a partial unique index's WHERE clause and a custom index's expression are both rewritten after RENAME COLUMN; a CHECK constraint on a dropped column is dropped with it, no CASCADE needed). But several facts below exist anyway, purely so the reversed down sequence stays valid. Only genuinely cross-table dependencies (a foreign key on another table) block a Postgres statement directly, which is what :column_fk_dropped is for.

Facts

Each fact is a 2-tuple {name, key}. key is {schema, table} for a table-scoped fact, or {schema, table, x} for a fact scoped to some x within that table — see key/2, key/3. That third element is a column for every fact below except :custom_index_removed (an index name) and :unique_index_created (a sorted column list); facts are named table_*/column_*/index_* to match what their key actually scopes over, not just its shape.

Table-scoped (key = {schema, table}):

These first four track "how done is this table", but not as one single chain — :table_ready is provided by a disjoint set of operations (CreateTable/RenameTable/MoveTableSchema) from :table_columns_settled (AddAttribute/RenameAttribute/AlterAttribute/RemoveAttribute), so requiring both together is not redundant — neither subsumes the other. Both converge at :table_structure_ready and :table_finalized, which every structural operation provides:

  • :table_ready — the table exists (CreateTable/RenameTable/ MoveTableSchema).
  • :table_columns_settled — every attribute add/alter/rename/remove for this table has already run — a conservative margin for consumers whose raw SQL (a filtered unique index's where, a custom index's expression, a check constraint's check:) might reference a column that's about to be added, altered, renamed, or removed, and can't be parsed to know which columns it actually touches.
  • :table_structure_ready — this table's structural (DDL) work is done: provided by every structural operation on this table (including :table_ready's and :table_columns_settled's providers).
  • :table_fk_dropped — every foreign key on another table that referenced any column of this table has been dropped (direction: :up). The table-scoped counterpart to :column_fk_dropped, for DropTable, which knows only its own name — not which of its columns other tables' foreign keys point at. Postgres refuses to DROP TABLE while another table's foreign key still references it, so the drop must come after those DropForeignKey operations even though DropTable is early-tier (the required_by_early_tier exemption in toposort_operations/1 is what lets that specific dependency win over the blanket barrier).
  • :table_finalized — this table is truly done, including any custom_statements declared on it: provided by everything that provides :table_structure_ready, plus each AddCustomStatement on the table (a table with no custom statements is finalized as soon as its structure is ready). Kept separate from :table_structure_ready because AddCustomStatement's own implicit "wait for my own table" requirement must use the narrower fact — were it to require :table_finalized, two custom statements on the same table would each provide and require the same fact, a guaranteed cycle. Only the explicit, opt-in after_tables cross-table reference requires :table_finalized, so it also waits for the target table's own custom statements.

Column-scoped (key = {schema, table, column}):

  • :column_ready — a specific column (by its current name) exists.
  • :column_unique_index_removed — a unique index covering this column has been removed. RenameAttribute requires this for the same down-validity reason as :column_custom_index_removed.
  • :column_custom_index_removed — every RemoveCustomIndex whose structured fields list includes this column has run. RenameAttribute requires this for its own column so down stays valid (see above). Only tracks fields, not raw where/expression text that might reference the column without listing it — a known gap, to be closed later by letting custom_indexes declare the columns a raw where/expression touches, rather than by conservatively widening this back to table scope.
  • :column_check_constraint_removed — every check constraint covering this column has been removed. RemoveAttribute/RenameAttribute require this for their own column so down stays valid: their down recreates/renames the column back, which must happen before RemoveCheckConstraint's down recreates a constraint that needs it — i.e. RemoveCheckConstraint must run first in up.
  • :column_fk_dropped — every foreign key on another table that referenced this specific column has been dropped (direction: :up). Postgres refuses to drop a unique constraint/primary key that's still referenced by another table's foreign key (verified directly, and not limited to primary keys), so RemovePrimaryKey/RemoveUniqueIndex require this for each of their own columns.
  • :reference_index_removed — the reference index on this FK column has been dropped (RemoveReferenceIndex). AddReferenceIndex requires this for its own source column: both derive the same auto-generated index name from their columns, so rewriting an existing reference index (a Remove/Add pair, e.g. when its index_where predicate changes) must drop the old index before creating the new one.

Index-scoped (key = {schema, table, index}, same shape as a column-scoped key but the third element is an index name, not a column):

  • :custom_index_removed — a specific named custom index has been dropped, so a same-named index can be recreated (Postgres index names are unique per schema).

Column-set-scoped (key = {schema, table, sorted_columns}, the third element a sorted list of column atoms):

  • :unique_index_created — a unique index covering exactly this column set exists (AddUniqueIndex for identities, unique AddCustomIndex). The set is the columns the rendered index actually covers — including the tenant attribute that attribute-strategy multitenancy prefixes at render time. A foreign key requires this fact for exactly its referenced column set (destination_attribute plus match_with destinations), matching Postgres's rule that an FK target must be backed by a unique index on exactly the referenced columns — per-column unique indexes don't satisfy it, and correspondingly don't provide this fact.

Summary

Functions

Operation types that must run before every other operation in the batch.

Facts made true once op has run.

Facts that must already be provided (by some other operation) before op can run.

Functions

early_tier?(op)

Operation types that must run before every other operation in the batch.

provides(op)

Facts made true once op has run.

requires(op)

Facts that must already be provided (by some other operation) before op can run.