lib/ecto_autoslug_field/slug.ex

defmodule EctoAutoslugField.SlugBase do
  @moduledoc """
  This module defines all functions `Slug` module uses.
  """

  alias Ecto.Changeset
  alias EctoAutoslugField.SlugGenerator

  @doc """
  This function is used to generate slug.

  It is called 'maybe' since it may not generate slug for several reasons:

    1. It was already created and `:always_change` option was not set
    2. The source fields for the slug were empty

  This function takes changeset as an input and returns changeset.
  """
  @spec maybe_generate_slug(Changeset.t(), atom() | list() | none, Keyword.t()) ::
          Changeset.t()
  def maybe_generate_slug(changeset, sources, opts) do
    SlugGenerator.maybe_generate_slug(changeset, sources, opts)
  end

  @doc """
  This is just a helper function to check for uniqueness.

  It basically just wraps `Ecto.Changeset` and set a proper field name.
  """
  @spec unique_constraint(Changeset.t(), atom(), Keyword.t()) :: Changeset.t()
  def unique_constraint(changeset, to, opts \\ []) do
    Changeset.unique_constraint(changeset, to, opts)
  end

  @doc """
  This function is used to get sources for the slug.

  There can be many use cases when this behavior is required,
  here are the brief examples:

    1. Conditional slug sources
    2. Add any data from different sources

  This function should return `list` of atoms or binaries, or `nil`.

  When processing the returned list:

    1. `atom`-key is supposed to identify the model field
    2. `binary`-key is treated as a data itself, it won't be changed
  """
  @spec get_sources(Changeset.t(), Keyword.t()) :: list(atom() | binary())
  def get_sources(_changeset, [from: from] = _opts) do
    # This code is only used in macros, so it is not tracked by `coveralls`.
    # coveralls-ignore-start
    [from]
    # coveralls-ignore-stop
  end

  @doc """
  This function is used to build the slug itself.

  This function is a place to modify the result slug.
  For convenience you can call `super(sources, changeset)`
  which will return the slug binary.
  `super(sources)` uses [`slugify`](https://github.com/jayjun/slugify),
  but you can completely change slug-engine to your own.

  Note: this function will only be called if `sources` is not empty.
  Also important this function will be called only
  once for the normal workflow. And every time for `:always_change`.
  So you can do some heavy computations.

  If for some reason slug should not be set -
  just return `nil` or empty `binary`.

  It should return a `binary` or `nil`.
  """
  @spec build_slug(Keyword.t(), Changeset.t() | nil) :: String.t() | nil
  def build_slug(sources, changeset),
    do: SlugGenerator.build_slug(sources, changeset)
end

defmodule EctoAutoslugField.Slug do
  @moduledoc ~S"""
  This module defines all the required functions and modules to work with.

  ## Examples

  To create a simple 'Slug' field do:

      defmodule MyCustomSlug do
        use EctoAutoslugField.Slug, from: :name_field, to: :slug_field
      end

  It is also possible to override `get_sources/2` and `build_slug/2` functions
  which are part of the AutoslugField's API.

  More complex example with the optional sources
  and custom slug generation function:

      defmodule MyComplexSlug do
        use EctoAutoslugField.Slug, to: :slug_field

        def get_sources(changeset, _opts) do
          basic_fields = [:name, :surname]

          if is_company_info_set(changeset) do
            # We want to track changes in the person's company:
            basic_fields ++ [:company, :position]
          else
            basic_fields
          end
        end

        def build_slug(sources, changeset) do
          super(sources, changeset)  # Calls the `SlugGenerator.build_slug/1`
          |> String.replace("-", "+")
        end
      end

  It is also possible to always change your slug, even if it was already set:

      defmodule ThisSlugShouldChange do
        use EctoAutoslugField.Slug, from: :some_field,
          to: :slug_field, always_change: true
      end

  If you want to change slug for only one instance
  without setting `always_change` option, use `force_generate_slug/1` function:

      defmodule SimpleSlugForce do
        use EctoAutoslugField.Slug, from: :name, to: :some_slug
      end

  Then you can use `SimpleSlugForce.force_generate_slug(changeset)`
  for any instances, that needs to recreate slugs for some reason.

  Be careful with these options, since
  [cool URIs do not change](https://www.w3.org/Provider/Style/URI.html).
  """

  defmacro __using__(options) do
    caller = __CALLER__.module

    quote location: :keep, bind_quoted: [options: options, caller: caller] do
      alias EctoAutoslugField.SlugBase

      # Opts:

      @from Keyword.get(options, :from, nil)
      @to Keyword.get(options, :to, :slug)
      @always_change Keyword.get(options, :always_change, false)

      # Custom Type:

      defmodule Module.concat(caller, "Type") do
        @moduledoc """
        This module represent the auto-generated `Ecto.Type` for slug-field.

        It basically just calls the methods of the basic `Type` module.
        """
        @behaviour Ecto.Type

        alias EctoAutoslugField.Type

        def type, do: Type.type()
        def cast(value), do: Type.cast(value)
        def load(value), do: Type.load(value)
        def dump(value), do: Type.dump(value)
        def embed_as(value), do: Type.embed_as(value)
        def equal?(term1, term2), do: Type.equal?(term1, term2)
      end

      defp generate_slug_opts do
        [
          from: @from,
          to: @to,
          always_change: @always_change,
          slug_builder: &build_slug/2
        ]
      end

      defp generate_slug_sources(changeset, opts) do
        if opts[:from] == nil do
          get_sources(changeset, opts)
        else
          @from
        end
      end

      # Public functions:

      def maybe_generate_slug(changeset) do
        opts = generate_slug_opts()
        sources = generate_slug_sources(changeset, opts)

        SlugBase.maybe_generate_slug(changeset, sources, opts)
      end

      def force_generate_slug(changeset) do
        opts = generate_slug_opts() |> Keyword.put(:always_change, true)
        sources = generate_slug_sources(changeset, opts)

        SlugBase.maybe_generate_slug(changeset, sources, opts)
      end

      def unique_constraint(changeset, opts \\ []) do
        SlugBase.unique_constraint(changeset, @to, opts)
      end

      # Client API:

      def get_sources(changeset, opts) do
        SlugBase.get_sources(changeset, opts)
      end

      def build_slug(sources) do
        SlugBase.build_slug(sources, nil)
      end

      def build_slug(sources, _changeset) do
        build_slug(sources)
      end

      defoverridable get_sources: 2, build_slug: 2, build_slug: 1
    end
  end
end