lib/nostrum/cache/presence_cache.ex

defmodule Nostrum.Cache.PresenceCache do
  @default_cache_implementation Nostrum.Cache.PresenceCache.ETS
  @moduledoc """
  Cache behaviour & dispatcher for Discord presences.

  By default, `#{@default_cache_implementation}` will be use for caching
  presences.  You can override this in the `:caches` option of the `nostrum`
  application by setting the `:presences` fields to a different module
  implementing the `Nostrum.Cache.PresenceCache` behaviour. Any module below
  `Nostrum.Cache.PresenceCache` implements this behaviour and can be used as a
  cache.

  ## Writing your own presence cache

  As with the other caches, the presence cache API consists of two parts:

  - The functions that nostrum calls, such as `c:create/1` or `c:update/1`.
  These **do not create any objects in the Discord API**, they are purely
  created to update the cached data from data that Discord sends us. If you
  want to create objects on Discord, use the functions exposed by `Nostrum.Api`
  instead.

  - the QLC query handle for read operations, `c:query_handle/0`, and

  - the `c:child_spec/1` callback for starting the cache under a supervisor.

  You need to implement both of them for nostrum to work with your custom
  cache.
  """

  @moduledoc since: "0.5.0"

  @configured_cache :nostrum
                    |> Application.compile_env(
                      [:caches, :presences],
                      @default_cache_implementation
                    )

  alias Nostrum.Struct.{Guild, User}
  alias Nostrum.Util
  import Nostrum.Snowflake, only: [is_snowflake: 1]

  # Types
  @typedoc """
  Represents a presence as received from Discord.
  See [Presence Update](https://discord.com/developers/docs/topics/gateway#presence-update).
  """
  @typedoc since: "0.5.0"
  @opaque presence :: map()

  # Callbacks
  @doc ~S"""
  Retrieves a presence for a user from the cache by guild and id.

  If successful, returns `{:ok, presence}`. Otherwise returns `{:error, reason}`.

  ## Example
  ```elixir
  case Nostrum.Cache.PresenceCache.get(111133335555, 222244446666) do
    {:ok, presence} ->
      "They're #{presence.status}"
    {:error, _reason} ->
      "They're dead Jim"
  end
  ```
  """
  @spec get(Guild.id(), User.id()) :: {:ok, presence()} | {:error, :presence_not_found}
  @spec get(Guild.id(), User.id(), module()) :: {:ok, presence()} | {:error, :presence_not_found}
  def get(guild_id, user_id, cache \\ @configured_cache) do
    handle = :nostrum_presence_cache_qlc.get(guild_id, user_id, cache)

    wrap_qlc(cache, fn ->
      case :qlc.eval(handle) do
        [presence] -> {:ok, presence}
        [] -> {:error, :not_found}
      end
    end)
  end

  @doc """
  Create a presence in the cache.
  """
  @callback create(presence) :: :ok

  @doc """
  Bulk create multiple presences for the given guild in the cache.
  """
  @callback bulk_create(Guild.id(), [presence()]) :: :ok

  @doc """
  Update the given presence in the cache from upstream data.

  ## Return value

  Return the guild ID along with the old presence (if it was cached, otherwise
  `nil`) and the updated presence structure. If the `:activities` or `:status`
  fields of the presence did not change, return `:noop`.
  """
  @callback update(map()) ::
              {Guild.id(), old_presence :: presence() | nil, new_presence :: presence()} | :noop

  @doc """
  Retrieve the child specification for starting this mapping under a supervisor.
  """
  @callback child_spec(term()) :: Supervisor.child_spec()

  @doc """
  Return a QLC query handle for cache read operations.

  This is used by nostrum to provide any read operations on the cache. Write
  operations still need to be implemented separately.

  The Erlang manual on [Implementing a QLC
  Table](https://www.erlang.org/doc/man/qlc.html#implementing_a_qlc_table)
  contains examples for implementation. To prevent full table scans, accept
  match specifications in your `TraverseFun` and implement a `LookupFun` as
  documented.

  The query handle must return items in the form `{{guild_id, user_id}, presence}`, where:
  - `guild_id` is a `t:Nostrum.Struct.Guild.id/0`, and
  - `user_id` is a `t:Nostrum.Struct.User.id/0`, and
  - `presence` is a `t:presence/0`.

  If your cache needs some form of setup or teardown for QLC queries (such as
  opening connections), see `c:wrap_qlc/1`.
  """
  @doc since: "0.8.0"
  @callback query_handle() :: :qlc.query_handle()

  @doc """
  A function that should wrap any `:qlc` operations.

  If you implement a cache that is backed by a database and want to perform
  cleanup and teardown actions such as opening and closing connections,
  managing transactions and so on, you want to implement this function. nostrum
  will then effectively call `wrap_qlc(fn -> :qlc.e(...) end)`.

  If your cache does not need any wrapping, you can omit this.
  """
  @doc since: "0.8.0"
  @callback wrap_qlc((-> result)) :: result when result: term()
  @optional_callbacks wrap_qlc: 1

  # Dispatch
  @doc false
  defdelegate create(presence), to: @configured_cache
  @doc false
  defdelegate update(presence), to: @configured_cache
  @doc false
  defdelegate bulk_create(guild_id, presences), to: @configured_cache
  @doc false
  defdelegate child_spec(opts), to: @configured_cache

  # Dispatch helpers
  @doc "Same as `get/1`, but raise `Nostrum.Error.CacheError` in case of a failure."
  @spec get!(User.id(), Guild.id()) :: presence() | no_return()
  @spec get!(User.id(), Guild.id(), module()) :: presence() | no_return()
  def get!(guild_id, user_id, cache \\ @configured_cache)
      when is_snowflake(user_id) and is_snowflake(guild_id) do
    guild_id
    |> get(user_id, cache)
    |> Util.bangify_find({guild_id, user_id}, cache)
  end

  @doc """
  Call `c:wrap_qlc/1` on the given cache, if implemented.

  If no cache is given, calls out to the default cache.
  """
  @doc since: "0.8.0"
  @spec wrap_qlc((-> result)) :: result when result: term()
  @spec wrap_qlc(module(), (-> result)) :: result when result: term()
  def wrap_qlc(cache \\ @configured_cache, fun) do
    if function_exported?(cache, :wrap_qlc, 1) do
      cache.wrap_qlc(fun)
    else
      fun.()
    end
  end

  @doc """
  Return the QLC handle of the configured cache.
  """
  @doc since: "0.8.0"
  defdelegate query_handle(), to: @configured_cache
end