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 """
Properties
"""
@type property() :: any()
@type pattern() :: any()
@type value() :: any()
@type property_value() :: {property(), value()}
@typedoc """
PropertyTable configuration options
See `start_link/2` for usage.
"""
@type option() ::
{:name, table_id()} | {:properties, [property_value()]} | {:tuple_events, boolean()}
@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.
Additional options are:
* `: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.
Persistent options:
* You MUST set at least `:persist_data_path` for any of the other options to be respected!
* If the table can restore its data from disk, it will IGNORE your initial `:properties` value.
* `: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.
"""
@spec start_link([option()]) :: 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(keyword()) :: Supervisor.child_spec()
def child_spec(opts) do
%{
id: Keyword.get(opts, :name, PropertyTable),
start: {PropertyTable, :start_link, [opts]},
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 """
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.
"""
@spec snapshot(table_id()) :: {:ok, String.t()} | :noop
defdelegate snapshot(table), to: Updater
@doc """
If persistence is enabled for this property table, save the current state
to disk immediately.
"""
@spec flush_to_disk(table_id()) :: :ok
defdelegate flush_to_disk(table), to: Updater
@doc """
Returns a list of availiable snapshot IDs and full name tuples for
a property table with persistence enable
"""
@spec get_snapshots(table_id()) :: [{String.t(), String.t()}]
defdelegate get_snapshots(table), to: Updater
@doc """
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