144 lines
4.1 KiB
Elixir
144 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
|