Skip to main content

lib/operations.ex

defmodule ActiveMemory.Operations do
  @moduledoc """
  The shared implementation of the table operations and store setup used by
  `ActiveMemory.Store` and `ActiveMemory.ActiveRepo`.

  Every function takes the table module explicitly and dispatches to that table's
  configured adapter, applying the common validation, `uuid` handling, seeding and
  `before_init` logic. This keeps the single-table `Store` and the multi-table
  `Repo` sharing one implementation rather than duplicating it.
  """

  @spec all(atom()) :: list(map())
  def all(table), do: adapter(table).all(table) |> reject_expired(table)

  @doc """
  Run the `before_init` methods for a store.

  `spec` is `:default`, a single `{method, args}` tuple, or a list of such tuples.
  `module` is the module the methods are defined on (the `Store` or `Repo`).
  """
  @spec before_init(:default | tuple() | list(), module()) :: {:ok, atom()}
  def before_init(:default, _module), do: {:ok, :default}

  def before_init({method, args}, module) when is_list(args) do
    :erlang.apply(module, method, args)
    {:ok, :before_init_success}
  end

  def before_init(methods, module) when is_list(methods) do
    Enum.each(methods, &before_init(&1, module))
    {:ok, :before_init_success}
  end

  @spec create_table(atom()) :: {:ok, :created | :recovered} | {:error, any()}
  def create_table(table), do: adapter(table).create_table(table)

  @doc """
  Delete the record provided.

  Returns `:ok` for a struct matching `table` or for `nil`, and
  `{:error, :bad_schema}` when the struct does not match `table`.
  """
  @spec delete(any(), atom()) :: :ok | {:error, any()}
  def delete(%{__struct__: table} = struct, table), do: adapter(table).delete(struct, table)

  def delete(nil, _table), do: :ok

  def delete(_struct, _table), do: {:error, :bad_schema}

  @spec delete_all(atom()) :: :ok | {:error, any()}
  def delete_all(table), do: adapter(table).delete_all(table)

  @doc """
  Delete every record in `table` whose `expires_at` is at or before `now`
  (milliseconds). Used by the `Store`/`ActiveRepo` sweep to reclaim memory; reads
  already hide expired records, so this is only about freeing them.
  """
  @spec delete_expired(atom(), integer()) :: :ok
  def delete_expired(table, now) do
    table
    |> adapter(table).all()
    |> Enum.each(fn record ->
      case expired_at?(record, now) do
        true -> adapter(table).delete(record, table)
        false -> :ok
      end
    end)
  end

  @doc """
  Get one record matching an attributes map or a `match` query. An expired record
  is treated as `{:error, :not_found}`.
  """
  @spec one(map() | tuple(), atom()) :: {:ok, map()} | {:error, any()}
  def one(query, table) do
    case adapter(table).one(query, table) do
      {:ok, %{} = record} -> reject_if_expired(record, table)
      {:error, message} -> {:error, message}
    end
  end

  @doc """
  Evaluate `seed_file` and write its records to `table`.

  A `nil` `seed_file` is a no-op. Returns `{:ok, :seed_success}` or
  `{:error, reason}`.
  """
  @spec seed(binary() | nil, atom()) :: {:ok, :seed_success} | {:error, any()}
  def seed(nil, _table), do: {:ok, :seed_success}

  def seed(seed_file, table) do
    with {seeds, _bindings} when is_list(seeds) <- Code.eval_file(seed_file),
         true <- write_seeds(seeds, table) do
      {:ok, :seed_success}
    else
      {:error, message} -> {:error, message}
      _ -> {:error, :seed_failure}
    end
  end

  @doc """
  Get all records matching an attributes map or a `match` query.

  Returns `{:error, :bad_select_query}` for any other query shape.
  """
  @spec select(map() | tuple(), atom()) :: {:ok, list(map())} | {:error, any()}
  def select(query, table) when is_map(query) do
    filter_select(adapter(table).select(query, table), table)
  end

  def select({_operand, _lhs, _rhs} = query, table) do
    filter_select(adapter(table).select(query, table), table)
  end

  def select(_query, _table), do: {:error, :bad_select_query}

  @doc """
  Get one record matching the query, delete it, and return it. An expired record
  is treated as `{:error, :not_found}`.
  """
  @spec withdraw(map() | tuple(), atom()) :: {:ok, map()} | {:error, any()}
  def withdraw(query, table) do
    case adapter(table).withdraw(query, table) do
      {:ok, %{} = record} -> reject_if_expired(record, table)
      other -> other
    end
  end

  @doc """
  Write a record to `table`.

  When the schema has a `uuid` field a value is generated if one is absent. When
  the table has a `ttl` the record's `expires_at` is stamped from the current time.
  Returns `{:error, :bad_schema}` when the struct does not match `table`.
  """
  @spec write(map(), atom()) :: {:ok, map()} | {:error, any()}
  def write(%{__struct__: table} = struct, table) do
    struct = put_expiry(struct, table)

    case Map.has_key?(struct, :uuid) do
      true -> write_with_uuid(struct, table)
      false -> normal_write(struct, table)
    end
  end

  def write(_struct, _table), do: {:error, :bad_schema}

  defp adapter(table), do: table.__attributes__(:adapter)

  defp expired?(record, table) do
    case table.__attributes__(:ttl) do
      nil -> false
      _ttl -> expired_at?(record, now_ms())
    end
  end

  defp expired_at?(record, now) do
    case Map.get(record, :expires_at) do
      expires_at when is_integer(expires_at) -> expires_at <= now
      _not_set -> false
    end
  end

  defp filter_select({:ok, records}, table), do: {:ok, reject_expired(records, table)}

  defp filter_select({:error, _message} = error, _table), do: error

  defp normal_write(struct, table), do: adapter(table).write(struct, table)

  defp now_ms, do: System.system_time(:millisecond)

  defp put_expiry(struct, table) do
    case table.__attributes__(:ttl) do
      nil -> struct
      ttl -> Map.put(struct, :expires_at, now_ms() + ttl)
    end
  end

  defp reject_expired(records, table) do
    case table.__attributes__(:ttl) do
      nil -> records
      _ttl -> Enum.reject(records, &expired?(&1, table))
    end
  end

  defp reject_if_expired(record, table) do
    case expired?(record, table) do
      true -> {:error, :not_found}
      false -> {:ok, record}
    end
  end

  defp write_seeds(seeds, table) do
    seeds
    |> Task.async_stream(fn seed -> write(seed, table) end)
    |> Enum.all?(fn {:ok, {result, _seed}} -> result == :ok end)
  end

  defp write_with_uuid(struct, table) do
    case Map.get(struct, :uuid) do
      nil ->
        with_uuid = Map.put(struct, :uuid, Ecto.UUID.generate())
        adapter(table).write(with_uuid, table)

      uuid when is_binary(uuid) ->
        adapter(table).write(struct, table)
    end
  end
end