Skip to content

Commit 51b7747

Browse files
authored
fix: massive tests, fixed query param and filter minor bugs (#19)
1 parent 45f8289 commit 51b7747

File tree

11 files changed

+1206
-9
lines changed

11 files changed

+1206
-9
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,5 @@ result
3434
.elixir-tools/
3535
.lexical/
3636
/priv/plts/
37+
38+
/.claude/

lib/supabase/postgrest/filter_builder.ex

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,20 @@ defmodule Supabase.PostgREST.FilterBuilder do
230230
end
231231

232232
def process_condition({op, column, value}) when is_filter_op(op) do
233-
Enum.join([column, op, value], ".")
233+
formatted_value =
234+
case {op, value} do
235+
{:in, values} when is_list(values) -> "(#{Enum.join(values, ",")})"
236+
{:is, nil} -> "null"
237+
{_, values} when is_list(values) -> "[#{Enum.join(values, ",")}]"
238+
{_, v} -> v
239+
end
240+
241+
Enum.join([column, op, formatted_value], ".")
242+
end
243+
244+
# Handle between operator separately
245+
def process_condition({:between, column, [from, to]}) do
246+
"#{column}.between.[#{from},#{to}]"
234247
end
235248

236249
@doc """

lib/supabase/postgrest/helpers.ex

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
defmodule Supabase.PostgREST.Helpers do
2+
@moduledoc """
3+
Helper functions for PostgREST operations
4+
"""
5+
6+
@doc """
7+
Get a header value from a headers list
8+
"""
9+
def get_header(headers, key) when is_list(headers) do
10+
case List.keyfind(headers, key, 0) do
11+
{^key, value} -> value
12+
nil -> nil
13+
end
14+
end
15+
16+
@doc """
17+
Get a query parameter value from a query list
18+
"""
19+
def get_query_param(query, key) when is_list(query) do
20+
case List.keyfind(query, key, 0) do
21+
{^key, value} -> value
22+
nil -> nil
23+
end
24+
end
25+
end

lib/supabase/postgrest/query_builder.ex

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,20 +76,26 @@ defmodule Supabase.PostgREST.QueryBuilder do
7676
@impl true
7777
def insert(%Request{} = b, data, opts \\ []) when is_map(data) do
7878
on_conflict = Keyword.get(opts, :on_conflict)
79-
on_conflict = if on_conflict, do: "on_conflict=#{on_conflict}"
79+
on_conflict_header = if on_conflict, do: "on_conflict=#{on_conflict}"
8080
upsert = if on_conflict, do: "resolution=merge-duplicates"
8181
returning = Keyword.get(opts, :returning, :representation)
8282
count = Keyword.get(opts, :count, :exact)
83-
prefer = ["return=#{returning}", "count=#{count}", on_conflict, upsert]
83+
prefer = ["return=#{returning}", "count=#{count}", on_conflict_header, upsert]
8484
prefer = Enum.join(Enum.reject(prefer, &is_nil/1), ",")
8585

8686
b
8787
|> Request.with_method(:post)
8888
|> Request.with_headers(%{"prefer" => prefer})
89-
|> Request.with_query(%{"on_conflict" => on_conflict})
89+
|> maybe_add_conflict_query(on_conflict)
9090
|> Request.with_body(data)
9191
end
9292

93+
defp maybe_add_conflict_query(request, nil), do: request
94+
95+
defp maybe_add_conflict_query(request, on_conflict) do
96+
Request.with_query(request, %{"on_conflict" => on_conflict})
97+
end
98+
9399
@doc """
94100
Upserts data into a table, allowing for conflict resolution and specifying return options.
95101
@@ -107,16 +113,23 @@ defmodule Supabase.PostgREST.QueryBuilder do
107113
@impl true
108114
def upsert(%Request{} = b, data, opts \\ []) when is_map(data) do
109115
on_conflict = Keyword.get(opts, :on_conflict)
116+
on_conflict_header = if on_conflict, do: "on_conflict=#{on_conflict}"
110117
returning = Keyword.get(opts, :returning, :representation)
111118
count = Keyword.get(opts, :count, :exact)
112119

113-
prefer =
114-
Enum.join(["resolution=merge-duplicates", "return=#{returning}", "count=#{count}"], ",")
120+
prefer_parts = [
121+
"resolution=merge-duplicates",
122+
"return=#{returning}",
123+
"count=#{count}",
124+
on_conflict_header
125+
]
126+
127+
prefer = Enum.join(Enum.reject(prefer_parts, &is_nil/1), ",")
115128

116129
b
117130
|> Request.with_method(:post)
118131
|> Request.with_headers(%{"prefer" => prefer})
119-
|> Request.with_query(%{"on_conflict" => on_conflict})
132+
|> maybe_add_conflict_query(on_conflict)
120133
|> Request.with_body(data)
121134
end
122135

lib/supabase/postgrest/transform_builder.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ defmodule Supabase.PostgREST.TransformBuilder do
66
"""
77

88
alias Supabase.Fetcher.Request
9+
alias Supabase.PostgREST.Helpers
910

1011
@behaviour Supabase.PostgREST.TransformBuilder.Behaviour
1112

@@ -211,7 +212,8 @@ defmodule Supabase.PostgREST.TransformBuilder do
211212

212213
# postgrest-ex sends always only one Accept header
213214
# and always sets a default (application/json)
214-
for_mediatype = "for=#{b.headers["accept"]}"
215+
accept_header = Helpers.get_header(b.headers, "accept") || "application/json"
216+
for_mediatype = "for=#{accept_header}"
215217

216218
plan = "application/vnd.pgrst.plan#{format};#{for_mediatype};#{opts}"
217219

test/supabase/postgrest/filter_builder_test.exs

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,162 @@ defmodule Supabase.PostgREST.FilterBuilderTest do
7878
test "process empty or condition" do
7979
assert process_condition({:or, []}) == "or()"
8080
end
81+
82+
describe "complex filter combinations" do
83+
test "handles triple nested conditions" do
84+
result =
85+
process_condition(
86+
{:and,
87+
[
88+
{:or, [{:eq, "type", "admin"}, {:eq, "type", "moderator"}]},
89+
{:not,
90+
{:and,
91+
[
92+
{:lt, "created_at", "2023-01-01"},
93+
{:or, [{:eq, "status", "banned"}, {:eq, "status", "suspended"}]}
94+
]}}
95+
]}
96+
)
97+
98+
assert result ==
99+
"and(or(type.eq.admin,type.eq.moderator),not.and(created_at.lt.2023-01-01,or(status.eq.banned,status.eq.suspended)))"
100+
end
101+
102+
test "handles multiple not operators in sequence" do
103+
result = process_condition({:not, {:not, {:eq, "active", true}}})
104+
assert result == "not.not.active.eq.true"
105+
end
106+
107+
test "processes complex array conditions with modifiers" do
108+
result =
109+
process_condition(
110+
{:and,
111+
[
112+
{:eq, "roles", ["admin", "editor"], any: true},
113+
{:not, {:eq, "permissions", ["delete", "archive"], all: true}}
114+
]}
115+
)
116+
117+
assert result ==
118+
"and(roles=eq(any).{admin,editor},not.permissions=eq(all).{delete,archive})"
119+
end
120+
121+
test "handles mixed operator types" do
122+
result =
123+
process_condition(
124+
{:or,
125+
[
126+
{:gte, "score", 90},
127+
{:and, [{:between, "score", [80, 89]}, {:eq, "bonus", true}]},
128+
{:lte, "score", 50}
129+
]}
130+
)
131+
132+
assert result == "or(score.gte.90,and(score.between.[80,89],bonus.eq.true),score.lte.50)"
133+
end
134+
end
135+
136+
describe "additional operators" do
137+
test "process lt (less than) operator" do
138+
assert process_condition({:lt, "price", 100}) == "price.lt.100"
139+
end
140+
141+
test "process gte (greater than or equal) operator" do
142+
assert process_condition({:gte, "quantity", 5}) == "quantity.gte.5"
143+
end
144+
145+
test "process lte (less than or equal) operator" do
146+
assert process_condition({:lte, "stock", 10}) == "stock.lte.10"
147+
end
148+
149+
test "process neq (not equal) operator" do
150+
assert process_condition({:neq, "status", "deleted"}) == "status.neq.deleted"
151+
end
152+
153+
test "process like operator" do
154+
assert process_condition({:like, "email", "%@example.com"}) == "email.like.%@example.com"
155+
end
156+
157+
test "process ilike (case insensitive like) operator" do
158+
assert process_condition({:ilike, "name", "%john%"}) == "name.ilike.%john%"
159+
end
160+
161+
test "process in operator" do
162+
assert process_condition({:in, "category", ["electronics", "books", "clothing"]}) ==
163+
"category.in.(electronics,books,clothing)"
164+
end
165+
166+
test "process is operator for null checks" do
167+
assert process_condition({:is, "deleted_at", nil}) == "deleted_at.is.null"
168+
assert process_condition({:is, "verified", true}) == "verified.is.true"
169+
assert process_condition({:is, "archived", false}) == "archived.is.false"
170+
end
171+
172+
test "process between operator" do
173+
assert process_condition({:between, "age", [18, 65]}) == "age.between.[18,65]"
174+
end
175+
end
176+
177+
describe "edge cases and error handling" do
178+
test "handles string values with special characters" do
179+
assert process_condition({:eq, "name", "O'Brien"}) == "name.eq.O'Brien"
180+
181+
assert process_condition({:eq, "description", "Line 1\nLine 2"}) ==
182+
"description.eq.Line 1\nLine 2"
183+
end
184+
185+
test "handles numeric values" do
186+
assert process_condition({:eq, "price", 19.99}) == "price.eq.19.99"
187+
assert process_condition({:gt, "count", 1000}) == "count.gt.1000"
188+
end
189+
190+
test "handles boolean values" do
191+
assert process_condition({:eq, "active", true}) == "active.eq.true"
192+
assert process_condition({:neq, "archived", false}) == "archived.neq.false"
193+
end
194+
195+
test "single element and/or conditions" do
196+
assert process_condition({:and, [{:eq, "status", "active"}]}) == "and(status.eq.active)"
197+
assert process_condition({:or, [{:gt, "age", 18}]}) == "or(age.gt.18)"
198+
end
199+
200+
test "deeply nested empty conditions" do
201+
assert process_condition({:and, [{:or, []}, {:and, []}]}) == "and(or(),and())"
202+
end
203+
204+
test "array values without modifiers default behavior" do
205+
# This should raise or have specific behavior - adjust based on actual implementation
206+
assert process_condition({:eq, "tags", ["tag1", "tag2"]}) == "tags.eq.[tag1,tag2]"
207+
end
208+
end
209+
210+
describe "filter builder integration" do
211+
alias Supabase.Fetcher.Request
212+
alias Supabase.PostgREST.FilterBuilder
213+
214+
setup do
215+
client = Supabase.init_client!("http://example.com", "test-key")
216+
request = Request.new(client)
217+
{:ok, %{request: request}}
218+
end
219+
220+
test "multiple filter functions create proper query params", %{request: request} do
221+
result =
222+
request
223+
|> FilterBuilder.eq("status", "active")
224+
|> FilterBuilder.gt("age", 18)
225+
|> FilterBuilder.within("role", ["admin", "moderator"])
226+
227+
assert %Request{query: query} = result
228+
assert {"status", "eq.active"} in query
229+
assert {"age", "gt.18"} in query
230+
assert {"role", "in.(admin,moderator)"} in query
231+
end
232+
233+
test "filter functions handle nil values", %{request: request} do
234+
result = FilterBuilder.is(request, "deleted_at", nil)
235+
assert %Request{query: query} = result
236+
assert {"deleted_at", "is.null"} in query
237+
end
238+
end
81239
end

0 commit comments

Comments
 (0)