defmodule Mix.Tenant do @moduledoc """ Useful functions for any tenant mix task. Here is the list of tasks we have for now: - [`mix tenant.gen.migration`](./Mix.Tasks.Tenant.Gen.Migration.html) - generates a tenant migration for the repo - [`mix tenant.migrate`](./Mix.Tasks.Tenant.Migrate.html) - runs the repository tenant migrations - [`mix tenant.migrations`](./Mix.Tasks.Tenant.Migrations.html) - displays the repository migration status - [`mix tenant.rollback`](./Mix.Tasks.Tenant.Rollback.html) - rolls back the repository tenant migrations """ alias Ecto.Migrator alias Logger.App alias Mix.Ecto alias Mix.Project @aliases [ r: :repo, n: :step ] @switches [ all: :boolean, step: :integer, to: :integer, start: :boolean, quiet: :boolean, prefix: :string, pool_size: :integer, log_sql: :boolean, strict_version_order: :boolean, repo: [:keep, :string], no_compile: :boolean, no_deps_check: :boolean ] @doc """ Ensures the migrations path exists for the given `repo`. Returns the path for the `repo` tenant migrations folder if succeeds or `Mix.raise`'s if it fails. """ def ensure_tenant_migrations_path(repo) do path = Path.join(source_repo_priv(repo), Tenant.config().migrations_path) if not Project.umbrella?() and not File.dir?(path) do raise_missing_migrations(Path.relative_to_cwd(path), repo) end path end @doc """ Generates a nicely formatted timestamp """ def timestamp() do {{y, m, d}, {hh, mm, ss}} = :calendar.universal_time() "#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}" end defp raise_missing_migrations(path, repo) do Mix.raise(""" Could not find migrations directory #{inspect(path)} for repo #{inspect(repo)}. This may be because you are in a new project and the migration directory has not been created yet. Creating an empty directory at the path above will fix this error. If you expected existing migrations to be found, please make sure your repository has been properly configured and the configured path exists. """) end @doc """ Runs the tenant migrations with the given `args` and using `migrator` function. """ def run_tenant_migrations(args, direction, migrator \\ &Migrator.run/4) do repos = Ecto.parse_repo(args) {opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) opts = if opts[:to] || opts[:step] || opts[:all], do: opts, else: Keyword.put(opts, :step, 1) opts = if opts[:quiet], do: Keyword.merge(opts, log: false, log_sql: false), else: opts Enum.each(repos, &run_tenant_migrations(&1, args, opts, direction, migrator)) end defp run_tenant_migrations(repo, args, opts, direction, migrator) do Ecto.ensure_repo(repo, args) path = ensure_tenant_migrations_path(repo) {:ok, pid, apps} = ensure_started(repo, opts) pool = repo.config()[:pool] Code.compiler_options(ignore_module_conflict: true) migrated = Enum.flat_map(Tenant.all(repo), fn tenant -> opts = Keyword.put(opts, :prefix, tenant) if function_exported?(pool, :unboxed_run, 2) do pool.unboxed_run(repo, fn -> migrator.(repo, path, direction, opts) end) else migrator.(repo, path, direction, opts) end end) Code.compiler_options(ignore_module_conflict: false) pid && repo.stop() restart_apps_if_migrated(apps, migrated) end @doc """ Returns the private repository path relative to the source. """ def source_repo_priv(repo) do config = repo.config() priv = config[:priv] || "priv/#{repo |> Module.split() |> List.last() |> Macro.underscore()}" app = Keyword.fetch!(config, :otp_app) Path.join(Project.deps_paths()[app] || File.cwd!(), priv) end @doc """ Ensures the given repository is started and running. """ @spec ensure_started(Ecto.Repo.t(), Keyword.t()) :: {:ok, pid | nil, [atom]} def ensure_started(repo, opts) do {:ok, started} = Application.ensure_all_started(:ecto_sql) # If we starting EctoSQL just now, assume # logger has not been properly booted yet. if :ecto_sql in started && Process.whereis(Logger) do backends = Application.get_env(:logger, :backends, []) try do App.stop() Application.put_env(:logger, :backends, [:console]) :ok = App.start() after Application.put_env(:logger, :backends, backends) end end {:ok, apps} = repo.__adapter__().ensure_all_started(repo.config(), :temporary) pool_size = Keyword.get(opts, :pool_size, 2) case repo.start_link(pool_size: pool_size) do {:ok, pid} -> {:ok, pid, apps} {:error, {:already_started, _pid}} -> {:ok, nil, apps} {:error, error} -> Mix.raise("Could not start repo #{inspect(repo)}, error: #{inspect(error)}") end end @doc """ Restarts the app if there was any migration command. """ @spec restart_apps_if_migrated([atom], list()) :: :ok def restart_apps_if_migrated(_apps, []), do: :ok def restart_apps_if_migrated(apps, [_ | _]) do # Silence the logger to avoid application down messages. Logger.remove_backend(:console) for app <- Enum.reverse(apps) do Application.stop(app) end for app <- apps do Application.ensure_all_started(app) end :ok after Logger.add_backend(:console, flush: true) end defp pad(i), do: i |> to_string() |> String.pad_leading(2, "0") end