lib/topical/topic.ex

defmodule Topical.Topic do
  @moduledoc """
  This module provides functions for instantiating and manipulating topic state.

  The state of a topic is composed of a _value_, observed by subscribed clients, and also _hidden_
  internal state, which can be used to share state between calls. The value must only be
  manipulated by the helper functions (which track the individual updates). The hidden `state` can
  be modified directly.

  This module also contains a macro - `use`-ing it sets up a topic server:

      defmodule MyApp.Topics.List do
        use Topical.Topic, route: "lists/:list_id"

        # Initialise the topic
        def init(params) do
          list_id = Keyword.fetch!(params, :list_id)

          value = %{items: %{}, order: []} # exposed 'value' of the topic
          state = %{list_id: list_id} # hidden server state
          topic = Topic.new(value, state)

          {:ok, topic}
        end

        # Optionally, handle subscribe
        def handle_subscribe(topic, _context) do
          {:ok, topic}
        end

        # Optionally, handle unsubscribe
        def handle_unsubscribe(topic, _context) do
          {:ok, topic}
        end

        # Optionally, handle capture
        def handle_capture(topic, _context) do
          {:ok, topic}
        end

        # Optionally, handle execution of an action
        def handle_execute("add_item", {text}, topic, _context) do
          id = Integer.to_string(:erlang.system_time())

          # Update the topic by putting the item in 'items', and appending the id to 'order'
          topic =
            topic
            |> Topic.set([:items, id], %{text: text, done: false})
            |> Topic.insert([:order], id)

          # Return the result (the 'id'), and the updated topic
          {:ok, id, topic}
        end

        # Optionally, handle a notification (an action without a result)
        def handle_notify("update_text", {id, text}, topic, _context) do
          topic  = Topic.set(topic, [:items, id, :text], text)
          {:ok, topic}
        end

        # Optionally, handle Erlang messages
        def handle_info({:done, id}, topic) do
          topic  = Topic.set(topic, [:items, id, :done], true)
          {:ok, topic}
        end

        # Optionally, handle the topic being terminated (e.g., once clients have disconnected)
        def terminate(_reason, topic) do
          # ...
        end
      end
  """

  alias Topical.Topic.Update

  @doc false
  defmacro __using__(opts) do
    quote location: :keep, bind_quoted: [opts: opts] do
      @behaviour Topical.Topic.Server

      alias Topical.Topic

      def route() do
        unquote(Keyword.fetch!(opts, :route))
      end

      def handle_subscribe(topic, _context) do
        {:ok, topic}
      end

      def handle_unsubscribe(topic, _context) do
        {:ok, topic}
      end

      def handle_capture(topic, _context) do
        {:ok, topic}
      end

      def handle_execute(_action, _args, _topic, _context) do
        raise "no handle_execute/4 implemented"
      end

      def handle_notify(_action, _args, _topic, _context) do
        raise "no handle_notify/4 implemented"
      end

      def handle_info(_msg, topic) do
        # TODO: log warning?
        {:ok, topic}
      end

      def terminate(_reason, _topic) do
        :ok
      end

      defoverridable handle_subscribe: 2,
                     handle_unsubscribe: 2,
                     handle_capture: 2,
                     handle_execute: 4,
                     handle_notify: 4,
                     handle_info: 2,
                     terminate: 2
    end
  end

  defstruct [:state, :value, :updates]

  @doc """
  Instantiates a new instance of a topic.

  `value` is the initial value of the client-visible state. `state` is the 'hidden' internal state.
  """
  def new(value, state \\ nil) do
    %__MODULE__{value: value, state: state, updates: []}
  end

  defp update(topic, update) do
    topic_value = Update.apply(topic.value, update)

    topic
    |> Map.update!(:updates, &[update | &1])
    |> Map.put(:value, topic_value)
  end

  @doc """
  Updates the topic by setting the `value` at the `path`.

      %{foo: %{bar: 2}}
      |> Topic.new()
      |> Topic.set([:foo, :bar], 3)
      |> Map.fetch!(:value)
      #=> %{foo: %{bar: 3}}
  """
  def set(topic, path, value) do
    update(topic, {:set, path, value})
  end

  @doc """
  Updates the topic by unsetting the `key` of the object at the `path`.

      %{foo: %{bar: 2}}
      |> Topic.new()
      |> Topic.unset([:foo], :bar)
      |> Map.fetch!(:value)
      #=> %{foo: %{}}
  """
  def unset(topic, path, key) do
    update(topic, {:unset, path, key})
  end

  @doc """
  Updates the topic by inserting the specified `values` into the array at the `path`, at the
  `index` (or append them to the end, if no `index` is specified).

  If `value` is a list, all the items will be added (to add a list as a single item, wrap it in a
  list).

      %{foo: %{bar: [1, 4]}}
      |> Topic.new()
      |> Topic.insert([:foo, :bar], 1, [2, 3])
      |> Map.fetch!(:value)
      #=> %{foo: %{bar: [1, 2, 3, 4]}}
  """
  def insert(topic, path, index \\ nil, value) do
    value = List.wrap(value)

    if Enum.any?(value) do
      update(topic, {:insert, path, index, value})
    else
      topic
    end
  end

  @doc """
  Updates the topic be deleting `count` values from the array at the `path`, from the `index`.

      %{foo: %{bar: [1, 2, 3, 4]}}
      |> Topic.new()
      |> Topic.delete([:foo, :bar], 2)
      |> Map.fetch!(:value)
      #=> %{foo: %{bar: [1, 2, 4]}}
  """
  def delete(topic, path, index, count \\ 1) do
    update(topic, {:delete, path, index, count})
  end
end