lib/ex_typesense/collection.ex

defmodule ExTypesense.Collection do
  @moduledoc since: "0.1.0"
  @moduledoc """
  Module for creating, listing and deleting collections and aliases.

  In Typesense, a [Collection](https://typesense.org/docs/latest/api/collections.html) is a group of related [Documents](https://typesense.org/docs/latest/api/documents.html) that is roughly equivalent to a table in a relational database. When we create a collection, we give it a name and describe the fields that will be indexed when a document is added to the collection.
  """

  alias ExTypesense.HttpClient

  defmodule Field do
    @moduledoc since: "0.1.0"
    @derive Jason.Encoder
    @enforce_keys [:name, :type]

    defstruct [
      :facet,
      :index,
      :infix,
      :locale,
      :name,
      :nested,
      :nested_array,
      :num_dim,
      :optional,
      :sort,
      :type,
      :vec_dist
    ]

    @type t() :: %__MODULE__{
            facet: boolean(),
            index: boolean(),
            infix: boolean(),
            locale: String.t(),
            name: String.t(),
            nested: boolean(),
            nested_array: integer(),
            num_dim: integer(),
            optional: boolean(),
            sort: boolean(),
            type: field_type(),
            vec_dist: String.t()
          }

    @type field_type() ::
            :string
            | :"string[]"
            | :int32
            | :"int32[]"
            | :int64
            | :"int64[]"
            | :float
            | :"float[]"
            | :bool
            | :"bool[]"
            | :geopoint
            | :"geopoint[]"
            | :object
            | :"object[]"
            | :"string*"
  end

  @collections_path "/collections"
  @aliases_path "/aliases"

  @derive Jason.Encoder
  @enforce_keys [
    :created_at,
    :name,
    :fields,
    :default_sorting_field
  ]

  defstruct [
    :created_at,
    :name,
    :default_sorting_field,
    :enable_nested_fields,
    :fields,
    num_documents: 0,
    token_separators: [],
    symbols_to_index: []
  ]

  @type t() :: %__MODULE__{
          created_at: integer(),
          name: String.t(),
          default_sorting_field: String.t(),
          enable_nested_fields: boolean(),
          fields: Field.t(),
          num_documents: integer(),
          token_separators: list(),
          symbols_to_index: list()
        }

  @type response() :: t() | [t() | map()] | map() | {:error, map()}

  @doc """
  Lists all collections.
  """
  @doc since: "0.1.0"
  @spec list_collections() :: response()
  def list_collections do
    case HttpClient.run(:get, @collections_path) do
      {:ok, collections} ->
        Stream.map(collections, &convert_to_struct/1) |> Enum.to_list()

      {:error, reason} ->
        {:error, reason}
    end
  end

  @doc """
  Get the collection name by alias.
  """
  @doc since: "0.3.0"
  @spec get_collection_name(String.t() | module()) :: String.t()
  def get_collection_name(alias_name) do
    alias_name
    |> get_collection_alias()
    |> Map.get("collection_name")
  end

  @doc """
  Get a specific collection by string or module name.
  """
  @doc since: "0.1.0"
  @spec get_collection(String.t() | module()) :: response()
  def get_collection(name) when is_atom(name) do
    collection_name = name.__schema__(:source)

    [@collections_path, collection_name]
    |> Path.join()
    |> do_get_collection()
  end

  def get_collection(name) when is_binary(name) do
    [@collections_path, name]
    |> Path.join()
    |> do_get_collection()
  end

  defp do_get_collection(path) do
    case HttpClient.run(:get, path) do
      {:ok, collection} ->
        convert_to_struct(collection)

      {:error, reason} ->
        {:error, reason}
    end
  end

  @doc """
  Create collection from a map, or module name. Collection name is matched on table name if using Ecto schema by default.

  Please refer to these [list of schema params](https://typesense.org/docs/latest/api/collections.html#schema-parameters).

  ## Examples
      iex> schema = %{
      ...>   name: "companies",
      ...>   fields: [
      ...>     %{name: "company_name", type: "string"},
      ...>     %{name: "company_id", type: "int32"},
      ...>     %{name: "country", type: "string", facet: true}
      ...>   ],
      ...>   default_sorting_field: "company_id"
      ...> }
      iex> ExTypesense.create_collection(schema)
      %ExTypesense.Collection{
        created_at: 1234567890,
        default_sorting_field: "company_id",
        fields: [...],
        name: "companies",
        num_documents: 0,
        symbols_to_index: [],
        token_separators: []
      }

      iex> ExTypesense.create_collection(Person)
      %ExTypesense.Collection{
        created_at: 1234567890,
        default_sorting_field: "person_id",
        fields: [...],
        name: "persons",
        num_documents: 0,
        symbols_to_index: [],
        token_separators: []
      }
  """
  @doc since: "0.1.0"
  @spec create_collection(schema :: module() | map()) :: response()
  def create_collection(schema) when is_atom(schema) do
    schema =
      schema.get_field_types()
      |> Map.put(:name, schema.__schema__(:source))

    do_create_collection(schema)
  end

  def create_collection(schema) when is_map(schema) do
    schema = Map.put(schema, :name, schema[:name])

    do_create_collection(schema)
  end

  def create_collection(_schema), do: {:error, "wrong argument(s) passed"}

  defp do_create_collection(schema) do
    body = Jason.encode!(schema)

    case HttpClient.run(:post, @collections_path, body) do
      {:ok, collection} ->
        convert_to_struct(collection)

      {:error, reason} ->
        {:error, reason}
    end
  end

  @doc """
  Make changes in a collection's fields: adding, removing
  or updating an existing field(s). Key name is `drop` to
  indicate which field is removed (example described below).
  Only `fields` can only be updated at the moment.

  > **Note**: Typesense supports updating all fields
  > except the `id` field (since it's a special field
  > within Typesense).

  ## Examples
      iex> fields = %{
      ...>  fields: [
      ...>    %{name: "num_employees", drop: true},
      ...>    %{name: "company_category", type: "string"},
      ...>  ],
      ...> }
      iex> ExTypesense.update_collection_fields("companies", fields)
      %ExTypesense.Collection{
        created_at: 1234567890,
        name: companies,
        default_sorting_field: "company_id",
        fields: [...],
        num_documents: 0,
        symbols_to_index: [],
        token_separators: []
      }

      iex> ExTypesense.update_collection_fields(Company, fields)
      %ExTypesense.Collection{
        created_at: 1234567890,
        name: companies,
        default_sorting_field: "company_id",
        fields: [...],
        num_documents: 0,
        symbols_to_index: [],
        token_separators: []
      }
  """
  @doc since: "0.1.0"
  @spec update_collection_fields(name :: String.t() | module(), map()) :: response()
  def update_collection_fields(name, fields \\ %{})

  def update_collection_fields(name, fields) when is_atom(name) do
    collection_name = name.__schema__(:source)

    [@collections_path, collection_name]
    |> Path.join()
    |> do_update_collection_fields(Jason.encode!(fields))
  end

  def update_collection_fields(name, fields) when is_binary(name) do
    [@collections_path, name]
    |> Path.join()
    |> do_update_collection_fields(Jason.encode!(fields))
  end

  def update_collection_fields(_name, _schema), do: {:error, "wrong argument(s) passed"}

  defp do_update_collection_fields(path, body) do
    case HttpClient.run(:patch, path, body) do
      {:ok, collection} ->
        convert_to_struct(collection)

      {:error, reason} ->
        {:error, reason}
    end
  end

  @doc """
  Permanently drops a collection by collection name or module name.

  **Note**: dropping a collection does not remove the referenced alias, only the indexed documents.
  """
  @doc since: "0.1.0"
  @spec drop_collection(name :: String.t() | module()) :: response()
  def drop_collection(name) when is_atom(name) do
    collection_name = name.__schema__(:source)

    [@collections_path, collection_name]
    |> Path.join()
    |> do_drop_collection()
  end

  def drop_collection(name) when is_binary(name) do
    [@collections_path, name]
    |> Path.join()
    |> do_drop_collection()
  end

  defp do_drop_collection(path) do
    case HttpClient.run(:delete, path) do
      {:ok, collection} ->
        convert_to_struct(collection)

      {:error, reason} ->
        {:error, reason}
    end
  end

  @doc """
  List all aliases and the corresponding collections that they map to.
  """
  @doc since: "0.1.0"
  @spec list_collection_aliases() :: response()
  def list_collection_aliases do
    case HttpClient.run(:get, @aliases_path) do
      {:ok, %{"aliases" => aliases}} ->
        aliases

      {:error, reason} ->
        {:error, reason}
    end
  end

  @doc """
  Get a specific collection alias by string or module name.
  """
  @doc since: "0.1.0"
  @spec get_collection_alias(String.t() | module()) :: response()
  def get_collection_alias(alias_name) when is_atom(alias_name) do
    [@aliases_path, alias_name.__schema__(:source)]
    |> Path.join()
    |> do_get_collection_alias()
  end

  def get_collection_alias(alias_name) when is_binary(alias_name) do
    [@aliases_path, alias_name]
    |> Path.join()
    |> do_get_collection_alias()
  end

  def get_collection_alias(_alias_name), do: {:error, "wrong argument(s) passed"}

  @spec do_get_collection_alias(String.t()) :: response()
  defp do_get_collection_alias(path) do
    case HttpClient.run(:get, path) do
      {:ok, collection_alias} ->
        collection_alias

      {:error, reason} ->
        {:error, reason}
    end
  end

  @doc """
  Upserts a collection alias.
  """
  @doc since: "0.1.0"
  @spec upsert_collection_alias(String.t() | module(), String.t()) :: response()
  def upsert_collection_alias(alias_name, collection_name) when is_atom(alias_name) do
    path = Path.join([@aliases_path, alias_name.__schema__(:source)])
    body = Jason.encode!(%{collection_name: collection_name})
    do_upsert_collection_alias(path, body)
  end

  def upsert_collection_alias(alias_name, collection_name) when is_binary(alias_name) do
    path = Path.join([@aliases_path, alias_name])
    body = Jason.encode!(%{collection_name: collection_name})
    do_upsert_collection_alias(path, body)
  end

  def upsert_collection_alias(_alias_name), do: {:error, "wrong argument(s) passed"}

  defp do_upsert_collection_alias(path, body) do
    case HttpClient.run(:put, path, body) do
      {:ok, collection_alias} ->
        collection_alias

      {:error, reason} ->
        {:error, reason}
    end
  end

  @doc """
  Deletes a collection alias. The collection itself
  is not affected by this action.
  """
  @doc since: "0.1.0"
  @spec delete_collection_alias(String.t() | module()) :: response()
  def delete_collection_alias(alias_name) when is_atom(alias_name) do
    [@aliases_path, alias_name.__schema__(:source)]
    |> Path.join()
    |> do_delete_collection_alias()
  end

  def delete_collection_alias(alias_name) when is_binary(alias_name) do
    [@aliases_path, alias_name]
    |> Path.join()
    |> do_delete_collection_alias()
  end

  def delete_collection_alias(_alias_name), do: {:error, "wrong argument(s) passed"}

  defp do_delete_collection_alias(path) do
    case HttpClient.run(:delete, path) do
      {:ok, collection_alias} ->
        collection_alias

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp convert_to_struct(collection) do
    collection =
      Map.new(collection, fn {k, v} ->
        if k === :fields do
          Map.new(v, &to_atom/1)
        else
          {String.to_atom(k), v}
        end
      end)

    struct(__MODULE__, collection)
  end

  defp to_atom({k, v}), do: {String.to_atom(k), v}
end