From e22fc06b9edf52522eae0483f4fe19d1be0007f9 Mon Sep 17 00:00:00 2001 From: Victor Batarse Date: Tue, 18 Aug 2026 23:58:46 -0600 Subject: [PATCH 1/4] feat: add granular custom statement dependencies --- .formatter.exs | 2 + .../dsls/DSL-AshPostgres.DataLayer.md | 12 ++++ lib/data_layer.ex | 10 ++++ lib/migration_generator/operation_deps.ex | 27 ++++++++- lib/statement.ex | 18 +++++- .../operation_deps_test.exs | 60 +++++++++++++++++++ 6 files changed, 125 insertions(+), 4 deletions(-) diff --git a/.formatter.exs b/.formatter.exs index 30e82596..a00126c7 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -3,6 +3,8 @@ # SPDX-License-Identifier: MIT spark_locals_without_parens = [ + after_statements: 1, + after_table_structures: 1, after_tables: 1, all_tenants?: 1, base_filter_sql: 1, diff --git a/documentation/dsls/DSL-AshPostgres.DataLayer.md b/documentation/dsls/DSL-AshPostgres.DataLayer.md index 3cb82251..fb684384 100644 --- a/documentation/dsls/DSL-AshPostgres.DataLayer.md +++ b/documentation/dsls/DSL-AshPostgres.DataLayer.md @@ -137,6 +137,9 @@ By default, a statement has no declared dependency on other tables, so `down` st operation and `up` statements run after all other operations for that statement's table. If your statement's `up` depends on structure from another table (e.g. a foreign key referencing a unique index defined via `identities`), declare it with `after_tables` so the migration generator orders it correctly relative to that table's operations. +Use `after_table_structures` when only the target table's structural operations are required and waiting for its +custom statements would introduce a cycle. Use `after_statements` for dependencies between named custom statements +on the same resource, such as creating a function before a trigger that invokes it. Additionally, when changing a custom statement, we must make some assumptions, i.e that we should migrate the old structure down using the previously configured `down` and recreate it. @@ -164,6 +167,13 @@ custom_statements do up "ALTER TABLE children ADD CONSTRAINT children_parent_fk FOREIGN KEY (region_id, parent_id) REFERENCES parents (region_id, id);" down "ALTER TABLE children DROP CONSTRAINT children_parent_fk;" end + + statement :create_audit_trigger do + after_table_structures ["audit_entries"] + after_statements [:create_audit_function] + up "CREATE TRIGGER ..." + down "DROP TRIGGER ..." + end end ``` @@ -207,6 +217,8 @@ end | [`code?`](#postgres-custom_statements-statement-code?){: #postgres-custom_statements-statement-code? } | `boolean` | `false` | By default, we place the strings inside of ecto migration's `execute/1` function and assume they are sql. Use this option if you want to provide custom elixir code to be placed directly in the migrations | | [`global?`](#postgres-custom_statements-statement-global?){: #postgres-custom_statements-statement-global? } | `boolean` | `false` | By default, a multi-tenant resource's custom statements will be written into the tenant migration folder. Set this to true for statements that create global, shared structures so they are written into the public migration folder even when defined on a tenant resource. | | [`after_tables`](#postgres-custom_statements-statement-after_tables){: #postgres-custom_statements-statement-after_tables } | `list(String.t)` | `[]` | Table names that this statement's `up` depends on being fully finalized (including their columns and indexes) before it runs. Use this when a raw SQL statement references structure (e.g. a foreign key referencing a unique index) on another table so the migration generator can order it correctly. | +| [`after_table_structures`](#postgres-custom_statements-statement-after_table_structures){: #postgres-custom_statements-statement-after_table_structures } | `list(String.t)` | `[]` | Table names whose structural operations must be complete before this statement's `up` runs. Unlike `after_tables`, this does not wait for custom statements declared on those tables. Use this for raw SQL that references another table's columns or indexes when waiting for that table's custom statements would create an unnecessary dependency cycle. | +| [`after_statements`](#postgres-custom_statements-statement-after_statements){: #postgres-custom_statements-statement-after_statements } | `list(atom)` | `[]` | Names of other custom statements on this resource that must run before this statement's `up`. This is useful for dependencies such as a trigger statement that requires a function created by another statement. | diff --git a/lib/data_layer.ex b/lib/data_layer.ex index b5af83ae..aa64fa46 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -102,6 +102,9 @@ defmodule AshPostgres.DataLayer do operation and `up` statements run after all other operations for that statement's table. If your statement's `up` depends on structure from another table (e.g. a foreign key referencing a unique index defined via `identities`), declare it with `after_tables` so the migration generator orders it correctly relative to that table's operations. + Use `after_table_structures` when only the target table's structural operations are required and waiting for its + custom statements would introduce a cycle. Use `after_statements` for dependencies between named custom statements + on the same resource, such as creating a function before a trigger that invokes it. Additionally, when changing a custom statement, we must make some assumptions, i.e that we should migrate the old structure down using the previously configured `down` and recreate it. @@ -124,6 +127,13 @@ defmodule AshPostgres.DataLayer do up "ALTER TABLE children ADD CONSTRAINT children_parent_fk FOREIGN KEY (region_id, parent_id) REFERENCES parents (region_id, id);" down "ALTER TABLE children DROP CONSTRAINT children_parent_fk;" end + + statement :create_audit_trigger do + after_table_structures ["audit_entries"] + after_statements [:create_audit_function] + up "CREATE TRIGGER ..." + down "DROP TRIGGER ..." + end end """ ], diff --git a/lib/migration_generator/operation_deps.ex b/lib/migration_generator/operation_deps.ex index dbfe1982..bc0c57f5 100644 --- a/lib/migration_generator/operation_deps.ex +++ b/lib/migration_generator/operation_deps.ex @@ -104,6 +104,11 @@ defmodule AshPostgres.MigrationGenerator.OperationDeps do cross-table reference requires `:table_finalized`, so it also waits for the target table's own custom statements. + Statement-scoped (`key = {schema, table, statement_name}`): + + - `:custom_statement_ready` — a named custom statement on the table has run. + `AddCustomStatement` uses this for explicit `after_statements` dependencies. + Column-scoped (`key = {schema, table, column}`): - `:column_ready` — a specific column (by its current name) exists. @@ -325,8 +330,11 @@ defmodule AshPostgres.MigrationGenerator.OperationDeps do %Operation.RemovePrimaryKeyDown{table: table, schema: schema} -> structure_ready_facts(table, schema) - %Operation.AddCustomStatement{table: own_table, schema: schema} -> - [{:table_finalized, key(own_table, schema)}] + %Operation.AddCustomStatement{table: own_table, schema: schema, statement: statement} -> + [ + {:table_finalized, key(own_table, schema)}, + {:custom_statement_ready, key(own_table, schema, statement.name)} + ] _ -> [] @@ -536,6 +544,11 @@ defmodule AshPostgres.MigrationGenerator.OperationDeps do %Operation.AddCustomStatement{table: own_table, schema: schema, statement: statement} -> after_tables = statement |> Map.get(:after_tables) |> List.wrap() + after_table_structures = + statement |> Map.get(:after_table_structures) |> List.wrap() + + after_statements = statement |> Map.get(:after_statements) |> List.wrap() + # Own table: the narrower `:table_structure_ready` (not # `:table_finalized`) — using the broader fact here would make two # custom statements on the same table each require the other's @@ -544,7 +557,15 @@ defmodule AshPostgres.MigrationGenerator.OperationDeps do # so this statement also waits for *that* table's own custom # statements, not just its structure. [{:table_structure_ready, key(own_table, schema)}] ++ - Enum.map(after_tables, &{:table_finalized, key(&1, schema)}) + Enum.map(after_tables, &{:table_finalized, key(&1, schema)}) ++ + Enum.map( + after_table_structures, + &{:table_structure_ready, key(&1, schema)} + ) ++ + Enum.map( + after_statements, + &{:custom_statement_ready, key(own_table, schema, &1)} + ) _ -> [] diff --git a/lib/statement.ex b/lib/statement.ex index 009dc87c..0e04c3fe 100644 --- a/lib/statement.ex +++ b/lib/statement.ex @@ -11,7 +11,9 @@ defmodule AshPostgres.Statement do :down, :code?, :global?, - :after_tables + :after_tables, + :after_table_structures, + :after_statements ] defstruct @fields ++ [:__spark_metadata__] @@ -58,6 +60,20 @@ defmodule AshPostgres.Statement do doc: """ Table names that this statement's `up` depends on being fully finalized (including their columns and indexes) before it runs. Use this when a raw SQL statement references structure (e.g. a foreign key referencing a unique index) on another table so the migration generator can order it correctly. """ + ], + after_table_structures: [ + type: {:list, :string}, + default: [], + doc: """ + Table names whose structural operations must be complete before this statement's `up` runs. Unlike `after_tables`, this does not wait for custom statements declared on those tables. Use this for raw SQL that references another table's columns or indexes when waiting for that table's custom statements would create an unnecessary dependency cycle. + """ + ], + after_statements: [ + type: {:list, :atom}, + default: [], + doc: """ + Names of other custom statements on this resource that must run before this statement's `up`. This is useful for dependencies such as a trigger statement that requires a function created by another statement. + """ ] ] diff --git a/test/migration_generator/operation_deps_test.exs b/test/migration_generator/operation_deps_test.exs index f122d993..8984b828 100644 --- a/test/migration_generator/operation_deps_test.exs +++ b/test/migration_generator/operation_deps_test.exs @@ -657,6 +657,66 @@ defmodule AshPostgres.MigrationGenerator.OperationDepsTest do &match?({:table_finalized, _}, &1) ) end + + test "after_table_structures waits for table structure without waiting for its custom statements" do + create = %Operation.CreateTable{table: "parents", schema: nil} + + parent_statement = %Operation.AddCustomStatement{ + table: "parents", + schema: nil, + statement: %{name: :parent_view, up: "", down: "", code?: false} + } + + child_statement = %Operation.AddCustomStatement{ + table: "children", + schema: nil, + statement: %{ + name: :child_view, + up: "", + down: "", + code?: false, + after_table_structures: ["parents"] + } + } + + [structure_fact] = + OperationDeps.provides(create) + |> Enum.filter(&match?({:table_structure_ready, _}, &1)) + + assert structure_fact in OperationDeps.requires(child_statement) + refute structure_fact in OperationDeps.provides(parent_statement) + + refute Enum.any?( + OperationDeps.requires(child_statement), + &match?({:table_finalized, {"public", "parents"}}, &1) + ) + end + + test "after_statements orders named statements on the same table" do + function_statement = %Operation.AddCustomStatement{ + table: "widgets", + schema: nil, + statement: %{name: :create_function, up: "", down: "", code?: false} + } + + trigger_statement = %Operation.AddCustomStatement{ + table: "widgets", + schema: nil, + statement: %{ + name: :create_trigger, + up: "", + down: "", + code?: false, + after_statements: [:create_function] + } + } + + statement_fact = + {:custom_statement_ready, {"public", "widgets", :create_function}} + + assert statement_fact in OperationDeps.provides(function_statement) + assert statement_fact in OperationDeps.requires(trigger_statement) + end end describe "early tier" do From 0d1ac5ec9579d46665b28449cbf72481d4274a44 Mon Sep 17 00:00:00 2001 From: Victor Batarse Date: Wed, 19 Aug 2026 21:32:55 -0600 Subject: [PATCH 2/4] refactor: rename custom statement dependency options --- CHANGELOG.md | 2 +- lib/data_layer.ex | 10 ++++---- lib/migration_generator/operation_deps.ex | 15 ++++++------ lib/statement.ex | 8 +++---- .../operation_deps_test.exs | 24 +++++++++---------- 5 files changed, 29 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20ea3458..f54396f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ See [Conventional Commits](https://www.conventionalcommits.org) for commit guide * add match_tenant? option for attribute multitenancy FKs (#813) by TravelCurry02 -* add after_tables to custom_statements so raw SQL can depend on another table (#799) by Jechol Lee +* add after_resource to custom_statements so raw SQL can depend on another resource (#799) by Jechol Lee ### Improvements: diff --git a/lib/data_layer.ex b/lib/data_layer.ex index aa64fa46..8c67cfa7 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -101,9 +101,9 @@ defmodule AshPostgres.DataLayer do By default, a statement has no declared dependency on other tables, so `down` statements run before any other operation and `up` statements run after all other operations for that statement's table. If your statement's `up` depends on structure from another table (e.g. a foreign key referencing a unique index defined via `identities`), - declare it with `after_tables` so the migration generator orders it correctly relative to that table's operations. - Use `after_table_structures` when only the target table's structural operations are required and waiting for its - custom statements would introduce a cycle. Use `after_statements` for dependencies between named custom statements + declare it with `after_resource` so the migration generator orders it correctly relative to that table's operations. + Use `after_tables` when only the target table's structural operations are required. Use `after_resource` when the + target resource's custom statements must also run first. Use `after_statements` for dependencies between named custom statements on the same resource, such as creating a function before a trigger that invokes it. Additionally, when changing a custom statement, we must make some assumptions, i.e that we should migrate @@ -123,13 +123,13 @@ defmodule AshPostgres.DataLayer do statement :children_parent_composite_fk do # ensures this runs after `parents`'s columns and unique indexes are finalized - after_tables ["parents"] + after_resource ["parents"] up "ALTER TABLE children ADD CONSTRAINT children_parent_fk FOREIGN KEY (region_id, parent_id) REFERENCES parents (region_id, id);" down "ALTER TABLE children DROP CONSTRAINT children_parent_fk;" end statement :create_audit_trigger do - after_table_structures ["audit_entries"] + after_tables ["audit_entries"] after_statements [:create_audit_function] up "CREATE TRIGGER ..." down "DROP TRIGGER ..." diff --git a/lib/migration_generator/operation_deps.ex b/lib/migration_generator/operation_deps.ex index bc0c57f5..7e37a053 100644 --- a/lib/migration_generator/operation_deps.ex +++ b/lib/migration_generator/operation_deps.ex @@ -23,7 +23,7 @@ defmodule AshPostgres.MigrationGenerator.OperationDeps do — 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) + `AddCustomStatement`'s own-table or `after_resource` requirement) transparently waits for all of that table's work, without needing to enumerate every attribute/index/constraint by hand. @@ -100,7 +100,7 @@ defmodule AshPostgres.MigrationGenerator.OperationDeps do `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` + same fact, a guaranteed cycle. Only the explicit, opt-in `after_resource` cross-table reference requires `:table_finalized`, so it also waits for the target table's own custom statements. @@ -542,10 +542,9 @@ defmodule AshPostgres.MigrationGenerator.OperationDeps do Enum.map(List.wrap(keys), &{:column_fk_dropped, key(table, schema, &1)}) %Operation.AddCustomStatement{table: own_table, schema: schema, statement: statement} -> - after_tables = statement |> Map.get(:after_tables) |> List.wrap() + after_resource = statement |> Map.get(:after_resource) |> List.wrap() - after_table_structures = - statement |> Map.get(:after_table_structures) |> List.wrap() + after_tables = statement |> Map.get(:after_tables) |> List.wrap() after_statements = statement |> Map.get(:after_statements) |> List.wrap() @@ -553,13 +552,13 @@ defmodule AshPostgres.MigrationGenerator.OperationDeps do # `:table_finalized`) — using the broader fact here would make two # custom statements on the same table each require the other's # `:table_finalized` (each provides it too), a guaranteed cycle. - # Declared `after_tables` targets: the broader `:table_finalized`, + # Declared `after_resource` targets: the broader `:table_finalized`, # so this statement also waits for *that* table's own custom # statements, not just its structure. [{:table_structure_ready, key(own_table, schema)}] ++ - Enum.map(after_tables, &{:table_finalized, key(&1, schema)}) ++ + Enum.map(after_resource, &{:table_finalized, key(&1, schema)}) ++ Enum.map( - after_table_structures, + after_tables, &{:table_structure_ready, key(&1, schema)} ) ++ Enum.map( diff --git a/lib/statement.ex b/lib/statement.ex index 0e04c3fe..b5360960 100644 --- a/lib/statement.ex +++ b/lib/statement.ex @@ -11,8 +11,8 @@ defmodule AshPostgres.Statement do :down, :code?, :global?, + :after_resource, :after_tables, - :after_table_structures, :after_statements ] @@ -54,18 +54,18 @@ defmodule AshPostgres.Statement do doc: "How to tear down the structure of the statement", required: true ], - after_tables: [ + after_resource: [ type: {:list, :string}, default: [], doc: """ Table names that this statement's `up` depends on being fully finalized (including their columns and indexes) before it runs. Use this when a raw SQL statement references structure (e.g. a foreign key referencing a unique index) on another table so the migration generator can order it correctly. """ ], - after_table_structures: [ + after_tables: [ type: {:list, :string}, default: [], doc: """ - Table names whose structural operations must be complete before this statement's `up` runs. Unlike `after_tables`, this does not wait for custom statements declared on those tables. Use this for raw SQL that references another table's columns or indexes when waiting for that table's custom statements would create an unnecessary dependency cycle. + Table names whose structural operations must be complete before this statement's `up` runs. This does not wait for custom statements declared on those tables. Use this for raw SQL that references another table's columns or indexes. """ ], after_statements: [ diff --git a/test/migration_generator/operation_deps_test.exs b/test/migration_generator/operation_deps_test.exs index 8984b828..eb6fa6a8 100644 --- a/test/migration_generator/operation_deps_test.exs +++ b/test/migration_generator/operation_deps_test.exs @@ -561,7 +561,7 @@ defmodule AshPostgres.MigrationGenerator.OperationDepsTest do statement = %Operation.AddCustomStatement{ table: "widget", schema: nil, - statement: %{name: :some_statement, up: "", down: "", code?: false, after_tables: []} + statement: %{name: :some_statement, up: "", down: "", code?: false, after_resource: []} } [fact] = @@ -570,7 +570,7 @@ defmodule AshPostgres.MigrationGenerator.OperationDepsTest do assert fact in OperationDeps.requires(statement) end - test "AddCustomStatement with after_tables is satisfied by a CreateTable for the declared table (via table_finalized)" do + test "AddCustomStatement with after_resource is satisfied by a CreateTable for the declared table (via table_finalized)" do create = %Operation.CreateTable{table: "parents", schema: nil} statement = %Operation.AddCustomStatement{ @@ -581,7 +581,7 @@ defmodule AshPostgres.MigrationGenerator.OperationDepsTest do up: "", down: "", code?: false, - after_tables: ["parents"] + after_resource: ["parents"] } } @@ -590,11 +590,11 @@ defmodule AshPostgres.MigrationGenerator.OperationDepsTest do assert fact in OperationDeps.requires(statement) end - test "AddCustomStatement with after_tables is satisfied by another custom statement declared on the target table" do + test "AddCustomStatement with after_resource is satisfied by another custom statement declared on the target table" do # This is the whole point of the two-tier fact split: a shared, # foundational custom statement (e.g. one that creates a structure # another table's FK needs) can live on the table it actually concerns, - # and other resources' `after_tables` will wait for it too — not just + # and other resources' `after_resource` will wait for it too — not just # for that table's plain structural (DDL) operations. parent_statement = %Operation.AddCustomStatement{ table: "parents", @@ -604,7 +604,7 @@ defmodule AshPostgres.MigrationGenerator.OperationDepsTest do up: "", down: "", code?: false, - after_tables: [] + after_resource: [] } } @@ -616,7 +616,7 @@ defmodule AshPostgres.MigrationGenerator.OperationDepsTest do up: "", down: "", code?: false, - after_tables: ["parents"] + after_resource: ["parents"] } } @@ -631,17 +631,17 @@ defmodule AshPostgres.MigrationGenerator.OperationDepsTest do statement_a = %Operation.AddCustomStatement{ table: "widget", schema: nil, - statement: %{name: :a, up: "", down: "", code?: false, after_tables: []} + statement: %{name: :a, up: "", down: "", code?: false, after_resource: []} } statement_b = %Operation.AddCustomStatement{ table: "widget", schema: nil, - statement: %{name: :b, up: "", down: "", code?: false, after_tables: []} + statement: %{name: :b, up: "", down: "", code?: false, after_resource: []} } # Each provides :table_finalized for their shared table (so *other* - # tables' after_tables can depend on either of them), but neither's own + # tables' after_resource can depend on either of them), but neither's own # implicit requirement is written in terms of that same broad fact — # only the narrower :table_structure_ready, which neither custom # statement provides. If this ever regresses, `AddCustomStatement`s on @@ -658,7 +658,7 @@ defmodule AshPostgres.MigrationGenerator.OperationDepsTest do ) end - test "after_table_structures waits for table structure without waiting for its custom statements" do + test "after_tables waits for table structure without waiting for its custom statements" do create = %Operation.CreateTable{table: "parents", schema: nil} parent_statement = %Operation.AddCustomStatement{ @@ -675,7 +675,7 @@ defmodule AshPostgres.MigrationGenerator.OperationDepsTest do up: "", down: "", code?: false, - after_table_structures: ["parents"] + after_tables: ["parents"] } } From 595158073d265dd59ca413be428e743b283e422c Mon Sep 17 00:00:00 2001 From: Victor Batarse Date: Wed, 19 Aug 2026 21:49:30 -0600 Subject: [PATCH 3/4] docs: regenerate custom statement DSL references --- .formatter.exs | 2 +- documentation/dsls/DSL-AshPostgres.DataLayer.md | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.formatter.exs b/.formatter.exs index a00126c7..e2db015c 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -3,8 +3,8 @@ # SPDX-License-Identifier: MIT spark_locals_without_parens = [ + after_resource: 1, after_statements: 1, - after_table_structures: 1, after_tables: 1, all_tenants?: 1, base_filter_sql: 1, diff --git a/documentation/dsls/DSL-AshPostgres.DataLayer.md b/documentation/dsls/DSL-AshPostgres.DataLayer.md index fb684384..3ec0bd4a 100644 --- a/documentation/dsls/DSL-AshPostgres.DataLayer.md +++ b/documentation/dsls/DSL-AshPostgres.DataLayer.md @@ -136,9 +136,9 @@ A section for configuring custom statements to be added to migrations. By default, a statement has no declared dependency on other tables, so `down` statements run before any other operation and `up` statements run after all other operations for that statement's table. If your statement's `up` depends on structure from another table (e.g. a foreign key referencing a unique index defined via `identities`), -declare it with `after_tables` so the migration generator orders it correctly relative to that table's operations. -Use `after_table_structures` when only the target table's structural operations are required and waiting for its -custom statements would introduce a cycle. Use `after_statements` for dependencies between named custom statements +declare it with `after_resource` so the migration generator orders it correctly relative to that table's operations. +Use `after_tables` when only the target table's structural operations are required. Use `after_resource` when the +target resource's custom statements must also run first. Use `after_statements` for dependencies between named custom statements on the same resource, such as creating a function before a trigger that invokes it. Additionally, when changing a custom statement, we must make some assumptions, i.e that we should migrate @@ -163,13 +163,13 @@ custom_statements do statement :children_parent_composite_fk do # ensures this runs after `parents`'s columns and unique indexes are finalized - after_tables ["parents"] + after_resource ["parents"] up "ALTER TABLE children ADD CONSTRAINT children_parent_fk FOREIGN KEY (region_id, parent_id) REFERENCES parents (region_id, id);" down "ALTER TABLE children DROP CONSTRAINT children_parent_fk;" end statement :create_audit_trigger do - after_table_structures ["audit_entries"] + after_tables ["audit_entries"] after_statements [:create_audit_function] up "CREATE TRIGGER ..." down "DROP TRIGGER ..." @@ -216,8 +216,8 @@ end | [`down`](#postgres-custom_statements-statement-down){: #postgres-custom_statements-statement-down .spark-required} | `String.t` | | How to tear down the structure of the statement | | [`code?`](#postgres-custom_statements-statement-code?){: #postgres-custom_statements-statement-code? } | `boolean` | `false` | By default, we place the strings inside of ecto migration's `execute/1` function and assume they are sql. Use this option if you want to provide custom elixir code to be placed directly in the migrations | | [`global?`](#postgres-custom_statements-statement-global?){: #postgres-custom_statements-statement-global? } | `boolean` | `false` | By default, a multi-tenant resource's custom statements will be written into the tenant migration folder. Set this to true for statements that create global, shared structures so they are written into the public migration folder even when defined on a tenant resource. | -| [`after_tables`](#postgres-custom_statements-statement-after_tables){: #postgres-custom_statements-statement-after_tables } | `list(String.t)` | `[]` | Table names that this statement's `up` depends on being fully finalized (including their columns and indexes) before it runs. Use this when a raw SQL statement references structure (e.g. a foreign key referencing a unique index) on another table so the migration generator can order it correctly. | -| [`after_table_structures`](#postgres-custom_statements-statement-after_table_structures){: #postgres-custom_statements-statement-after_table_structures } | `list(String.t)` | `[]` | Table names whose structural operations must be complete before this statement's `up` runs. Unlike `after_tables`, this does not wait for custom statements declared on those tables. Use this for raw SQL that references another table's columns or indexes when waiting for that table's custom statements would create an unnecessary dependency cycle. | +| [`after_resource`](#postgres-custom_statements-statement-after_resource){: #postgres-custom_statements-statement-after_resource } | `list(String.t)` | `[]` | Table names that this statement's `up` depends on being fully finalized (including their columns and indexes) before it runs. Use this when a raw SQL statement references structure (e.g. a foreign key referencing a unique index) on another table so the migration generator can order it correctly. | +| [`after_tables`](#postgres-custom_statements-statement-after_tables){: #postgres-custom_statements-statement-after_tables } | `list(String.t)` | `[]` | Table names whose structural operations must be complete before this statement's `up` runs. This does not wait for custom statements declared on those tables. Use this for raw SQL that references another table's columns or indexes. | | [`after_statements`](#postgres-custom_statements-statement-after_statements){: #postgres-custom_statements-statement-after_statements } | `list(atom)` | `[]` | Names of other custom statements on this resource that must run before this statement's `up`. This is useful for dependencies such as a trigger statement that requires a function created by another statement. | From c6d7d7457e395a0602872f2d8682a8bc76dac08d Mon Sep 17 00:00:00 2001 From: Victor Batarse Date: Thu, 20 Aug 2026 10:26:49 -0600 Subject: [PATCH 4/4] fix: preserve custom statement ordering --- .formatter.exs | 2 - .../dsls/DSL-AshPostgres.DataLayer.md | 11 +- lib/data_layer.ex | 9 +- .../migration_generator.ex | 50 ++++- lib/migration_generator/operation_deps.ex | 57 +----- lib/statement.ex | 18 +- .../operation_deps_test.exs | 174 ++++++++---------- 7 files changed, 142 insertions(+), 179 deletions(-) diff --git a/.formatter.exs b/.formatter.exs index e2db015c..30e82596 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -3,8 +3,6 @@ # SPDX-License-Identifier: MIT spark_locals_without_parens = [ - after_resource: 1, - after_statements: 1, after_tables: 1, all_tenants?: 1, base_filter_sql: 1, diff --git a/documentation/dsls/DSL-AshPostgres.DataLayer.md b/documentation/dsls/DSL-AshPostgres.DataLayer.md index 3ec0bd4a..e2650606 100644 --- a/documentation/dsls/DSL-AshPostgres.DataLayer.md +++ b/documentation/dsls/DSL-AshPostgres.DataLayer.md @@ -136,10 +136,8 @@ A section for configuring custom statements to be added to migrations. By default, a statement has no declared dependency on other tables, so `down` statements run before any other operation and `up` statements run after all other operations for that statement's table. If your statement's `up` depends on structure from another table (e.g. a foreign key referencing a unique index defined via `identities`), -declare it with `after_resource` so the migration generator orders it correctly relative to that table's operations. -Use `after_tables` when only the target table's structural operations are required. Use `after_resource` when the -target resource's custom statements must also run first. Use `after_statements` for dependencies between named custom statements -on the same resource, such as creating a function before a trigger that invokes it. +declare it with `after_tables` so the migration generator orders it correctly relative to that table's structure. +Custom statements on the same table run in declaration order, such as creating a function before a trigger that invokes it. Additionally, when changing a custom statement, we must make some assumptions, i.e that we should migrate the old structure down using the previously configured `down` and recreate it. @@ -163,14 +161,13 @@ custom_statements do statement :children_parent_composite_fk do # ensures this runs after `parents`'s columns and unique indexes are finalized - after_resource ["parents"] + after_tables ["parents"] up "ALTER TABLE children ADD CONSTRAINT children_parent_fk FOREIGN KEY (region_id, parent_id) REFERENCES parents (region_id, id);" down "ALTER TABLE children DROP CONSTRAINT children_parent_fk;" end statement :create_audit_trigger do after_tables ["audit_entries"] - after_statements [:create_audit_function] up "CREATE TRIGGER ..." down "DROP TRIGGER ..." end @@ -216,9 +213,7 @@ end | [`down`](#postgres-custom_statements-statement-down){: #postgres-custom_statements-statement-down .spark-required} | `String.t` | | How to tear down the structure of the statement | | [`code?`](#postgres-custom_statements-statement-code?){: #postgres-custom_statements-statement-code? } | `boolean` | `false` | By default, we place the strings inside of ecto migration's `execute/1` function and assume they are sql. Use this option if you want to provide custom elixir code to be placed directly in the migrations | | [`global?`](#postgres-custom_statements-statement-global?){: #postgres-custom_statements-statement-global? } | `boolean` | `false` | By default, a multi-tenant resource's custom statements will be written into the tenant migration folder. Set this to true for statements that create global, shared structures so they are written into the public migration folder even when defined on a tenant resource. | -| [`after_resource`](#postgres-custom_statements-statement-after_resource){: #postgres-custom_statements-statement-after_resource } | `list(String.t)` | `[]` | Table names that this statement's `up` depends on being fully finalized (including their columns and indexes) before it runs. Use this when a raw SQL statement references structure (e.g. a foreign key referencing a unique index) on another table so the migration generator can order it correctly. | | [`after_tables`](#postgres-custom_statements-statement-after_tables){: #postgres-custom_statements-statement-after_tables } | `list(String.t)` | `[]` | Table names whose structural operations must be complete before this statement's `up` runs. This does not wait for custom statements declared on those tables. Use this for raw SQL that references another table's columns or indexes. | -| [`after_statements`](#postgres-custom_statements-statement-after_statements){: #postgres-custom_statements-statement-after_statements } | `list(atom)` | `[]` | Names of other custom statements on this resource that must run before this statement's `up`. This is useful for dependencies such as a trigger statement that requires a function created by another statement. | diff --git a/lib/data_layer.ex b/lib/data_layer.ex index 8c67cfa7..99b35206 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -101,10 +101,8 @@ defmodule AshPostgres.DataLayer do By default, a statement has no declared dependency on other tables, so `down` statements run before any other operation and `up` statements run after all other operations for that statement's table. If your statement's `up` depends on structure from another table (e.g. a foreign key referencing a unique index defined via `identities`), - declare it with `after_resource` so the migration generator orders it correctly relative to that table's operations. - Use `after_tables` when only the target table's structural operations are required. Use `after_resource` when the - target resource's custom statements must also run first. Use `after_statements` for dependencies between named custom statements - on the same resource, such as creating a function before a trigger that invokes it. + declare it with `after_tables` so the migration generator orders it correctly relative to that table's structure. + Custom statements on the same table run in declaration order, such as creating a function before a trigger that invokes it. Additionally, when changing a custom statement, we must make some assumptions, i.e that we should migrate the old structure down using the previously configured `down` and recreate it. @@ -123,14 +121,13 @@ defmodule AshPostgres.DataLayer do statement :children_parent_composite_fk do # ensures this runs after `parents`'s columns and unique indexes are finalized - after_resource ["parents"] + after_tables ["parents"] up "ALTER TABLE children ADD CONSTRAINT children_parent_fk FOREIGN KEY (region_id, parent_id) REFERENCES parents (region_id, id);" down "ALTER TABLE children DROP CONSTRAINT children_parent_fk;" end statement :create_audit_trigger do after_tables ["audit_entries"] - after_statements [:create_audit_function] up "CREATE TRIGGER ..." down "DROP TRIGGER ..." end diff --git a/lib/migration_generator/migration_generator.ex b/lib/migration_generator/migration_generator.ex index db27355a..fad77774 100644 --- a/lib/migration_generator/migration_generator.ex +++ b/lib/migration_generator/migration_generator.ex @@ -1807,10 +1807,13 @@ defmodule AshPostgres.MigrationGenerator do # dependencies at the same time) are broken by that original index, so # unconstrained operations keep their original relative order (matching # resource declaration order). - defp toposort_operations(operations) do + @doc false + def toposort_operations(operations) do count = length(operations) indexed = Enum.with_index(operations) + declaration_deps_by_index = custom_statement_declaration_dependencies(indexed) + provides_index = Enum.reduce(indexed, %{}, fn {op, index}, acc -> op @@ -1846,6 +1849,7 @@ defmodule AshPostgres.MigrationGenerator do dependencies = Map.new(indexed, fn {op, index} -> fact_deps = Map.fetch!(fact_deps_by_index, index) + declaration_deps = Map.get(declaration_deps_by_index, index, []) tier_deps = if OperationDeps.early_tier?(op) || MapSet.member?(required_by_early_tier, index) do @@ -1855,7 +1859,7 @@ defmodule AshPostgres.MigrationGenerator do end deps = - (fact_deps ++ tier_deps) + (fact_deps ++ declaration_deps ++ tier_deps) |> Enum.reject(&(&1 == index)) |> Enum.uniq() @@ -1918,6 +1922,48 @@ defmodule AshPostgres.MigrationGenerator do toposort_operation_indices(queue, adjacency, in_degrees, [index | acc]) end + # Preserve custom statement declaration order for additions and reverse it + # for removals because their SQL may contain dependencies we cannot inspect. + defp custom_statement_declaration_dependencies(indexed) do + {dependencies, _last_add, _last_remove} = + Enum.reduce(indexed, {%{}, %{}, %{}}, fn + {%Operation.AddCustomStatement{table: table, schema: schema}, index}, + {dependencies, last_add, last_remove} -> + key = {schema_key(schema), table} + + dependencies = + case Map.fetch(last_add, key) do + {:ok, previous_index} -> + Map.update(dependencies, index, [previous_index], &[previous_index | &1]) + + :error -> + dependencies + end + + {dependencies, Map.put(last_add, key, index), last_remove} + + {%Operation.RemoveCustomStatement{table: table, schema: schema}, index}, + {dependencies, last_add, last_remove} -> + key = {schema_key(schema), table} + + dependencies = + case Map.fetch(last_remove, key) do + {:ok, previous_index} -> + Map.update(dependencies, previous_index, [index], &[index | &1]) + + :error -> + dependencies + end + + {dependencies, last_add, Map.put(last_remove, key, index)} + + {_operation, _index}, acc -> + acc + end) + + dependencies + end + defp fetch_operations(snapshots, opts) do # Reference diffs need to know when a prefix change is caused by moving # the referenced table instead of by changing the foreign key itself. diff --git a/lib/migration_generator/operation_deps.ex b/lib/migration_generator/operation_deps.ex index 7e37a053..6078174b 100644 --- a/lib/migration_generator/operation_deps.ex +++ b/lib/migration_generator/operation_deps.ex @@ -8,7 +8,7 @@ defmodule AshPostgres.MigrationGenerator.OperationDeps do 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` + `AshPostgres.MigrationGenerator.toposort_operations/1` turns these into a dependency graph and topologically sorts it. There is deliberately no symmetric "late tier" counterpart to @@ -21,9 +21,9 @@ defmodule AshPostgres.MigrationGenerator.OperationDeps do 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_resource` requirement) + `:table_structure_ready` works as a catch-all: many operation types provide + it, so an op that requires it (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. @@ -69,8 +69,8 @@ defmodule AshPostgres.MigrationGenerator.OperationDeps do (`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: + Both converge at `:table_structure_ready`, which every structural operation + provides: - `:table_ready` — the table exists (`CreateTable`/`RenameTable`/ `MoveTableSchema`). @@ -92,22 +92,6 @@ defmodule AshPostgres.MigrationGenerator.OperationDeps do `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_resource` - cross-table reference requires `:table_finalized`, so it also waits for - the target table's own custom statements. - - Statement-scoped (`key = {schema, table, statement_name}`): - - - `:custom_statement_ready` — a named custom statement on the table has run. - `AddCustomStatement` uses this for explicit `after_statements` dependencies. Column-scoped (`key = {schema, table, column}`): @@ -330,19 +314,13 @@ defmodule AshPostgres.MigrationGenerator.OperationDeps do %Operation.RemovePrimaryKeyDown{table: table, schema: schema} -> structure_ready_facts(table, schema) - %Operation.AddCustomStatement{table: own_table, schema: schema, statement: statement} -> - [ - {:table_finalized, key(own_table, schema)}, - {:custom_statement_ready, key(own_table, schema, statement.name)} - ] - _ -> [] end end defp structure_ready_facts(table, schema) do - [{:table_structure_ready, key(table, schema)}, {:table_finalized, key(table, schema)}] + [{:table_structure_ready, key(table, schema)}] end @doc "Facts that must already be provided (by some other operation) before `op` can run." @@ -542,29 +520,10 @@ defmodule AshPostgres.MigrationGenerator.OperationDeps do Enum.map(List.wrap(keys), &{:column_fk_dropped, key(table, schema, &1)}) %Operation.AddCustomStatement{table: own_table, schema: schema, statement: statement} -> - after_resource = statement |> Map.get(:after_resource) |> List.wrap() - after_tables = statement |> Map.get(:after_tables) |> List.wrap() - after_statements = statement |> Map.get(:after_statements) |> List.wrap() - - # Own table: the narrower `:table_structure_ready` (not - # `:table_finalized`) — using the broader fact here would make two - # custom statements on the same table each require the other's - # `:table_finalized` (each provides it too), a guaranteed cycle. - # Declared `after_resource` targets: the broader `:table_finalized`, - # so this statement also waits for *that* table's own custom - # statements, not just its structure. [{:table_structure_ready, key(own_table, schema)}] ++ - Enum.map(after_resource, &{:table_finalized, key(&1, schema)}) ++ - Enum.map( - after_tables, - &{:table_structure_ready, key(&1, schema)} - ) ++ - Enum.map( - after_statements, - &{:custom_statement_ready, key(own_table, schema, &1)} - ) + Enum.map(after_tables, &{:table_structure_ready, key(&1, schema)}) _ -> [] diff --git a/lib/statement.ex b/lib/statement.ex index b5360960..726977e3 100644 --- a/lib/statement.ex +++ b/lib/statement.ex @@ -11,9 +11,7 @@ defmodule AshPostgres.Statement do :down, :code?, :global?, - :after_resource, - :after_tables, - :after_statements + :after_tables ] defstruct @fields ++ [:__spark_metadata__] @@ -54,26 +52,12 @@ defmodule AshPostgres.Statement do doc: "How to tear down the structure of the statement", required: true ], - after_resource: [ - type: {:list, :string}, - default: [], - doc: """ - Table names that this statement's `up` depends on being fully finalized (including their columns and indexes) before it runs. Use this when a raw SQL statement references structure (e.g. a foreign key referencing a unique index) on another table so the migration generator can order it correctly. - """ - ], after_tables: [ type: {:list, :string}, default: [], doc: """ Table names whose structural operations must be complete before this statement's `up` runs. This does not wait for custom statements declared on those tables. Use this for raw SQL that references another table's columns or indexes. """ - ], - after_statements: [ - type: {:list, :atom}, - default: [], - doc: """ - Names of other custom statements on this resource that must run before this statement's `up`. This is useful for dependencies such as a trigger statement that requires a function created by another statement. - """ ] ] diff --git a/test/migration_generator/operation_deps_test.exs b/test/migration_generator/operation_deps_test.exs index eb6fa6a8..93aef945 100644 --- a/test/migration_generator/operation_deps_test.exs +++ b/test/migration_generator/operation_deps_test.exs @@ -9,6 +9,7 @@ defmodule AshPostgres.MigrationGenerator.OperationDepsTest do """ use ExUnit.Case, async: true + alias AshPostgres.MigrationGenerator alias AshPostgres.MigrationGenerator.Operation alias AshPostgres.MigrationGenerator.OperationDeps @@ -561,7 +562,7 @@ defmodule AshPostgres.MigrationGenerator.OperationDepsTest do statement = %Operation.AddCustomStatement{ table: "widget", schema: nil, - statement: %{name: :some_statement, up: "", down: "", code?: false, after_resource: []} + statement: %{name: :some_statement, up: "", down: "", code?: false} } [fact] = @@ -570,152 +571,135 @@ defmodule AshPostgres.MigrationGenerator.OperationDepsTest do assert fact in OperationDeps.requires(statement) end - test "AddCustomStatement with after_resource is satisfied by a CreateTable for the declared table (via table_finalized)" do + test "after_tables waits for table structure without waiting for its custom statements" do create = %Operation.CreateTable{table: "parents", schema: nil} - statement = %Operation.AddCustomStatement{ - table: "widget", + parent_statement = %Operation.AddCustomStatement{ + table: "parents", + schema: nil, + statement: %{name: :parent_view, up: "", down: "", code?: false} + } + + child_statement = %Operation.AddCustomStatement{ + table: "children", schema: nil, statement: %{ - name: :widget_parent_composite_fk, + name: :child_view, up: "", down: "", code?: false, - after_resource: ["parents"] + after_tables: ["parents"] } } - [fact] = OperationDeps.provides(create) |> Enum.filter(&match?({:table_finalized, _}, &1)) + [structure_fact] = + OperationDeps.provides(create) + |> Enum.filter(&match?({:table_structure_ready, _}, &1)) - assert fact in OperationDeps.requires(statement) + assert structure_fact in OperationDeps.requires(child_statement) + refute structure_fact in OperationDeps.provides(parent_statement) end - test "AddCustomStatement with after_resource is satisfied by another custom statement declared on the target table" do - # This is the whole point of the two-tier fact split: a shared, - # foundational custom statement (e.g. one that creates a structure - # another table's FK needs) can live on the table it actually concerns, - # and other resources' `after_resource` will wait for it too — not just - # for that table's plain structural (DDL) operations. - parent_statement = %Operation.AddCustomStatement{ - table: "parents", + test "custom statements preserve declaration order around after_tables dependencies" do + table_a = %Operation.CreateTable{table: "widgets", schema: nil} + table_b = %Operation.CreateTable{table: "audit_entries", schema: nil} + + function_statement = %Operation.AddCustomStatement{ + table: "widgets", schema: nil, statement: %{ - name: :parents_composite_unique_index, - up: "", - down: "", + name: :create_function, + up: "CREATE FUNCTION audit_widget() RETURNS trigger ...", + down: "DROP FUNCTION audit_widget()", code?: false, - after_resource: [] + after_tables: ["audit_entries"] } } - child_statement = %Operation.AddCustomStatement{ - table: "widget", + trigger_statement = %Operation.AddCustomStatement{ + table: "widgets", schema: nil, statement: %{ - name: :widget_parent_composite_fk, - up: "", - down: "", + name: :create_trigger, + up: "CREATE TRIGGER audit_widget EXECUTE FUNCTION audit_widget()", + down: "DROP TRIGGER audit_widget ON widgets", code?: false, - after_resource: ["parents"] + after_tables: ["audit_entries"] } } - [fact] = - OperationDeps.provides(parent_statement) - |> Enum.filter(&match?({:table_finalized, _}, &1)) + operations = + MigrationGenerator.toposort_operations([ + table_a, + function_statement, + trigger_statement, + table_b + ]) - assert fact in OperationDeps.requires(child_statement) - end - - test "two custom statements declared on the same table do not require each other (no sibling cycle)" do - statement_a = %Operation.AddCustomStatement{ - table: "widget", - schema: nil, - statement: %{name: :a, up: "", down: "", code?: false, after_resource: []} - } + statement_names = + for %Operation.AddCustomStatement{statement: statement} <- operations, do: statement.name - statement_b = %Operation.AddCustomStatement{ - table: "widget", - schema: nil, - statement: %{name: :b, up: "", down: "", code?: false, after_resource: []} - } - - # Each provides :table_finalized for their shared table (so *other* - # tables' after_resource can depend on either of them), but neither's own - # implicit requirement is written in terms of that same broad fact — - # only the narrower :table_structure_ready, which neither custom - # statement provides. If this ever regresses, `AddCustomStatement`s on - # a shared table would deadlock (a real cycle) via each other's - # `:table_finalized`. - refute Enum.any?( - OperationDeps.provides(statement_a), - &match?({:table_structure_ready, _}, &1) - ) - - refute Enum.any?( - OperationDeps.requires(statement_b), - &match?({:table_finalized, _}, &1) - ) + assert statement_names == [:create_function, :create_trigger] + assert Enum.reverse(statement_names) == [:create_trigger, :create_function] end - test "after_tables waits for table structure without waiting for its custom statements" do - create = %Operation.CreateTable{table: "parents", schema: nil} + test "a later custom statement cannot overtake an earlier statement with after_tables" do + table_a = %Operation.CreateTable{table: "widgets", schema: nil} + table_b = %Operation.CreateTable{table: "audit_entries", schema: nil} - parent_statement = %Operation.AddCustomStatement{ - table: "parents", - schema: nil, - statement: %{name: :parent_view, up: "", down: "", code?: false} - } - - child_statement = %Operation.AddCustomStatement{ - table: "children", + function_statement = %Operation.AddCustomStatement{ + table: "widgets", schema: nil, statement: %{ - name: :child_view, + name: :create_function, up: "", down: "", code?: false, - after_tables: ["parents"] + after_tables: ["audit_entries"] } } - [structure_fact] = - OperationDeps.provides(create) - |> Enum.filter(&match?({:table_structure_ready, _}, &1)) + trigger_statement = %Operation.AddCustomStatement{ + table: "widgets", + schema: nil, + statement: %{name: :create_trigger, up: "", down: "", code?: false} + } - assert structure_fact in OperationDeps.requires(child_statement) - refute structure_fact in OperationDeps.provides(parent_statement) + operations = + MigrationGenerator.toposort_operations([ + table_a, + function_statement, + trigger_statement, + table_b + ]) - refute Enum.any?( - OperationDeps.requires(child_statement), - &match?({:table_finalized, {"public", "parents"}}, &1) - ) + statement_names = + for %Operation.AddCustomStatement{statement: statement} <- operations, do: statement.name + + assert statement_names == [:create_function, :create_trigger] end - test "after_statements orders named statements on the same table" do - function_statement = %Operation.AddCustomStatement{ + test "removed custom statements use reverse declaration order" do + remove_function = %Operation.RemoveCustomStatement{ table: "widgets", schema: nil, statement: %{name: :create_function, up: "", down: "", code?: false} } - trigger_statement = %Operation.AddCustomStatement{ + remove_trigger = %Operation.RemoveCustomStatement{ table: "widgets", schema: nil, - statement: %{ - name: :create_trigger, - up: "", - down: "", - code?: false, - after_statements: [:create_function] - } + statement: %{name: :create_trigger, up: "", down: "", code?: false} } - statement_fact = - {:custom_statement_ready, {"public", "widgets", :create_function}} + operations = + MigrationGenerator.toposort_operations([remove_function, remove_trigger]) + + statement_names = + for %Operation.RemoveCustomStatement{statement: statement} <- operations, + do: statement.name - assert statement_fact in OperationDeps.provides(function_statement) - assert statement_fact in OperationDeps.requires(trigger_statement) + assert statement_names == [:create_trigger, :create_function] end end