defmodule Mix.Tasks.Outbox.Gen.Migration do @shortdoc "Generates the outbox_events migration in the host's migrations directory" @moduledoc """ Generates the `outbox_events` table migration in the host's `priv/repo/migrations/` directory. ## Examples mix outbox.gen.migration mix outbox.gen.migration --prefix tenant ## Options * `--prefix` - Postgres schema prefix to use. Defaults to `"public"`. * `--repo` - the Repo to introspect for the migrations path. Defaults to the first repo in `:ecto_repos` config. """ use Mix.Task @switches [prefix: :string, repo: :string] @impl Mix.Task def run(args) do {opts, _rest, _invalid} = OptionParser.parse(args, switches: @switches) prefix = Keyword.get(opts, :prefix, "public") migrations_path = resolve_migrations_path(opts) File.mkdir_p!(migrations_path) path = write_migration(migrations_path, prefix, &default_timestamp/0) Mix.shell().info("* creating #{Path.relative_to_cwd(path)}") path end @doc """ Renders the migration body. Exposed so the generator's test can verify the canonical template without writing to disk. """ @spec render(keyword()) :: String.t() def render(opts) do module_name = Keyword.fetch!(opts, :module_name) prefix = Keyword.get(opts, :prefix, "public") table_opts = if prefix == "public", do: "primary_key: false", else: "primary_key: false, prefix: \"#{prefix}\"" index_prefix = if prefix == "public", do: "", else: "prefix: \"#{prefix}\", " """ defmodule #{module_name} do use Ecto.Migration def change do execute("CREATE EXTENSION IF NOT EXISTS \\"uuid-ossp\\"", "") create table(:outbox_events, #{table_opts}) do add :id, :uuid, primary_key: true, default: fragment("uuid_generate_v4()") add :name, :text, null: false add :payload, :jsonb, null: false add :inserted_at, :utc_datetime_usec, null: false, default: fragment("NOW()") add :dispatched_at, :utc_datetime_usec end create index(:outbox_events, [:id], #{index_prefix}where: "dispatched_at IS NULL", name: :outbox_events_undispatched_idx ) end end """ end @doc """ Writes a migration to the given directory. The `timestamp_fun` allows tests to inject a deterministic timestamp. """ @spec write_migration(Path.t(), String.t(), (-> String.t())) :: Path.t() def write_migration(dir, prefix, timestamp_fun) do timestamp = timestamp_fun.() filename = "#{timestamp}_create_outbox_events_table.exs" path = Path.join(dir, filename) module_name = "Outbox.Repo.Migrations.CreateOutboxEventsTable" body = render(prefix: prefix, module_name: module_name) File.write!(path, body) path end defp default_timestamp do {{y, m, d}, {h, mm, s}} = :calendar.universal_time() :io_lib.format("~4..0B~2..0B~2..0B~2..0B~2..0B~2..0B", [y, m, d, h, mm, s]) |> IO.iodata_to_binary() end defp resolve_migrations_path(opts) do case Keyword.get(opts, :repo) do nil -> # Default to first :ecto_repos entry; fall back to a sensible default case Application.get_env(Mix.Project.config()[:app], :ecto_repos, []) do [repo | _] -> Path.join([File.cwd!(), "priv", priv_dir_for_repo(repo), "migrations"]) [] -> Path.join([File.cwd!(), "priv", "repo", "migrations"]) end repo_str -> repo = Module.concat([repo_str]) Path.join([File.cwd!(), "priv", priv_dir_for_repo(repo), "migrations"]) end end defp priv_dir_for_repo(repo) do repo |> Module.split() |> List.last() |> Macro.underscore() end end