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 Schema do
    @moduledoc false
    @derive Jason.Encoder
    @enforce_keys [:name, :type]
    defstruct [
      :facet,
      :index,
      :infix,
      :locale,
      :name,
      :optional,
      :sort,
      :type
    ]

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

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

  @collections_path "/collections"
  @alias_path "/aliases"

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

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

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

  @type response() :: {:ok, %__MODULE__{}} | {:ok, 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} ->
        {:ok, Enum.map(collections, &map_to_struct/1)}

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

  @doc """
  Get a specific collection using collection name.
  """
  @doc since: "0.1.0"
  @spec get_collection(String.t()) :: response()
  def get_collection(collection_name) do
    path = Path.join([@collections_path, collection_name])

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

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

  @doc """
  Create collection from map or from an ecto schema module.

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

      iex> ExTypesense.Parser.struct_to_map(AppModule, "title") |> ExTypesense.create_collection()
      {:ok,
        %ExTypesense.Collection{
          "created_at" => 1234567890,
          "default_sorting_field" => "num_employees",
          "fields" => [...],
          "name" => "companies",
          "num_documents" => 0,
          "symbols_to_index" => [],
          "token_separators" => []
        }
      }
  """
  @doc since: "0.1.0"
  @spec create_collection(schema :: map() | %__MODULE__{}) :: response()
  def create_collection(schema) do
    body = Jason.encode!(schema)

    case HttpClient.run(:post, @collections_path, body) do
      {:ok, collection} ->
        {:ok, map_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
       new_schema =
        %{
         fields: [
           %{name: "num_employees", drop: true},
           %{name: "company_category", type: "string"},
         ],
        }

      iex> ExTypesense.update_collection("companies", new_schema)
      {:ok,
        %ExTypesense.Collection{
          "created_at" => nil,
          "name" => nil,
          "default_sorting_field" => nil,
          "fields" => [...],
          "num_documents" => 0,
          "symbols_to_index" => [],
          "token_separators" => []
        }
      }
  """
  @doc since: "0.1.0"
  @spec update_collection(String.t(), collection :: map() | %__MODULE__{}) :: response()
  def update_collection(collection_name, collection) do
    path = Path.join([@collections_path, collection_name])
    body = Jason.encode!(collection)

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

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

  @doc """
  Deletes a collection using collection name.
  """
  @doc since: "0.1.0"
  @spec delete_collection(String.t()) :: response()
  def delete_collection(collection_name) do
    path = Path.join([@collections_path, collection_name])

    case HttpClient.run(:delete, path) do
      {:ok, collection} ->
        {:ok, map_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
    HttpClient.run(:get, @alias_path)
  end

  @doc """
  Get a specific collection alias.
  """
  @doc since: "0.1.0"
  @spec get_collection_alias(String.t()) :: response()
  def get_collection_alias(alias_name) do
    path = Path.join([@collections_path, alias_name])

    HttpClient.run(:get, path)
  end

  @doc """
  Upserts a collection alias.
  """
  @doc since: "0.1.0"
  @spec upsert_collection_alias(String.t(), String.t()) :: response()
  def upsert_collection_alias(alias_name, collection_name) do
    path = Path.join([@collections_path, alias_name])
    body = Jason.encode!(%{collection_name: collection_name})

    HttpClient.run(:put, path, body)
  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()) :: response()
  def delete_collection_alias(alias_name) do
    path = Path.join([@collections_path, alias_name])

    HttpClient.run(:delete, path)
  end

  @spec map_to_struct(map()) :: %__MODULE__{}
  def map_to_struct(collection) do
    collection =
      collection
      |> Map.new(fn {key, val} ->
        if key === "fields" do
          # converting %{"name" => "sample", ...}
          # to %{name: "sample", ...}
          # finally into %Schema struct
          # because struct doesn't accept
          # string keys, but atoms instead.
          fields =
            Enum.map(val, fn map ->
              schema =
                Map.new(map, fn {field_key, field_val} ->
                  {String.to_atom(field_key), field_val}
                end)

              struct(Schema, schema)
            end)

          {:fields, fields}
        else
          {String.to_atom(key), val}
        end
      end)

    struct(__MODULE__, collection)
  end
end