Skip to content

Commit

Permalink
introduce_identifier_constant
Browse files Browse the repository at this point in the history
  • Loading branch information
greg-rychlewski committed Jan 9, 2025
1 parent c700071 commit c161ae3
Show file tree
Hide file tree
Showing 3 changed files with 122 additions and 1 deletion.
34 changes: 33 additions & 1 deletion lib/ecto/query/api.ex
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,38 @@ defmodule Ecto.Query.API do
"""
def literal(binary), do: doc!([binary])

@doc """
Allows a dynamic identifier to be injected into a fragment:
collation = "es_ES"
select("posts", [p], fragment("? COLLATE ?", p.title, identifier(^"es_ES")))
The example above will inject the value of `collation` directly
into the query instead of treating it as a query parameter. It will
generate a query such as `SELECT p0.title COLLATE "es_ES" FROM "posts" AS p0`
as opposed to `SELECT p0.title COLLATE $1 FROM "posts" AS p0`.
Note that each different value of `collation` will emit a different query,
which will be independently prepared and cached.
"""
def identifier(binary), do: doc!([binary])

@doc """
Allows a dynamic string or number to be injected into a fragment:
limit = 10
"posts" |> select([p], p.title) |> limit(fragment("?", constant(^limit)))
The example above will inject the value of `limit` directly
into the query instead of treating it as a query parameter. It will
generate a query such as `SELECT p0.title FROM "posts" AS p0 LIMIT 1`
as opposed to `SELECT p0.title FROM "posts" AS p0` LIMIT $1`.
Note that each different value of `limit` will emit a different query,
which will be independently prepared and cached.
"""
def constant(value), do: doc!([value])

@doc """
Allows a list argument to be spliced into a fragment.
Expand All @@ -504,7 +536,7 @@ defmodule Ecto.Query.API do
You may only splice runtime values. For example, this would not work because
query bindings are compile-time constructs:
from p in Post, where: fragment("concat(?)", splice(^[p.count, " ", "count"])
from p in Post, where: fragment("concat(?)", splice(^[p.count, " ", "count"]))
"""
def splice(list), do: doc!([list])

Expand Down
52 changes: 52 additions & 0 deletions lib/ecto/query/builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,34 @@ defmodule Ecto.Query.Builder do
end
end

defp escape_fragment({:identifier, _meta, [expr]}, params_acc, _vars, _env) do
case expr do
{:^, _, [expr]} ->
checked = quote do: Ecto.Query.Builder.identifier!(unquote(expr))
escaped = {:{}, [], [:identifier, [], [checked]]}
{escaped, params_acc}

_ ->
error!(
"identifier/1 in fragment expects an interpolated value, such as identifier(^value), got `#{Macro.to_string(expr)}`"
)
end
end

defp escape_fragment({:constant, _meta, [expr]}, params_acc, _vars, _env) do
case expr do
{:^, _, [expr]} ->
checked = quote do: Ecto.Query.Builder.constant!(unquote(expr))
escaped = {:{}, [], [:constant, [], [checked]]}
{escaped, params_acc}

_ ->
error!(
"constant/1 in fragment expects an interpolated value, such as constant(^value), got `#{Macro.to_string(expr)}`"
)
end
end

defp escape_fragment({:splice, _meta, [splice]}, params_acc, vars, env) do
case splice do
{:^, _, [value]} = expr ->
Expand Down Expand Up @@ -1265,6 +1293,30 @@ defmodule Ecto.Query.Builder do
end
end

@doc """
Called by escaper at runtime to verify identifier in fragments.
"""
def identifier!(identifier) do
if is_binary(identifier) do
identifier
else
raise ArgumentError,
"identifier(^value) expects `value` to be a string, got `#{inspect(identifier)}`"
end
end

@doc """
Called by escaper at runtime to verify constant in fragments.
"""
def constant!(constant) do
if is_binary(constant) or is_number(constant) do
constant
else
raise ArgumentError,
"constant(^value) expects `value` to be a string or a number, got `#{inspect(constant)}`"
end
end

@doc """
Called by escaper at runtime to verify splice in fragments.
"""
Expand Down
37 changes: 37 additions & 0 deletions test/ecto/query_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,43 @@ defmodule Ecto.QueryTest do
end
end

test "supports identifiers" do
query = from p in "posts", select: fragment("? COLLATE ?", p.name, identifier(^"es_ES"))
assert {:fragment, _, parts} = query.select.expr

assert [
raw: "",
expr: {{:., _, [{:&, _, [0]}, :name]}, _, _},
raw: " COLLATE ",
expr: {:identifier, _, ["es_ES"]},
raw: ""
] = parts

msg = "identifier(^value) expects `value` to be a string, got `123`"

assert_raise ArgumentError, msg, fn ->
from p in "posts", select: fragment("? COLLATE ?", p.name, identifier(^123))
end
end

test "supports constants" do
query =
from p in "posts",
select: fragment("?", constant(^"hi")),
limit: fragment("?", constant(^1))

assert {:fragment, _, select_parts} = query.select.expr
assert {:fragment, _, limit_parts} = query.limit.expr
assert [raw: "", expr: {:constant, _, ["hi"]}, raw: ""] = select_parts
assert [raw: "", expr: {:constant, _, [1]}, raw: ""] = limit_parts

msg = "constant(^value) expects `value` to be a string or a number, got `%{}`"

assert_raise ArgumentError, msg, fn ->
from p in "posts", limit: fragment("?", constant(^%{}))
end
end

test "supports list splicing" do
two = 2
three = 3
Expand Down

0 comments on commit c161ae3

Please sign in to comment.