lib/pow/store/base.ex

defmodule Pow.Store.Base do
  @moduledoc """
  Used to set up API for key-value stores.

  ## Usage

      defmodule MyApp.CustomCache do
        use Pow.Store.Base,
          ttl: :timer.minutes(30),
          namespace: "credentials"

        @impl true
        def put(config, key, value) do
          Pow.Store.Base.put(config, backend_config(config), {key, value})
        end
      end
  """
  alias Pow.Config
  alias Pow.Store.Backend.{EtsCache, MnesiaCache, Base}

  @type config :: Config.t()
  @type key :: Base.key()
  @type record :: Base.record()
  @type key_match :: Base.key_match()

  @callback put(config(), key(), any()) :: :ok
  @callback delete(config(), key()) :: :ok
  @callback get(config(), key()) :: any() | :not_found
  @callback all(config(), key_match()) :: [record()]

  @doc false
  defmacro __using__(defaults) do
    quote do
      @behaviour unquote(__MODULE__)

      @impl unquote(__MODULE__)
      def put(config, key, value) do
        unquote(__MODULE__).put(config, backend_config(config), {key, value})
      end

      @impl unquote(__MODULE__)
      def delete(config, key) do
        unquote(__MODULE__).delete(config, backend_config(config), key)
      end

      @impl unquote(__MODULE__)
      def get(config, key) do
        unquote(__MODULE__).get(config, backend_config(config), key)
      end

      @impl unquote(__MODULE__)
      def all(config, key_match) do
        unquote(__MODULE__).all(config, backend_config(config), key_match)
      end

      def backend_config(config) do
        [
          ttl: Config.get(config, :ttl, unquote(defaults[:ttl])),
          namespace: Config.get(config, :namespace, unquote(defaults[:namespace]))
        ]
      end

      defoverridable unquote(__MODULE__)

      # TODO: Remove by 1.1.0
      @doc false
      def put(config, backend_config, key, value) do
        config
        |> merge_backend_config(backend_config)
        |> put(key, value)
      end

      defp merge_backend_config(config, backend_config) do
        backend_config = Keyword.take(backend_config, [:ttl, :namespace])

        Keyword.merge(config, backend_config)
      end

      # TODO: Remove by 1.1.0
      @doc false
      def delete(config, backend_config, key) do
        config
        |> merge_backend_config(backend_config)
        |> delete(key)
      end

      # TODO: Remove by 1.1.0
      @doc false
      def get(config, backend_config, key) do
        config
        |> merge_backend_config(backend_config)
        |> get(key)
      end

      # TODO: Remove by 1.1.0
      defoverridable put: 4, delete: 3, get: 3
    end
  end

  @spec put(config(), config(), record() | [record()]) :: :ok
  def put(config, backend_config, record_or_records) do
    # TODO: Update by 1.1.0
    backwards_compatible_call(store(config), :put, [backend_config, record_or_records])
  end

  @doc false
  @spec delete(config(), config(), key()) :: :ok
  def delete(config, backend_config, key) do
    # TODO: Update by 1.1.0
    backwards_compatible_call(store(config), :delete, [backend_config, key])
  end

  @doc false
  @spec get(config(), config(), key()) :: any() | :not_found
  def get(config, backend_config, key) do
    # TODO: Update by 1.1.0
    backwards_compatible_call(store(config), :get, [backend_config, key])
  end

  @doc false
  @spec all(config(), config(), key_match()) :: [record()]
  def all(config, backend_config, key_match) do
    # TODO: Update by 1.1.0
    backwards_compatible_call(store(config), :all, [backend_config, key_match])
  end

  defp store(config) do
    Config.get(config, :backend, EtsCache)
  end

  # TODO: Remove by 1.1.0
  defp backwards_compatible_call(store, method, args) do
    store
    |> has_binary_keys?()
    |> case do
      false ->
        apply(store, method, args)

      true ->
        IO.warn("binary key for backend stores is depecated, update `#{inspect store}` to accept erlang terms instead")

        case method do
          :put    -> binary_key_put(store, args)
          :get    -> binary_key_get(store, args)
          :delete -> binary_key_delete(store, args)
          :all    -> binary_key_all(store, args)
        end
    end
  end

  # TODO: Remove by 1.1.0
  defp has_binary_keys?(store) when store in [EtsCache, MnesiaCache], do: false
  defp has_binary_keys?(store) do
    {:module, ^store} = Code.ensure_loaded(store)

    not function_exported?(store, :all, 2)
  end

  # TODO: Remove by 1.1.0
  defp binary_key_put(store, [backend_config, record_or_records]) do
    record_or_records
    |> List.wrap()
    |> Enum.each(fn {key, value} ->
      key = binary_key(key)

      store.put(backend_config, key, value)
    end)
  end

  # TODO: Remove by 1.1.0
  defp binary_key_get(store, [backend_config, key]) do
    key = binary_key(key)

    store.get(backend_config, key)
  end

  # TODO: Remove by 1.1.0
  defp binary_key_delete(store, [backend_config, key]) do
    key = binary_key(key)

    store.delete(backend_config, key)
  end

  # TODO: Remove by 1.1.0
  defp binary_key_all(store, [backend_config, match_spec]) do
    match_spec = :ets.match_spec_compile([{match_spec, [], [:"$_"]}])

    backend_config
    |> store.keys()
    |> Enum.map(&:erlang.binary_to_term/1)
    |> :ets.match_spec_run(match_spec)
    |> Enum.map(&{&1, binary_key_get(store, [backend_config, &1])})
  end

  # TODO: Remove by 1.1.0
  defp binary_key(key) do
    key
    |> List.wrap()
    |> :erlang.term_to_binary()
  end

  # TODO: Remove by 1.1.0
  @doc false
  @deprecated "Use `put/3` instead"
  def put(config, backend_config, key, value) do
    put(config, backend_config, {key, value})
  end

  # TODO: Remove by 1.1.0
  @doc false
  @deprecated "Use `all/2` instead"
  def keys(config, backend_config) do
    store(config).keys(backend_config)
  end
end