lib/forage/search_postgresql.ex

defmodule Forage.SearchPostgreSQL do
  @moduledoc """
  Utilities to help with search in PostgreSQL databases.
  Support for other databases may be added in the future.
  """

  alias Ecto.Migration
  require ExUnit.Assertions, as: Assertions

  @doc """
  *For use in migration files*.
  Create the necessary extensions in the database.
  """
  def define_forage_unaccent() do
    sql_unaccent_up = "CREATE EXTENSION IF NOT EXISTS unaccent;"
    sql_pg_trgm_up = "CREATE EXTENSION IF NOT EXISTS pg_trgm;"

    sql_define_function_up = """
    CREATE OR REPLACE FUNCTION public.forage_unaccent(text)
      RETURNS text AS
    $func$
    SELECT public.unaccent('public.unaccent', $1)
    $func$

    LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT;
    """

    # These "down" commands revert the effect of the "up" commands.
    # However, we don't actually want to revert the effect of the commands above
    # when reverting the migration, because that might make some older migrations invalid.
    # These "down" commands are only kept here for reference purposes

    _sql_unaccent_down = "DROP EXTENSION unaccent;"
    _sql_pg_trgm_down = "DROP EXTENSION pg_trgm;"
    _sql_define_function_down = "DROP FUNCTION public.forage_unaccent(text);"

    # When running the migrations, the "down" command will be the empty string.
    Migration.execute(sql_unaccent_up, "")
    Migration.execute(sql_pg_trgm_up, "")
    Migration.execute(sql_define_function_up, "")
  end

  defp make_column_builder(columns) do
    coalesced = Enum.map(columns, fn name -> "coalesce(#{name}, '')" end)
    concatenated = Enum.intersperse(coalesced, " || ' ' || ")
    "(forage_unaccent(#{concatenated}))"
  end

  @doc """
  Adds a trigram index for the given `column` in the given `table`.

  This function is meant to be used in a migration file.

  ## Example

  *TODO*
  """
  def add_trigram_index(table, column) do
    # SQL statements to create and drop the index
    sql_index_up = """
    CREATE INDEX #{table}_#{column}_idx ON #{table}
      USING GIN (#{column} gin_trgm_ops);
    """

    sql_index_down = """
    DROP INDEX #{table}_#{column}_idx;
    """

    Migration.execute(sql_index_up, sql_index_down)
  end

  @doc """
  Adds a new column (named `column_name`) to the `table`.
  The new column will be a concatenation of the `columns`.
  The new column will be updated whenever any of the `columns` changes.

  This function is meant to be used in a migration file.

  To be able to use this functions you must have already defined the
  `forage_unaccent()` PostgreSQL function.
  The reason why we can't use `unaccent` directly is quite obscure;
  you can read more aboute it [here](#)

  ## Example

  *TODO*
  """
  def add_unnaccented_search_column(table, column_name, columns) do
    # Validate arguments:
    # - :table must be an atom
    Assertions.assert(is_atom(table))
    # - :columns must be a list of atoms
    Assertions.assert(is_list(columns))
    Assertions.assert(Enum.all?(columns, fn col -> is_atom(col) end))

    column_builder = make_column_builder(columns)

    # SQL statements to create and drop the column
    sql_column_up = """
    ALTER TABLE #{table}
      ADD COLUMN #{column_name} text
        GENERATED ALWAYS AS #{column_builder} STORED;
    """

    sql_column_down = """
    ALTER TABLE #{table}
      DROP COLUMN #{column_name};
    """

    Migration.execute(sql_column_up, sql_column_down)
  end

  @doc """
  Adds a GENERATED column to help with accent-insensitive search across several columns.

  This column
  """
  def add_unaccented_search_column_and_index(table, search_column, columns) do
    add_unnaccented_search_column(table, search_column, columns)
    add_trigram_index(table, search_column)
  end

  @doc """
  Convert a search term into params that can be used in a forage plan.
  Search will be case-insensitive.

  This is implemented internally as an `ILIKE` operator.
  By default, the `ILIKE` operator doesn't ignore accents.
  If you want to ignore accents, you need to use `unaccented_search_params/2`
  and prepare a special column in your database (see [here](#)).

  If it makes sense for your application, you can implement
  better search yourself.
  """
  def naive_search_params(%{"_search" => term} = params, field) do
    field_string = to_string(field)

    map_with_new_filter = %{
      field_string => %{
        "op" => "contains",
        "val" => term
      }
    }

    replace_search_by_a_filter(params, map_with_new_filter)
  end

  @doc """
  Convert a search term into params that can be used in a forage plan.
  Search will be case-insensitive and will ignore accents.

  *Currently this function requires PostgreSQL*.

  This is implemented internally as an `ILIKE` operator.
  Thus function calls the special `forage_unaccent()` function,
  which you should have defined somehwere in your PostgreSQL database.

  If it makes sense for your application, you can implement
  better search yourself.
  """
  def unaccented_search_params(%{"_search" => term} = params, field) do
    field_string = to_string(field)

    map_with_new_filter = %{
      field_string => %{
        "op" => "postgresql:contains_ignore_accents",
        "val" => term
      }
    }

    replace_search_by_a_filter(params, map_with_new_filter)
  end

  defp replace_search_by_a_filter(params, map_with_new_filter) do
    params
    |> Map.delete("_search")
    |> Map.update(
      "_filter",
      map_with_new_filter,
      fn filters -> Map.merge(filters, map_with_new_filter) end
    )
  end
end