From a78312b6daed09708930006bee4ba277b34a7779 Mon Sep 17 00:00:00 2001 From: Zoey de Souza Pessanha Date: Mon, 30 Dec 2024 22:09:45 -0300 Subject: [PATCH] feat: missing filter builder functions also implement a custom and simpler DSL to represent conditions to be used in `any_of`, `all_of` functions renamed: - `and/2` to `all_of/2` - `or/2` to `any_of/2` - `in/2` to `within/2` - `not/3` to `negate/3` to avoid conflict with [Kernel](https://hexdocs.pm/elixir/Kernel.html) --- lib/supabase/postgrest.ex | 2 +- lib/supabase/postgrest/builder.ex | 1 + lib/supabase/postgrest/filter_builder.ex | 325 ++++++++++++++---- .../postgrest/filter_builder/behaviour.ex | 97 ++++-- .../postgrest/filter_builder_test.exs | 81 +++++ test/supabase/postgrest_test.exs | 4 +- 6 files changed, 417 insertions(+), 93 deletions(-) create mode 100644 test/supabase/postgrest/filter_builder_test.exs diff --git a/lib/supabase/postgrest.ex b/lib/supabase/postgrest.ex index 50b49cb..a28b43b 100644 --- a/lib/supabase/postgrest.ex +++ b/lib/supabase/postgrest.ex @@ -43,7 +43,7 @@ defmodule Supabase.PostgREST do ## Filter Builder - for {fun, arity} <- FilterBuilder.__info__(:functions) do + for {fun, arity} <- FilterBuilder.__info__(:functions), fun != :process_condition do 1..arity |> Enum.map(&Macro.var(:"arg_#{&1}", QueryBuilder)) |> then(fn args -> diff --git a/lib/supabase/postgrest/builder.ex b/lib/supabase/postgrest/builder.ex index 8279cc0..a1b6fa4 100644 --- a/lib/supabase/postgrest/builder.ex +++ b/lib/supabase/postgrest/builder.ex @@ -28,6 +28,7 @@ defmodule Supabase.PostgREST.Builder do @doc "Creates a new `#{__MODULE__}` instance" def new(%Client{} = client, relation: relation) do %__MODULE__{ + client: client, schema: client.db.schema, method: :get, params: %{}, diff --git a/lib/supabase/postgrest/filter_builder.ex b/lib/supabase/postgrest/filter_builder.ex index 7dac3e3..278a87c 100644 --- a/lib/supabase/postgrest/filter_builder.ex +++ b/lib/supabase/postgrest/filter_builder.ex @@ -9,6 +9,45 @@ defmodule Supabase.PostgREST.FilterBuilder do @behaviour Supabase.PostgREST.FilterBuilder.Behaviour + @filter_ops [ + :eq, + :gt, + :gte, + :lt, + :lte, + :neq, + :like, + :ilike, + :match, + :imatch, + :in, + :is, + :isdistinct, + :fts, + :plfts, + :phfts, + :wfts, + :cs, + :cd, + :ov, + :sl, + :sr, + :nxr, + :nxl, + :adj, + :not, + :and, + :or, + :all, + :any + ] + + @doc """ + Guard to validates if the filter operator passed to + `__MODULE__.filter/3` is a valid operator. + """ + defguard is_filter_op(op) when op in @filter_ops + @doc """ Match only rows which satisfy the filter. This is an escape hatch - you hould use the specific filter methods wherever possible. @@ -19,14 +58,16 @@ defmodule Supabase.PostgREST.FilterBuilder do ## Parameters - `column` - The column to filter on - `operator` - The operator to filter with, following PostgREST syntax - - `value` - The value to filter with, following PostgREST syntax + - `value` - The value to filter with, following PostgREST syntax, must implement the `String.Chars` protocol ## Examples iex> PostgREST.filter(builder, "id", "not", 12) """ @impl true - def filter(%Builder{} = b, column, op, value) do - Builder.add_query_param(b, column, "#{op}.#{value}") + def filter(%Builder{} = b, column, op, value) + when is_binary(column) and is_filter_op(op) do + condition = process_condition({op, column, value}) + Builder.add_query_param(b, column, condition) end @doc """ @@ -37,25 +78,44 @@ defmodule Supabase.PostgREST.FilterBuilder do It's currently not possible to do an `.and()` filter across multiple tables. + You can optionally use the custom DSL to represent conditions instead of a raw string, look at the examples and the `Supabase.PostgREST.FilterBuilder.Behaviour.condition()` type spec. + ## Parameters - `builder`: The Builder instance. - `columns`: A list of conditions that should all be met. - `opts`: Optional parameters, which can include specifying a foreign table. ## Examples - iex> PostgREST.and(builder, ["age > 18", "status = 'active'"]) + iex> PostgREST.all_of(builder, [{:gt, "age", 18}, {:eq, "status", "active"}]) + iex> PostgREST.all_of([ + iex> {:gt, "age", 18}, + iex> {:and, [ + iex> {:lt, "salary", 5000}, + iex> {:eq, "role", "junior"} + iex> ]} + iex> ]) ## See also - Supabase logical operations: https://supabase.com/docs/reference/javascript/using-filters#logical-operators """ @impl true - def unquote(:and)(%Builder{} = b, columns, opts \\ []) do - columns = Enum.join(columns, ",") + def all_of(builder, patterns, opts \\ []) + def all_of(%Builder{} = b, patterns, opts) when is_binary(patterns) do if foreign = Keyword.get(opts, :foreign_table) do - Builder.add_query_param(b, "#{foreign}.and", "(#{columns})") + Builder.add_query_param(b, "#{foreign}.and", "(#{patterns})") else - Builder.add_query_param(b, "and", "(#{columns})") + Builder.add_query_param(b, "and", "(#{patterns})") + end + end + + def all_of(%Builder{} = b, patterns, opts) when is_list(patterns) do + filters = Enum.map_join(patterns, ",", &process_condition/1) + + if foreign = Keyword.get(opts, :foreign_table) do + Builder.add_query_param(b, "#{foreign}.and", "(#{filters})") + else + Builder.add_query_param(b, "and", "(#{filters})") end end @@ -67,25 +127,44 @@ defmodule Supabase.PostgREST.FilterBuilder do It's currently not possible to do an `.and()` filter across multiple tables. + You can optionally use the custom DSL to represent conditions instead of a raw string, look at the examples and the `Supabase.PostgREST.FilterBuilder.Behaviour.condition()` type spec. + ## Parameters - `builder`: The Builder instance. - `columns`: A list of conditions where at least one should be met. - `opts`: Optional parameters, which can include specifying a foreign table. ## Examples - iex> PostgREST.or(builder, ["age < 18", "status = 'inactive'"]) + iex> PostgREST.any_of(builder, [{:gt, "age", 18}, {:eq, "status", "active"}]) + iex> PostgREST.any_of([ + iex> {:gt, "age", 18}, + iex> {:or, [ + iex> {:eq, "status", "active"}, + iex> {:eq, "status", "pending"} + iex> ]}, + iex> ]) ## See also - Further details on logical operations in Supabase: https://supabase.com/docs/reference/javascript/using-filters#logical-operators """ @impl true - def unquote(:or)(%Builder{} = b, columns, opts \\ []) do - columns = Enum.join(columns, ",") + def any_of(builder, patterns, opts \\ []) + + def any_of(%Builder{} = b, patterns, opts) when is_binary(patterns) do + if foreign = Keyword.get(opts, :foreign_table) do + Builder.add_query_param(b, "#{foreign}.or", "(#{patterns})") + else + Builder.add_query_param(b, "or", "(#{patterns})") + end + end + + def any_of(%Builder{} = b, patterns, opts) when is_list(patterns) do + filters = Enum.map_join(patterns, ",", &process_condition/1) if foreign = Keyword.get(opts, :foreign_table) do - Builder.add_query_param(b, "#{foreign}.or", "(#{columns})") + Builder.add_query_param(b, "#{foreign}.or", "(#{filters})") else - Builder.add_query_param(b, "or", "(#{columns})") + Builder.add_query_param(b, "or", "(#{filters})") end end @@ -100,7 +179,7 @@ defmodule Supabase.PostgREST.FilterBuilder do - `builder`: The Builder instance. - `column`: The column to apply the negation. - `op`: The operator used in the condition (e.g., "eq", "gt"). - - `value`: The value to compare against. + - `value`: The value to compare against, must implement the `String.Chars` protocol ## Examples iex> PostgREST.not(builder, "status", "eq", "active") @@ -109,15 +188,56 @@ defmodule Supabase.PostgREST.FilterBuilder do - Supabase negation filters: https://supabase.com/docs/reference/javascript/using-filters#negation """ @impl true - def unquote(:not)(%Builder{} = b, column, op, value) do + def negate(%Builder{} = b, column, op, value) + when is_binary(column) and is_filter_op(op) do Builder.add_query_param(b, column, "not.#{op}.#{value}") end + alias Supabase.PostgREST.FilterBuilder.Behaviour, as: Interface + + defguardp is_op_mod(op) when op in [:eq, :like, :ilike, :gt, :gte, :lt, :lte, :match, :imatch] + defguardp is_fts_op(op) when op in [:fts, :plfts, :phfts, :wfts] + + @spec process_condition(Interface.condition()) :: String.t() + def process_condition({:not, condition}) do + "not.#{process_condition(condition)}" + end + + def process_condition({:and, conditions}) when is_list(conditions) do + "and(#{Enum.map_join(conditions, ",", &process_condition/1)})" + end + + def process_condition({:or, conditions}) when is_list(conditions) do + "or(#{Enum.map_join(conditions, ",", &process_condition/1)})" + end + + def process_condition({op, column, values, opts}) + when is_list(values) and is_op_mod(op) do + op = to_string(op) + all = Keyword.get(opts, :all, false) + any = Keyword.get(opts, :any, false) + + cond do + all -> Enum.join([op <> "(all)", "{#{Enum.join(values, ",")}}"], ".") + any -> Enum.join([op <> "(any)", "{#{Enum.join(values, ",")}}"], ".") + true -> Enum.join([op, "{#{Enum.join(values, ",")}}"], ".") + end + |> then(&(column <> "=" <> &1)) + end + + def process_condition({op, column, value, lang: lang}) when is_fts_op(op) do + "#{column}=#{op}(#{lang}).#{value}" + end + + def process_condition({op, column, value}) when is_filter_op(op) do + Enum.join([column, op, value], ".") + end + @doc """ Match only rows where each column in `query` keys is equal to its associated value. Shorthand for multiple `.eq()`s. ## Parameters - - `query` - The object to filter with, with column names as keys mapped to their filter values + - `query` - The object to filter with, with column names as keys mapped to their filter values, and all values must implement the `String.Chars` protocol ## Examples iex> PostgREST.match(builder, %{"col1" => true, "col2" => false}) @@ -133,14 +253,14 @@ defmodule Supabase.PostgREST.FilterBuilder do end @doc """ - Adds an equality filter to the query, specifying that the column must equal a certain value. + Match only rows where `column` is equal to `value`. - To check if the value of `column` is `NULL`, you should use `.is()` instead. + To check if the value of `column` is NULL, you should use `.is()` instead. ## Parameters - `builder`: The Builder instance. - `column`: The column to apply the filter. - - `value`: The value the column must equal. + - `value`: The value the column must equal, must implement `String.Chars` protocol ## Examples iex> PostgREST.eq(builder, "id", 123) @@ -149,17 +269,17 @@ defmodule Supabase.PostgREST.FilterBuilder do - Supabase equality filters: https://supabase.com/docs/reference/javascript/using-filters#equality """ @impl true - def eq(%Builder{} = f, column, value) do + def eq(%Builder{} = f, column, value) when is_binary(column) do Builder.add_query_param(f, column, "eq.#{value}") end @doc """ - Adds a 'not equal' filter to the query, specifying that the column's value must not equal the specified value. + Match only rows where `column` is not equal to `value`. ## Parameters - `builder`: The Builder instance. - `column`: The column to apply the filter. - - `value`: The value that the column must not equal. + - `value`: The value that the column must not equal, must implement `String.Chars` protocol ## Examples iex> PostgREST.neq(builder, "status", "inactive") @@ -168,7 +288,7 @@ defmodule Supabase.PostgREST.FilterBuilder do - Supabase not equal filter: https://supabase.com/docs/reference/javascript/using-filters#not-equal """ @impl true - def neq(%Builder{} = f, column, value) do + def neq(%Builder{} = f, column, value) when is_binary(column) do Builder.add_query_param(f, column, "neq.#{value}") end @@ -178,7 +298,7 @@ defmodule Supabase.PostgREST.FilterBuilder do ## Parameters - `builder`: The Builder instance. - `column`: The column to apply the filter. - - `value`: The value that the column must be greater than. + - `value`: The value that the column must be greater than, must implement the `String.Chars` protocol ## Examples iex> PostgREST.gt(builder, "age", 21) @@ -187,7 +307,7 @@ defmodule Supabase.PostgREST.FilterBuilder do - Supabase greater than filter: https://supabase.com/docs/reference/javascript/using-filters#greater-than """ @impl true - def gt(%Builder{} = f, column, value) do + def gt(%Builder{} = f, column, value) when is_binary(column) do Builder.add_query_param(f, column, "gt.#{value}") end @@ -197,7 +317,7 @@ defmodule Supabase.PostgREST.FilterBuilder do ## Parameters - `builder`: The Builder instance. - `column`: The column to apply the filter. - - `value`: The value that the column must be greater than or equal to. + - `value`: The value that the column must be greater than or equal to, must implement the `String.Chars` protocol ## Examples iex> PostgREST.gte(builder, "age", 21) @@ -206,7 +326,7 @@ defmodule Supabase.PostgREST.FilterBuilder do - Supabase greater than or equal filter: https://supabase.com/docs/reference/javascript/using-filters#greater-than-or-equal """ @impl true - def gte(%Builder{} = f, column, value) do + def gte(%Builder{} = f, column, value) when is_binary(column) do Builder.add_query_param(f, column, "gte.#{value}") end @@ -216,7 +336,7 @@ defmodule Supabase.PostgREST.FilterBuilder do ## Parameters - `builder`: The Builder instance. - `column`: The column to apply the filter. - - `value`: The value that the column must be less than. + - `value`: The value that the column must be less than, must implement the `String.Chars` protocol ## Examples iex> PostgREST.lt(builder, "age", 65) @@ -225,7 +345,7 @@ defmodule Supabase.PostgREST.FilterBuilder do - Supabase less than filter: https://supabase.com/docs/reference/javascript/using-filters#less-than """ @impl true - def lt(%Builder{} = f, column, value) do + def lt(%Builder{} = f, column, value) when is_binary(column) do Builder.add_query_param(f, column, "lt.#{value}") end @@ -235,7 +355,7 @@ defmodule Supabase.PostgREST.FilterBuilder do ## Parameters - `builder`: The Builder instance. - `column`: The column to apply the filter. - - `value`: The value that the column must be less than or equal to. + - `value`: The value that the column must be less than or equal to, must implement the `String.Chars` protocol ## Examples iex> PostgREST.lte(builder, "age", 65) @@ -244,7 +364,7 @@ defmodule Supabase.PostgREST.FilterBuilder do - Supabase less than or equal filter: https://supabase.com/docs/reference/javascript/using-filters#less-than-or-equal """ @impl true - def lte(%Builder{} = f, column, value) do + def lte(%Builder{} = f, column, value) when is_binary(column) do Builder.add_query_param(f, column, "lte.#{value}") end @@ -254,7 +374,7 @@ defmodule Supabase.PostgREST.FilterBuilder do ## Parameters - `builder`: The Builder instance. - `column`: The column to apply the filter. - - `value`: The pattern to match against the column's value. + - `value`: The pattern to match against the column's value, must implement the `String.Chars` protocol ## Examples iex> PostgREST.like(builder, "name", "%John%") @@ -263,17 +383,49 @@ defmodule Supabase.PostgREST.FilterBuilder do - Supabase like filter: https://supabase.com/docs/reference/javascript/using-filters#like """ @impl true - def like(%Builder{} = f, column, value) do + def like(%Builder{} = f, column, value) when is_binary(column) do Builder.add_query_param(f, column, "like.#{value}") end + @doc """ + Match only rows where `column` matches **all** of `patterns` case-sensitively. + + ## Params + - `column`: the column to apply the filter + - `values`: a list of patterns of filters (needs to implement the `String.Chars` protocol) + + ## Examples + iex> PostgREST.like_all_of(builder, "name", ["jhon", "maria", "joão"]) + """ + @impl true + def like_all_of(%Builder{} = b, column, values) + when is_binary(column) and is_list(values) do + Builder.add_query_param(b, column, "like(all).{#{Enum.join(values, ",")}}") + end + + @doc """ + Match only rows where `column` matches **any** of `patterns` case-sensitively. + + ## Params + - `column`: the column to apply the filter + - `values`: a list of patterns of filters (needs to implement the `String.Chars` protocol) + + ## Examples + iex> PostgREST.like_any_of(builder, "name", ["jhon", "maria", "joão"]) + """ + @impl true + def like_any_of(%Builder{} = b, column, values) + when is_binary(column) and is_list(values) do + Builder.add_query_param(b, column, "like(any).{#{Enum.join(values, ",")}}") + end + @doc """ Adds an 'ilike' filter to the query, allowing for case-insensitive pattern matching (SQL ILIKE). ## Parameters - `builder`: The Builder instance. - `column`: The column to apply the filter. - - `value`: The pattern to match against the column's value, ignoring case. + - `value`: The pattern to match against the column's value, ignoring case, must implement the `String.Chars` protocol ## Examples iex> PostgREST.ilike(builder, "name", "%john%") @@ -282,15 +434,47 @@ defmodule Supabase.PostgREST.FilterBuilder do - Supabase ilike filter: https://supabase.com/docs/reference/javascript/using-filters#ilike """ @impl true - def ilike(%Builder{} = f, column, value) do + def ilike(%Builder{} = f, column, value) when is_binary(column) do Builder.add_query_param(f, column, "ilike.#{value}") end + @doc """ + Match only rows where `column` matches **all** of `patterns` case-insensitively. + + ## Params + - `column`: the column to apply the filter + - `values`: a list of patterns of filters (needs to implement the `String.Chars` protocol) + + ## Examples + iex> PostgREST.ilike_all_of(builder, "name", ["jhon", "maria", "joão"]) + """ + @impl true + def ilike_all_of(%Builder{} = f, column, values) + when is_binary(column) and is_list(values) do + Builder.add_query_param(f, column, "ilike(all).{#{Enum.join(values, ",")}}") + end + + @doc """ + Match only rows where `column` matches **any** of `patterns` case-insensitively. + + ## Params + - `column`: the column to apply the filter + - `values`: a list of patterns of filters (needs to implement the `String.Chars` protocol) + + ## Examples + iex> PostgREST.ilike_any_of(builder, "name", ["jhon", "maria", "joão"]) + """ + @impl true + def ilike_any_of(%Builder{} = f, column, values) + when is_binary(column) and is_list(values) do + Builder.add_query_param(f, column, "ilike(any).{#{Enum.join(values, ",")}}") + end + @doc """ Match only rows where `column` IS `value`. For non-boolean columns, this is only relevant for checking if the value of - `column` is NULL by setting `value` to `null`. + `column` is NULL by setting `value` to `nil`. For boolean columns, you can also set `value` to `true` or `false` and it will behave the same way as `.eq()`. @@ -298,7 +482,7 @@ defmodule Supabase.PostgREST.FilterBuilder do ## Parameters - `builder`: The Builder instance. - `column`: The column to apply the filter. - - `value`: The value to check the column against (typically null or a boolean). + - `value`: The value to check the column against (typically nil or a boolean). ## Examples iex> PostgREST.is(builder, "name", nil) @@ -307,7 +491,11 @@ defmodule Supabase.PostgREST.FilterBuilder do - Supabase is filter: https://supabase.com/docs/reference/javascript/using-filters#is """ @impl true - def is(%Builder{} = f, column, value) do + def is(%Builder{} = f, column, nil) when is_binary(column) do + Builder.add_query_param(f, column, "is.null") + end + + def is(%Builder{} = f, column, value) when is_binary(column) and is_boolean(value) do Builder.add_query_param(f, column, "is.#{value}") end @@ -317,7 +505,7 @@ defmodule Supabase.PostgREST.FilterBuilder do ## Parameters - `builder`: The Builder instance. - `column`: The column to filter. - - `values`: A list of acceptable values for the column. + - `values`: A list of acceptable values for the column, all elements must implement the `String.Chars` protocol ## Examples iex> PostgREST.in(builder, "status", ["active", "pending", "closed"]) @@ -326,9 +514,14 @@ defmodule Supabase.PostgREST.FilterBuilder do - Supabase "IN" filters: https://supabase.com/docs/reference/javascript/using-filters#in """ @impl true - def unquote(:in)(%Builder{} = f, column, values) - when is_list(values) do - Builder.add_query_param(f, column, "in.(#{Enum.join(values, ",")})") + def within(%Builder{} = f, column, values) + when is_binary(column) and is_list(values) do + values = + Enum.map_join(values, ",", fn v -> + if String.match?(v, ~r/[,()]/), do: "#{to_string(v)}", else: to_string(v) + end) + + Builder.add_query_param(f, column, "in.(#{values})") end @doc """ @@ -346,15 +539,18 @@ defmodule Supabase.PostgREST.FilterBuilder do - Supabase contains filter: https://supabase.com/docs/reference/javascript/using-filters#contains """ @impl true - def contains(%Builder{} = b, column, value) when is_binary(value) do + def contains(%Builder{} = b, column, value) + when is_binary(column) and is_binary(value) do do_contains(b, column, value) end - def contains(%Builder{} = b, column, values) when is_list(values) do + def contains(%Builder{} = b, column, values) + when is_binary(column) and is_list(values) do do_contains(b, column, "{#{Enum.join(values, ",")}}") end - def contains(%Builder{} = b, column, values) when is_map(values) do + def contains(%Builder{} = b, column, values) + when is_binary(column) and is_map(values) do do_contains(b, column, Jason.encode!(values)) end @@ -377,15 +573,18 @@ defmodule Supabase.PostgREST.FilterBuilder do - Supabase contained by filter: https://supabase.com/docs/reference/javascript/using-filters#contained-by """ @impl true - def contained_by(%Builder{} = b, column, value) when is_binary(value) do + def contained_by(%Builder{} = b, column, value) + when is_binary(column) and is_binary(value) do do_contained_by(b, column, value) end - def contained_by(%Builder{} = b, column, values) when is_list(values) do + def contained_by(%Builder{} = b, column, values) + when is_binary(column) and is_list(values) do do_contained_by(b, column, "{#{Enum.join(values, ",")}}") end - def contained_by(%Builder{} = b, column, values) when is_map(values) do + def contained_by(%Builder{} = b, column, values) + when is_binary(column) and is_map(values) do do_contained_by(b, column, Jason.encode!(values)) end @@ -399,7 +598,7 @@ defmodule Supabase.PostgREST.FilterBuilder do ## Parameters - `builder`: The Builder instance. - `column`: The column to apply the filter. - - `value`: The upper bound value of the range. + - `value`: The upper bound value of the range, must implement the `String.Chars` protocol ## Examples iex> PostgREST.range_lt(builder, "age", 30) @@ -408,7 +607,7 @@ defmodule Supabase.PostgREST.FilterBuilder do - Supabase range filters: https://supabase.com/docs/reference/javascript/using-filters#range """ @impl true - def range_lt(%Builder{} = f, column, value) do + def range_lt(%Builder{} = f, column, value) when is_binary(column) do Builder.add_query_param(f, column, "sl.#{value}") end @@ -418,7 +617,7 @@ defmodule Supabase.PostgREST.FilterBuilder do ## Parameters - `builder`: The Builder instance. - `column`: The column to apply the filter. - - `value`: The lower bound value of the range. + - `value`: The lower bound value of the range, must implement the `String.Chars` protocol ## Examples iex> PostgREST.range_gt(builder, "age", 20) @@ -427,7 +626,7 @@ defmodule Supabase.PostgREST.FilterBuilder do - More on range filters at Supabase: https://supabase.com/docs/reference/javascript/using-filters#range """ @impl true - def range_gt(%Builder{} = f, column, value) do + def range_gt(%Builder{} = f, column, value) when is_binary(column) do Builder.add_query_param(f, column, "sr.#{value}") end @@ -437,7 +636,7 @@ defmodule Supabase.PostgREST.FilterBuilder do ## Parameters - `builder`: The Builder instance. - `column`: The column to apply the filter. - - `value`: The starting value of the range. + - `value`: The starting value of the range, must implement the `String.Chars` protocol ## Examples iex> PostgREST.range_gte(builder, "age", 18) @@ -446,7 +645,7 @@ defmodule Supabase.PostgREST.FilterBuilder do - Supabase documentation on range filters: https://supabase.com/docs/reference/javascript/using-filters#range """ @impl true - def range_gte(%Builder{} = f, column, value) do + def range_gte(%Builder{} = f, column, value) when is_binary(column) do Builder.add_query_param(f, column, "nxl.#{value}") end @@ -456,7 +655,7 @@ defmodule Supabase.PostgREST.FilterBuilder do ## Parameters - `builder`: The Builder instance. - `column`: The column to apply the filter. - - `value`: The ending value of the range. + - `value`: The ending value of the range, must implement the `String.Chars` protocol ## Examples iex> PostgREST.range_lte(builder, "age", 65) @@ -465,7 +664,7 @@ defmodule Supabase.PostgREST.FilterBuilder do - Supabase guide on using range filters: https://supabase.com/docs/reference/javascript/using-filters#range """ @impl true - def range_lte(%Builder{} = f, column, value) do + def range_lte(%Builder{} = f, column, value) when is_binary(column) do Builder.add_query_param(f, column, "nxr.#{value}") end @@ -475,7 +674,7 @@ defmodule Supabase.PostgREST.FilterBuilder do ## Parameters - `builder`: The Builder instance. - `column`: The column to apply the filter. - - `value`: The adjacent range value. + - `value`: The adjacent range value, must implement the `String.Chars` protocol ## Examples iex> PostgREST.range_adjacent(builder, "scheduled_time", "2021-01-01T10:00:00Z/2021-01-01T12:00:00Z") @@ -484,7 +683,7 @@ defmodule Supabase.PostgREST.FilterBuilder do - Supabase adjacent range filters: https://supabase.com/docs/reference/javascript/using-filters#adjacent """ @impl true - def range_adjacent(%Builder{} = f, column, value) do + def range_adjacent(%Builder{} = f, column, value) when is_binary(column) do Builder.add_query_param(f, column, "adj.#{value}") end @@ -494,7 +693,7 @@ defmodule Supabase.PostgREST.FilterBuilder do ## Parameters - `builder`: The Builder instance. - `column`: The column to apply the filter. - - `values`: The array of values that must overlap with the column's value. + - `values`: The array of values that must overlap with the column's value, all elements must implement the `String.Chars` protocol ## Examples iex> PostgREST.overlaps(builder, "tags", ["urgent", "old"]) @@ -503,11 +702,13 @@ defmodule Supabase.PostgREST.FilterBuilder do - Supabase overlaps filter: https://supabase.com/docs/reference/javascript/using-filters#overlaps """ @impl true - def overlaps(%Builder{} = b, column, value) when is_binary(value) do + def overlaps(%Builder{} = b, column, value) + when is_binary(column) and is_binary(value) do Builder.add_query_param(b, column, "ov.#{value}") end - def overlaps(%Builder{} = b, column, values) when is_list(values) do + def overlaps(%Builder{} = b, column, values) + when is_binary(column) and is_list(values) do values |> Enum.join(",") |> then(&Builder.add_query_param(b, column, "ov.{#{&1}}")) @@ -529,7 +730,7 @@ defmodule Supabase.PostgREST.FilterBuilder do - Supabase full-text search capabilities: https://supabase.com/docs/reference/javascript/using-filters#full-text-search """ @impl true - def text_search(%Builder{} = f, column, query, opts \\ []) do + def text_search(%Builder{} = f, column, query, opts \\ []) when is_binary(column) do type = search_type_to_code(Keyword.get(opts, :type)) config = if config = Keyword.get(opts, :config), do: "(#{config})", else: "" diff --git a/lib/supabase/postgrest/filter_builder/behaviour.ex b/lib/supabase/postgrest/filter_builder/behaviour.ex index f936c80..2c9517f 100644 --- a/lib/supabase/postgrest/filter_builder/behaviour.ex +++ b/lib/supabase/postgrest/filter_builder/behaviour.ex @@ -3,37 +3,78 @@ defmodule Supabase.PostgREST.FilterBuilder.Behaviour do alias Supabase.PostgREST.Builder - @type operator :: atom + @type operator :: + :eq + | :gt + | :gte + | :lt + | :lte + | :neq + | :like + | :ilike + | :match + | :imatch + | :in + | :is + | :isdistinct + | :fts + | :plfts + | :phfts + | :wfts + | :cs + | :cd + | :ov + | :sl + | :sr + | :nxr + | :nxl + | :adj + | :not + | :and + | :or + | :all + | :any + @type condition :: + {operator, column :: String.t(), value :: String.Chars.t()} + | {:not, condition} + | {:and | :or, list(condition)} + | {:eq | :like | :ilike | :gt | :gte | :lt | :lte | :match | :imatch, + column :: String.t(), pattern :: list(String.Chars.t())} + | {:eq | :like | :ilike | :gt | :gte | :lt | :lte | :match | :imatch, + column :: String.t(), pattern :: list(String.Chars.t()), + list({:any | :all, boolean})} @type text_search_options :: [type: :plain | :phrase | :websearch] - @callback filter(Builder.t(), column :: String.t(), operator, term) :: Builder.t() - @callback unquote(:and)(Builder.t(), list(String.t())) :: Builder.t() - @callback unquote(:and)(Builder.t(), list(String.t()), foreign_table: String.t()) :: - Builder.t() - @callback unquote(:or)(Builder.t(), list(String.t())) :: Builder.t() - @callback unquote(:or)(Builder.t(), list(String.t()), foreign_table: String.t()) :: - Builder.t() - @callback unquote(:not)(Builder.t(), column :: String.t(), operator, term) :: - Builder.t() - @callback match(Builder.t(), query :: map) :: Builder.t() - @callback eq(Builder.t(), column :: String.t(), term) :: Builder.t() - @callback neq(Builder.t(), column :: String.t(), term) :: Builder.t() - @callback gt(Builder.t(), column :: String.t(), term) :: Builder.t() - @callback gte(Builder.t(), column :: String.t(), term) :: Builder.t() - @callback lt(Builder.t(), column :: String.t(), term) :: Builder.t() - @callback lte(Builder.t(), column :: String.t(), term) :: Builder.t() - @callback like(Builder.t(), column :: String.t(), term) :: Builder.t() + @callback filter(Builder.t(), column :: String.t(), operator, String.Chars.t()) :: Builder.t() + @callback all_of(Builder.t(), list(condition)) :: Builder.t() + @callback all_of(Builder.t(), list(condition), foreign_table: String.t()) :: Builder.t() + @callback any_of(Builder.t(), list(condition)) :: Builder.t() + @callback any_of(Builder.t(), list(condition), foreign_table: String.t()) :: Builder.t() + @callback negate(Builder.t(), column :: String.t(), operator, String.Chars.t()) :: Builder.t() + @callback match(Builder.t(), query :: matcher) :: Builder.t() + when matcher: %{String.t() => String.Chars.t()} + @callback eq(Builder.t(), column :: String.t(), String.Chars.t()) :: Builder.t() + @callback neq(Builder.t(), column :: String.t(), String.Chars.t()) :: Builder.t() + @callback gt(Builder.t(), column :: String.t(), String.Chars.t()) :: Builder.t() + @callback gte(Builder.t(), column :: String.t(), String.Chars.t()) :: Builder.t() + @callback lt(Builder.t(), column :: String.t(), String.Chars.t()) :: Builder.t() + @callback lte(Builder.t(), column :: String.t(), String.Chars.t()) :: Builder.t() + @callback like(Builder.t(), column :: String.t(), String.Chars.t()) :: Builder.t() + @callback like_all_of(Builder.t(), column :: String.t(), list(String.Chars.t())) :: Builder.t() + @callback like_any_of(Builder.t(), column :: String.t(), list(String.Chars.t())) :: Builder.t() @callback ilike(Builder.t(), column :: String.t(), term) :: Builder.t() - @callback is(Builder.t(), column :: String.t(), term) :: Builder.t() - @callback unquote(:in)(Builder.t(), column :: String.t(), term) :: Builder.t() - @callback contains(Builder.t(), column :: String.t(), list(term)) :: Builder.t() - @callback contained_by(Builder.t(), column :: String.t(), list(term)) :: Builder.t() - @callback range_lt(Builder.t(), column :: String.t(), term) :: Builder.t() - @callback range_gt(Builder.t(), column :: String.t(), term) :: Builder.t() - @callback range_gte(Builder.t(), column :: String.t(), term) :: Builder.t() - @callback range_lte(Builder.t(), column :: String.t(), term) :: Builder.t() - @callback range_adjacent(Builder.t(), column :: String.t(), term) :: Builder.t() - @callback overlaps(Builder.t(), column :: String.t(), list(term)) :: Builder.t() + @callback ilike_all_of(Builder.t(), column :: String.t(), list(String.Chars.t())) :: Builder.t() + @callback ilike_any_of(Builder.t(), column :: String.t(), list(String.Chars.t())) :: Builder.t() + @callback is(Builder.t(), column :: String.t(), String.Chars.t()) :: Builder.t() + @callback within(Builder.t(), column :: String.t(), String.Chars.t()) :: Builder.t() + @callback contains(Builder.t(), column :: String.t(), list(String.Chars.t())) :: Builder.t() + @callback contained_by(Builder.t(), column :: String.t(), list(String.Chars.t())) :: Builder.t() + @callback range_lt(Builder.t(), column :: String.t(), String.Chars.t()) :: Builder.t() + @callback range_gt(Builder.t(), column :: String.t(), String.Chars.t()) :: Builder.t() + @callback range_gte(Builder.t(), column :: String.t(), String.Chars.t()) :: Builder.t() + @callback range_lte(Builder.t(), column :: String.t(), String.Chars.t()) :: Builder.t() + @callback range_adjacent(Builder.t(), column :: String.t(), String.Chars.t()) :: Builder.t() + @callback overlaps(Builder.t(), column :: String.t(), list(String.Chars.t())) :: Builder.t() @callback text_search(Builder.t(), column :: String.t(), query :: String.t()) :: Builder.t() @callback text_search( diff --git a/test/supabase/postgrest/filter_builder_test.exs b/test/supabase/postgrest/filter_builder_test.exs new file mode 100644 index 0000000..aa364bf --- /dev/null +++ b/test/supabase/postgrest/filter_builder_test.exs @@ -0,0 +1,81 @@ +defmodule Supabase.PostgREST.FilterBuilderTest do + use ExUnit.Case, async: true + + import Supabase.PostgREST.FilterBuilder, only: [process_condition: 1] + + test "process not condition for a single clause" do + result = process_condition({:not, {:eq, "status", "active"}}) + assert result == "not.status.eq.active" + end + + test "process not condition with nested and" do + result = process_condition({:not, {:and, [{:gt, "age", 18}, {:eq, "status", "active"}]}}) + assert result == "not.and(age.gt.18,status.eq.active)" + end + + test "process not condition with nested or" do + result = process_condition({:not, {:or, [{:lt, "age", 18}, {:eq, "status", "inactive"}]}}) + assert result == "not.or(age.lt.18,status.eq.inactive)" + end + + test "process deeply nested not condition" do + result = + process_condition( + {:not, + {:or, + [{:not, {:eq, "status", "active"}}, {:and, [{:lt, "age", 18}, {:gt, "score", 90}]}]}} + ) + + assert result == "not.or(not.status.eq.active,and(age.lt.18,score.gt.90))" + end + + test "process simple condition with eq operator" do + assert process_condition({:eq, "age", 18}) == "age.eq.18" + end + + test "process simple condition with gt operator" do + assert process_condition({:gt, "age", 18}) == "age.gt.18" + end + + test "process and condition" do + result = process_condition({:and, [{:gt, "age", 18}, {:eq, "status", "active"}]}) + assert result == "and(age.gt.18,status.eq.active)" + end + + test "process or condition with nested and" do + result = + process_condition( + {:or, [{:eq, "status", "active"}, {:and, [{:lt, "age", 18}, {:gt, "score", 90}]}]} + ) + + assert result == "or(status.eq.active,and(age.lt.18,score.gt.90))" + end + + test "process any modifier condition" do + result = + process_condition({:eq, "tags", ["elixir", "phoenix"], any: true}) + + assert result == "tags=eq(any).{elixir,phoenix}" + end + + test "process all modifier condition" do + result = + process_condition({:like, "tags", ["*backend*", "*frontend*"], all: true}) + + assert result == "tags=like(all).{*backend*,*frontend*}" + end + + test "raises error for invalid operator" do + assert_raise FunctionClauseError, fn -> + process_condition({:invalid_op, "age", 18}) + end + end + + test "process empty and condition" do + assert process_condition({:and, []}) == "and()" + end + + test "process empty or condition" do + assert process_condition({:or, []}) == "or()" + end +end diff --git a/test/supabase/postgrest_test.exs b/test/supabase/postgrest_test.exs index ef858c6..1fc2af8 100644 --- a/test/supabase/postgrest_test.exs +++ b/test/supabase/postgrest_test.exs @@ -130,11 +130,11 @@ defmodule Supabase.PostgRESTTest do PostgREST.ilike(fb, "name", "%john%") end - test "in function checks if a column's value is within a specified list", %{ + test "within function checks if a column's value is within a specified list", %{ builder: fb } do assert %Builder{params: %{"status" => "in.(active,pending,closed)"}} = - PostgREST.in(fb, "status", ["active", "pending", "closed"]) + PostgREST.within(fb, "status", ["active", "pending", "closed"]) end end end