lib/algoliax/indexer.ex

defmodule Algoliax.Indexer do
  @moduledoc """

  ### Usage

  - `:index_name`: specificy the index where the object will be added on. **Required**
  - `:object_id`: specify the attribute used to as algolia objectID. Default `:id`.
  - `:repo`: Specify an Ecto repo to be use to fecth records. Default `nil`
  - `:cursor_field`: specify the column to be used to order and go through a given table. Default `:id`
  - `:schemas`: Specify which schemas used to populate index, Default: `[__CALLER__]`
  - `:algolia`: Any valid Algolia settings, using snake case or camel case. Ex: Algolia `attributeForFaceting` can be configured with `:attribute_for_faceting`

  On first call to Algolia, we check that the settings on Algolia are up to date.

  ### Example

      defmodule People do
        use Algoliax.Indexer,
          index_name: :people,
          object_id: :reference,
          algolia: [
            attribute_for_faceting: ["age"],
            custom_ranking: ["desc(updated_at)"]
          ]

        defstruct reference: nil, last_name: nil, first_name: nil, age: nil
      end

  ### Customize object

  By default the object contains only algolia `objectID`. To add more attributes to objects, override `build_object/1` functions to return a Map (objectID is automatically set by Algoliax)

      defmodule People do
        use Algoliax.Indexer,
          index_name: :people,
          object_id: :reference,
          algolia: [
            attribute_for_faceting: ["age"],
            custom_ranking: ["desc(updated_at)"]
          ]

        defstruct reference: nil, last_name: nil, first_name: nil, age: nil

        @impl Algoliax.Indexer
        def build_object(person) do
          %{
            age: person.age,
            last_name: person.last_name,
            first_name: person.first_name
          }
        end
      end

  ### Schemas

  `:schemas` options allows to define a list of module you want to index into the current index. By default only the module defining the indexer.

      defmodule Global do
        use Algoliax.Indexer,
          index_name: :people,
          object_id: :reference,
          schemas: [People, Animal],
          algolia: [
            attribute_for_faceting: ["age"],
            custom_ranking: ["desc(updated_at)"]
          ]

      end

    This option allows to define also the preloads use during `reindex`/`reindex_atomic` (preload on `save_object` and `save_objects` have to be done manually)

        defmodule People do
          use Algoliax.Indexer,
            index_name: :people,
            object_id: :reference,
            schemas: [
              {__MODULE__, [:animals]}
            ]
            algolia: [
              attribute_for_faceting: ["age"],
              custom_ranking: ["desc(updated_at)"]
            ]

        end

  """

  alias Algoliax.Resources.{Index, Object, Search}

  @doc """
  Search for index values

  ## Example

      iex> People.search("John")

      {:ok,
        %{
          "exhaustiveNbHits" => true,
          "hits" => [
            %{
              "_highlightResult" => %{
                "full_name" => %{
                  "fullyHighlighted" => false,
                  "matchLevel" => "full",
                  "matchedWords" => ["john"],
                  "value" => "Pierre <em>Jon</em>es"
                }
              },
              "age" => 69,
              "first_name" => "Pierre",
              "full_name" => "Pierre Jones",
              "indexed_at" => 1570908223,
              "last_name" => "Jones",
              "objectID" => "b563deb6-2a06-4428-8e5a-ca1ecc08f4e2"
            },
            %{
              "_highlightResult" => %{
                "full_name" => %{
                  "fullyHighlighted" => false,
                  "matchLevel" => "full",
                  "matchedWords" => ["john"],
                  "value" => "Glennie <em>Jon</em>es"
                }
              },
              "age" => 27,
              "first_name" => "Glennie",
              "full_name" => "Glennie Jones",
              "indexed_at" => 1570908223,
              "last_name" => "Jones",
              "objectID" => "58e8ff8d-2794-41e1-a4ef-6f8db8d432b6"
            },
            ...
          ],
      "hitsPerPage" => 20,
      "nbHits" => 16,
      "nbPages" => 1,
      "page" => 0,
      "params" => "query=john",
      "processingTimeMS" => 1,
      "query" => "john"
      }}
  """

  @callback search(query :: binary(), params :: map()) ::
              {:ok, Algoliax.Response.t()} | {:not_indexable, model :: map()}

  @doc """
  Search for facet values

  ## Example
      iex> People.search_facet("age")
      {:ok,
        %{
          "exhaustiveFacetsCount" => true,
          "facetHits" => [
            %{"count" => 22, "highlighted" => "46", "value" => "46"},
            %{"count" => 21, "highlighted" => "38", "value" => "38"},
            %{"count" => 19, "highlighted" => "54", "value" => "54"},
            %{"count" => 19, "highlighted" => "99", "value" => "99"},
            %{"count" => 18, "highlighted" => "36", "value" => "36"},
            %{"count" => 18, "highlighted" => "45", "value" => "45"},
            %{"count" => 18, "highlighted" => "52", "value" => "52"},
            %{"count" => 18, "highlighted" => "56", "value" => "56"},
            %{"count" => 18, "highlighted" => "59", "value" => "59"},
            %{"count" => 18, "highlighted" => "86", "value" => "86"}
          ],
          "processingTimeMS" => 1
        }}
  """
  @callback search_facet(facet_name :: binary(), facet_query :: binary(), params :: map()) ::
              {:ok, Algoliax.Response.t()} | {:not_indexable, model :: map()}

  @doc """
  Add/update object. The object is added/updated to algolia with the object_id configured.

  ## Example
      people = %People{reference: 10, last_name: "Doe", first_name: "John", age: 20},

      People.save_object(people)
  """
  @callback save_object(object :: map() | struct()) ::
              {:ok, Algoliax.Response.t()} | {:not_indexable, model :: map()}

  @doc """
  Save multiple object at once

  ## Options

    * `:force_delete` - if `true` will trigger a "deleteObject" on object that must not be indexed. Default `false`

  ## Example

      peoples = [
        %People{reference: 10, last_name: "Doe", first_name: "John", age: 20},
        %People{reference: 89, last_name: "Einstein", first_name: "Albert", age: 65}
      ]

      People.save_objects(peoples)
      People.save_objects(peoples, force_delete: true)
  """
  @callback save_objects(models :: list(map()) | list(struct()), opts :: Keyword.t()) ::
              {:ok, Algoliax.Response.t()} | {:error, map()}

  @doc """
  Fetch object from algolia. By passing the model, the object is retrieved using the object_id configured

  ## Example
      people = %People{reference: 10, last_name: "Doe", first_name: "John", age: 20}

      People.get_object(people)
  """
  @callback get_object(model :: map() | struct()) ::
              {:ok, Algoliax.Response.t()} | {:error, map()}

  @doc """
  Delete object from algolia. By passing the model, the object is retrieved using the object_id configured

  ## Example
      people = %People{reference: 10, last_name: "Doe", first_name: "John", age: 20}

      People.delete_object(people)
  """
  @callback delete_object(model :: map() | struct()) ::
              {:ok, Algoliax.Response.t()} | {:error, map()}

  if Code.ensure_loaded?(Ecto) do
    @doc """
    Reindex a subset of records by providing an Ecto query or query filters as a Map([Ecto](https://hexdocs.pm/ecto/Ecto.html) specific)

    ## Example
        import Ecto.Query

        query = from(
          p in People,
          where: p.age > 45
        )

        People.reindex(query)

        # OR
        filters = %{where: [name: "john"]}
        People.reindex(filters)

    Available options:

    - `:force_delete`: delete objects that are in query and where `to_be_indexed?` is false

    > NOTE: filters as Map supports only `:where` and equality
    """
    @callback reindex(query :: Ecto.Query.t(), opts :: Keyword.t()) ::
                {:ok, [Algoliax.Response.t()]}

    @doc """
    Reindex all objects ([Ecto](https://hexdocs.pm/ecto/Ecto.html) specific)

    ## Example

        People.reindex(query)

    Available options:

    - `:force_delete`: delete objects where `to_be_indexed?` is `false`
    """
    @callback reindex(opts :: Keyword.t()) :: {:ok, [Algoliax.Response.t()]}

    @doc """
    Reindex atomically ([Ecto](https://hexdocs.pm/ecto/Ecto.html) specific)
    """
    @callback reindex_atomic() :: {:ok, :completed}
  end

  @doc """
  Build the object sent to algolia. By default the object contains only `objectID` set by Algoliax.Indexer

  ## Example
      @impl Algoliax.Indexer
      def build_object(person) do
        %{
          age: person.age,
          last_name: person.last_name,
          first_name: person.first_name
        }
      end
  """
  @callback build_object(model :: map()) :: map()

  @doc """
  Check if current object must be indexed or not. By default it's always true. To override this behaviour override this function in your model

  ## Example

      defmodule People do
        use Algoliax.Indexer,
          index_name: :people,
          object_id: :reference,
          algolia: [
            attribute_for_faceting: ["age"],
            custom_ranking: ["desc(update_at)"]
          ]

        #....

        @impl Algoliax.Indexer
        def to_be_indexed?(model) do
          model.age > 50
        end
      end
  """
  @callback to_be_indexed?(model :: map()) :: true | false

  @doc """
  Override this function to provide custom objectID for the model

  ## Example
      @impl Algoliax.Indexer
      def get_object_id(%Cat{id: id}), do: "Cat:" <> to_string(id)
      def get_object_id(%Dog{id: id}), do: "Dog:" <> to_string(id)
  """
  @callback get_object_id(model :: map()) :: binary() | :default

  @doc """
  Get index settings from Algolia
  """
  @callback get_settings() :: {:ok, map()} | {:error, map()}

  @doc """
  Configure index
  """
  @callback configure_index() :: {:ok, map()} | {:error, map()}

  @doc """
  Delete index
  """
  @callback delete_index() :: {:ok, map()} | {:error, map()}

  defmacro __using__(settings) do
    quote do
      @behaviour Algoliax.Indexer

      settings = unquote(settings)
      @settings settings

      @impl Algoliax.Indexer
      def search(query, params \\ %{}) do
        Search.search(__MODULE__, @settings, query, params)
      end

      @impl Algoliax.Indexer
      def search_facet(facet_name, facet_query \\ nil, params \\ %{}) do
        Search.search_facet(__MODULE__, @settings, facet_name, facet_query, params)
      end

      @impl Algoliax.Indexer
      def get_settings do
        Index.get_settings(__MODULE__, @settings)
      end

      @impl Algoliax.Indexer
      def configure_index do
        Index.configure_index(__MODULE__, @settings)
      end

      @impl Algoliax.Indexer
      def delete_index do
        Index.delete_index(__MODULE__, @settings)
      end

      @impl Algoliax.Indexer
      def save_objects(models, opts \\ []) do
        Object.save_objects(__MODULE__, @settings, models, opts)
      end

      @impl Algoliax.Indexer
      def save_object(model) do
        Object.save_object(__MODULE__, @settings, model)
      end

      @impl Algoliax.Indexer
      def delete_object(model) do
        Object.delete_object(__MODULE__, @settings, model)
      end

      @impl Algoliax.Indexer
      def get_object(model) do
        Object.get_object(__MODULE__, @settings, model)
      end

      if Code.ensure_loaded?(Ecto) do
        alias Algoliax.Resources.ObjectEcto

        @impl Algoliax.Indexer
        def reindex(opts) when is_list(opts) do
          ObjectEcto.reindex(__MODULE__, @settings, %{}, opts)
        end

        @impl Algoliax.Indexer
        def reindex(query) when is_map(query) do
          ObjectEcto.reindex(__MODULE__, @settings, query, [])
        end

        @impl Algoliax.Indexer
        def reindex(query \\ nil, opts \\ []) do
          ObjectEcto.reindex(__MODULE__, @settings, query, opts)
        end

        @impl Algoliax.Indexer
        def reindex_atomic do
          ObjectEcto.reindex_atomic(__MODULE__, @settings)
        end
      end

      @impl Algoliax.Indexer
      def build_object(_) do
        %{}
      end

      @impl Algoliax.Indexer
      def to_be_indexed?(_) do
        true
      end

      @impl Algoliax.Indexer
      def get_object_id(_) do
        :default
      end

      defoverridable(to_be_indexed?: 1, build_object: 1, get_object_id: 1)
    end
  end
end