lib/dynamo_migration.ex

defmodule DynamoMigration do
  @moduledoc """
  Version management module for migration file of DynamoDB.
  Dependes on ExAws and ExAws.Dynamo.
  See also https://github.com/ex-aws/ex_aws_dynamo.
  Usage:
    1. $ mix dynamo.setup # Creates migrations table.
    2. $ mix dynamo.gen.migration create_tests_table --table Tests # Generates migration file.
    ```elixir
    defmodule Dynamo.Migrations.CreateTestsTable do
      @table_name "Tests"

      def table_name, do: @table_name

      def change do
        # Rewrite any migration code.
        ExAws.Dynamo.create_table(
          "Tests",
          [id: :hash],
          %{id: :number},
          1,
          1,
          :provisioned
        )
        |> ExAws.request!()
      end
    end
    ```
    3. $ mix dynamo.migrate # Migrates `priv/dynamo/migrations/*`.
  """
  require Logger
  alias ExAws.Dynamo
  @table "migrations"
  @migration_file_path "priv/dynamo/migrations"

  @doc false
  @spec migration_file_path :: String.t()
  def migration_file_path, do: @migration_file_path

  @doc """
  Called from `mix dynamo.migrate`
  Executes migration files if there had not migrated.
  """
  @spec migrate :: :ok
  def migrate do
    case File.ls(migration_file_path()) do
      {:ok, files} ->
        files
        |> compile_migrations()
        |> Enum.filter(fn {_, version} -> migration_required?(version) end)
        |> Enum.each(fn {mod, version} ->
          mod.change()
          insert_migration_version(mod.table_name(), version)
          Logger.debug("Migrate #{mod} was succeed")
        end)

      error ->
        error
    end
  end

  @doc """
  Creates migrations table for version management.
  Called from `mix dynamo.setup`
  """
  @spec setup :: :ok
  def setup do
    if @table in table_names() do
      Logger.debug("Migrations table was already created.")
    else
      Dynamo.create_table(
        @table,
        [version: :hash],
        [version: :number],
        1,
        1,
        :provisioned
      )
      |> ExAws.request!()

      Logger.debug("Migrations table was created.")
    end
  end

  @doc """
  Reset table migrations.
  """
  def reset(only_prefix \\ nil) do
    IO.inspect(only_prefix)
    current_versions = versions()

    table_names()
    |> Enum.reject(&(&1 == @table))
    |> Enum.filter(fn table_name ->
      unless only_prefix do
        true
      else
        table_name =~ Regex.compile!("\A#{table_name}")
      end
    end)
    |> Enum.each(&delete_table(&1, current_versions))

    migrate()
  end

  @doc false
  @spec migration_required?(binary()) :: boolean
  def migration_required?(version) do
    Dynamo.get_item(@table, %{version: version})
    |> ExAws.request!()
    |> (fn result -> result == %{} end).()
  end

  defp versions() do
    Dynamo.scan(@table)
    |> ExAws.request!()
    |> Map.get("Items", [])
    |> Enum.map(fn version ->
      %{
        version: version["version"]["N"] |> String.to_integer(),
        table_name: version["table_name"]["S"]
      }
    end)
  end

  defp insert_migration_version(table_name, version) do
    Dynamo.put_item(
      @table,
      %{table_name: table_name, version: version}
    )
    |> ExAws.request!()
  end

  defp compile_migrations(files) do
    files
    |> Enum.map(fn file ->
      [mod | _] =
        (migration_file_path() <> "/" <> file)
        |> Code.compile_file()
        |> Enum.map(&elem(&1, 0))

      version =
        Regex.scan(~r/[0-9]/, file)
        |> Enum.join()
        |> String.to_integer()

      {mod, version}
    end)
  end

  defp delete_table(table_name, versions) do
    table_name
    |> Dynamo.delete_table()
    |> ExAws.request!()

    versions
    |> Enum.filter(&(table_name == &1[:table_name]))
    |> Enum.each(fn version ->
      Dynamo.delete_item(@table, %{version: version[:version]})
      |> ExAws.request!()
    end)
  end

  defp table_names do
    Dynamo.list_tables()
    |> ExAws.request!()
    |> Map.get("TableNames", [])
  end
end