Current section
Files
Jump to
Current section
Files
lib/torus.ex
defmodule Torus do
@external_resource readme = Path.join([__DIR__, "../README.md"])
@moduledoc readme
|> File.read!()
|> String.split("<!-- MDOC -->")
|> Enum.fetch!(1)
import Ecto.Query
@default_language "english"
## Similarity searches
@doc """
Case-insensitive pattern matching search using
[PostgreSQL `ILIKE`](https://www.postgresql.org/docs/current/functions-matching.html#FUNCTIONS-LIKE) operator.
**Doesn't clean the term, so it needs to be sanitized before being passed in. See
[LIKE-injections](https://githubengineering.com/like-injection/).**
## Examples
iex> insert_posts!(titles: ["Wand", "Magic wand", "Owl"])
...> Post
...> |> Torus.ilike([p], [p.title], "wan%")
...> |> select([p], p.title)
...> |> Repo.all()
["Wand"]
iex> insert_posts!([%{title: "hogwarts", body: nil}, %{title: nil, body: "HOGWARTS"}])
...> Post
...> |> Torus.ilike([p], [p.title, p.body], "%OGWART%")
...> |> select([p], %{title: p.title, body: p.body})
...> |> order_by(:id)
...> |> Repo.all()
[%{title: "hogwarts", body: nil}, %{title: nil, body: "HOGWARTS"}]
iex> insert_post!(title: "MaGiC")
...> Post
...> |> Torus.ilike([p], p.title, "magi%")
...> |> select([p], p.title)
...> |> Repo.all()
["MaGiC"]
## Optimizations
See `like/5` optimization section for more details.
"""
defmacro ilike(query, bindings, qualifiers, term, _args \\ []) do
qualifiers = List.wrap(qualifiers)
where_ast =
Enum.reduce(qualifiers, false, fn qualifier, conditions_acc ->
quote do
dynamic(
[unquote_splicing(bindings)],
ilike(unquote(qualifier), ^unquote(term)) or ^unquote(conditions_acc)
)
end
end)
quote do
where(unquote(query), ^unquote(where_ast))
end
end
@doc """
Case-sensitive pattern matching search using [PostgreSQL `LIKE`](https://www.postgresql.org/docs/current/functions-matching.html#FUNCTIONS-LIKE) operator.
**Doesn't clean the term, so it needs to be sanitized before being passed in, see
[LIKE-injections](https://githubengineering.com/like-injection/)**
## Examples
iex> insert_posts!([%{title: "hogwarts", body: nil}, %{title: nil, body: "HOGWARTS"}])
...> Post
...> |> Torus.like([p], [p.title, p.body], "%OGWART%")
...> |> select([p], p.body)
...> |> Repo.all()
["HOGWARTS"]
## Optimizations
- `like/5` is case-sensitive, so it can take advantage of B-tree indexes when there
is no wildcard (%) at the beginning of the search term, prefer it over `ilike/5` if
possible.
Adding a B-tree index:
```sql
CREATE INDEX index_posts_on_title ON posts (title);
```
- Use `GIN` or `GiST` Index with `pg_trgm` extension for LIKE and ILIKE.
When searching for substrings (%word%), B-tree indexes won't help. Instead,
use trigram indexing (`pg_trgm` extension):
```sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX posts_title_trgm_idx ON posts USING GIN (title gin_trgm_ops);
```
- If using prefix search, convert data to lowercase and use B-tree index for
case-insensitive search:
```sql
ALTER TABLE posts ADD COLUMN title_lower TEXT GENERATED ALWAYS AS (LOWER(title)) STORED;
CREATE INDEX index_posts_on_title ON posts (title_lower);
```
```elixir
Torus.like([p], [p.title_lower], "hogwarts%")
```
- Use full-text search for large text fields, see `full_text_dynamic/5` for more
details.
"""
defmacro like(query, binding, qualifiers, term, _args \\ []) do
qualifiers = List.wrap(qualifiers)
where_ast =
Enum.reduce(qualifiers, false, fn qualifier, conditions_acc ->
quote do
dynamic(
[unquote_splicing(binding)],
like(unquote(qualifier), ^unquote(term)) or ^unquote(conditions_acc)
)
end
end)
quote do
where(unquote(query), ^unquote(where_ast))
end
end
@doc """
Similar to `like/5`, except that it interprets the pattern using the SQL standard's
definition of a regular expression. SQL regular expressions are a curious cross between
LIKE notation and common (POSIX) regular expression notation. See
[PostgreSQL `SIMILAR TO`](https://postgresql.org/docs/current/interactive/functions-matching.html?fts_query=ilike#FUNCTIONS-SIMILARTO-REGEXP)
## Examples
iex> insert_post!(body: "abc")
...> Post
...> |> Torus.similar_to([p], [p.title, p.body], "%(b|d)%")
...> |> select([p], p.body)
...> |> Repo.all()
["abc"]
## Optimizations
- If regex is needed, use POSIX regex with `~` or `~*` operators since they _may_
leverage GIN or GiST indexes in some cases. These operators will be introduced later on.
- Use `ilike/5` or `like/5` when possible, `similar_to/5` almost always does full table scans
- Filter and limit the result set as much as possible before calling `similar_to/5`
"""
# TODO: Adjust the description when POSIX regex is added
defmacro similar_to(query, bindings, qualifiers, term, _args \\ []) do
qualifiers = List.wrap(qualifiers)
where_ast =
Enum.reduce(qualifiers, false, fn qualifier, conditions_acc ->
quote do
dynamic(
[unquote_splicing(bindings)],
operator(unquote(qualifier), "SIMILAR TO", ^unquote(term)) or ^unquote(conditions_acc)
)
end
end)
quote do
where(unquote(query), ^unquote(where_ast))
end
end
# -----------------------------------
# TODO: Add POSIX Regular Expressions
# -----------------------------------
@similarity_types ~w[word strict full]a
@similarity_order_types ~w[desc asc none]a
@true_false ~w[true false]a
@doc """
Case-insensitive similarity search using [PostgreSQL similarity functions](https://postgresql.org/docs/current/interactive/pgtrgm.html#PGTRGM-FUNCS-OPS).
**You need to have pg_trgm extension installed.**
## Options
* `:type` - similarity type. Possible options are:
- `:full` (default) - uses `similarity` function.
- `:word` - uses `word_similarity` function. If you're dealing with sentences
and you don't want the length of the strings to affect the search result.
- `:strict` - uses `strict_word_similarity` function. Prioritizes full matches,
forces extent boundaries to match word boundaries. Since we don't have
cross-word trigrams, this function actually returns greatest similarity between
first string and any continuous extent of words of the second string.
* `:order` - describes the ordering of the results. Possible values are
- `:desc` (default) - orders the results by similarity rank in descending order.
- `:asc` - orders the results by similarity rank in ascending order.
- `:none` - doesn't apply ordering and returns
* `:limit` - limits the number of results returned (PostgreSQL `LIMIT`). By
default limit is not applied and the results are above
`pg_trgm.similarity_threshold`, which defaults to 0.3.
* `:pre_filter` - whether or not to pre-filter the results:
- `false` (default) - omits pre-filtering and returns all results.
- `true` - before applying the order, pre filters (using boolean
operators which potentially use GIN indexes) the result set.
## Examples
iex> insert_post!(title: "Hogwarts Shocker", body: "A spell disrupts the Quidditch Cup.")
...> insert_post!(title: "Diagon Bombshell", body: "Secrets uncovered in the heart of Hogwarts.")
...> insert_post!(title: "Completely unrelated", body: "No magic here!")
...> Post
...> |> Torus.similarity([p], [p.title, p.body], "Diagon Bombshell", limit: 1)
...> |> select([p], p.title)
...> |> Repo.all()
["Diagon Bombshell"]
iex> insert_posts!(["Wand", "Owl", "What an amazing cloak"])
...> Post
...> |> Torus.similarity([p], [p.title], "what a cloak", pre_filter: true)
...> |> select([p], p.title)
...> |> Repo.all()
["What an amazing cloak"]
iex> insert_posts!(["Magic wand", "Wand", "Owlyth"])
...> Post
...> |> Torus.similarity([p], [p.title], "wand", pre_filter: true)
...> |> select([p], p.title)
...> |> Repo.all()
["Wand", "Magic wand"]
## Optimizations
- Use `pre_filter: true` to pre-filter the results before applying the order.
This would significantly reduce the number of rows to order. The pre-filtering
phase uses different (boolean) similarity operators which more actively leverage
GIN indexes.
- Use `limit` to limit the number of results returned.
- Use `order: `:none` argument if you don't care about the order of the results.
The query will return all results that are above the similarity threshold, which
you can set globally via `SET pg_trgm.similarity_threshold = 0.3;`.
- When `order: `:desc` (default) and the limit is not set, the query will do a full
table scan, so it's recommended to manually limit the results (by applying `where`
clauses to as little rows as possible).
### Adding an index
```sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX index_posts_on_title ON posts USING GIN (title gin_trgm_ops);
```
## Already implemented optimizations
- Instead of scanning all rows and computing similarity for each, we first filter
with `%` (which could use a GIN index), and next we compute (potentially expensive)
the ranks using `similarity` function and order the results.
"""
# TODO: Add `:join_type` argument
# I'll fix the complexity, I promise!
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defmacro similarity(query, bindings, qualifiers, term, args \\ []) do
# Arguments validation
qualifiers = List.wrap(qualifiers)
limit = Keyword.get(args, :limit)
pre_filter = get_arg!(args, :pre_filter, false, @true_false)
if not is_nil(limit) and not is_integer(limit) do
raise """
`:limit` should be an integer. Got: #{inspect(limit)}
"""
end
similarity_type = get_arg!(args, :type, :full, @similarity_types)
order = get_arg!(args, :order, :desc, @similarity_order_types)
# Arguments preparation
{similarity_function, operator} =
case similarity_type do
:strict -> {"strict_word_similarity", "<<%"}
:word -> {"word_similarity", "<%"}
:full -> {"similarity", "%"}
end
desc_asc = order |> to_string() |> String.upcase()
similarity_string = "#{similarity_function}(?, ?) #{desc_asc}"
multiple_qualifiers? = length(qualifiers) > 1
# Query building
quote do
unquote(query)
|> apply_if(unquote(pre_filter) and unquote(multiple_qualifiers?), fn query ->
where(
query,
[unquote_splicing(bindings)],
operator(^unquote(term), unquote(operator), concat_ws(" ", unquote(qualifiers)))
)
end)
|> apply_if(unquote(pre_filter) and not unquote(multiple_qualifiers?), fn query ->
where(
query,
[unquote_splicing(bindings)],
operator(^unquote(term), unquote(operator), unquote(List.first(qualifiers)))
)
end)
|> apply_if(unquote(order) != :none and unquote(multiple_qualifiers?), fn query ->
order_by(
query,
[unquote_splicing(bindings)],
fragment(unquote(similarity_string), unquote(term), concat_ws(" ", unquote(qualifiers)))
)
end)
|> apply_if(unquote(order) != :none and not unquote(multiple_qualifiers?), fn query ->
order_by(
query,
[unquote_splicing(bindings)],
fragment(unquote(similarity_string), unquote(term), unquote(List.first(qualifiers)))
)
end)
|> apply_if(unquote(limit), &limit(&1, ^unquote(limit)))
end
end
# ----------------------------------------------------------------
# TODO: Combine different types of searches (or at least show how)
# ----------------------------------------------------------------
@supported_weights ~w[A B C D]
@term_functions ~w[websearch_to_tsquery plainto_tsquery phraseto_tsquery]a
@rank_functions ~w[ts_rank_cd ts_rank]a
@filter_types ~w[or concat none]a
@doc """
Full text search with rank ordering. Accepts a list of columns to search in.
Cleans the term, so it can be input directly by the user. The default preset of
settings is optimal for most cases.
## Options
* `:language` - language used for the search. Defaults to `"english"`.
* `:prefix_search` - whether to apply prefix search.
- `true` (default) - the term is treated as a prefix
- `false` - only counts full-word matches
* `:term_function` - function used to convert the term to `ts_query`. Can be one of:
- `:websearch_to_tsquery` (default) - converts term to a tsquery, normalizing
words according to the specified or default configuration. Quoted word sequences
are converted to phrase tests. The word “or” is understood as producing an OR
operator, and a dash produces a NOT operator; other punctuation is ignored. This
approximates the behavior of some common web search tools.
- `:plainto_tsquery` - converts term to a tsquery, normalizing words according
to the specified or default configuration. Any punctuation in the string is
ignored (it does not determine query operators). The resulting query matches
documents containing all non-stopwords in the term.
- `:phraseto_tsquery` - converts term to a tsquery, normalizing words according
to the specified or default configuration. Any punctuation in the string is
ignored (it does not determine query operators). The resulting query matches
phrases containing all non-stopwords in the text.
* `:rank_function` - function used to rank the results.
- `:ts_rank_cd` (default) - computes a score showing how well the vector matches
the query, using a cover density algorithm. See [Ranking Search Results](https://postgresql.org/docs/current/interactive/textsearch-controls.html#TEXTSEARCH-RANKING) for more details.
- `:ts_rank` - computes a score showing how well the vector matches the query.
* `:rank_weights` - a list of weights for each column. Defaults to `[:A, :B, :C, :D]`.
The length of weights (if provided) should be the same as the length of the columns we search for.
A single weight can be either a string or an atom. Possible values are:
- `:A` - 1.0
- `:B` - 0.4
- `:C` - 0.2
- `:D` - 0.1
* `:rank_normalization` - a string that specifies whether and how a document's
length should impact its rank. The integer option controls several behaviors, so
it is a bit mask: you can specify one or more behaviors using `|` (for example, `2|4`).
- `0` (default for `ts_rank`) - ignores the document length
- `1` - divides the rank by 1 + the logarithm of the document length
- `2` - divides the rank by the document length
- `4` (default for `ts_rank_cs`) - divides the rank by the mean harmonic
distance between extents (this is implemented only by `ts_rank_cd`)
- `8` - divides the rank by the number of unique words in document
- `16` - divides the rank by 1 + the logarithm of the number of unique words in
document
- `32` - divides the rank by itself + 1
* `:order` - describes the ordering of the results. Possible values are
- `:desc` (default) - orders the results by similarity rank in descending order.
- `:asc` - orders the results by similarity rank in ascending order.
- `:none` - doesn't apply ordering at all.
* `:filter_type`
- `:or` (default) - uses `OR` operator to combine different column matches.
Selecting this option means that the search term won't match across columns.
- `:concat` - joins the columns into a single tsvector and searches for the
term in the concatenated string containing all columns.
- `:none` - doesn't apply any filtering and returns all results.
* `:nullable_columns` - a list of columns which values can take `null`s, so that
they can be escaped during the search. Defaults to all qualifiers. When passed -
speeds up the search. For example `[p.title, a.first_name]` would mean that only
`p.title` and `a.first_name` columns can take `null`s, whereas other columns are
guaranteed by the caller to be non-nullable.
## Example usage
iex> insert_post!(title: "Hogwarts Shocker", body: "A spell disrupts the Quidditch Cup.")
...> insert_post!(title: "Diagon Bombshell", body: "Secrets uncovered in the heart of Hogwarts.")
...> insert_post!(title: "Completely unrelated", body: "No magic here!")
...> Post
...> |> Torus.full_text_dynamic([p], [p.title, p.body], "uncov hogwar", filter_type: :concat)
...> |> select([p], p.title)
...> |> Repo.all()
["Diagon Bombshell"]
## Optimizations
- Store precomputed tsvector in a separate column, add a GIN index to it, and use
`full_text_stored/5`. See more on how to add an index and how to store a column in
the `full_text_stored/5` docs. If that's not feasible, read on.
- Pass implicitly `nullable_columns` so that we're leveraging existing non-coalesce
GIN indexes (if present) and are not doing unneeded work.
- Add a GIN ts_vector index on the column(s) you search in.
Use `Torus.QueryInspector.tap_sql/2` on your query (with all the options passed) to see the exact search string and add an index to it. For example for nullable title, the GIN index could look like:
```sql
CREATE INDEX index_gin_posts_title
ON posts USING GIN (to_tsvector('english', COALESCE(title, '')));
```
"""
defmacro full_text_dynamic(query, bindings, qualifiers, term, args \\ []) do
# Arguments validation
qualifiers = List.wrap(qualifiers)
language = get_language(args)
prefix_search = get_arg!(args, :prefix_search, true, @true_false)
term_function = get_arg!(args, :term_function, :websearch_to_tsquery, @term_functions)
rank_function = get_arg!(args, :rank_function, :ts_rank_cd, @rank_functions)
filter_type = get_arg!(args, :filter_type, :or, @filter_types)
nullable_columns = Keyword.get(args, :nullable_columns, qualifiers)
order = get_arg!(args, :order, :desc, @similarity_order_types)
rank_weights =
Keyword.get_lazy(args, :rank_weights, fn ->
exceeding_size = max(length(qualifiers) - 4, 0)
[:A, :B, :C, :D] ++ List.duplicate(:D, exceeding_size)
end)
if length(rank_weights) < length(qualifiers) do
raise """
The length of `rank_weights` should be the same as the length of the qualifiers.
"""
end
if not Enum.all?(rank_weights, &(to_string(&1) in @supported_weights)) do
raise """
Each rank weight from `rank_weights` should be one of the: #{@supported_weights}
"""
end
rank_normalization =
Keyword.get_lazy(args, :rank_normalization, fn ->
if rank_function == :ts_rank_cd, do: 4, else: 1
end)
# Arguments preparation
prefix_search = if prefix_search, do: "::text || ':*'", else: ""
desc_asc = order |> to_string() |> String.upcase()
weights_prepared =
qualifiers
|> Enum.with_index()
|> Enum.map_join(" || ", fn {qualifier, index} ->
weight = Enum.fetch!(rank_weights, index)
coalesce = if qualifier in nullable_columns, do: "COALESCE(?, '')", else: "?"
"setweight(to_tsvector(#{language}, #{coalesce}), '#{weight}')"
end)
# TODO: Check if the trim is needed?
concat_filter_string =
"#{weights_prepared} @@ (#{term_function}(#{language}, ?)#{prefix_search})::tsquery"
concat_filter_fragment =
quote do
fragment(
unquote(concat_filter_string),
unquote_splicing(qualifiers),
^unquote(term)
)
end
order_string = """
#{rank_function}(#{weights_prepared}, #{term_function}(#{language}, ?), #{rank_normalization}) #{desc_asc}
"""
order_fragment =
quote do
fragment(
unquote(order_string),
unquote_splicing(qualifiers),
^unquote(term)
)
end
or_filter_ast =
Enum.reduce(qualifiers, false, fn qualifier, conditions_acc ->
quote do
dynamic(
[unquote_splicing(bindings)],
to_tsquery_dynamic(unquote(qualifier), ^unquote(term), unquote(args)) or
^unquote(conditions_acc)
)
end
end)
# Query building
quote do
unquote(query)
|> apply_case(
unquote(filter_type),
fn
:none, query ->
query
:or, query ->
where(unquote(query), ^unquote(or_filter_ast))
:concat, query ->
where(unquote(query), [unquote_splicing(bindings)], unquote(concat_filter_fragment))
end
)
|> apply_if(unquote(order) != :none, fn query ->
order_by(
query,
[unquote_splicing(bindings)],
unquote(order_fragment)
)
end)
end
end
@doc false
defmacro to_tsquery_dynamic(column, query_text, args \\ []) do
term_function = get_arg!(args, :term_function, :websearch_to_tsquery, @term_functions)
prefix_search = if Keyword.get(args, :prefix_search, true), do: "::text || ':*'", else: ""
fragment_string =
"""
CASE
WHEN trim(#{term_function}(?, ?)::text) = '' THEN FALSE
ELSE to_tsvector(?, ?) @@ (#{term_function}(?, ?)#{prefix_search})::tsquery
END
"""
quote do
fragment(
unquote(fragment_string),
unquote(@default_language),
unquote(query_text),
unquote(@default_language),
unquote(column),
unquote(@default_language),
unquote(query_text)
)
end
end
@doc """
The substring function with three parameters provides extraction of a substring
that matches an SQL regular expression pattern. The function can be written
according to standard SQL syntax:
```sql
substring('foobar' similar '%#"o_b#"%' escape '#') oob
substring('foobar' similar '#"o_b#"%' escape '#') NULL
```
## Examples
```elixir
insert_post!(title: "Hello123World")
Post |> select([p], substring(p.title, "[0-9]+", "#")) |> Repo.all()
["123"]
```
"""
# TODO: Fix the doctest. For now this test is duped in the test file.
defmacro substring(string, pattern, escape_character) do
quote do
fragment(
"substring(? similar ? escape ?)",
unquote(string),
unquote(pattern),
unquote(escape_character)
)
end
end
# Private helpers
@doc false
defmacro operator(a, operator, b) do
quote do
fragment(
unquote("? #{operator} ?"),
unquote(a),
unquote(b)
)
end
end
@doc false
def apply_if(query, condition, query_fun) do
if condition, do: query_fun.(query), else: query
end
def apply_case(query, case_condition, query_fun) do
query_fun.(case_condition, query)
end
@doc false
defmacro concat_ws(separator, qualifiers) do
fragment_string = "concat_ws(?" <> String.duplicate(", ?", length(qualifiers)) <> ")"
quote do
fragment(
unquote(fragment_string),
unquote(separator),
unquote_splicing(qualifiers)
)
end
end
defp get_language(args) do
args |> Keyword.get(:language, @default_language) |> then(&("'" <> &1 <> "'"))
end
defp get_arg!(args, value_key, value_default, supported_values) do
value = Keyword.get(args, value_key, value_default)
if value not in supported_values do
raise """
The value of `#{value_key}` should be one of the: #{supported_values}
"""
end
value
end
end