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