lib/ex_aws/cloud_search/document.ex

defmodule ExAws.CloudSearch.Document do
  @moduledoc """
  Operations to add and remove documents from a CloudSearch domain index.
  """

  alias ExAws.Operation.CloudSearch, as: Operation

  @typedoc """
  Field values may be strings (`text`, `literal`, `latlon`, `date`), lists of
  strings (`text-array`, `literal-array`, `date-array`), numbers (`int`,
  `double`), or lists of numbers (`int-array`, `double-array`). Empty or `nil`
  values are not permitted.
  """
  @type field_value :: String.t() | list(String.t()) | number | list(number)

  @typedoc """
  Fields are maps of field names (which must be strings matching the pattern
  `[a-zA-Z0-9][a-zA-Z0-9_]{0,63}`) with field values. At least one field/value
  pair must be provided.
  """
  @type fields :: %{required(String.t() | atom) => field_value}

  @type add :: %{type: :add, id: String.t(), fields: fields}
  @type delete :: %{type: :delete, id: String.t()}

  @type document :: add | delete
  @type batch :: list(document)

  @type request :: %Operation{
          params: batch,
          request_type: :doc
        }

  @doc """
  Adds the provided document(s) to the index. Note that nothing in this
  function prevents a request from being too large to process in CloudSearch
  (batches may be at most 5Mb, with each document being at most 1Mb).

  Documents may be added in several ways:

      add(%Band{id: 3, name: "Grimes"})
      add(%{id: 3, name: "Grimes"})
      add(%{"id" => 3, "name" => "Grimes"})
      add(%{3 => %{"name" => "Grimes"}})
      add({3, %{"name" => "Grimes"}})
      add([%Band{id: 3, name: "Grimes"}])
      add([%{id: 3, name: "Grimes"}])
      add([%{"id" => 3, "name" => "Grimes"}])
      add([%{3 => %{"name" => "Grimes"}}])
      add([{3, %{name: "Grimes"}}])

  However the document is provided, it must have an ID and the list of fields.
  The list of fields *must* match the document as defined in the index field
  configuration in CloudSearch (or the document upload will fail at least for
  that document).

  The ID will be converted to a string with `to_string/1`. Fields may not be
  `nil` or empty (`""` or `[]`), so those will be removed.
  """
  def add(%_mod{} = document) do
    add(add_normalize(document))
  end

  def add(%{id: _} = document) do
    add(add_normalize(document))
  end

  def add(%{"id" => _} = document) do
    add(add_normalize(document))
  end

  def add(documents) when is_map(documents) do
    add(Enum.into(documents, []))
  end

  def add({_id, _fields} = document) do
    add([document])
  end

  def add([]) do
    raise(ExAws.Error, "Must provide documents to index.")
  end

  def add(documents) when is_list(documents) do
    documents
    |> Enum.map(&add_normalize/1)
    |> Enum.map(&to_add_request/1)
    |> request()
  end

  @doc """
  Removes the provided document(s) by ID from the index.

      remove(%Band{id: 3})
      remove(%{id: 3})
      remove(%{"id" => 3})
      remove(3)
      remove([%Band{id: 3}])
      remove([%{id: 3}])
      remove([%{"id" => 3}])
      remove([3])
  """
  def remove(%_mod{} = document) do
    remove(remove_normalize(document))
  end

  def remove(%{id: _} = document) do
    remove(remove_normalize(document))
  end

  def remove(%{"id" => _} = document) do
    remove(remove_normalize(document))
  end

  def remove([]) do
    raise(ExAws.Error, "Must provide documents to remove.")
  end

  def remove(documents) when is_list(documents) do
    documents
    |> Enum.flat_map(&remove_normalize/1)
    |> Enum.map(&to_remove_request/1)
    |> request()
  end

  def remove(document) do
    remove(remove_normalize(document))
  end

  defp add_normalize(%_mod{id: _} = document) do
    add_normalize(Map.from_struct(document))
  end

  defp add_normalize(%mod{}) do
    raise ExAws.Error,
          "Cannot add a document directly from struct #{mod}; it does not have an id field."
  end

  defp add_normalize(%{id: id} = document) do
    add_normalize({id, Map.delete(document, :id)})
  end

  defp add_normalize(%{"id" => id} = document) do
    add_normalize({id, Map.delete(document, "id")})
  end

  defp add_normalize(%{}) do
    raise ExAws.Error,
          "Cannot add a document from a map that does not have an id field."
  end

  defp add_normalize({id, fields}) when is_map(fields) do
    if Enum.empty?(fields) do
      raise ExAws.Error, "At least one field must be specified for document #{id}."
    else
      {to_string(id), fields}
    end
  end

  defp add_normalize({id, _}) do
    raise ExAws.Error, "Cannot add document #{id} because it does not have a map for fields."
  end

  defp remove_normalize(%_mod{id: id}) do
    [id]
  end

  defp remove_normalize(%{id: id}) do
    [id]
  end

  defp remove_normalize(%{"id" => id}) do
    [id]
  end

  defp remove_normalize(%mod{}) do
    raise(
      ExAws.Error,
      "Cannot remove a document of struct #{mod}; it does not have an id field."
    )
  end

  defp remove_normalize(%{}) do
    raise(
      ExAws.Error,
      "Cannot find an id field in the provided map to remove this document."
    )
  end

  defp remove_normalize(id) do
    List.wrap(id)
  end

  defp to_add_request({id, fields}) do
    %{type: :add, id: to_string(id), fields: add_normalize_fields(fields)}
  end

  defp to_remove_request(id) do
    %{type: :delete, id: to_string(id)}
  end

  defp add_normalize_fields(%{} = fields) do
    fields
    |> Enum.map(&add_normalize_field/1)
    |> Enum.filter(&elem(&1, 1))
    |> Enum.into(%{})
  end

  defp add_normalize_field({key, %Date{} = value}) do
    {key, "#{Date.to_iso8601(value)}T00:00:00Z"}
  end

  defp add_normalize_field({key, %Time{}}) do
    raise ExAws.Error, "Cannot add a Time as a document field for #{key}."
  end

  defp add_normalize_field({key, %DateTime{} = value}) do
    {key, DateTime.to_iso8601(value)}
  end

  defp add_normalize_field({key, []}) do
    {key, nil}
  end

  defp add_normalize_field({key, ""}) do
    {key, nil}
  end

  defp add_normalize_field(pair) do
    pair
  end

  defp request(batch) do
    %Operation{
      path: "/documents/batch",
      data: List.wrap(batch),
      request_type: :doc
    }
  end
end