defmodule ActiveMemory.Store do @moduledoc """ # The Store ## Store API - `Store.all/0` Get all records stored - `Store.delete/1` Delete the record provided - `Store.delete_all/0` Delete all records stored - `Store.one/1` Get one record matching either an attributes search or `match` query - `Store.select/1` Get all records matching either an attributes search or `match` query - `Store.withdraw/1` Get one record matching either an attributes search or `match` query, delete the record and return it - `Store.write/1` Write a record into the memmory table ## Seeding When starting a `Store` there is an option to provide a valid seed file and have the `Store` auto load seeds contained in the file. ```elixir defmodule MyApp.People.Store do use ActiveMemory.Store, table: MyApp.People.Person, seed_file: Path.expand("person_seeds.exs", __DIR__) end ``` ## Before `init` All stores are `GenServers` and have `init` functions. While those are abstracted you can still specify methods to run during the `init` phase of the GenServer startup. Use the `before_init` keyword and add the methods as tuples with the arguments. ```elixir defmodule MyApp.People.Store do use ActiveMemory.Store, table: MyApp.People.Person, before_init: [{:run_me, ["arg1", "arg2", ...]}, {:run_me_too, []}] end ``` ## Initial State All stores are `GenServers` and thus have a state. The default state is a map as such: ```elixir %{started_at: "date time when first started", table_name: MyApp.People.Person} ``` This default state can be overwritten with a new state structure or values by supplying a method and arguments as a tuple to the keyword `initial_state`. The method must return `{:ok, new_state}`. ```elixir defmodule MyApp.People.Store do use ActiveMemory.Store, table: MyApp.People.Person, initial_state: {:initial_state_method, ["arg1", "arg2", ...]} end ``` """ defmacro __using__(opts) do quote do import unquote(__MODULE__) use GenServer opts = unquote(Macro.expand(opts, __CALLER__)) @table Keyword.get(opts, :table) @before_init Keyword.get(opts, :before_init, :default) @initial_state Keyword.get(opts, :initial_state, :default) @seed_file Keyword.get(opts, :seed_file, nil) def start_link(options \\ []) do GenServer.start_link(__MODULE__, options, name: __MODULE__) end @impl true def init(_) do with :ok <- create_table(), {:ok, :seed_success} <- __run_seeds_file__(), {:ok, _result} <- before_init(@before_init), {:ok, initial_state} <- __initial_state__() do {:ok, initial_state} end end @spec all() :: list(map()) def all, do: :erlang.apply(@table.__attributes__(:adapter), :all, [@table]) def create_table do :erlang.apply(@table.__attributes__(:adapter), :create_table, [@table]) end @spec delete(any()) :: :ok | {:error, any()} def delete(%{__struct__: @table} = struct) do :erlang.apply(@table.__attributes__(:adapter), :delete, [struct, @table]) end def delete(nil), do: :ok def delete(_), do: {:error, :bad_schema} @spec delete_all() :: :ok | {:error, any()} def delete_all do :erlang.apply(@table.__attributes__(:adapter), :delete_all, [@table]) end @spec one(map() | list(any())) :: {:ok, map()} | {:error, any()} def one(query) do case :erlang.apply(@table.__attributes__(:adapter), :one, [query, @table]) do {:ok, %{} = record} -> {:ok, record} {:error, message} -> {:error, message} end end def reload_seeds do GenServer.call(__MODULE__, :reload_seeds) end @spec select(map() | list(any())) :: {:ok, list(map())} | {:error, any()} def select(query) when is_map(query) do :erlang.apply(@table.__attributes__(:adapter), :select, [query, @table]) end def select({_operand, _lhs, _rhs} = query) do :erlang.apply(@table.__attributes__(:adapter), :select, [query, @table]) end def select(_), do: {:error, :bad_select_query} def state do GenServer.call(__MODULE__, :state) end @spec withdraw(map() | list(any())) :: {:ok, map()} | {:error, any()} def withdraw(query) do with {:ok, %{} = record} <- one(query), :ok <- delete(record) do {:ok, record} else {:error, message} -> {:error, message} end end @spec write(map()) :: {:ok, map()} | {:error, any()} def write(%@table{} = struct) do case Map.has_key?(struct, :uuid) do true -> write_with_uuid(struct) false -> normal_write(struct) end end def write(_), do: {:error, :bad_schema} defp write_with_uuid(%@table{} = struct) do case Map.get(struct, :uuid) do nil -> with_uuid = Map.put(struct, :uuid, Ecto.UUID.generate()) :erlang.apply(@table.__attributes__(:adapter), :write, [with_uuid, @table]) uuid when is_binary(uuid) -> :erlang.apply(@table.__attributes__(:adapter), :write, [struct, @table]) end end def normal_write(%@table{} = struct) do :erlang.apply(@table.__attributes__(:adapter), :write, [struct, @table]) end @impl true def handle_call(:reload_seeds, _from, state) do {:reply, __run_seeds_file__(), state} end @impl true def handle_call(:state, _from, state), do: {:reply, state, state} defp before_init(:default), do: {:ok, :default} defp before_init({method, args}) when is_list(args) do :erlang.apply(__MODULE__, method, args) {:ok, :before_init_success} end defp before_init(methods) when is_list(methods) do Enum.each(methods, &before_init(&1)) {:ok, :before_init_success} end # Only the clause matching the compile-time option is generated so the # Elixir 1.19+ type checker never sees an unreachable clause. if @initial_state == :default do defp __initial_state__ do {:ok, %{ started_at: DateTime.utc_now(), table_name: @table }} end else defp __initial_state__ do {method, args} = @initial_state :erlang.apply(__MODULE__, method, args) end end if @seed_file == nil do defp __run_seeds_file__, do: {:ok, :seed_success} else defp __run_seeds_file__ do with {seeds, _} when is_list(seeds) <- Code.eval_file(@seed_file), true <- write_seeds(seeds) do {:ok, :seed_success} else {:error, message} -> {:error, message} _ -> {:error, :seed_failure} end end defp write_seeds(seeds) do seeds |> Task.async_stream(&write(&1)) |> Enum.all?(fn {:ok, {result, _seed}} -> result == :ok end) end end end end end