diff --git a/documentation/topics/development/migrations-and-tasks.md b/documentation/topics/development/migrations-and-tasks.md index 1ec02e83..18cdb949 100644 --- a/documentation/topics/development/migrations-and-tasks.md +++ b/documentation/topics/development/migrations-and-tasks.md @@ -25,6 +25,10 @@ dev migrations and run them. For more information on generating migrations, run `mix help ash_postgres.generate_migrations` (the underlying task that is called by `mix ash.migrate`) +When you remove a resource from your domain, run the migration generator (e.g. `mix ash_postgres.generate_migrations --name remove_my_resource`). It will generate a migration to drop the table and remove the snapshot for that table. + +When you rename a resource's table (e.g. change the `table "..."` in the `postgres do` block), the generator will ask whether you are renaming the table. If you answer yes, it generates a single `rename table(...), to: table(...)` migration so the table is renamed in place and data and foreign keys are preserved. + > ### all_tenants/0 {: .info} > > If you are using schema-based multitenancy, you will also need to define a `all_tenants/0` function in your repo module. See `AshPostgres.Repo` for more. diff --git a/lib/migration_generator/migration_generator.ex b/lib/migration_generator/migration_generator.ex index e2cd9edd..3c16628a 100644 --- a/lib/migration_generator/migration_generator.ex +++ b/lib/migration_generator/migration_generator.ex @@ -440,16 +440,51 @@ defmodule AshPostgres.MigrationGenerator do |> Enum.flat_map(fn {repo, snapshots} -> deduped = deduplicate_snapshots(snapshots, opts, non_tenant_snapshots) - snapshots_with_operations = + current_table_keys = deduped + |> Enum.map(fn {%{table: t, schema: s}, _} -> {t, s} end) + |> MapSet.new() + + {drop_operations, orphan_snapshots, rename_map} = + drop_operations_for_orphan_tables(repo, current_table_keys, opts, tenant?) + + deduped_with_renames = + Enum.map(deduped, fn {%{table: table, schema: schema} = snapshot, existing_snapshot} -> + case Map.get(rename_map, {table, schema}) do + nil -> + {snapshot, existing_snapshot} + + %{snapshot: old_snapshot} -> + {snapshot, old_snapshot} + end + end) + + snapshots_with_operations = + deduped_with_renames |> fetch_operations(opts) |> Enum.map(&add_order_to_operations/1) snapshots = Enum.map(snapshots_with_operations, &elem(&1, 0)) - snapshots_with_operations - |> Enum.flat_map(&elem(&1, 1)) - |> Enum.uniq() + rename_operations = + Enum.map(rename_map, fn {{new_table, schema}, %{old_table: old_table, snapshot: snapshot}} -> + %Operation.RenameTable{ + old_table: old_table, + new_table: new_table, + schema: schema, + multitenancy: snapshot.multitenancy, + repo: repo + } + end) + + operations = + snapshots_with_operations + |> Enum.flat_map(&elem(&1, 1)) + |> Enum.concat(rename_operations) + |> Enum.concat(drop_operations) + |> Enum.uniq() + + operations |> case do [] -> [] @@ -500,6 +535,10 @@ defmodule AshPostgres.MigrationGenerator do end) end |> Enum.concat(create_new_snapshot(snapshots, repo_name(repo), opts, tenant?)) + |> then(fn files -> + remove_orphan_snapshots(orphan_snapshots, opts) + files + end) end end) end @@ -1056,6 +1095,38 @@ defmodule AshPostgres.MigrationGenerator do end end + defp drop_table_confirmed?(existing_snapshot, opts) do + cond do + opts.check -> + false + + opts.dev -> + true + + opts.no_shell? -> + table_label = + if existing_snapshot.schema do + "#{existing_snapshot.schema}.#{existing_snapshot.table}" + else + existing_snapshot.table + end + + raise "Unimplemented: cannot determine whether to generate DROP for table #{table_label} without shell input" + + true -> + message = + if existing_snapshot.schema do + "Table #{existing_snapshot.schema}.#{existing_snapshot.table} no longer has a resource. " <> + "Generate a migration to DROP this table? This will permanently remove the table and its data." + else + "Table #{existing_snapshot.table} no longer has a resource. " <> + "Generate a migration to DROP this table? This will permanently remove the table and its data." + end + + yes?(opts, message) + end + end + defp prompt(opts, message) do if opts.check do "response" @@ -1390,6 +1461,40 @@ defmodule AshPostgres.MigrationGenerator do ) end + defp group_into_phases( + [ + %Operation.DropTable{ + table: table, + schema: schema, + multitenancy: multitenancy, + repo: repo + } + | rest + ], + nil, + acc + ) do + group_into_phases(rest, nil, [ + %Phase.Drop{ + table: table, + schema: schema, + multitenancy: multitenancy, + repo: repo + } + | acc + ]) + end + + defp group_into_phases( + [%Operation.DropTable{} = op | rest], + phase, + acc + ) + when not is_nil(phase) do + phase = %{phase | operations: Enum.reverse(phase.operations)} + group_into_phases([op | rest], nil, [phase | acc]) + end + defp group_into_phases( [%Operation.AddAttribute{table: table, schema: schema} = op | rest], %{table: table, schema: schema} = phase, @@ -1980,6 +2085,21 @@ defmodule AshPostgres.MigrationGenerator do defp after?(%Operation.AddCheckConstraint{}, _), do: true defp after?(%Operation.RemoveCheckConstraint{}, _), do: true + defp after?( + op, + %Operation.RenameTable{ + new_table: table, + schema: schema + } + ) do + match?(%{table: ^table, schema: ^schema}, op) + end + + defp after?(%Operation.RenameTable{}, _), do: false + + defp after?(_, %Operation.DropTable{}), do: true + defp after?(%Operation.DropTable{}, _), do: false + defp after?(_, _), do: false defp fetch_operations(snapshots, opts) do @@ -2935,6 +3055,55 @@ defmodule AshPostgres.MigrationGenerator do end end + defp get_latest_snapshot_file_path(snapshot, opts) do + folder = get_snapshot_folder(snapshot, opts) + snapshot_dir = get_snapshot_path(snapshot, folder) + + if File.exists?(snapshot_dir) do + snapshot_files = + File.ls!(snapshot_dir) + |> Enum.filter( + &(String.match?(&1, ~r/^\d{14}\.json$/) or + (opts.dev and String.match?(&1, ~r/^\d{14}\_dev\.json$/))) + ) + + case snapshot_files do + [] -> + if snapshot.schema do + path = Path.join(folder, "#{snapshot.schema}.#{snapshot.table}.json") + if File.exists?(path), do: path, else: nil + else + path = Path.join(folder, "#{snapshot.table}.json") + if File.exists?(path), do: path, else: nil + end + + files -> + Path.join(snapshot_dir, Enum.max(files)) + end + else + nil + end + end + + defp record_drop_table_opt_out(snapshot, opts) do + if opts.dry_run || opts.check || opts.snapshots_only do + :ok + else + case get_latest_snapshot_file_path(snapshot, opts) do + nil -> + :ok + + path -> + path + |> File.read!() + |> Jason.decode!(keys: :atoms!) + |> Map.put(:drop_table_opted_out, true) + |> Jason.encode!(pretty: true) + |> then(&File.write!(path, &1)) + end + end + end + defp get_old_snapshot(folder, snapshot) do schema_file = if snapshot.schema do @@ -2960,6 +3129,147 @@ defmodule AshPostgres.MigrationGenerator do end end + defp list_snapshot_tables(repo, opts, tenant?) do + base_folder = + if tenant? do + opts + |> snapshot_path(repo) + |> Path.join(repo_name(repo)) + |> Path.join("tenants") + else + opts + |> snapshot_path(repo) + |> Path.join(repo_name(repo)) + end + + if File.exists?(base_folder) do + base_folder + |> File.ls!() + |> Enum.filter(fn name -> + path = Path.join(base_folder, name) + File.dir?(path) and name != "extensions" + end) + |> Enum.map(fn name -> + if String.contains?(name, ".") do + [schema, table] = String.split(name, ".", parts: 2) + {table, schema} + else + {name, nil} + end + end) + else + [] + end + end + + defp drop_operations_for_orphan_tables(repo, current_table_keys, opts, tenant?) do + snapshot_table_keys = + list_snapshot_tables(repo, opts, tenant?) + |> MapSet.new() + + orphan_keys = MapSet.difference(snapshot_table_keys, current_table_keys) + added_keys = MapSet.difference(current_table_keys, snapshot_table_keys) + + added_keys_list = MapSet.to_list(added_keys) + + multitenancy = + if tenant? do + %{strategy: :context, attribute: nil, global: nil} + else + %{strategy: nil, attribute: nil, global: nil} + end + + {drop_ops, rename_map, loaded} = + Enum.reduce(orphan_keys, {[], %{}, []}, fn {table, schema} = old_key, + {ops, rename_map, snapshots} -> + minimal_snapshot = %{ + table: table, + schema: schema, + repo: repo, + multitenancy: multitenancy + } + + case get_existing_snapshot(minimal_snapshot, opts) do + nil -> + {ops, rename_map, snapshots} + + existing_snapshot -> + if Map.get(existing_snapshot, :drop_table_opted_out, false) do + {ops, rename_map, snapshots} + else + # Only consider new tables in the same schema as candidates + candidates = + Enum.filter(added_keys_list, fn {new_table, new_schema} -> + new_schema == schema && new_table != table + end) + + {ops, rename_map, snapshots} = + case candidates do + [{new_table, new_schema}] -> + if renaming_table_to?(old_key, {new_table, new_schema}, opts) do + new_rename_map = + Map.put(rename_map, {new_table, new_schema}, %{ + old_table: table, + old_schema: schema, + snapshot: existing_snapshot + }) + + {ops, new_rename_map, [existing_snapshot | snapshots]} + else + if drop_table_confirmed?(existing_snapshot, opts) do + drop_op = %Operation.DropTable{ + table: existing_snapshot.table, + schema: existing_snapshot.schema, + repo: existing_snapshot.repo, + multitenancy: existing_snapshot.multitenancy + } + + {[drop_op | ops], rename_map, [existing_snapshot | snapshots]} + else + record_drop_table_opt_out(existing_snapshot, opts) + {ops, rename_map, snapshots} + end + end + + _ -> + if drop_table_confirmed?(existing_snapshot, opts) do + drop_op = %Operation.DropTable{ + table: existing_snapshot.table, + schema: existing_snapshot.schema, + repo: existing_snapshot.repo, + multitenancy: existing_snapshot.multitenancy + } + + {[drop_op | ops], rename_map, [existing_snapshot | snapshots]} + else + record_drop_table_opt_out(existing_snapshot, opts) + {ops, rename_map, snapshots} + end + end + + {ops, rename_map, snapshots} + end + end + end) + + {Enum.reverse(drop_ops), Enum.reverse(loaded), rename_map} + end + + defp remove_orphan_snapshots(orphan_snapshots, opts) do + if opts.dry_run || opts.check || opts.snapshots_only || orphan_snapshots == [] do + :ok + else + Enum.each(orphan_snapshots, fn snapshot -> + folder = get_snapshot_folder(snapshot, opts) + path = get_snapshot_path(snapshot, folder) + + if File.exists?(path) do + File.rm_rf(path) + end + end) + end + end + defp resolve_renames(_table, adding, [], _opts), do: {adding, [], []} defp resolve_renames(_table, [], removing, _opts), do: {[], removing, []} @@ -3013,6 +3323,25 @@ defmodule AshPostgres.MigrationGenerator do end end + defp renaming_table_to?({old_table, schema}, {new_table, _new_schema}, opts) do + if opts.dev do + false + else + message = + if schema do + "Are you renaming #{schema}.#{old_table} to #{schema}.#{new_table}?" + else + "Are you renaming #{old_table} to #{new_table}?" + end + + if opts.no_shell? do + raise "Unimplemented: cannot determine: #{message} without shell input" + else + yes?(opts, message) + end + end + end + defp get_new_attribute(adding, opts, tries \\ 3) defp get_new_attribute(_adding, _opts, 0) do @@ -3726,6 +4055,7 @@ defmodule AshPostgres.MigrationGenerator do }) |> Map.update!(:multitenancy, &load_multitenancy/1) |> Map.put_new(:base_filter, nil) + |> Map.put_new(:drop_table_opted_out, false) end defp load_check_constraints(constraints) do diff --git a/lib/migration_generator/operation.ex b/lib/migration_generator/operation.ex index e206ebfe..64c3ca4c 100644 --- a/lib/migration_generator/operation.ex +++ b/lib/migration_generator/operation.ex @@ -152,6 +152,66 @@ defmodule AshPostgres.MigrationGenerator.Operation do defstruct [:table, :schema, :multitenancy, :old_multitenancy, :repo, :create_table_options] end + defmodule DropTable do + @moduledoc false + defstruct [:table, :schema, :multitenancy, :repo] + end + + defmodule RenameTable do + @moduledoc false + defstruct [:old_table, :new_table, :schema, :multitenancy, :repo, no_phase: true] + + import Helper, only: [as_atom: 1] + + def up(%{ + old_table: old_table, + new_table: new_table, + schema: schema, + multitenancy: multitenancy + }) do + {old_table_expr, new_table_expr} = + table_expressions(old_table, new_table, schema, multitenancy) + + "rename #{old_table_expr}, to: #{new_table_expr}" + end + + def down(%{ + old_table: old_table, + new_table: new_table, + schema: schema, + multitenancy: multitenancy + }) do + {old_table_expr, new_table_expr} = + table_expressions(old_table, new_table, schema, multitenancy) + + # Reverse the direction for the down migration + "rename #{new_table_expr}, to: #{old_table_expr}" + end + + defp table_expressions(old_table, new_table, schema, multitenancy) do + case multitenancy.strategy do + :context -> + { + "table(:#{as_atom(old_table)}, prefix: prefix())", + "table(:#{as_atom(new_table)}, prefix: prefix())" + } + + _ -> + prefix_opt = + if schema do + ~s[, prefix: "#{schema}"] + else + "" + end + + { + "table(:#{as_atom(old_table)}#{prefix_opt})", + "table(:#{as_atom(new_table)}#{prefix_opt})" + } + end + end + end + defmodule AddAttribute do @moduledoc false defstruct [:attribute, :table, :schema, :multitenancy, :old_multitenancy] diff --git a/lib/migration_generator/phase.ex b/lib/migration_generator/phase.ex index 336e5373..e4b03953 100644 --- a/lib/migration_generator/phase.ex +++ b/lib/migration_generator/phase.ex @@ -139,4 +139,30 @@ defmodule AshPostgres.MigrationGenerator.Phase do end end end + + defmodule Drop do + @moduledoc false + defstruct [:table, :schema, :multitenancy, :repo, commented?: false] + + import AshPostgres.MigrationGenerator.Operation.Helper, only: [as_atom: 1] + + def up(%{schema: schema, table: table, multitenancy: multitenancy}) do + if multitenancy.strategy == :context do + "drop table(:#{as_atom(table)}, prefix: prefix())" + else + opts = + if schema do + ", prefix: \"#{schema}\"" + else + "" + end + + "drop table(:#{as_atom(table)}#{opts})" + end + end + + def down(_) do + "# Rollback would require recreating the table manually" + end + end end diff --git a/lib/mix/tasks/ash_postgres.generate_migrations.ex b/lib/mix/tasks/ash_postgres.generate_migrations.ex index 4bcc69ef..54593fec 100644 --- a/lib/mix/tasks/ash_postgres.generate_migrations.ex +++ b/lib/mix/tasks/ash_postgres.generate_migrations.ex @@ -55,6 +55,14 @@ defmodule Mix.Tasks.AshPostgres.GenerateMigrations do For example, if you remove an attribute and add an attribute, it will ask you if you are renaming the column in question. If not, it will remove one column and add the other. + #### Dropping tables when resources are removed + + When you remove a resource from your domain, the generator compares current resources to existing snapshots. Tables that have a snapshot but no longer exist in the domain are treated as removed. Before generating a migration to drop each such table, the generator will ask whether or not you'd like to generate a migration to drop the table. If you answer no, the generator will not ask again for that table on future runs. If you answer yes, a migration is generated to drop the table and the corresponding snapshot directory is removed. Run `mix ash_postgres.generate_migrations --name remove_my_resource` (or similar) after deleting a resource to generate the drop migration. + + #### Renaming tables + + When you change a resource's table name, if the generator detects one removed table and one new table in the same schema, it will ask if you are intending to rename the table. If you answer yes, a single migration is generated using Ecto's `rename table(...), to: table(...)`, which renames the table in place and preserves data and foreign keys. If you answer no, it generates a drop of the old table and a create of the new one instead. + Additionally, it lowers things to the database where possible: #### Defaults diff --git a/priv/resource_snapshots/test_repo/posts/20260305045125.json.license b/priv/resource_snapshots/test_repo/posts/20260305045125.json.license new file mode 100644 index 00000000..c5bd9989 --- /dev/null +++ b/priv/resource_snapshots/test_repo/posts/20260305045125.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2019 ash_postgres contributors + +SPDX-License-Identifier: MIT diff --git a/priv/resource_snapshots/test_repo/profiles.profile/20240327211150.json b/priv/resource_snapshots/test_repo/profiles.profile/20240327211150.json index ebe6397a..ead8eeaf 100644 --- a/priv/resource_snapshots/test_repo/profiles.profile/20240327211150.json +++ b/priv/resource_snapshots/test_repo/profiles.profile/20240327211150.json @@ -6,9 +6,9 @@ "type": "uuid", "source": "id", "references": null, - "primary_key?": true, "allow_nil?": false, - "generated?": false + "generated?": false, + "primary_key?": true }, { "default": "nil", @@ -16,9 +16,9 @@ "type": "text", "source": "description", "references": null, - "primary_key?": false, "allow_nil?": true, - "generated?": false + "generated?": false, + "primary_key?": false }, { "default": "nil", @@ -28,40 +28,41 @@ "references": { "name": "profile_author_id_fkey", "table": "authors", + "schema": "public", + "primary_key?": true, + "destination_attribute": "id", "multitenancy": { "global": null, "attribute": null, "strategy": null }, - "primary_key?": true, - "destination_attribute": "id", - "schema": "public", - "deferrable": false, - "destination_attribute_default": null, - "destination_attribute_generated": null, "on_delete": null, "on_update": null, + "deferrable": false, + "match_type": null, "match_with": null, - "match_type": null + "destination_attribute_default": null, + "destination_attribute_generated": null }, - "primary_key?": false, "allow_nil?": true, - "generated?": false + "generated?": false, + "primary_key?": false } ], "table": "profile", "hash": "4D5319352707FBF8EEEC4221C202ABF4F77AFE3A931697795D125CBB87F8F78C", "repo": "Elixir.AshPostgres.TestRepo", + "identities": [], + "schema": "profiles", "multitenancy": { "global": null, "attribute": null, "strategy": null }, - "schema": "profiles", - "identities": [], - "has_create_action": true, + "check_constraints": [], "custom_indexes": [], - "custom_statements": [], "base_filter": null, - "check_constraints": [] + "custom_statements": [], + "has_create_action": true, + "drop_table_opted_out": true } \ No newline at end of file diff --git a/priv/resource_snapshots/test_repo/schematic_groups/20240821213522.json b/priv/resource_snapshots/test_repo/schematic_groups/20240821213522.json index 06016f77..a1082cfe 100644 --- a/priv/resource_snapshots/test_repo/schematic_groups/20240821213522.json +++ b/priv/resource_snapshots/test_repo/schematic_groups/20240821213522.json @@ -1,29 +1,30 @@ { "attributes": [ { - "allow_nil?": false, "default": "fragment(\"gen_random_uuid()\")", - "generated?": false, - "primary_key?": true, - "references": null, "size": null, + "type": "uuid", "source": "id", - "type": "uuid" + "references": null, + "primary_key?": true, + "allow_nil?": false, + "generated?": false } ], - "base_filter": null, - "check_constraints": [], - "custom_indexes": [], - "custom_statements": [], - "has_create_action": true, + "table": "schematic_groups", "hash": "F24673A4219DEC6873571CCF68B8F0CC34B5843DAA2D7B71A16EFE576C385C1C", - "identities": [], + "repo": "Elixir.AshPostgres.TestRepo", "multitenancy": { - "attribute": null, "global": null, + "attribute": null, "strategy": null }, - "repo": "Elixir.AshPostgres.TestRepo", "schema": null, - "table": "schematic_groups" + "identities": [], + "has_create_action": true, + "custom_indexes": [], + "custom_statements": [], + "base_filter": null, + "check_constraints": [], + "drop_table_opted_out": true } \ No newline at end of file diff --git a/priv/resource_snapshots/test_repo/subquery_access/20240130133933.json b/priv/resource_snapshots/test_repo/subquery_access/20240130133933.json index 63dc281c..ac92648c 100644 --- a/priv/resource_snapshots/test_repo/subquery_access/20240130133933.json +++ b/priv/resource_snapshots/test_repo/subquery_access/20240130133933.json @@ -6,9 +6,9 @@ "type": "uuid", "source": "id", "references": null, + "primary_key?": true, "allow_nil?": false, - "generated?": false, - "primary_key?": true + "generated?": false }, { "default": "nil", @@ -18,8 +18,6 @@ "references": { "name": "subquery_access_parent_id_fkey", "table": "subquery_parent", - "schema": "public", - "on_delete": null, "multitenancy": { "global": null, "attribute": null, @@ -27,16 +25,18 @@ }, "primary_key?": true, "destination_attribute": "id", + "schema": "public", "deferrable": false, - "match_type": null, - "match_with": null, - "on_update": null, "destination_attribute_default": null, - "destination_attribute_generated": null + "destination_attribute_generated": null, + "on_delete": null, + "on_update": null, + "match_with": null, + "match_type": null }, + "primary_key?": false, "allow_nil?": true, - "generated?": false, - "primary_key?": false + "generated?": false }, { "default": "nil", @@ -44,24 +44,25 @@ "type": "text", "source": "email", "references": null, + "primary_key?": false, "allow_nil?": true, - "generated?": false, - "primary_key?": false + "generated?": false } ], "table": "subquery_access", "hash": "B7DFB67DE808049D03358E207644E9865B03C3C7707F6FE1FA6F581605046CFF", "repo": "Elixir.AshPostgres.TestRepo", - "identities": [], - "schema": null, - "check_constraints": [], - "custom_indexes": [], - "base_filter": null, "multitenancy": { "global": null, "attribute": null, "strategy": null }, + "schema": null, + "identities": [], + "has_create_action": true, + "custom_indexes": [], "custom_statements": [], - "has_create_action": true + "base_filter": null, + "check_constraints": [], + "drop_table_opted_out": true } \ No newline at end of file diff --git a/priv/resource_snapshots/test_repo/subquery_child/20240130133933.json b/priv/resource_snapshots/test_repo/subquery_child/20240130133933.json index b7ba6b33..4f902461 100644 --- a/priv/resource_snapshots/test_repo/subquery_child/20240130133933.json +++ b/priv/resource_snapshots/test_repo/subquery_child/20240130133933.json @@ -6,9 +6,9 @@ "type": "uuid", "source": "id", "references": null, + "primary_key?": true, "allow_nil?": false, - "generated?": false, - "primary_key?": true + "generated?": false }, { "default": "nil", @@ -16,24 +16,25 @@ "type": "text", "source": "state", "references": null, + "primary_key?": false, "allow_nil?": true, - "generated?": false, - "primary_key?": false + "generated?": false } ], "table": "subquery_child", "hash": "14EB56AF533B090146C1A6FA930E83747D6D2765EE518576A4D6329B5CFA2B9E", "repo": "Elixir.AshPostgres.TestRepo", - "identities": [], - "schema": null, - "check_constraints": [], - "custom_indexes": [], - "base_filter": null, "multitenancy": { "global": null, "attribute": null, "strategy": null }, + "schema": null, + "identities": [], + "has_create_action": true, + "custom_indexes": [], "custom_statements": [], - "has_create_action": true + "base_filter": null, + "check_constraints": [], + "drop_table_opted_out": true } \ No newline at end of file diff --git a/priv/resource_snapshots/test_repo/subquery_parent/20240130133933.json b/priv/resource_snapshots/test_repo/subquery_parent/20240130133933.json index 9ac0d750..ed9185ab 100644 --- a/priv/resource_snapshots/test_repo/subquery_parent/20240130133933.json +++ b/priv/resource_snapshots/test_repo/subquery_parent/20240130133933.json @@ -6,9 +6,9 @@ "type": "uuid", "source": "id", "references": null, + "primary_key?": true, "allow_nil?": false, - "generated?": false, - "primary_key?": true + "generated?": false }, { "default": "nil", @@ -16,9 +16,9 @@ "type": "text", "source": "owner_email", "references": null, + "primary_key?": false, "allow_nil?": true, - "generated?": false, - "primary_key?": false + "generated?": false }, { "default": "nil", @@ -26,9 +26,9 @@ "type": "text", "source": "other_owner_email", "references": null, + "primary_key?": false, "allow_nil?": true, - "generated?": false, - "primary_key?": false + "generated?": false }, { "default": "nil", @@ -36,24 +36,25 @@ "type": "boolean", "source": "visible", "references": null, + "primary_key?": false, "allow_nil?": true, - "generated?": false, - "primary_key?": false + "generated?": false } ], "table": "subquery_parent", "hash": "4F7C9D2564C1C54AB4E45BE8A5209B764EDC05F060C9B9D3E8EBE8A134FAF797", "repo": "Elixir.AshPostgres.TestRepo", - "identities": [], - "schema": null, - "check_constraints": [], - "custom_indexes": [], - "base_filter": null, "multitenancy": { "global": null, "attribute": null, "strategy": null }, + "schema": null, + "identities": [], + "has_create_action": true, + "custom_indexes": [], "custom_statements": [], - "has_create_action": true + "base_filter": null, + "check_constraints": [], + "drop_table_opted_out": true } \ No newline at end of file diff --git a/priv/resource_snapshots/test_repo/subquery_through/20240130133933.json b/priv/resource_snapshots/test_repo/subquery_through/20240130133933.json index c8c4a2bf..b8ae6add 100644 --- a/priv/resource_snapshots/test_repo/subquery_through/20240130133933.json +++ b/priv/resource_snapshots/test_repo/subquery_through/20240130133933.json @@ -8,8 +8,6 @@ "references": { "name": "subquery_through_parent_id_fkey", "table": "subquery_parent", - "schema": "public", - "on_delete": null, "multitenancy": { "global": null, "attribute": null, @@ -17,16 +15,18 @@ }, "primary_key?": true, "destination_attribute": "id", + "schema": "public", "deferrable": false, - "match_type": null, - "match_with": null, - "on_update": null, "destination_attribute_default": null, - "destination_attribute_generated": null + "destination_attribute_generated": null, + "on_delete": null, + "on_update": null, + "match_with": null, + "match_type": null }, + "primary_key?": true, "allow_nil?": false, - "generated?": false, - "primary_key?": true + "generated?": false }, { "default": "nil", @@ -34,24 +34,25 @@ "type": "uuid", "source": "child_id", "references": null, + "primary_key?": true, "allow_nil?": false, - "generated?": false, - "primary_key?": true + "generated?": false } ], "table": "subquery_through", "hash": "D6357C195FC4B8B9227D3BC0E5F31FD0038ACA5D5951DA14104C3F157F520177", "repo": "Elixir.AshPostgres.TestRepo", - "identities": [], - "schema": null, - "check_constraints": [], - "custom_indexes": [], - "base_filter": null, "multitenancy": { "global": null, "attribute": null, "strategy": null }, + "schema": null, + "identities": [], + "has_create_action": true, + "custom_indexes": [], "custom_statements": [], - "has_create_action": true + "base_filter": null, + "check_constraints": [], + "drop_table_opted_out": true } \ No newline at end of file diff --git a/priv/resource_snapshots/test_repo/temp.temp_entities/20240327211917.json b/priv/resource_snapshots/test_repo/temp.temp_entities/20240327211917.json index 62800014..ad8a3e81 100644 --- a/priv/resource_snapshots/test_repo/temp.temp_entities/20240327211917.json +++ b/priv/resource_snapshots/test_repo/temp.temp_entities/20240327211917.json @@ -6,9 +6,9 @@ "type": "uuid", "source": "id", "references": null, - "primary_key?": true, "allow_nil?": false, - "generated?": false + "generated?": false, + "primary_key?": true }, { "default": "nil", @@ -16,9 +16,9 @@ "type": "text", "source": "full_name", "references": null, - "primary_key?": false, "allow_nil?": false, - "generated?": false + "generated?": false, + "primary_key?": false }, { "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", @@ -26,9 +26,9 @@ "type": "utc_datetime_usec", "source": "inserted_at", "references": null, - "primary_key?": false, "allow_nil?": false, - "generated?": false + "generated?": false, + "primary_key?": false }, { "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", @@ -36,24 +36,25 @@ "type": "utc_datetime_usec", "source": "updated_at", "references": null, - "primary_key?": false, "allow_nil?": false, - "generated?": false + "generated?": false, + "primary_key?": false } ], "table": "temp_entities", "hash": "96A9F379FDC5364E80B0E424BFF3F43F58733AB25F6CDF5F921E36D4261E6AE1", "repo": "Elixir.AshPostgres.TestRepo", + "identities": [], + "schema": "temp", "multitenancy": { "global": null, "attribute": null, "strategy": null }, - "schema": "temp", - "identities": [], - "has_create_action": true, + "check_constraints": [], "custom_indexes": [], - "custom_statements": [], "base_filter": null, - "check_constraints": [] + "custom_statements": [], + "has_create_action": true, + "drop_table_opted_out": true } \ No newline at end of file diff --git a/priv/test_repo/migrations/20260305045124_add_keyword_map.exs b/priv/test_repo/migrations/20260305045124_add_keyword_map.exs index e9442721..29cb30e2 100644 --- a/priv/test_repo/migrations/20260305045124_add_keyword_map.exs +++ b/priv/test_repo/migrations/20260305045124_add_keyword_map.exs @@ -1,3 +1,7 @@ +# SPDX-FileCopyrightText: 2019 ash_postgres contributors +# +# SPDX-License-Identifier: MIT + defmodule AshPostgres.TestRepo.Migrations.AddKeywordMap do @moduledoc """ Updates resources based on their most recent snapshots. diff --git a/test/migration_generator_test.exs b/test/migration_generator_test.exs index 29b66731..bf58e711 100644 --- a/test/migration_generator_test.exs +++ b/test/migration_generator_test.exs @@ -3845,4 +3845,478 @@ defmodule AshPostgres.MigrationGeneratorTest do ~S[create table(:posts, primary_key: false, prefix: prefix(), options: "PARTITION BY RANGE (id)") do] end end + + describe "dropping tables when resources are removed" do + test "generates drop table migration when a resource is removed from the domain", %{ + snapshot_path: snapshot_path, + migration_path: migration_path + } do + defresource PostForDrop, "posts_for_drop" do + attributes do + uuid_primary_key(:id) + attribute(:title, :string, public?: true) + end + + actions do + defaults([:create, :read, :update, :destroy]) + end + end + + defresource MessageForDrop, "messages_for_drop" do + attributes do + uuid_primary_key(:id) + attribute(:body, :string, public?: true) + end + + actions do + defaults([:create, :read, :update, :destroy]) + end + end + + defdomain([PostForDrop, MessageForDrop]) + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true, + name: "add_posts_and_messages" + ) + + migration_files = + Path.wildcard("#{migration_path}/**/*.exs") + |> Enum.reject(&String.contains?(&1, "extensions")) + |> Enum.sort() + + assert migration_files != [] + + first_migration = File.read!(List.first(migration_files)) + assert first_migration =~ "create table(:posts_for_drop" + assert first_migration =~ "create table(:messages_for_drop" + + assert File.exists?(Path.join(snapshot_path, "test_repo/posts_for_drop")) + assert File.exists?(Path.join(snapshot_path, "test_repo/messages_for_drop")) + + defdomain([PostForDrop]) + + send(self(), {:mix_shell_input, :yes?, true}) + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true, + name: "remove_messages" + ) + + migration_files_after = + Path.wildcard("#{migration_path}/**/*.exs") + |> Enum.reject(&String.contains?(&1, "extensions")) + |> Enum.sort() + + assert length(migration_files_after) >= 2 + + latest_migration = + migration_files_after + |> List.last() + |> File.read!() + + assert latest_migration =~ "drop table(:messages_for_drop)", + "Expected migration to contain 'drop table(:messages_for_drop)', got:\n#{latest_migration}" + + refute File.exists?(Path.join(snapshot_path, "test_repo/messages_for_drop")), + "Orphan snapshot dir should be removed after generating drop migration" + + assert File.exists?(Path.join(snapshot_path, "test_repo/posts_for_drop")) + end + + test "second generate after drop reports no changes", %{ + snapshot_path: snapshot_path, + migration_path: migration_path + } do + defresource SoloPost, "solo_posts" do + attributes do + uuid_primary_key(:id) + end + + actions do + defaults([:create, :read, :update, :destroy]) + end + end + + defdomain([SoloPost]) + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true, + name: "add_solo" + ) + + count_before = + Path.wildcard("#{migration_path}/**/*.exs") + |> Enum.reject(&String.contains?(&1, "extensions")) + |> length() + + defresource OtherResource, "other_table" do + attributes do + uuid_primary_key(:id) + end + + actions do + defaults([:create, :read, :update, :destroy]) + end + end + + defdomain([OtherResource]) + + send(self(), {:mix_shell_input, :yes?, false}) + send(self(), {:mix_shell_input, :yes?, true}) + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true, + name: "drop_solo_add_other" + ) + + count_after_first_drop = + Path.wildcard("#{migration_path}/**/*.exs") + |> Enum.reject(&String.contains?(&1, "extensions")) + |> length() + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true, + name: "no_op" + ) + + count_after_second = + Path.wildcard("#{migration_path}/**/*.exs") + |> Enum.reject(&String.contains?(&1, "extensions")) + |> length() + + assert count_after_second == count_after_first_drop, + "Expected no new migration files (count #{count_after_first_drop}), got #{count_after_second}" + end + + test "when user opts out of drop, snapshot is updated and we do not ask again", %{ + snapshot_path: snapshot_path, + migration_path: migration_path + } do + defresource OptOutPost, "opt_out_posts" do + attributes do + uuid_primary_key(:id) + end + + actions do + defaults([:create, :read, :update, :destroy]) + end + end + + defresource OptOutMessage, "opt_out_messages" do + attributes do + uuid_primary_key(:id) + end + + actions do + defaults([:create, :read, :update, :destroy]) + end + end + + defdomain([OptOutPost, OptOutMessage]) + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true, + name: "add_opt_out_tables" + ) + + assert File.exists?(Path.join(snapshot_path, "test_repo/opt_out_messages")) + + defdomain([OptOutPost]) + + send(self(), {:mix_shell_input, :yes?, false}) + + count_before = + Path.wildcard("#{migration_path}/**/*.exs") + |> Enum.reject(&String.contains?(&1, "extensions")) + |> length() + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true, + name: "would_remove_opt_out_messages" + ) + + count_after_opt_out = + Path.wildcard("#{migration_path}/**/*.exs") + |> Enum.reject(&String.contains?(&1, "extensions")) + |> length() + + assert count_after_opt_out == count_before, + "Expected no new migration when opting out of drop, got #{count_after_opt_out - count_before} new file(s)" + + assert File.exists?(Path.join(snapshot_path, "test_repo/opt_out_messages")), + "Opted-out table snapshot dir should remain" + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true, + name: "no_op_after_opt_out" + ) + + count_after_second = + Path.wildcard("#{migration_path}/**/*.exs") + |> Enum.reject(&String.contains?(&1, "extensions")) + |> length() + + assert count_after_second == count_after_opt_out, + "Expected no new migration on second run after opt-out (count #{count_after_opt_out}), got #{count_after_second}" + + assert File.exists?(Path.join(snapshot_path, "test_repo/opt_out_messages")) + end + + test "drop table migration uses correct prefix when resource has schema", %{ + snapshot_path: snapshot_path, + migration_path: migration_path + } do + defresource SchemaPost, "schema_posts" do + postgres do + table "schema_posts" + schema "my_schema" + repo(AshPostgres.TestRepo) + end + + attributes do + uuid_primary_key(:id) + end + + actions do + defaults([:create, :read, :update, :destroy]) + end + end + + defdomain([SchemaPost]) + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true, + name: "add_schema_post" + ) + + defresource DummyForSchema, "dummy_table" do + attributes do + uuid_primary_key(:id) + end + + actions do + defaults([:create, :read, :update, :destroy]) + end + end + + defdomain([DummyForSchema]) + + send(self(), {:mix_shell_input, :yes?, true}) + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true, + name: "remove_schema_post" + ) + + migration_files = + Path.wildcard("#{migration_path}/**/*.exs") + |> Enum.reject(&String.contains?(&1, "extensions")) + |> Enum.sort() + + latest = File.read!(List.last(migration_files)) + + assert latest =~ "drop table(:schema_posts" + assert latest =~ ~S(prefix: "my_schema") + end + end + + describe "renaming tables when resources change" do + test "generates a rename table migration when a resource table is renamed", %{ + snapshot_path: snapshot_path, + migration_path: migration_path + } do + defresource MessageRename, "messages_rename" do + attributes do + uuid_primary_key(:id) + attribute(:body, :string, public?: true) + end + + actions do + defaults([:create, :read, :update, :destroy]) + end + end + + defdomain([MessageRename]) + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true, + name: "add_messages_rename" + ) + + migration_files_before = + Path.wildcard("#{migration_path}/**/*.exs") + |> Enum.reject(&String.contains?(&1, "extensions")) + |> Enum.sort() + + assert migration_files_before != [] + + defresource MessageRename, "messages_rename_new" do + postgres do + table "messages_rename_new" + repo(AshPostgres.TestRepo) + end + + attributes do + uuid_primary_key(:id) + attribute(:body, :string, public?: true) + end + + actions do + defaults([:create, :read, :update, :destroy]) + end + end + + defdomain([MessageRename]) + + send(self(), {:mix_shell_input, :yes?, true}) + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true, + name: "rename_messages_table" + ) + + migration_files_after = + Path.wildcard("#{migration_path}/**/*.exs") + |> Enum.reject(&String.contains?(&1, "extensions")) + |> Enum.sort() + + assert length(migration_files_after) >= length(migration_files_before) + 1 + + latest_migration = + migration_files_after + |> List.last() + |> File.read!() + + assert latest_migration =~ + "rename table(:messages_rename), to: table(:messages_rename_new)" + + refute latest_migration =~ "drop table(:messages_rename)" + refute latest_migration =~ "create table(:messages_rename_new" + end + + test "rename table migration respects schema prefix", %{ + snapshot_path: snapshot_path, + migration_path: migration_path + } do + defresource SchemaMessageRename, "schema_messages_rename" do + postgres do + table "schema_messages_rename" + schema "my_schema_rename" + repo(AshPostgres.TestRepo) + end + + attributes do + uuid_primary_key(:id) + attribute(:body, :string, public?: true) + end + + actions do + defaults([:create, :read, :update, :destroy]) + end + end + + defdomain([SchemaMessageRename]) + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true, + name: "add_schema_messages_rename" + ) + + defresource SchemaMessageRename, "schema_messages_rename_new" do + postgres do + table "schema_messages_rename_new" + schema "my_schema_rename" + repo(AshPostgres.TestRepo) + end + + attributes do + uuid_primary_key(:id) + attribute(:body, :string, public?: true) + end + + actions do + defaults([:create, :read, :update, :destroy]) + end + end + + defdomain([SchemaMessageRename]) + + send(self(), {:mix_shell_input, :yes?, true}) + + AshPostgres.MigrationGenerator.generate(Domain, + snapshot_path: snapshot_path, + migration_path: migration_path, + quiet: true, + format: false, + auto_name: true, + name: "rename_schema_messages_table" + ) + + migration_files = + Path.wildcard("#{migration_path}/**/*.exs") + |> Enum.reject(&String.contains?(&1, "extensions")) + |> Enum.sort() + + latest = + migration_files + |> List.last() + |> File.read!() + + assert latest =~ + ~S[rename table(:schema_messages_rename, prefix: "my_schema_rename"), to: table(:schema_messages_rename_new, prefix: "my_schema_rename")] + end + end end