lib/vectorex.ex

defmodule Vectorex do
  @moduledoc """
  This module is a container for controls for a full text search for postgres.

  It provides a fluent api to build full text search queries which can be transformed to a string
  that can be passed for execution using `&to_sql/1`


  The module contains four functions that can be used to build the query
    1. ts_and
    2. ts_or
    3. ts_not
    4. ts_followed_by

  Same functions exist in Vectorex.Subquery

  Depending on the function you pass to `new/2`, the behaviour changes. If the control
  is supported by the function, we append it to the query. If not, the parameter is ignored.

  Check [the official documentation](https://www.postgresql.org/docs/current/textsearch-controls.html)
  to verify which controls are supported by the function

  Only to_tsquery supports all 4 controls.

  You can create a new instance of Vectorex using `new/2` by passing the postgres function
  you want to use and the parameter

  Available Postgres functions are: :to_tsquery, :phraseto_tsquery, :plainto_tsquery, :websearch_to_tsquery

  ## Examples
    iex> Vectorex.new(:to_tsquery, "elixir")    
    %Vectorex{params: ["elixir"], function: :to_tsquery}

    iex> Vectorex.new(:phraseto_tsquery, "elixir")    
    %Vectorex{params: ["elixir"], function: :phraseto_tsquery}

    iex> Vectorex.new(:plainto_tsquery, "elixir")    
    %Vectorex{params: ["elixir"], function: :plainto_tsquery}

    iex> Vectorex.new(:websearch_to_tsquery, "elixir")    
    %Vectorex{params: ["elixir"], function: :websearch_to_tsquery}

  After that, you can start building the query.

  ## Building queries

  You can do that by using pipes
  ```elixir
  # and 
  Vectorex.new(:to_tsquery, "elixir") |> Vectorex.ts_and("ocaml")

  # or
  Vectorex.new(:to_tsquery, "elixir") |> Vectorex.ts_or("ocaml")

  # not
  Vectorex.new(:to_tsquery, "elixir") |> Vectorex.ts_not("ocaml")

  # not
  Vectorex.new(:to_tsquery, "elixir") |> Vectorex.ts_followed_by("ocaml")
  ```

  Obviously, you can pipe multiple times

  ```elixir
  Vectorex.new(:to_tsquery, "elixir") 
  |> Vectorex.ts_and("ocaml")
  |> Vectorex.ts_and("scala")
  ```

  Or you can use a list

  ```elixir
  Vectorex.new(:to_tsquery, "elixir") 
  |> Vectorex.ts_and(["ocaml", "scala"])
  ```

  ## Building subqueries
  You can also group operators together using `Vectorex.Subquery.new/1`. 
  You can do this if you want parentheses around your terms

  ```elixir
  inner =
    Vectorex.Subquery.new("ocaml")
    |> Vectorex.Subquery.ts_and("scala")

  result =
    Vectorex.new(:to_tsquery, "elixir")
    |> Vectorex.ts_and(inner)
  ```

  Subqueries also support the list option

  ```elixir
  inner =
    Vectorex.Subquery.new("ocaml")
    |> Vectorex.Subquery.ts_or(["scala", "haskell"])

  result =
    Vectorex.new(:to_tsquery, "elixir")
    |> Vectorex.ts_and(inner)
  ```

  We can also pass a list of subqueries

  ```elixir
  inner =
    Vectorex.Subquery.new("ocaml")
    |> Vectorex.Subquery.ts_or(["scala", "haskell"])

  inner =
    Vectorex.Subquery.new("java")
    |> Vectorex.Subquery.ts_or("c#")

  result =
    Vectorex.new(:to_tsquery, "elixir")
    |> Vectorex.ts_and([inner, inner2])
  ```

  ## Converting to sql
  After we build the query we want, we need to convert it to a string. We can do that using `to_sql/1`

  ## Examples
    iex> Vectorex.new(:to_tsquery, "elixir") |> Vectorex.ts_and("ocaml") |> Vectorex.ts_and("haskell") |>  Vectorex.to_sql()
    "elixir & ocaml & haskell"

    iex> Vectorex.new(:to_tsquery, "elixir") |> Vectorex.ts_and(Vectorex.Subquery.new("ocaml") |> Vectorex.Subquery.ts_or("scala")) |> Vectorex.to_sql()
    "elixir & (ocaml | scala)"

  We can use that string in an Ecto fragment for execution 

  ```elixir
  vectorex = Vectorex.new(:to_tsquery, "elixir")

  from e in Event,
      where: fragment("textsearchable_index_col @@ to_tsquery(?)", ^Vectorex.to_sql(vectorex))
  ```

  """
  defstruct [:params, :function]
  @before_compile {FunctionGenerator, :new}
  @before_compile {FunctionGenerator, :operators}

  @type t() :: %__MODULE__{
          params: [String.t()] | [Vectorex.Subquery.t()],
          function: :to_tsquery | :phraseto_tsquery | :plainto_tsquery | :websearch_to_tsquery
        }

  defmodule Subquery do
    @before_compile {FunctionGenerator, :new_subquery}
    @before_compile {FunctionGenerator, :operators}
    defstruct [:params]

    @type t() :: %__MODULE__{
            params: [String.t()] | [Vectorex.Subquery.t()]
          }

    @doc false
    @spec to_sql(subquery :: __MODULE__.t()) :: String.t()
    def to_sql(%__MODULE__{} = vector) do
      List.foldr([:end | vector.params], "(", fn item, acc ->
        case item do
          param when is_binary(param) -> acc <> param
          {:and, %__MODULE__{} = param} -> acc <> " & " <> to_sql(param)
          {:or, %__MODULE__{} = param} -> acc <> " | " <> to_sql(param)
          {:not, %__MODULE__{} = param} -> acc <> " & !" <> to_sql(param)
          {:followed_by, %__MODULE__{} = param} -> acc <> " <-> " <> to_sql(param)
          :end -> acc <> ")"
          _ -> Vectorex.append_operator(item, acc)
        end
      end)
    end
  end

  @doc """
  Transforms a Vectorex instance to a sql string
  """
  @spec to_sql(vectorex :: __MODULE__.t()) :: String.t()
  def to_sql(vectorex)

  def to_sql(%__MODULE__{function: :to_tsquery} = vector) do
    List.foldr(vector.params, "", fn item, acc ->
      case item do
        param when is_binary(param) -> acc <> param
        {:and, %Subquery{} = subquery} -> acc <> " & " <> Subquery.to_sql(subquery)
        {:or, %Subquery{} = subquery} -> acc <> " | " <> Subquery.to_sql(subquery)
        {:not, %Subquery{} = subquery} -> acc <> " & ! " <> Subquery.to_sql(subquery)
        {:followed_by, %Subquery{} = subquery} -> acc <> " <-> " <> Subquery.to_sql(subquery)
        _ -> append_operator(item, acc)
      end
    end)
  end

  def to_sql(%__MODULE__{function: :websearch_to_tsquery} = vector) do
    List.foldr(vector.params, "", fn item, acc ->
      case item do
        param when is_binary(param) -> acc <> "" <> param
        {:or, param} -> acc <> " or " <> param
        {:not, param} -> acc <> " -" <> param
        _ -> acc
      end
    end)
  end

  def to_sql(%__MODULE__{function: :plainto_tsquery} = vector) do
    List.foldr(vector.params, "", fn item, acc ->
      case item do
        param when is_binary(param) -> acc <> "" <> param
        {:and, param} -> acc <> " " <> param
        _ -> acc
      end
    end)
  end

  def to_sql(%__MODULE__{function: :phraseto_tsquery} = vector) do
    List.foldr(vector.params, "", fn item, acc ->
      case item do
        param when is_binary(param) -> acc <> "" <> param
        {:followed_by, param} -> acc <> " " <> param
        _ -> acc
      end
    end)
  end

  def append_operator(item, query) do
    case item do
      {:and, param} -> query <> " & " <> param
      {:or, param} -> query <> " | " <> param
      {:not, param} -> query <> " & !" <> param
      {:followed_by, param} -> query <> " <-> " <> param
      _ -> query
    end
  end
end