Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions lib/sql_implementation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,122 @@ defmodule AshSqlite.SqlImplementation do
end
end

def expr(
query,
%Ash.Query.Function.Contains{arguments: [left, right], embedded?: pred_embedded?},
bindings,
embedded?,
acc,
type
) do
if ci_string?(left) or ci_string?(right) do
{left, acc} =
AshSql.Expr.dynamic_expr(query, left, bindings, pred_embedded? || embedded?, :string, acc)

{right, acc} =
AshSql.Expr.dynamic_expr(
query,
right,
bindings,
pred_embedded? || embedded?,
:string,
acc
)

inner_dyn = Ecto.Query.dynamic(fragment("instr(LOWER(?), LOWER(?)) > 0", ^left, ^right))

if boolean_type?(type) do
{:ok, Ecto.Query.dynamic(type(^inner_dyn, :boolean)), acc}
else
{:ok, inner_dyn, acc}
end
else
:error
end
end

def expr(
query,
%Ash.Query.Function.StringStartsWith{arguments: [left, right], embedded?: pred_embedded?},
bindings,
embedded?,
acc,
type
) do
if ci_string?(left) or ci_string?(right) do
{left, acc} =
AshSql.Expr.dynamic_expr(query, left, bindings, pred_embedded? || embedded?, :string, acc)

{right, acc} =
AshSql.Expr.dynamic_expr(
query,
right,
bindings,
pred_embedded? || embedded?,
:string,
acc
)

inner_dyn = Ecto.Query.dynamic(fragment("instr(LOWER(?), LOWER(?)) = 1", ^left, ^right))

if boolean_type?(type) do
{:ok, Ecto.Query.dynamic(type(^inner_dyn, :boolean)), acc}
else
{:ok, inner_dyn, acc}
end
else
:error
end
end

def expr(
query,
%Ash.Query.Function.StringEndsWith{arguments: [left, right], embedded?: pred_embedded?},
bindings,
embedded?,
acc,
type
) do
if ci_string?(left) or ci_string?(right) do
{left, acc} =
AshSql.Expr.dynamic_expr(query, left, bindings, pred_embedded? || embedded?, :string, acc)

{inner_dyn, acc} =
cond do
is_binary(right) ->
text = escape_ends_with(String.downcase(right))
{Ecto.Query.dynamic(like(fragment("LOWER(?)", ^left), ^text)), acc}

is_struct(right, Ash.CiString) ->
text = escape_ends_with(String.downcase(right.string))
{Ecto.Query.dynamic(like(fragment("LOWER(?)", ^left), ^text)), acc}

true ->
{right, acc} =
AshSql.Expr.dynamic_expr(
query,
right,
bindings,
pred_embedded? || embedded?,
:string,
acc
)

{Ecto.Query.dynamic(
like(fragment("LOWER(?)", ^left), fragment("('%' || LOWER(?))", ^right))
), acc}
end

if boolean_type?(type) do
{:ok, Ecto.Query.dynamic(type(^inner_dyn, :boolean)), acc}
else
{:ok, inner_dyn, acc}
end
else
:error
end
end

def expr(
query,
%Ash.Query.Function.GetPath{
Expand Down Expand Up @@ -786,6 +902,32 @@ defmodule AshSqlite.SqlImplementation do
defp boolean_type?({:boolean, _}), do: true
defp boolean_type?(_), do: false

defp ci_string?(%Ash.CiString{}), do: true

defp ci_string?(%Ash.Query.Ref{attribute: %{type: type, constraints: constraints}}) do
ci_string_type?(type, constraints)
end

defp ci_string?(%Ash.Query.Ref{attribute: %{type: type}}) do
ci_string_type?(type, [])
end

defp ci_string?(%Ash.Query.Calculation{type: type, constraints: constraints}) do
ci_string_type?(type, constraints)
end

defp ci_string?(val) do
case Ash.Expr.determine_type(val) do
{:ok, {type, constraints}} -> ci_string_type?(type, constraints)
{:ok, type} -> ci_string_type?(type, [])
_ -> false
end
end

defp escape_ends_with(text) do
"%" <> String.replace(text, ~r/([\\%_])/u, "\\\\\\0")
end

defp ci_string_type?({:parameterized, {inner_type, constraints}}, []) do
parameterized_ci_string_type?(inner_type, constraints)
end
Expand Down
67 changes: 67 additions & 0 deletions test/calculation_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,52 @@ defmodule AshSqlite.CalculationTest do
assert Post
|> Ash.Query.filter(contains(title, "-dude-"))
|> Ash.read_one!()

refute Post
|> Ash.Query.filter(contains(title, "-DUDE-"))
|> Ash.read_one!()
end

test "contains with CiString matches case-insensitively" do
Post
|> Ash.Changeset.for_create(:create, %{
title: "foo-dude-bar",
category: "News"
})
|> Ash.create!()

assert Post
|> Ash.Query.filter(contains(title, ^Ash.CiString.new("-DUDE-")))
|> Ash.read_one!()

assert Post
|> Ash.Query.filter(contains(category, "NEW"))
|> Ash.read_one!()

assert Post
|> Ash.Query.filter(contains(category, ^Ash.CiString.new("new")))
|> Ash.read_one!()
end

test "string_starts_with with CiString matches case-insensitively" do
Post
|> Ash.Changeset.for_create(:create, %{
title: "foo-dude-bar",
category: "News"
})
|> Ash.create!()

assert Post
|> Ash.Query.filter(string_starts_with(title, ^Ash.CiString.new("FOO-")))
|> Ash.read_one!()

refute Post
|> Ash.Query.filter(string_starts_with(title, "FOO-"))
|> Ash.read_one!()

assert Post
|> Ash.Query.filter(string_starts_with(category, "NEW"))
|> Ash.read_one!()
end

test "string_starts_with matches a prefix" do
Expand All @@ -301,6 +347,27 @@ defmodule AshSqlite.CalculationTest do
|> Ash.read_one!()
end

test "string_ends_with with CiString matches case-insensitively" do
Post
|> Ash.Changeset.for_create(:create, %{
title: "foo-dude-bar",
category: "News"
})
|> Ash.create!()

assert Post
|> Ash.Query.filter(string_ends_with(title, ^Ash.CiString.new("-BAR")))
|> Ash.read_one!()

refute Post
|> Ash.Query.filter(string_ends_with(title, "-BAR"))
|> Ash.read_one!()

assert Post
|> Ash.Query.filter(string_ends_with(category, "WS"))
|> Ash.read_one!()
end

test "string_ends_with matches a suffix" do
Post
|> Ash.Changeset.for_create(:create, %{title: "foo-dude-bar"})
Expand Down