lib/adapters/mnesia/migration.ex

defmodule ActiveMemory.Adapters.Mnesia.Migration do
  @moduledoc """
  Migrations will get run on app startup and are designed to modify :mnesia's schema.

  ## Table Copies
  In the `options` of an ActiveMemory.Table, the copy type and nodes which should have them can be specified.

  ### Ram copies 
  Tables that only reside in ram on the nodes specified. The default is `node()`
  Example table using default setting:
  ```elixir
  defmodule Test.Support.Dogs.Dog do
    use ActiveMemory.Table,
      options: [compressed: true, read_concurrency: true]
    .
    # module code
    .
  end
  ```
  The default will be `[node()]` and this table will reside on the `node()` ram. 
  Example table spcifing nodes and ram copies:
  ```elixir
  defmodule Test.Support.Dogs.Dog do
    use ActiveMemory.Table,
      options: [compressed: true, read_concurrency: true, ram_copes: [node() | Node.list()]
    .
    # module code
    .
  end
  ```
  All the active nodes in Node.list() and node() will have ram copes of the table.

  ### Disc copies
  Disc copy tables reside **both** in ram and disc on the nodes specified. 
  In order to persist to disc the schema must be setup on at lest one running node.
  The default is [] (no nodes).
  Example table spcifing nodes and disc copies:
  ```elixir
  defmodule Test.Support.Dogs.Dog do
    use ActiveMemory.Table,
      options: [compressed: true, read_concurrency: true, disc_copes: [node()]
    .
    # module code
    .
  end
  ```
  The table will have a ram copy and disc copy on `node()` 

  ### Disc only copies
  Disc oly tables reside **only** on disc on the nodes specified. 
  In order to persist to disc the schema must be setup on at lest one running node.
  The default is [] (no nodes).
  Example table spcifing nodes and disc copies:
  ```elixir
  defmodule Test.Support.Dogs.Dog do
    use ActiveMemory.Table,
      options: [compressed: true, read_concurrency: true, disc_only_copes: [node()]
    .
    # module code
    .
  end
  ```
  The table will only have a disc copy on `node()`

  ## Table Read and Write Access
  Mnesia tables can be set to `read_only` or `read_write`. The default is `read_write`.
  Read only tables updates cannot be performed.
  if you need to change the access use the following syntax: `[access_mode: :read_only]`

  ## Table Types
  Tables can be either a `:set`, `:ordered_set`, or a `:bag`. The default is `:set`
  if you need to change the type use the following syntax: `[type: :bag]`

  ## Indexes
  If Indexes are desired specify an atom attribute list for which Mnesia is to build and maintain an extra index table. 
  The qlc query compiler may be able to optimize queries if there are indexes available.
  To specify Indexes use the following syntax: `[index: [:age, :hair_color, :cylon?]]`

  ## Table Load Order
  The load order priority is by default 0 (zero) but can be set to any integer. The tables with the highest load order priority are loaded first at startup.
  If you need to change the load order use the following syntax: `[load_order: 2]`

  ## Majority
  If true, any (non-dirty) update to the table is aborted, unless a majority of the table replicas are available for the commit. When used on a fragmented table, all fragments are given the same the same majority setting.
  If you need to modify the majority use the following syntax: `[majority: true]`
  """

  def migrate_table_options(table) do
    table.__attributes__(:table_options)
    |> migrate_table_copies_to_add(table)
    |> migrate_table_copies_to_delete(table)
    |> migrate_access_mode(table)
    |> migrate_indexes(table)
    |> migrate_load_order(table)
    |> migrate_majority(table)

    :ok
  end

  # Supporting methods in alphabetical order
  defp add_copy_type([], _table, _copy_type), do: :ok

  defp add_copy_type(nodes, table, copy_type) do
    for node <- nodes do
      case :mnesia.add_table_copy(table, node, copy_type) do
        {:aborted, {:already_exists, _, _}} ->
          change_table_copy_type(table, node, copy_type)

        {:atomic, :ok} ->
          :ok
      end
    end

    :ok
  end

  defp add_copy_types(options_nodes, table, copy_type) do
    table
    |> :mnesia.table_info(copy_type)
    |> Enum.sort()
    |> compare_nodes_to_add(options_nodes)
    |> add_copy_type(table, copy_type)
  end

  defp add_indexes([], _table), do: nil

  defp add_indexes(indexes, table) do
    Enum.each(indexes, fn index -> :mnesia.add_table_index(table, index) end)
  end

  defp change_table_copy_type(table, node, copy_type) do
    case :mnesia.change_table_copy_type(table, node, copy_type) do
      {:atomic, :ok} -> :ok
      other -> other
    end
  end

  defp compare_nodes_to_add([], options_nodes), do: options_nodes

  defp compare_nodes_to_add(_current_nodes, []), do: []

  defp compare_nodes_to_add(current_nodes, options_nodes) do
    options_nodes -- current_nodes
  end

  defp compare_nodes_to_remove([], _options_nodes), do: []

  defp compare_nodes_to_remove(current_nodes, []), do: current_nodes

  defp compare_nodes_to_remove(current_nodes, options_nodes) do
    current_nodes -- options_nodes
  end

  defp copy_type_validation(_ram_nodes, [], []), do: :ok

  defp copy_type_validation([], _disc_nodes, []), do: :ok

  defp copy_type_validation([], [], _disc_only_nodes), do: :ok

  defp copy_type_validation([], disc_nodes, disc_only_nodes) do
    disc_nodes_validation(disc_nodes, disc_only_nodes)
  end

  defp copy_type_validation(ram_nodes, [], disc_only_nodes) do
    ram_nodes
    |> Enum.any?(&Enum.member?(disc_only_nodes, &1))
    |> parse_check(:ram_copies)
  end

  defp copy_type_validation(ram_nodes, disc_nodes, []) do
    ram_nodes
    |> Enum.any?(&Enum.member?(disc_nodes, &1))
    |> parse_check(:ram_copies)
  end

  defp copy_type_validation(ram_nodes, disc_nodes, disc_only_nodes) do
    with {:ok, :ram_copies} <- ram_nodes_validation(ram_nodes, disc_nodes, disc_only_nodes),
         {:ok, :disc_copies} <- disc_nodes_validation(disc_nodes, disc_only_nodes) do
      :ok
    end
  end

  defp delete_copy_type([], _table), do: :ok

  defp delete_copy_type(nodes, table) do
    Enum.each(nodes, &:mnesia.del_table_copy(table, &1))
  end

  defp delete_indexes([], _table), do: nil

  defp delete_indexes(indexes, table) do
    Enum.each(indexes, fn index -> :mnesia.del_table_index(table, index) end)
  end

  defp disc_nodes_validation(disc_nodes, disc_only_nodes) do
    disc_nodes
    |> Enum.any?(&Enum.member?(disc_only_nodes, &1))
    |> parse_check(:disc_copies)
  end

  defp get_indexes([], _attributes), do: []

  defp get_indexes(indexes, attributes) do
    indexes
    |> Enum.map(fn index -> Enum.at(attributes, index - 2) end)
  end

  defp migrate_access_mode(options, table) do
    option = Keyword.get(options, :access_mode, :read_write)

    case :mnesia.table_info(table, :access_mode) do
      ^option -> :ok
      _ -> :mnesia.change_table_access_mode(table, option)
    end

    options
  end

  defp migrate_indexes(options, table) do
    new_indexes = Keyword.get(options, :index, [])
    indexes = :mnesia.table_info(table, :index)

    current_indexes = get_indexes(indexes, :mnesia.table_info(table, :attributes))

    add_indexes(new_indexes -- current_indexes, table)
    delete_indexes(current_indexes -- new_indexes, table)
    options
  end

  defp migrate_load_order(options, table) do
    load_order = Keyword.get(options, :load_order, 0)

    case :mnesia.table_info(table, :load_order) do
      ^load_order -> :ok
      _ -> :mnesia.change_table_load_order(table, load_order)
    end

    options
  end

  defp migrate_majority(options, table) do
    majority = Keyword.get(options, :majority, false)

    case :mnesia.table_info(table, :majority) do
      ^majority -> :ok
      _ -> :mnesia.change_table_majority(table, majority)
    end

    options
  end

  defp migrate_table_copies_to_add(options, table) do
    options_disc_nodes = Keyword.get(options, :disc_copies, []) |> Enum.sort()

    options_ram_nodes =
      Keyword.get(options, :ram_copies, ram_copy_default(options_disc_nodes)) |> Enum.sort()

    options_disc_only_nodes = Keyword.get(options, :disc_only_copies, []) |> Enum.sort()

    with :ok <-
           copy_type_validation(options_ram_nodes, options_disc_nodes, options_disc_only_nodes),
         :ok <- add_copy_types(options_ram_nodes, table, :ram_copies),
         :ok <- add_copy_types(options_disc_nodes, table, :disc_copies),
         :ok <-
           add_copy_types(
             options_disc_only_nodes,
             table,
             :disc_only_copies
           ) do
      options
    end
  end

  defp ram_copy_default(options_disc_nodes) do
    case Enum.member?(options_disc_nodes, node()) do
      true -> []
      false -> [node()]
    end
  end

  defp migrate_table_copies_to_delete(options, table) do
    with :ok <- remove_copy_types(options, table, :ram_copies, [node()]),
         :ok <- remove_copy_types(options, table, :disc_copies),
         :ok <- remove_copy_types(options, table, :disc_only_copies) do
      options
    end
  end

  defp parse_check(false, _copy_type), do: :ok

  defp parse_check(true, copy_type),
    do: {:error, "#{copy_type} options are invalid. Please read the documentation"}

  defp remove_copy_types(options, table, copy_type, default_nodes \\ []) do
    options_nodes =
      options
      |> Keyword.get(copy_type, default_nodes)
      |> Enum.sort()

    table
    |> :mnesia.table_info(copy_type)
    |> Enum.sort()
    |> compare_nodes_to_remove(options_nodes)
    |> delete_copy_type(table)
  end

  defp ram_nodes_validation(ram_nodes, disc_nodes, disc_only_nodes) do
    ram_nodes
    |> Enum.any?(&(Enum.member?(disc_nodes, &1) or Enum.member?(disc_only_nodes, &1)))
    |> parse_check(:ram_copies)
  end
end