language_colours/lib/ets_database.ex
2021-09-03 21:51:45 +03:00

143 lines
4.1 KiB
Elixir

defmodule LanguageColours.ETSDatabase do
@moduledoc """
Database that stores the language colours in an ETS table. Get operations will directly read
from the database, update operations will be serialized through the database process.
Please see [the main module documentation](LanguageColours.html#module-configuration) for the
available configuration options and how to set up the database.
"""
use GenServer
require Logger
@behaviour LanguageColours.Database
@table :language_colours_etsdatabase
defmodule Options do
@enforce_keys [:file]
defstruct [:file, server_opts: [], ets_table: :language_colours_etsdatabase]
@typedoc """
Options to give to the `LanguageColours.ETSDatabase.start_link/1` function.
* `:server_opts`: Any options to give to `GenServer.start_link/3`.
* `:file`: The file to read JSON data from.
* `:ets_table`: Name of the ETS table to store the data in.
"""
@type t :: %__MODULE__{
server_opts: GenServer.options(),
file: Path.t(),
ets_table: atom()
}
end
defmodule State do
@moduledoc false
@enforce_keys [:ets_table, :init_file]
defstruct [:ets_table, :init_file]
@type t :: %__MODULE__{
ets_table: :ets.tid() | atom(),
init_file: Path.t()
}
end
@spec start_link(Options.t()) :: GenServer.on_start()
def start_link(%Options{} = opts) do
opts = update_in(opts.server_opts[:name], &(&1 || __MODULE__))
GenServer.start_link(__MODULE__, opts, opts.server_opts)
end
@impl true
@spec init(Options.t()) :: {:ok, State.t()}
def init(%Options{} = opts) do
tid =
:ets.new(opts.ets_table, [
:named_table,
:set,
:protected,
read_concurrency: true
])
load_file(tid, opts.file)
{:ok, %State{ets_table: tid, init_file: opts.file}}
end
@impl true
def handle_call(msg, from, state)
def handle_call({:update, file}, _from, %State{} = state) do
file = file || state.init_file
load_file(state.ets_table, file)
{:reply, :ok, state}
end
@impl true
def get(language, db_config) do
table = Map.get(db_config, :ets_table, @table)
case :ets.lookup(table, String.downcase(language)) do
[] -> nil
[{_, colour}] -> colour
end
end
@impl true
def update(db_config) do
server = Map.get(db_config, :server_name, __MODULE__)
file = Map.get(db_config, :file)
GenServer.call(server, {:update, file})
end
@doc """
Get an `t:LanguageColours.ETSDatabase.Options.t/0` struct corresponding to the given database
configuration.
This is a helper for forming the options struct for starting the database in a supervisor.
NOTE: When using this function, the `:file` option is required.
"""
@spec startup_options(map()) :: Options.t()
def startup_options(db_config) do
%Options{
file: Map.fetch!(db_config, :file),
ets_table: Map.get(db_config, :ets_table, @table),
server_opts: [name: Map.get(db_config, :server_name)]
}
end
@spec load_file(:ets.tid() | atom(), Path.t()) :: :ok
defp load_file(tid, file) do
json_parser = Application.get_env(:language_colours, :json_parser, Jason)
with {:read, {:ok, data}} <- {:read, File.read(file)},
{:json, {:ok, parsed}} <- {:json, json_parser.decode(data)} do
data =
Enum.map(parsed, fn {key, value} ->
{String.downcase(key), Map.get(value, "color")}
end)
:ets.insert(tid, data)
# Delete any languages that were not in updated data
new_languages = parsed |> Map.keys() |> MapSet.new(&String.downcase/1)
all_languages = tid |> :ets.tab2list() |> Enum.map(&elem(&1, 0)) |> MapSet.new()
old_languages = MapSet.difference(all_languages, new_languages)
Enum.each(old_languages, &:ets.delete(tid, &1))
Logger.debug(
"Read #{Enum.count(data)} languages from JSON and deleted #{Enum.count(old_languages)} stale."
)
else
{:read, _} -> raise "Could not read file #{file}"
{:json, err} -> raise "Could not parse file #{file}: #{inspect(err)}"
end
end
end