lib/vintage_net/property_table.ex

defmodule VintageNet.PropertyTable do
  @moduledoc """
  PropertyTables are in-memory key-value stores

  Users can subscribe to keys or groups of keys to be notified of changes.

  Keys are hierarchically layed out with each key being represented as a list
  for the path to the key. For example, to get the current state of the network
  interface `eth0`, you would get the value of the key, `["net", "ethernet",
  "eth0"]`.

  Values can be any Elixir data structure except for `nil`. `nil` is used to
  identify non-existent keys. Therefore, setting a property to `nil` deletes
  the property.

  Users can get and listen for changes in multiple keys by specifying prefix
  paths. For example, if you wants to get every network property, run:

      PropertyTable.get_by_prefix(table, ["net"])

  Likewise, you can subscribe to changes in the network status by running:

      PropertyTable.subscribe(table, ["net"])

  Properties can include metadata. `PropertyTable` only specifies that metadata
  is a map.
  """

  alias VintageNet.PropertyTable.Table

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

  @typedoc """
  Properties
  """
  @type property :: [String.t()]
  @type property_with_wildcards :: [String.t() | :_]
  @type value :: any()
  @type property_value :: {property(), value()}
  @type metadata :: map()

  @type options :: [name: table_id(), properties: [property_value()]]

  @spec start_link(options()) :: {:ok, pid} | {:error, term}
  def start_link(options) do
    name = Keyword.get(options, :name)

    unless !is_nil(name) and is_atom(name) do
      raise ArgumentError, "expected :name to be given and to be an atom, got: #{inspect(name)}"
    end

    VintageNet.PropertyTable.Supervisor.start_link(options)
  end

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

  @doc """
  Subscribe to receive events
  """
  @spec subscribe(table_id(), property_with_wildcards()) :: :ok
  def subscribe(table, name) when is_list(name) do
    assert_property_with_wildcards(name)

    registry = VintageNet.PropertyTable.Supervisor.registry_name(table)
    {:ok, _} = Registry.register(registry, :property_registry, name)

    :ok
  end

  @doc """
  Stop subscribing to a property
  """
  @spec unsubscribe(table_id(), property_with_wildcards()) :: :ok
  def unsubscribe(table, name) when is_list(name) do
    registry = VintageNet.PropertyTable.Supervisor.registry_name(table)
    Registry.unregister(registry, :property_registry)
  end

  @doc """
  Get the current value of a property
  """
  @spec get(table_id(), property(), value()) :: value()
  def get(table, name, default \\ nil) when is_list(name) do
    assert_property(name)
    Table.get(table, name, default)
  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, name) when is_list(name) do
    assert_property(name)
    Table.fetch_with_timestamp(table, name)
  end

  @doc """
  Get a list of all properties matching the specified prefix
  """
  @spec get_by_prefix(table_id(), property()) :: [{property(), value()}]
  def get_by_prefix(table, prefix) when is_list(prefix) do
    assert_property(prefix)

    Table.get_by_prefix(table, prefix)
  end

  @doc """
  Get a list of all properties matching the specified prefix
  """
  @spec match(table_id(), property_with_wildcards()) :: [{property(), value()}]
  def match(table, pattern) when is_list(pattern) do
    assert_property_with_wildcards(pattern)

    Table.match(table, pattern)
  end

  @doc """
  Update a property and notify listeners
  """
  @spec put(table_id(), property(), value(), metadata()) :: :ok
  def put(table, name, value, metadata \\ %{}) when is_list(name) do
    Table.put(table, name, value, metadata)
  end

  @doc """
  Clear out a property
  """
  defdelegate clear(table, name), to: Table

  @doc """
  Clear out all properties under a prefix
  """
  defdelegate clear_prefix(table, name), to: Table

  defp assert_property(name) do
    Enum.each(name, fn
      v when is_binary(v) -> :ok
      :_ -> raise ArgumentError, "Wildcards not allowed in this property"
      _ -> raise ArgumentError, "Property should be a list of strings"
    end)
  end

  defp assert_property_with_wildcards(name) do
    Enum.each(name, fn
      v when is_binary(v) -> :ok
      :_ -> :ok
      _ -> raise ArgumentError, "Property should be a list of strings"
    end)
  end
end