# credo:disable-for-next-line Credo.Check.Refactor.ModuleDependencies defmodule Mix.Tasks.Enumex.Static.Gen.Migration do @shortdoc "Generates a migration for updating a database enum" args = [ %{help: "Specifies an enum module", required: true, value_name: "MODULE"}, %{help: "Specifies an enum name", required: true, value_name: "ENUM_NAME"}, %{help: "Specifies an ecto repo", required: true, value_name: "ECTO_REPO"} ] flags = [ %{help: "Specifies an enum adapter", long: "--adapter", short: "-a", value_name: "ADAPTER"}, %{ help: "Specifies a query prefix", long: "--query-prefix", short: "-q", value_name: "QUERY_PREFIX" } ] task = Enumex.Generator.Task.new("enumex.static.gen.migration", args, flags) if Mix.env() == :test do try do :meck.new(:io, [:unstick, :passthrough]) rescue _exception -> :ok end :meck.expect(:io, :columns, fn -> {:ok, 80} end) end help = Enumex.Generator.Task.help(task, @shortdoc, true) @moduledoc Enumex.Generator.Task.moduledoc(help) use Mix.Task alias Ecto.Repo alias Enumex.Generator.Task alias Enumex.Static.Adapter alias Enumex.Static.Components alias Enumex.Value alias Mix.Generator alias Mix.Project alias Mix.Tasks.Ecto.Gen.Migration alias Mix.Tasks.Enumex.Static.Gen.Migration.New require Generator @help help @opts [ aliases: Task.aliases(~w[adapter query_prefix]a), return_separator: true, strict: Task.options(adapter: :string, query_prefix: :string) ] @task_name task.name @version Task.version(task) @impl Mix.Task def run(args) do Task.static_migration_run(@task_name, args, @help, @version, @opts, &run(&1, &2, &3, &4, args)) end @spec run( [Value.enum_module() | Value.enum_name()], [query_prefix: Adapter.query_prefix()], Adapter.t(), [], OptionParser.argv() ) :: :ok defp run([_module], _opts, _adapter, [], _all_args) do Task.missing_args(@task_name, ~w[ENUM_NAME ECTO_REPO ECTO_GEN_MIGRATION_ARGS]) end defp run([_module], _opts, _adapter, _ecto_args, _all_args) do Task.missing_args(@task_name, ~w[ENUM_NAME ECTO_REPO]) end defp run([_module, _name], _opts, _adapter, [], _all_args) do Task.missing_args(@task_name, ~w[ECTO_REPO ECTO_GEN_MIGRATION_ARGS]) end defp run([_module, _name, _repo], _opts, _adapter, [], _all_args) do Task.missing_args(@task_name, ~w[ECTO_GEN_MIGRATION_ARGS]) end @spec run( [Value.enum_module() | Value.enum_name()], [query_prefix: Adapter.query_prefix()], Adapter.t(), OptionParser.argv(), OptionParser.argv() ) :: :ok defp run([_module, _name], _opts, _adapter, _ecto_args, _all_args) do Task.missing_args(@task_name, ~w[ECTO_REPO]) end @spec run( [Value.enum_module() | Value.enum_name() | String.t()], [query_prefix: Adapter.query_prefix()], Adapter.t(), OptionParser.argv(), OptionParser.argv() ) :: :ok | any() defp run([module, name, ecto_repo], opts, adapter, ecto_args, all_args) do {:ok, _aps} = Application.ensure_all_started(Project.config()[:app]) # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom repo = Module.concat([ecto_repo]) cond do Code.ensure_loaded?(repo) == false -> Task.print_errors(@task_name, ["repo module #{inspect(repo)} could not be loaded"]) {:behaviour, [Repo]} not in repo.__info__(:attributes) -> Task.behaviour_not_implemented(@task_name, repo, Repo) GenServer.whereis(repo) == nil -> Task.print_errors(@task_name, [ "the #{inspect(repo)} has not been started, " <> "ensure it's added to your application's supervisor tree" ]) {:behaviour, [Components.List]} not in module.__info__(:attributes) -> Task.behaviour_not_implemented(@task_name, module, Components.List) true -> run(module, name, repo, adapter, opts, ecto_args, all_args) end end defp run([_enum_module, _enum_name, _repo | args], _enum_opts, _adapter, ecto_args, _all_args) do Task.static_migration_invalid_args(@task_name, args, ecto_args) end @spec run( Value.enum_module(), Value.enum_name(), Repo.t(), Adapter.t(), [query_prefix: Adapter.query_prefix()], OptionParser.argv(), OptionParser.argv() ) :: any() defp run(module, name, repo, adapter, opts, ecto_args, all_args) do case adapter.get_values_with_index(repo, name, log: false, query_prefix: opts[:query_prefix]) do {:error, %{postgres: %{pg_code: "42704"}}} -> all_args |> List.delete_at(2) |> New.run() {:ok, %{rows: values}} -> values # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom |> Map.new(fn [id, index] -> {index, String.to_atom(id)} end) |> gen_migration(module.list(name), ecto_args, adapter: adapter, module: module, name: name, prefix: opts[:query_prefix] ) end end @spec gen_migration(%{Value.index() => Value.id()}, [Value.t()], OptionParser.argv(), adapter: Adapter.t(), args: String.t(), module: Value.enum_module(), name: Value.enum_name() ) :: any() defp gen_migration(db_values, values, ecto_args, assigns) do db_values |> gen_changes(Map.new(values, &{&1.index, &1.id}), assigns) |> String.trim_trailing() |> then(&changes_template([{:changes, &1} | assigns])) |> String.trim_trailing() # credo:disable-for-next-line Credo.Check.Refactor.Apply |> then(&Migration.run(["--change", &1 | ecto_args])) end @spec gen_changes( %{Value.index() => Value.id()}, %{Value.index() => Value.id()}, adapter: Adapter.t(), args: String.t(), module: Value.enum_module(), name: Value.enum_name() ) :: String.t() defp gen_changes(db_values, values, assigns) when is_map(db_values) do db_values |> Enum.reduce({values, [], [], []}, &collect_changes/2) |> then(fn {adds, drops, renames, remaining} -> {Map.to_list(adds), drops, renames, remaining} end) |> gen_changes(db_values, assigns) end @spec gen_changes( {[{Value.index(), Value.id()}], [{Value.index(), Value.id()}], [{Value.index(), Value.id(), Value.id()}], [Value.t()]}, %{Value.index() => Value.id()}, adapter: Adapter.t(), args: String.t(), module: Value.enum_module(), name: Value.enum_name() ) :: String.t() defp gen_changes({adds, drops, renames, remaining}, db_values, assigns) do remaining |> List.last(List.last(renames)) |> then(&{default_value_template(value: to_value(&1, assigns)), db_values}) |> do_gen_changes(renames, :renames, assigns) |> do_gen_changes(adds, :adds, assigns) |> do_gen_changes(drops, :drops, assigns) |> then(fn {changes, _values} -> changes end) end @spec do_gen_changes( acc, [{Value.index(), Value.id()} | {Value.index(), Value.id(), Value.id()}], :adds | :drops | :renames, adapter: Adapter.t(), args: String.t(), module: Value.enum_module(), name: Value.enum_name() ) :: acc when acc: {String.t(), %{Value.index() => Value.id()}} defp do_gen_changes(acc, data, :adds, assigns) do Enum.reduce(data, acc, fn {index, id}, {changes, values} -> change = add_value_template([{:value, to_value({index, id}, assigns)} | assigns]) {changes <> change, Map.put(values, index, id)} end) end defp do_gen_changes(acc, data, :drops, assigns) do Enum.reduce(data, acc, fn {index, id}, {changes, values} -> change = drop_value_template([{:value, to_value({index, id}, assigns)} | assigns]) {changes <> change, Map.delete(values, index)} end) end defp do_gen_changes(acc, data, :renames, assigns) do Enum.reduce(data, acc, fn {index, old_id, new_id}, {changes, values} -> [ {:new_value, to_value({index, new_id}, assigns)}, {:old_value, to_value({index, old_id}, assigns)} | assigns ] |> rename_value_template() |> then(&{changes <> &1, Map.replace(values, index, new_id)}) end) end @spec collect_changes({Value.index(), Value.id()}, acc) :: acc when acc: {%{Value.index() => Value.id()}, [{Value.index(), Value.id()}], [{Value.index(), Value.id(), Value.id()}], [{Value.index(), Value.id()}]} defp collect_changes({index, id}, {values, drops, renames, remaining}) when not is_map_key(values, index) do {values, [{index, id} | drops], renames, remaining} end defp collect_changes({index, id}, {values, drops, renames, remaining}) when :erlang.map_get(index, values) != id do {Map.delete(values, index), drops, [{index, id, values[index]} | renames], remaining} end defp collect_changes({index, id}, {values, drops, renames, remaining}) do {Map.delete(values, index), drops, renames, [{index, id} | remaining]} end @spec to_value({Value.index(), Value.id()}, adapter: Adapter.t(), args: String.t(), module: Value.enum_module(), name: Value.enum_name() ) :: Value.t() defp to_value({index, id}, assigns), do: Value.new(assigns[:module], assigns[:name], id, index) @spec changes_template( adapter: Adapter.t(), args: String.t(), changes: String.t(), module: Value.enum_module(), name: Value.enum_name() ) :: String.t() Generator.embed_template(:changes, """ alias Enumex.Static.Migration alias Enumex.Value alias <%= inspect(@module) %> adapter = <%= inspect(@adapter) %> <%= @changes %> """) @spec default_value_template(value: Value.t()) :: String.t() Generator.embed_template(:default_value, """ # Note: a first valid value has been assigned as a default one # in order to generate a fully reversible migration # and you may be interested in changing it default_value = <%= Task.gen_value_from_template(value: @value) %> """) @spec changes_template( adapter: Adapter.t(), args: String.t(), module: Value.enum_module(), name: Value.enum_name(), value: Value.t() ) :: String.t() Generator.embed_template(:add_value, """ # Info: adding <%= @value.id %> with index <%= @value.index %> new_value = <%= Task.gen_value_from_template(value: @value) %> Migration.add_value(adapter, new_value, default_value<%= @prefix && ", query_prefix: " <> inspect(@prefix) %>) """) @spec drop_value_template( adapter: Adapter.t(), args: String.t(), module: Value.enum_module(), name: Value.enum_name(), value: Value.t() ) :: String.t() Generator.embed_template(:drop_value, """ # Info: removing <%= @value.id %> with index <%= @value.index %> old_value = <%= Task.gen_value_from_template(value: @value) %> Migration.drop_value(adapter, old_value, default_value<%= @prefix && ", query_prefix: " <> inspect(@prefix) %>) """) @spec rename_value_template( adapter: Adapter.t(), args: String.t(), module: Value.enum_module(), name: Value.enum_name(), new_value: Value.t(), old_value: Value.t() ) :: String.t() Generator.embed_template(:rename_value, """ # Info: renaming <%= @old_value.id %> with index <%= @old_value.index %> to <%= @new_value.id %> old_value = <%= Task.gen_value_from_template(value: @old_value) %> new_value = <%= Task.gen_value_from_template(value: @new_value) %> Migration.rename_value(adapter, old_value, new_value<%= @prefix && ", query_prefix: " <> inspect(@prefix) %>) """) end