lib/sanity/cache.ex

defmodule Sanity.Cache do
  @doc false
  defmacro __using__([]) do
    quote do
      import Sanity.Cache, only: [defq: 2]

      Module.register_attribute(__MODULE__, :sanity_cache_update_opts, accumulate: true)

      @before_compile Sanity.Cache
    end
  end

  @doc false
  defmacro __before_compile__(_env) do
    quote do
      def child_spec(_) do
        %{
          id: __MODULE__,
          start: {Sanity.Cache.Poller, :start_link, [@sanity_cache_update_opts]}
        }
      end

      def update_all(opts \\ []) do
        Enum.each(@sanity_cache_update_opts, fn update_opts ->
          Map.merge(Map.new(opts), Map.new(update_opts))
          |> Map.to_list()
          |> Sanity.Cache.update()
        end)
      end
    end
  end

  @common_opts_validation [
    config_key: [
      type: :atom,
      default: :default
    ],
    projection: [
      type: :string,
      required: true
    ]
  ]

  @fetch_opts_validation Keyword.merge(@common_opts_validation,
                           fetch_query: [
                             type: :string,
                             required: true
                           ]
                         )

  @list_query_validation [
    type: :string,
    required: true
  ]

  @fetch_pairs_opts_validation Keyword.merge(@common_opts_validation,
                                 list_query: @list_query_validation,
                                 keys: [
                                   type: {:list, :atom},
                                   required: true
                                 ]
                               )

  @defq_opts_validation Keyword.merge(@common_opts_validation,
                          list_query: @list_query_validation,
                          lookup: [
                            type: :keyword_list,
                            required: true
                          ]
                        )

  @doc """
  Defines a Sanity query.

  ## Options

  #{NimbleOptions.docs(@defq_opts_validation)}
  """
  defmacro defq(name, opts) when is_atom(name) do
    Enum.map(Keyword.fetch!(opts, :lookup), fn {lookup_name, lookup_opts} ->
      table = :"#{name}_by_#{lookup_name}"
      fetch_pairs = :"fetch_#{table}_pairs"

      quote do
        NimbleOptions.validate!(unquote(opts), unquote(@defq_opts_validation))

        Module.put_attribute(__MODULE__, :sanity_cache_update_opts,
          fetch_pairs_mfa: {__MODULE__, unquote(fetch_pairs), []},
          table: unquote(table)
        )

        def unquote(fetch_pairs)() do
          opts =
            Keyword.take(unquote(opts), Keyword.keys(unquote(@fetch_pairs_opts_validation)))
            |> Keyword.put(:keys, Keyword.fetch!(unquote(lookup_opts), :keys))

          Sanity.Cache.fetch_pairs(opts)
        end

        def unquote(:"get_#{table}")(key) do
          opts =
            Keyword.take(unquote(opts), Keyword.keys(unquote(@fetch_opts_validation)))
            |> Keyword.put(:fetch_query, Keyword.fetch!(unquote(lookup_opts), :fetch_query))

          Sanity.Cache.get(unquote(table), key, opts)
        end

        def unquote(:"get_#{table}!")(key) do
          opts =
            Keyword.take(unquote(opts), Keyword.keys(unquote(@fetch_opts_validation)))
            |> Keyword.put(:fetch_query, Keyword.fetch!(unquote(lookup_opts), :fetch_query))

          Sanity.Cache.get!(unquote(table), key, opts)
        end
      end
    end)
  end

  defmodule NotFoundError do
    defexception [:message]

    defimpl Plug.Exception do
      def status(_), do: :not_found
      def actions(_), do: []
    end
  end

  require Logger
  alias Sanity.Cache.CacheServer

  @doc """
  Gets a single document using cache. If the cache table doesn't exist then `fetch/2` will be
  called. Returns `{:ok, value}` or `{:error, :not_found}`.
  """
  def get(table, key, opts) when is_atom(table) do
    case CacheServer.fetch(table, key) do
      {:error, :no_table} ->
        fetch(key, opts)

      {:error, :not_found} ->
        {:error, :not_found}

      {:ok, result} ->
        {:ok, result}
    end
  end

  @doc """
  Like `get/3` except raises if not found.
  """
  def get!(table, key, opts) do
    case get(table, key, opts) do
      {:ok, value} -> value
      {:error, :not_found} -> raise NotFoundError, "can't find document with key #{inspect(key)}"
    end
  end

  @doc """
  Fetches a single document by making a request to the Sanity CMS API. The cache is not used.

  ## Options

  #{NimbleOptions.docs(@fetch_opts_validation)}
  """
  def fetch(key, opts) do
    opts = NimbleOptions.validate!(opts, @fetch_opts_validation)

    config_key = Keyword.fetch!(opts, :config_key)
    fetch_query = Keyword.fetch!(opts, :fetch_query)
    projection = Keyword.fetch!(opts, :projection)

    sanity = Application.get_env(:sanity_cache, :sanity_client, Sanity)

    Enum.join([fetch_query, projection], " | ")
    |> Sanity.query(%{key: key})
    |> sanity.request!(Application.fetch_env!(:sanity_cache, config_key))
    |> Sanity.result!()
    |> Sanity.atomize_and_underscore()
    |> case do
      [doc] -> {:ok, doc}
      [] -> {:error, :not_found}
    end
  end

  @doc """
  Fetches list of key/value pairs.

  ## Options

  #{NimbleOptions.docs(@fetch_pairs_opts_validation)}
  """
  def fetch_pairs(opts) do
    opts = NimbleOptions.validate!(opts, @fetch_pairs_opts_validation)

    config_key = Keyword.fetch!(opts, :config_key)
    list_query = Keyword.fetch!(opts, :list_query)
    keys = Keyword.fetch!(opts, :keys)
    projection = Keyword.fetch!(opts, :projection)

    sanity = Application.get_env(:sanity_cache, :sanity_client, Sanity)

    sanity_config =
      Application.fetch_env!(:sanity_cache, config_key)
      |> Keyword.put_new(:http_options, receive_timeout: 45_000)

    Enum.join([list_query, projection], " | ")
    |> Sanity.query()
    |> sanity.request!(sanity_config)
    |> Sanity.result!()
    |> Sanity.atomize_and_underscore()
    |> Enum.map(&{get_in(&1, keys), &1})
  end

  @update_opts_validation [
    fetch_pairs_mfa: [
      type: :mfa,
      required: true
    ],
    table: [
      type: :atom,
      required: true
    ],
    update_remote_nodes: [
      type: :boolean,
      default: false
    ]
  ]

  @doc """
  Updates a cache table.

  ## Options

  #{NimbleOptions.docs(@update_opts_validation)}
  """
  def update(opts) do
    opts = NimbleOptions.validate!(opts, @update_opts_validation)

    update_remote_nodes = Keyword.fetch!(opts, :update_remote_nodes)
    {module, function_name, args} = Keyword.fetch!(opts, :fetch_pairs_mfa)
    table = Keyword.fetch!(opts, :table)

    pairs = apply(module, function_name, args)

    if update_remote_nodes do
      Enum.each(Node.list(), fn node ->
        Logger.info("updating #{table} on remote node #{inspect(node)}")

        CacheServer.cast_put_table({CacheServer, node}, table, pairs)
      end)
    end

    Logger.info("updating #{table} on local node")
    CacheServer.put_table(table, pairs)
  end
end