defmodule Fsdb.Server do @moduledoc """ Server module. """ use GenServer @rowre ~r/row\.[0-9]+/ @rowid "row.id" ########################################## # Public API ########################################## @doc """ Starts the GenServer. `state` *must* contain a valid path in the `:path` key. `opts` is optional and is passed verbatim to GenServer. Returns `{:ok, pid}`. ## Example ``` Fsdb.Server.start_link([path: ".fsdb"], [name: Fsdb.Server]) ``` """ def start_link(state, opts \\ []) do GenServer.start_link(__MODULE__, state, opts) end @doc """ Stops the GenServer. Returns `:ok`. """ def stop(pid) do GenServer.cast(pid, :stop) end ########################################## # GenServer Implementation ########################################## def init(state) do #path expansion should happend only once at config time #to ensure it repeats even under current directory change path = Keyword.fetch!(state, :path) File.mkdir_p!(path) {:ok, {path}} end def handle_cast(:stop, state={_path}) do {:stop, :normal, state} end def handle_call({:create, table}, _from, state={path}) do {:reply, create(path, table), state} end def handle_call({:drop, table}, _from, state={path}) do {:reply, drop(path, table), state} end def handle_call({:list, table}, _from, state={path}) do {:reply, list(path, table), state} end def handle_call({:insert, table, row}, _from, state={path}) do {:reply, insert(path, table, row), state} end def handle_call({:save, table, id, row}, _from, state={path}) do {:reply, save(path, table, id, row), state} end def handle_call({:fetch, table, id}, _from, state={path}) do {:reply, fetch(path, table, id), state} end def handle_call({:update, table, id, row}, _from, state={path}) do {:reply, update(path, table, id, row), state} end def handle_call({:delete, table, id}, _from, state={path}) do {:reply, delete(path, table, id), state} end ########################################## # Internal Implementation ########################################## defp create(path, table) do folder = Path.join(path, table) if !File.exists?(folder) do File.mkdir_p!(folder) file = Path.join(folder, @rowid) :ok = dump(file, 0) end :ok end defp drop(path, table) do folder = Path.join(path, table) File.rm_rf!(folder) :ok #rm returns list of deleted files end defp list(path, table) do folder = Path.join(path, table) for file <- File.ls!(folder), Regex.match?(@rowre, file) do parse(Path.join(folder, file)) end end defp parse(file) do txt = File.read!(file) |> :erlang.binary_to_list {:ok, tokens, _} = :erl_scan.string(txt) {:ok, row} = :erl_parse.parse_term(tokens) row end defp insert(path, table, row) do folder = Path.join(path, table) id = nextid(folder) file = Path.join(folder, rowid(id)) :ok = dump(file, {id, row}) {:ok, id} end defp save(path, table, id, row) do folder = Path.join(path, table) file = Path.join(folder, rowid(id)) if File.exists?(file) do {^id, crow} = parse(file) :ok = dump(file, {id, row}) maxid(folder, id) {:ok, crow} else :ok = dump(file, {id, row}) maxid(folder, id) :ok end end defp nextid(folder) do file = Path.join(folder, @rowid) id = parse(file) + 1 :ok = dump(file, id) id end defp maxid(folder, id) do file = Path.join(folder, @rowid) cid = parse(file) mid = if id > cid, do: id, else: cid :ok = dump(file, mid) mid end defp dump(file, row) do File.write! file, :io_lib.format("~p.", [row]) end defp fetch(path, table, id) do folder = Path.join(path, table) file = Path.join(folder, rowid(id)) if File.exists?(file) do {^id, row} = parse(file) {:ok, row} else :nf end end defp rowid(id) do fid = Integer.to_string(id) |> String.rjust(6, ?0) "row.#{fid}" end defp update(path, table, id, row) do folder = Path.join(path, table) file = Path.join(folder, rowid(id)) if File.exists?(file) do {^id, crow} = parse(file) :ok = dump(file, {id, row}) {:ok, crow} else :nf end end defp delete(path, table, id) do folder = Path.join(path, table) file = Path.join(folder, rowid(id)) if File.exists?(file) do {^id, row} = parse(file) File.rm!(file) {:ok, row} else :nf end end end