lib/property_table.ex

defmodule PropertyTable do
  @moduledoc File.read!("README.md")
             |> String.split("<!-- MODULEDOC -->")
             |> Enum.fetch!(1)

  alias PropertyTable.Updater

  @typedoc """
  A table_id identifies a group of properties
  """
  @type table_id() :: atom()

  @typedoc """
  A property

  Properties can be anything, but in order to be useful, they need to be
  compatible with the `PropertyTable.Matcher` implementation.

  In is common for this to be a string list (`[String.t()]`) since the default
  `PropertyTable.Matcher` works with those.
  """
  @type property() :: any()

  @typedoc """
  A match pattern

  Just like properties, these can be anything but they have to be compatible
  with the `PropertyTable.Matcher` implementation.

  The default is that patterns are string lists with the addition of wildcards
  like `:_`.
  """
  @type pattern() :: any()

  @typedoc """
  A properties value

  These can be whatever makes sense to the PropertyTable user. The only
  constraint is that if you're using PropertyTable's persistence feature, it
  needs to be possible to save and restore them. This means that pids and
  references, for example, can't be used.
  """
  @type value() :: any()

  @typedoc """
  A property/value tuple
  """
  @type property_value() :: {property(), value()}

  @typedoc """
  PropertyTable configuration options

  See `start_link/1` for usage.
  """
  @type options() :: [
          name: table_id(),
          properties: [property_value()],
          tuple_events: boolean(),
          matcher: module(),
          persist_data_path: String.t(),
          persist_interval: pos_integer(),
          persist_max_snapshots: pos_integer(),
          persist_compression: 0..9
        ]

  @doc """
  Start a PropertyTable's supervision tree

  To create a PropertyTable for your application or library, add the following
  `child_spec` to one of your supervision trees:

  ```elixir
  {PropertyTable, name: MyTableName}
  ```

  The `:name` option is required. All calls to `PropertyTable` will need to
  know it and the process will be registered under than name so be sure it's
  unique.

  Options for properties and events:

  * `:properties` - a list of `{property, value}` tuples to initially populate
    the `PropertyTable`
  * `:matcher` - set the format for how properties and how they should be
    matched for triggering events. See `PropertyTable.Matcher`.
  * `:tuple_events` - set to `true` for change events to be in the old tuple
    format. This is not recommended for new code and hopefully will be removed
    in the future.

  Options for persisting properties:

  * `:persist_data_path` - set to a directory where PropertyTable will
    persist the contents of the table to disk, snapshots will also be stored here.
  * `:persist_interval` - if set PropertyTable will persist the contents of
    tables to disk in intervals of the provided value (in milliseconds) automatically.
  * `:persist_max_snapshots` - Maximum number of manual snapshots to keep on disk before they
    are replaced - (oldest snapshots are replaced first.) Defaults to 25.
  * `:persist_compression` - `0..9` range to compress the terms when written to disk, see `:erlang.term_to_binary/2`. Defaults to 6.

  > #### Important {: .info}
  >
  > Setting `:persist_data_path` enables persistence. On initialization, if
  > PropertyTable is able to load a snapshot, the data in the snapshot is used
  > instead of the `:properties` option.
  """
  @spec start_link(options()) :: Supervisor.on_start()
  def start_link(options) do
    name =
      case Keyword.fetch(options, :name) do
        {:ok, name} when is_atom(name) ->
          name

        {:ok, other} ->
          raise ArgumentError, "expected :name to be an atom, got: #{inspect(other)}"

        :error ->
          raise ArgumentError, "expected :name option to be present"
      end

    tuple_events = Keyword.get(options, :tuple_events, false)

    unless is_boolean(tuple_events) do
      raise ArgumentError, "expected :tuple_events to be boolean, got: #{inspect(tuple_events)}"
    end

    matcher = Keyword.get(options, :matcher, PropertyTable.Matcher.StringPath)

    unless is_atom(matcher) do
      raise ArgumentError, "expected :matcher to be module, got: #{inspect(matcher)}"
    end

    properties = Keyword.get(options, :properties, [])

    unless Enum.all?(properties, fn {k, _} -> matcher.check_property(k) == :ok end) do
      raise ArgumentError,
            "expected :properties to contain valid properties, got: #{inspect(properties)}"
    end

    persistence_options = maybe_get_persistence_options(options)

    Supervisor.start_link(
      __MODULE__.Supervisor,
      %{
        table: name,
        properties: properties,
        tuple_events: tuple_events,
        matcher: matcher,
        persistence_options: persistence_options
      },
      name: name
    )
  end

  @doc """
  Returns a specification to start a property_table under a supervisor.
  See `Supervisor`.
  """
  @spec child_spec(options()) :: Supervisor.child_spec()
  def child_spec(options) do
    %{
      id: Keyword.get(options, :name, PropertyTable),
      start: {PropertyTable, :start_link, [options]},
      type: :supervisor
    }
  end

  @doc """
  Subscribe to receive events
  """
  @spec subscribe(table_id(), pattern()) :: :ok
  def subscribe(table, pattern) do
    registry = PropertyTable.Supervisor.registry_name(table)
    {:ok, matcher} = Registry.meta(registry, :matcher)

    case matcher.check_pattern(pattern) do
      :ok ->
        {:ok, _} = Registry.register(registry, :subscriptions, pattern)
        :ok

      {:error, error} ->
        raise error
    end
  end

  @doc """
  Stop subscribing to a property
  """
  @spec unsubscribe(table_id(), pattern()) :: :ok
  def unsubscribe(table, pattern) do
    registry = PropertyTable.Supervisor.registry_name(table)
    Registry.unregister_match(registry, :subscriptions, pattern)
  end

  @doc """
  Get the current value of a property
  """
  @spec get(table_id(), property(), value()) :: value()
  def get(table, property, default \\ nil) do
    case :ets.lookup(table, property) do
      [{_property, value, _timestamp}] -> value
      [] -> default
    end
  end

  @doc """
  Fetch a property with the time that it was set

  Timestamps come from `System.monotonic_time()`
  """
  @spec fetch_with_timestamp(table_id(), property()) :: {:ok, value(), integer()} | :error
  def fetch_with_timestamp(table, property) do
    case :ets.lookup(table, property) do
      [{_property, value, timestamp}] -> {:ok, value, timestamp}
      [] -> :error
    end
  end

  @doc """
  Get all properties

  This function might return a really long list so it's mainly intended for
  debug or convenience when you know that the table only contains a few
  properties.
  """
  @spec get_all(table_id()) :: [{property(), value()}]
  def get_all(table) do
    :ets.foldl(
      fn {property, value, _timestamp}, acc -> [{property, value} | acc] end,
      [],
      table
    )
  end

  @doc """
  Get a list of all properties matching the specified property pattern
  """
  @spec match(table_id(), pattern()) :: [{property(), value()}]
  def match(table, pattern) do
    registry = PropertyTable.Supervisor.registry_name(table)
    {:ok, matcher} = Registry.meta(registry, :matcher)

    :ets.foldl(
      fn {property, value, _timestamp}, acc ->
        if matcher.matches?(pattern, property) do
          [{property, value} | acc]
        else
          acc
        end
      end,
      [],
      table
    )
  end

  @doc """
  Update a property and notify listeners
  """
  @spec put(table_id(), property(), value()) :: :ok
  defdelegate put(table, property, value), to: Updater

  @doc """
  Update many properties

  This is similar to calling `put/3` several times in a row, but atomically. It is
  also slightly more efficient when updating more than one property.
  """
  @spec put_many(table_id(), [{property(), value()}]) :: :ok
  defdelegate put_many(table, properties), to: Updater

  @doc """
  Delete the specified property
  """
  @spec delete(table_id(), property()) :: :ok
  defdelegate delete(table, property), to: Updater

  @doc """
  Delete all properties that match a pattern
  """
  @spec delete_matches(table_id(), pattern()) :: :ok
  defdelegate delete_matches(table, pattern), to: Updater

  @doc """
  Take a snapshot of the property table

  If persistence is enabled for this property table, save the current state
  and copy a snapshot of it into the `/snapshots` sub-directory of the set
  data directory.

  This returns an ID for the snapshot that can be passed `restore_snapshot/2`.
  """
  @spec snapshot(table_id()) :: {:ok, String.t()} | :noop
  defdelegate snapshot(table), to: Updater

  @doc """
  Write any changes to disk

  If persistence is enabled for this property table, save the current state to
  disk immediately. The table is already written every `:persist_interval`, but
  this is avoid waiting after important changes.
  """
  @spec flush_to_disk(table_id()) :: :ok | {:error, any()}
  defdelegate flush_to_disk(table), to: Updater

  @doc """
  Return available snapshot IDs

  This scans the `snapshots` directory and returns a list of tuples containing
  snapshot IDs and their full name.
  """
  @spec get_snapshots(table_id()) :: [{String.t(), String.t()}]
  defdelegate get_snapshots(table), to: Updater

  @doc """
  Restart a previously saved snapshot

  If persistence is enabled for this property table, restore the current state
  of the PropertyTable to that of a past named snapshot
  """
  @spec restore_snapshot(table_id(), String.t()) :: :ok | :noop
  defdelegate restore_snapshot(table, snapshot_name), to: Updater

  defp maybe_get_persistence_options(options) do
    if Keyword.has_key?(options, :persist_data_path) do
      table_name = Keyword.get(options, :name) |> Atom.to_string()

      # Set persistence options, and clean out any nil values
      # they will be filled with defaults in `PropertyTable.Persist`
      [
        data_directory: Keyword.get(options, :persist_data_path),
        table_name: table_name,
        interval: Keyword.get(options, :persist_interval),
        max_snapshots: Keyword.get(options, :persist_max_snapshots),
        compression: Keyword.get(options, :persist_compression)
      ]
      |> Enum.filter(fn {_, v} -> v != nil end)
    else
      # :persist_data_path must be set for any of the other options to be respected
      # no persistence will be configured
      nil
    end
  end
end