Current section
Files
Jump to
Current section
Files
lib/kv.ex
defmodule KV do
@moduledoc """
A modular key-value store for arbitrary,
disk-persisted term storage based on `:dets`.
## Usage
Add `KV` to your supervision tree:
{KV, root: "/tmp"}
"""
use TypedStruct
use GenServer
@type table :: atom()
@type key :: atom()
@type object :: term()
typedstruct module: State do
@moduledoc false
field :root, String.t(), required: true
field :tables, map(), default: %{}
end
@type start_opts :: [
root: Path.t()
]
@doc """
Starts the store.
"""
@spec start_link(start_opts()) :: :ok
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Puts the given `object` in `table`.
If the table does not exist yet, it is created on-demand.
## Examples
iex> KV.put(:users, 1, %User{id: 1, name: "Robin", age: 18})
iex> KV.put(:users, 2, %User{id: 2, name: "Gijs", age: 15})
:ok
"""
@spec put(table(), key(), object()) :: :ok | :error
def put(table, key, object) do
GenServer.call(__MODULE__, {:put, table, key, object})
end
@doc """
Gets the object for the given `key` from `table`.
## Examples
iex> KV.get(:users, 1)
{:ok, %User{id: 1, name: "Robin", age: 18}}
iex> KV.get(:users, 3)
:error
iex> KV.get(:non_existant, 3)
:error
"""
@spec fetch(table(), key()) :: {:ok, object()} | :error
def fetch(table, key) do
GenServer.call(__MODULE__, {:fetch, table, key})
end
@doc """
Same as `fetch/2`, but returns `nil` if the given table
or key cannot be found.
## Examples
iex> KV.get(:users, 1)
%User{id: 1, name: "Robin", age: 18}
iex> KV.get(:users, 3)
nil
iex> KV.get(:non_existant, 3)
nil
"""
@spec get(table(), key()) :: object() | nil
def get(table, key) do
case fetch(table, key) do
{:ok, object} -> object
:error -> nil
end
end
@doc """
Deletes the object for the given `key` from `table`.
## Examples
iex> KV.put(:users, 1, %User{id: 1, name: "Robin", age: 18})
iex> KV.get(:users, 1)
%User{id: 1, name: "Robin", age: 18}
iex> KV.delete(:users, 1)
iex> KV.get(:users, 1)
nil
"""
@spec delete(table(), key()) :: :ok | :error
def delete(table, key) do
GenServer.call(__MODULE__, {:delete, table, key})
end
@doc """
Lists all objects in the given `table`.
## Examples
iex> KV.list(:users)
[%User{id: 1, name: "Robin", age: 18}, %User{id: 2, name: "Gijs", age: 15}]
"""
@spec list(table()) :: [object()]
def list(table) do
GenServer.call(__MODULE__, {:list, table})
end
@doc """
Deletes all objects from the given `table`, effectively
emptying it.
## Examples
iex> KV.list(:users)
[%User{id: 1, name: "Robin", age: 18}, %User{id: 2, name: "Gijs", age: 15}]
iex> KV.clear(:users)
iex> KV.list(:users)
[]
"""
@spec clear(table()) :: :ok | :error
def clear(table) do
GenServer.call(__MODULE__, {:clear, table})
end
@doc false
@impl true
def init(opts) do
{:ok, %State{root: Keyword.get(opts, :root) || "/tmp"}}
end
@doc false
@impl true
def handle_call({:put, table, key, object}, _from, state) do
state = ensure_table!(state, table)
:dets.insert(table, {key, object})
{:reply, :ok, state}
end
@doc false
@impl true
def handle_call({:fetch, table, key}, _from, state) do
state = ensure_table!(state, table)
case :dets.lookup(table, key) do
[{^key, object}] -> {:reply, {:ok, object}, state}
[] -> {:reply, :error, state}
{:error, _} -> {:reply, :error, state}
end
end
@doc false
@impl true
def handle_call({:delete, table, key}, _from, state) do
state = ensure_table!(state, table)
case :dets.delete(table, key) do
:ok -> {:reply, :ok, state}
{:error, _} -> {:reply, :error, state}
end
end
@doc false
@impl true
def handle_call({:list, table}, _from, state) do
state = ensure_table!(state, table)
records = :dets.foldl(fn {_, v}, acc -> [v | acc] end, [], table)
{:reply, records, state}
end
@doc false
@impl true
def handle_call({:clear, table}, _from, state) do
state = ensure_table!(state, table)
case :dets.delete_all_objects(table) do
:ok -> {:reply, :ok, state}
{:error, _} -> {:reply, :error, state}
end
end
@doc false
@impl true
def terminate(_reason, state) do
state.tables
|> Map.keys()
|> Enum.each(&:dets.close/1)
end
defp ensure_table!(state, table)
when is_map_key(state.tables, table), do: state
defp ensure_table!(state, table) when is_atom(table) do
path = construct_path(state.root, table)
case :dets.open_file(table, file: path, type: :set) do
{:ok, ^table} ->
%State{state | tables: Map.put(state, table, true)}
{:error, {:already_exists, ^table}} ->
%State{state | tables: Map.put(state, table, true)}
{:error, reason} ->
raise "Failed to open table #{inspect(table)}: #{inspect(reason)}"
end
end
defp construct_path(root, table) do
root |> Path.join(to_string(table)) |> to_charlist()
end
end