defmodule MnesiaHelper do
@moduledoc """
This module simplifies :mnesia, and works well with Ecto schemas.
It's recommended to surround your code in a try catch as all of the functions will throw up any errors that occur.
All of the (non-mnesia) errors that will be thrown will look like {:error, "message", extra_data}.
To use this module, you MUST first use the init!() function, even if you already created a schema.
You might want to change the function that automatically sets the time to a format you prefer, by default it's DateTime.utc_now().
After that, you're free to use the rest of the module.
"""
###############################################################
@doc """
This function creates a schema using the passed `nodes`, starts mnesia and returns `:ok`.
If any mistake (except `:already_exists`) occurs, this function will throw an error.
## Examples
iex> init!()
:ok
"""
@spec init!(list()) :: :ok
def init!(nodes \\ [node()])
def init!(nodes) do
:mnesia.create_schema(nodes)
|> case do
:ok ->
GenServer.start_link(MnesiaHelperCounter, [], name: :counter)
GenServer.start_link(MnesiaHelperTimer, [], name: :timer)
start()
{:error, {_, {:already_exists, _}}} ->
GenServer.start_link(MnesiaHelperCounter, [], name: :counter)
GenServer.start_link(MnesiaHelperTimer, [], name: :timer)
start()
error ->
throw error
end
end
###############################################################
@doc """
This function creates (deletes if `:already_exists`) a table with a `name`, with all of the keys in `attributes_map`.
`attributes_map` can either be a normal map, or an Ecto schema (Your map/schema must at least have two keys, one of them always being `:id` (which must always be `nil`)).
If keys `:created_at` or `:updated_at` are present in `attributes_map`, they are automatically updated, and should almost always (see docs for update!()) be `nil`.
Any extra indexes need to be listed as atoms in `index_list`. Any other options should be passed to `extra_opts` as a keylist (the same way you would normally pass it to :mnesia.create_table()).
This function returns `:ok`.
If any mistake (except `:already_exists`) occurs, this function will throw an error.
## Examples
iex> create_table!(:people, %{id: nil, name: nil, age: nil, created_at: nil, updated_at: nil})
:ok
"""
@spec create_table!(any, map(), [atom()], [{atom(), list()}]) :: :ok
def create_table!(name, attributes_map, index_list \\ [], extra_opts \\ [])
def create_table!(name, attributes_struct, index_list, extras) when is_struct(attributes_struct) do
create_table!(name, to_map(attributes_struct), index_list, extras)
end
def create_table!(name, attributes_map, index_list, extras) do
:mnesia.create_table(name, [{:attributes, [:id | List.delete(Enum.sort(Map.keys(attributes_map)), :id)]} | [{:index, index_list} | extras]])
|> case do
{:atomic, :ok} ->
:ok
{:aborted, {:already_exists, _}} ->
:mnesia.delete_table(name)
GenServer.cast(:counter, {name, 0})
create_table!(name, attributes_map, index_list)
error ->
throw error
end
end
###############################################################
@doc """
This function writes all values in map/struct `input` into a table named `name`.
Keys `:created_at`, `:updated_at` and `:id` are always ignored as they are automatically set if they are present in table attributes.
This function returns `:ok`.
If any mistake occurs, this function will throw an error.
## Examples
iex> write!(:people, %{name: "John", age: 21})
:ok
"""
@spec write!(any, map()) :: :ok
def write!(name, input)
def write!(name, input) when is_struct(input) do
write!(name, to_map(input))
end
def write!(name, input) when not is_map(input) do
throw {:error, "Input must be a map or an ecto struct(prefer ecto structs)", {name, input}}
end
def write!(name, input) do
keys = List.delete(Enum.sort(get_keys!(name)), :id)
input = Enum.reduce(keys, {name, GenServer.call(:counter, name)}, fn
:created_at, acc ->
Tuple.append(acc, GenServer.call(:timer, 0))
:updated_at, acc ->
Tuple.append(acc, GenServer.call(:timer, 0))
key, acc ->
case Map.get(input, key, nil) do
nil ->
throw {:error, "Missing key", {input, key}}
value ->
Tuple.append(acc, value)
end
end)
:mnesia.transaction(fn ->
:mnesia.write(input)
end)
|> case do
{:atomic, :ok} ->
:ok
error ->
throw error
end
end
###############################################################
@doc """
This function overwrites all values under key `:id` == `id` inside the table named `name` by `input`.
IMPORTANT: If key `:created_at` is present in the table attributes, it should be set in `input`, as otherwise the function will have to pull this value from the original record, which INCREASES time to execute this function by about 1.5 times.
This function returns `:ok`.
Key `:updated_at` is always ignored as it is automatically set if it is present in table attributes.
If any mistake occurs, this function will throw an error.
## Examples
iex> update!(:people, %{name: "John", age: 25}, 0)
:ok
"""
@spec update!(any, map(), integer()) :: :ok
def update!(name, input, id)
def update!(name, input, id) when is_struct(input) do
update!(name, to_map(input), id)
end
def update!(name, input, _) when not is_map(input) do
throw {:error, "Input must be a map or an ecto struct(prefer ecto structs)", {name, input}}
end
def update!(name, input, id) do
keys = List.delete(Enum.sort(get_keys!(name)), :id)
input = Enum.reduce(keys, {name, id}, fn
:created_at, acc ->
out = if is_nil(Map.get(input, :created_at, nil)) do
[before | _] = index_read!(name, id, :id)
before
else
input
end
Tuple.append(acc, Map.get(out, :created_at))
:updated_at, acc ->
Tuple.append(acc, GenServer.call(:timer, 0))
key, acc ->
case Map.get(input, key, nil) do
nil ->
throw {:error, "Missing key", {input, key}}
value ->
Tuple.append(acc, value)
end
end)
:mnesia.transaction(fn ->
:mnesia.write(input)
end)
|> case do
{:atomic, :ok} ->
:ok
error ->
throw error
end
end
###############################################################
@doc """
This function matches the given map/structure `input` with any records that exist in table named `name`.
This function returns a list of maps.
If there are no records that match, it returns an empty list.
If any mistake occurs, this function will throw an error.
## Examples
iex> match!(:people, %{name: "John"})
[
%{
age: 25,
created_at: ~U[2022-05-31 17:56:38.658000Z],
id: 0,
name: "John",
updated_at: ~U[2022-05-31 17:57:19.492000Z]
}
]
"""
@spec match!(any, map()) :: [map()]
def match!(name, input)
def match!(name, input) when is_struct(input) do
match!(name, to_map(input))
end
def match!(name, input) when not is_map(input) do
throw {:error, "Input must be a map or an ecto struct(prefer ecto structs)", {name, input}}
end
def match!(name, input) do
keys = [:id | List.delete(Enum.sort(get_keys!(name)), :id)]
input = Enum.reduce(keys, {name}, fn
key, acc ->
case Map.get(input, key, :_) do
nil ->
Tuple.append(acc, :_)
i ->
Tuple.append(acc, i)
end
end)
:mnesia.transaction(fn ->
:mnesia.match_object(input)
end)
|> case do
{:atomic, out} when is_list(out) ->
create_output(out, keys)
error ->
throw error
end
end
###############################################################
@doc """
This function returns all records from a table named `name` where `index` == `input`.
This function returns a list of maps.
If there are no records that match, it returns an empty list.
If any mistake occurs, this function will throw an error.
## Examples
iex> index_read!(:people, 0, :id)
[
%{
age: 25,
created_at: ~U[2022-05-31 17:56:38.658000Z],
id: 0,
name: "John",
updated_at: ~U[2022-05-31 17:57:19.492000Z]
}
]
"""
@spec index_read!(any, any, atom()) :: [map()]
def index_read!(name, input, index)
def index_read!(name, input, :id) do
:mnesia.transaction(fn ->
:mnesia.read({name, input})
end)
|> case do
{:atomic, out} when is_list(out) ->
create_output(out, [:id | List.delete(Enum.sort(get_keys!(name)), :id)])
error ->
throw error
end
end
def index_read!(name, input, index) do
:mnesia.transaction(fn ->
:mnesia.index_read(name, input, index)
end)
|> case do
{:atomic, out} when is_list(out) ->
create_output(out, [:id | List.delete(Enum.sort(get_keys!(name)), :id)])
error ->
throw error
end
end
###############################################################
@doc """
This function selects any records from a table named `name` where all `guards` are true and returns only the keys in `columns`.
All `guards` are the same as in a regular :mnesia.select(), however, instead of an ambiguous lambda symbol, you can use any keys that are inside this table's attributes.
Be default `columns` is empty and returns every column in record.
This function returns a list of maps.
If there are no records that match, it returns an empty list.
If any mistake occurs, this function will throw an error.
## Examples
iex> select!(:people, [{:>, :age, 21}], [:age, :name, :created_at])
[%{age: 25, created_at: ~U[2022-05-31 17:56:38.658000Z], name: "John"}]
"""
@spec select!(any, [{atom(), atom(), any}], [atom()]) :: [map()]
def select!(name, guards, columns \\ [])
def select!(name, guards, columns) do
keys = [:id | List.delete(Enum.sort(get_keys!(name)), :id)]
count = Enum.count(keys) - 1
input = Enum.reduce(0..count, {name}, fn
index, acc ->
Tuple.append(acc, String.to_atom("$" <> to_string(index + 1)))
end)
guards = Enum.map(guards, fn {operation, key, value} ->
{operation, String.to_atom("$" <> to_string(1 + Enum.find_index(keys, fn kei -> kei == key end))), value}
end)
column_list = Enum.map(columns, fn item ->
String.to_atom("$" <> to_string(1 + Enum.find_index(keys, fn kei -> kei == item end)))
end)
[{input, guards, column_list}]
:mnesia.transaction(fn ->
:mnesia.select(name, [{input, guards, Enum.sort(case column_list do [] -> [:"$$"]; x -> [x] end)}])
end)
|> case do
{:atomic, out} when is_list(out) ->
Enum.map(out, fn
item when is_list(item) ->
item
item ->
[item]
end)
|> create_output(
case columns do
[] -> keys;
x -> x
end)
error ->
throw error
end
end
###############################################################
@doc """
This function returns a list of all keys that are present inside a table named `name`.
This function returns a list of atoms.
If any mistake occurs, this function will throw an error.
## Examples
iex> get_keys!(:people)
[:id, :age, :created_at, :name, :updated_at]
"""
@spec get_keys!(any) :: [atom()]
def get_keys!(name)
def get_keys!(name) do
:mnesia.table_info(name, :attributes)
|> case do
out when is_list(out) ->
out
error ->
throw error
end
end
@doc """
Use this function to set a function that will determine the datetime that is set in `:created_at` and `:updated_at`.
By default that function is `fn -> DateTime.utc_now() end`.
## Examples
iex> set_time_fn(fn -> DateTime.utc_now() |> DateTime.to_iso8601() end)
:ok
"""
@spec set_time_fn(function()) :: :ok
def set_time_fn(function) do
GenServer.cast(:timer, function)
end
###############################################################
#Private fns
###############################################################
#parses a standard mnesia record output into a normal map
defp create_output([check | _] = input, keys) when is_tuple(check) do
Enum.map(input, fn item ->
item = Tuple.delete_at(item, 0)
Enum.reduce(0..Enum.count(keys) - 1, %{}, fn index, acc ->
Map.put(acc, Enum.at(keys, index), elem(item, index))
end)
end)
end
defp create_output(input, keys) do
Enum.map(input, fn item ->
Enum.reduce(0..Enum.count(keys) - 1, %{}, fn index, map ->
Map.put(map, Enum.at(keys, index), Enum.at(item, index))
end)
end)
end
defp to_map(input) do
Map.from_struct(input)
|> Map.delete(:__meta__)
end
defp start() do
:mnesia.start()
|> case do
:ok ->
:ok
error ->
throw error
end
end
end
defmodule MnesiaHelperCounter do
@moduledoc false
use GenServer
@impl true
def init(_) do
{:ok, %{}}
end
@impl true
def handle_call(name, _, state) do
{:reply, Map.get(state, name, 0), Map.put(state, name, Map.get(state, name, 0) + 1)}
end
@impl true
def handle_cast({name, id}, state) do
{:noreply, Map.put(state, name, id)}
end
end
defmodule MnesiaHelperTimer do
@moduledoc false
use GenServer
@impl true
def init(_) do
{:ok, fn -> DateTime.utc_now() end}
end
@impl true
def handle_call(_, _, state) do
{:reply, state.(), state}
end
@impl true
def handle_cast(func, _) do
{:noreply, func}
end
end