lib/phil_columns/seeder.ex

defmodule PhilColumns.Seeder do
  require Logger

  alias PhilColumns.Seed.Runner
  alias PhilColumns.Seed.SchemaSeed

  @doc """
  Gets all migrated versions.

  This function ensures the migration table exists
  if no table has been defined yet.
  """
  @spec seeded_versions(Ecto.Repo.t(), String.t()) :: list()
  def seeded_versions(repo, tenant) do
    SchemaSeed.ensure_schema_seeds_table!(repo)
    SchemaSeed.seeded_versions(repo, tenant)
  end

  # @doc """
  # Runs an up migration on the given repository.

  ### Options

  # * `:log` - the level to use for logging. Defaults to `:info`.
  # Can be any of `Logger.level/0` values or `false`.
  # """
  # @spec up(Ecto.Repo.t, integer, Module.t, Keyword.t) :: :ok | :already_up | no_return
  # def up(repo, version, module, opts \\ []) do
  # versions = migrated_versions(repo)

  # if version in versions do
  # :already_up
  # else
  # do_up(repo, version, module, opts)
  # :ok
  # end
  # end

  defp do_up(repo, version, module, opts) do
    run_maybe_in_transaction(repo, module, fn ->
      attempt(repo, module, :forward, :up, :up, opts) ||
        attempt(repo, module, :forward, :change, :up, opts) ||
        raise PhilColumns.SeedError,
          message: "#{inspect(module)} does not implement a `up/0` function"

      SchemaSeed.up(repo, version, opts[:tenant])
    end)
  end

  # @doc """
  # Runs a down migration on the given repository.

  ### Options

  # * `:log` - the level to use for logging. Defaults to `:info`.
  # Can be any of `Logger.level/0` values or `false`.

  # """
  # @spec down(Ecto.Repo.t, integer, Module.t) :: :ok | :already_down | no_return
  # def down(repo, version, module, opts \\ []) do
  # versions = migrated_versions(repo)

  # if version in versions do
  # do_down(repo, version, module, opts)
  # :ok
  # else
  # :already_down
  # end
  # end

  defp do_down(repo, version, module, opts) do
    run_maybe_in_transaction(repo, module, fn ->
      attempt(repo, module, :forward, :down, :down, opts) ||
        attempt(repo, module, :backward, :change, :down, opts) ||
        raise PhilColumns.SeedError,
          message: "#{inspect(module)} does not implement a `down/0` function"

      SchemaSeed.down(repo, version, opts[:tenant])
    end)
  end

  defp run_maybe_in_transaction(repo, module, fun) do
    cond do
      module.__seed__[:disable_ddl_transaction] ->
        fun.()

      repo.__adapter__.supports_ddl_transaction? ->
        repo.transaction(fun, log: false, timeout: :infinity)

      true ->
        fun.()
    end
  end

  defp attempt(repo, module, direction, operation, reference, opts) do
    if Code.ensure_loaded?(module) and
         function_exported?(module, operation, 1) do
      Runner.run(repo, module, direction, operation, reference, opts)
      :ok
    end
  end

  @doc """
  Apply seeds in a directory to a repository with given strategy.

  A strategy must be given as an option.

  ## Options

    * `:all` - runs all available if `true`
    * `:step` - runs the specific number of seeds
    * `:to` - runs all until the supplied version is reached
    * `:log` - the level to use for logging. Defaults to `:info`.
      Can be any of `Logger.level/0` values or `false`.

  """
  @spec run(Ecto.Repo.t(), binary, atom, Keyword.t()) :: [integer]
  def run(repo, directory, direction, opts) do
    maybe_ensure_all_started(Application.get_env(:phil_columns, :ensure_all_started))

    versions = seeded_versions(repo, opts[:tenant])

    cond do
      opts[:all] ->
        run_all(repo, versions, directory, direction, opts)

      to = opts[:to] ->
        run_to(repo, versions, directory, direction, to, opts)

      # step = opts[:step] ->
      # run_step(repo, versions, directory, direction, step, opts)
      true ->
        raise ArgumentError, message: "expected one of :all, :to, or :step strategies"
    end
  end

  @doc """
  Returns an array of tuples as the seed status of the given repo,
  without actually running any seeds.
  """
  def seeds(repo, directory, opts) do
    versions = seeded_versions(repo, opts[:tenant])

    Enum.map(pending_in_direction(versions, directory, :down, opts) |> Enum.reverse(), fn {a, b,
                                                                                           _,
                                                                                           _} ->
      {:up, a, b}
    end) ++
      Enum.map(pending_in_direction(versions, directory, :up, opts), fn {a, b, _, _} ->
        {:down, a, b}
      end)
  end

  # defp run_to(repo, versions, directory, direction, 0, opts) do
  # pending_in_direction(versions, directory, direction, opts)
  # |> seed(direction, repo, opts)
  # end

  defp run_to(repo, versions, directory, direction, target, opts) do
    pending_in_direction(versions, directory, direction, opts)
    |> Enum.take_while(fn seed_info ->
      within_target_version?(seed_info, target, direction)
    end)
    |> seed(direction, repo, opts)
  end

  defp within_target_version?({version, _, _, _}, target, :up), do: version <= target
  defp within_target_version?({version, _, _, _}, target, :down), do: version >= target

  # defp run_step(repo, versions, directory, direction, count, opts) do
  # pending_in_direction(versions, directory, direction)
  # |> Enum.take(count)
  # |> seed(direction, repo, opts)
  # end

  defp run_all(repo, versions, directory, direction, opts) do
    pending_in_direction(versions, directory, direction, opts)
    |> seed(direction, repo, opts)
  end

  # defp pending_in_direction(versions, directory, :up) do
  # seeds_for(directory)
  # |> Enum.filter(fn {version, _name, _file} -> not (version in versions) end)
  # end

  # defp pending_in_direction(versions, directory, :down) do
  # seeds_for(directory)
  # |> Enum.filter(fn {version, _name, _file} -> version in versions end)
  # |> Enum.reverse
  # end

  defp pending_in_direction(versions, directory, :up, opts) do
    seeds_for(directory)
    |> Enum.filter(fn {version, _name, _file} -> not (version in versions) end)
    |> Enum.map(fn {version, name, file} ->
      [{mod, _bin}] = Code.compile_file(file)
      {version, name, file, mod}
    end)
    |> Enum.filter(fn {_version, _name, _file, mod} ->
      has_env_and_any_tags?(mod, opts[:env], opts[:tags])
    end)
  end

  defp pending_in_direction(versions, directory, :down, _opts) do
    seeds_for(directory)
    |> Enum.filter(fn {version, _name, _file} -> version in versions end)
    |> Enum.map(fn {version, name, file} ->
      [{mod, _bin}] = Code.compile_file(file)
      {version, name, file, mod}
    end)
    |> Enum.reverse()
  end

  defp seeds_for(directory) do
    query = Path.join(directory, "*")

    for entry <- Path.wildcard(query),
        info = extract_seed_info(entry),
        do: info
  end

  defp extract_seed_info(file) do
    base = Path.basename(file)
    ext = Path.extname(base)

    case Integer.parse(Path.rootname(base)) do
      {integer, "_" <> name} when ext == ".exs" ->
        {integer, name, file}

      _ ->
        nil
    end
  end

  defp seed(seeds, direction, repo, opts) do
    log_seeding_start(direction, opts)

    if Enum.empty?(seeds) do
      level = Keyword.get(opts, :log, :info)
      log(level, "Already #{direction}")
    end

    ensure_no_duplication(seeds)

    Enum.map(seeds, fn {version, _name, file, mod} ->
      function_exported?(mod, :__seed__, 0) || raise_no_seed_in_file(file)

      case direction do
        :up -> do_up(repo, version, mod, opts)
        :down -> do_down(repo, version, mod, opts)
      end

      version
    end)
  end

  defp log_seeding_start(:up, opts) do
    log(
      opts[:log],
      "=== Executing seeds up for env #{inspect(opts[:env])} and tags #{inspect(opts[:tags])}"
    )
  end

  defp log_seeding_start(:down, opts) do
    log(opts[:log], "=== Executing seeds down for env #{inspect(opts[:env])}")
  end

  defp ensure_no_duplication([{version, name, _, _} | t]) do
    if List.keyfind(t, version, 0) do
      raise Ecto.MigrationError,
        message: "seeds can't be executed, seed version #{version} is duplicated"
    end

    if List.keyfind(t, name, 1) do
      raise Ecto.MigrationError,
        message: "seeds can't be executed, seed name #{name} is duplicated"
    end

    ensure_no_duplication(t)
  end

  defp ensure_no_duplication([]), do: :ok

  defp raise_no_seed_in_file(file) do
    raise PhilColumns.SeedError,
      message: "file #{Path.relative_to_cwd(file)} does not contain any PhilColumns.Seed"
  end

  defp has_env_and_any_tags?(mod, env, tags) do
    Enum.member?(mod.envs, env) &&
      any_intersection?(mod, tags)
  end

  defp any_intersection?(_mod, []), do: true

  defp any_intersection?(mod, tags) do
    intersection(mod.tags, tags) |> Enum.count() > 0
  end

  defp intersection(list_a, list_b) do
    list_a -- list_a -- list_b
  end

  defp log(false, _msg), do: :ok
  defp log(level, msg), do: Logger.log(level, msg)

  defp maybe_ensure_all_started(nil), do: nil

  defp maybe_ensure_all_started(apps) when is_list(apps) do
    Enum.each(apps, &Application.ensure_all_started(&1))
  end

  defp maybe_ensure_all_started(_other), do: raise("ensure_all_started must be a list of apps")

  def with_repo(repo, fun, opts \\ []) do
    config = repo.config()
    mode = Keyword.get(opts, :mode, :permanent)
    apps = [:ecto_sql | config[:start_apps_before_migration] || []]

    extra_started =
      Enum.flat_map(apps, fn app ->
        {:ok, started} = Application.ensure_all_started(app, mode)
        started
      end)

    {:ok, repo_started} = repo.__adapter__.ensure_all_started(config, mode)
    started = extra_started ++ repo_started
    pool_size = Keyword.get(opts, :pool_size, 2)

    case repo.start_link(pool_size: pool_size) do
      {:ok, _} ->
        try do
          {:ok, fun.(repo), started}
        after
          repo.stop()
        end

      {:error, {:already_started, _pid}} ->
        {:ok, fun.(repo), started}

      {:error, _} = error ->
        error
    end
  end
end