lib/confeature.ex

defmodule Confeature do
  @moduledoc """
  Defines a macro for implementing your own feature settings store on top of
  Ecto and the cache store of your choice.

  You can simply declare your module like this:

      defmodule MyApp.Confeature do
        use Confeature,
            ecto_repo: MyApp.Repo,
            cache: MyApp.Cache.Feature
      end

  Providing an ecto_repo is mandatory, but by default, Confeature will use a
  placeholder cache module. Please refer to the `Confeature.Cache` module doc for detailed instructions
  on how to implement your cache.

  The functions docs below assume that you have the following feature modules
  declared, as references:

      defmodule MyApp.Features.UsageAlert do
        defstruct [:threshold]

        @type t :: %__MODULE__{
          threshold: float()
        }
      end

      defmodule MyApp.Features.HiddenPixel do
        defstruct [:enabled, :x, :y]

        @type t :: %__MODULE__{
          enabled: boolean(),
          x: integer(),
          y: integer()
        }
      end

  """

  @doc false
  defmacro __using__(opts) do
    quote bind_quoted: [opts: opts] do
      @behaviour Confeature

      @repo Keyword.fetch!(opts, :ecto_repo)
      @cache Keyword.get(opts, :cache, Confeature.Cache.Default)
      @table_name Keyword.get(opts, :table_name)

      def __repo__, do: @repo
      def __cache__, do: @cache
      def __table_name__, do: @table_name

      import Ecto.{
        Changeset,
        Query
      }

      alias Confeature.{
        Schema,
        Type,
        SQL
      }

      def get(name) when is_atom(name) do
        case apply(__cache__(), :get, [name]) do
          %Schema{} = feature ->
            {:ok, feature} = feature |> Type.load()

            feature

          nil ->
            case SQL.get(__MODULE__, name) do
              nil ->
                nil

              %Confeature.Schema{} = record ->
                {:ok, _result} = apply(__cache__(), :set, [name, record])
                {:ok, feature} = record |> Type.load()
                feature
            end
        end
      end

      def set(%{__struct__: name} = feature_struct) do
        {:ok, result} = SQL.upsert(__MODULE__, feature_struct)
        {:ok, _result} = apply(__cache__(), :set, [name, result])

        {:ok, _} = result |> Type.load()
      end

      def delete!(name) do
        feature = SQL.get(__MODULE__, name)

        {:ok, _result} = apply(__repo__(), :delete, [feature])
        {:ok, _result} = apply(__cache__(), :delete, [name])
      end

      def enabled?(name) do
        name
        |> get()
        |> Map.fetch!(:enabled)
      end

      def enable(name) do
        name
        |> get()
        |> Map.put(:enabled, true)
        |> set()
      end

      def disable(name) do
        name
        |> get()
        |> Map.put(:enabled, false)
        |> set()
      end
    end
  end

  @doc """
  Returns a feature struct based on the module name.

      MyApp.Confeature.get(MyApp.Features.UsageAlert)
      # If you never set it:
      => nil

      # After setting it:
      => %MyApp.Features.UsageAlert{threshold: 4.5}
  """
  @callback get(name :: atom()) :: struct() | nil

  @doc """
  Writes new settings using a feature struct. It'll write to the database
  and invalidate cache right after.

  This function allows incremental updates; parameters you don't provide
  won't get erased.

      MyApp.Confeature.set(%MyApp.Features.HiddenPixel{x: 43, y: 219})
      => {:ok, *your_updated_struct*}
  """
  @callback set(struct :: struct()) :: {:ok, struct()}

  @doc """
  Deletes the feature row from your database and invalidates the cache.

  You may want to call this function once you're completely done with a
  feature (eg. in a post-release Ecto migration).
  """
  @callback delete!(name :: atom()) :: {:ok, any()}

  @doc """
  Returns true if your feature is enabled. This is a helper function, and
  it requires you to declare the enabled boolean field on the feature
  struct.

      MyApp.Confeature.enabled?(MyApp.Features.UsageAlert)
      => *will throw an error*

      MyApp.Confeature.enabled?(MyApp.Features.HiddenPixel)
      => false
  """
  @callback enabled?(name :: atom()) :: boolean()

  @doc """
  Enables a feature. This is a helper function, and it requires you to
  declare the enabled boolean field on the feature struct.

      MyApp.Confeature.enable(MyApp.Features.UsageAlert)
      => *will throw an error*

      MyApp.Confeature.enable(MyApp.Features.HiddenPixel)
      => {:ok, *your_updated_struct*}
  """
  @callback enable(name :: atom()) :: {:ok, struct()}

  @doc """
  Disables a feature. This is a helper function, and it requires you to
  declare the enabled boolean field on the feature struct.

      MyApp.Confeature.disable(MyApp.Features.UsageAlert)
      => *will throw an error*

      MyApp.Confeature.disable(MyApp.Features.HiddenPixel)
      => {:ok, *your_updated_struct*}
  """
  @callback disable(name :: atom()) :: {:ok, struct()}
end