Skip to content

Experiment with supporting full-body query functions #4

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
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
88 changes: 37 additions & 51 deletions lib/ecto_function.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,10 @@ defmodule Ecto.Function do

import Ecto.Function

defqueryfunc foo # Define function without params
defqueryfunc bar(a, b) # Explicit parameter names
defqueryfunc baz/1 # Define function using arity
defqueryfunc qux(a, b \\ 0) # Define function with default arguments
defqueryfunc quux/1, for: "db_quux" # Define with alternative DB call
defq foo # Define function without params
defq bar(a, b) # Explicit parameter names
defq baz(a, b \\ 0) # Define function with default arguments
defq qux(a), for: "db_qux" # Define with alternative DB call

Then calling such functions in query would be equivalent to:

Expand All @@ -32,17 +31,14 @@ defmodule Ecto.Function do
from q in "bars", select: %{bar: bar(q.a, q.b)}
# => SELECT bar(bars.a, bars.b) AS bar FROM bars

from q in "bazs", where: baz(q.a) == true
# => SELECT * FROM bazs WHERE baz(bazs.a) = TRUE

from q in "quxs", select: %{one: qux(q.a), two: qux(q.a, q.b)}
from q in "bazs", select: %{one: baz(q.a), two: baz(q.a, q.b)}
# => SELECT
# qux(quxs.a, 0) AS one,
# qux(quxs.a, quxs.b) AS two
# FROM "quxs"
# baz(bazs.a, 0) AS one,
# baz(bazs.a, bazs.b) AS two
# FROM "bazs"

from q in "quuxs", select: %{quux: quux(q.a)}
# => SELECT db_quux(quuxs.a) FROM quuxs
from q in "quxs", select: %{qux: qux(q.a)}
# => SELECT db_qux(quxs.a) FROM quxs

## Gotchas

Expand All @@ -59,60 +55,50 @@ defmodule Ecto.Function do

[extract]: https://www.postgresql.org/docs/current/static/functions-datetime.html#functions-datetime-extract
"""
defmacro defqueryfunc(definition, opts \\ [])

defmacro defqueryfunc({:/, _, [{name, _, _}, params_count]}, opts)
when is_atom(name) and is_integer(params_count) do
require Logger

opts = Keyword.put_new(opts, :for, name)
params = Macro.generate_arguments(params_count, Elixir)
defmacro defq(definition, opts \\ []), do: build(:defmacro, __CALLER__, definition, opts)

Logger.warn("""
func/arity syntax is deprecated, instead use:
defmacro defqp(definition, opts \\ []), do: build(:defmacrop, __CALLER__, definition, opts)

defqueryfunc #{Macro.to_string(quote do: unquote(name)(unquote_splicing(params)))}
""")

macro(name, params, __CALLER__, opts)
end

defmacro defqueryfunc({name, _, params}, opts)
when is_atom(name) and is_list(params) do
defp build(macro, caller, {name, _, params}, opts)
when is_atom(name) and is_list(params) do
opts = Keyword.put_new(opts, :for, name)

macro(name, params, __CALLER__, opts)
macro(macro, name, params, caller, opts)
end

defmacro defqueryfunc({name, _, _}, opts) when is_atom(name) do
defp build(macro, caller, {name, _, _}, opts) when is_atom(name) do
opts = Keyword.put_new(opts, :for, name)

macro(name, [], __CALLER__, opts)
macro(macro, name, [], caller, opts)
end

defmacro defqueryfunc(tree, _) do
defp build(_, caller, tree, _) do
raise CompileError,
file: __CALLER__.file,
line: __CALLER__.line,
file: caller.file,
line: caller.line,
description: "Unexpected query function definition #{Macro.to_string(tree)}."
end

defp macro(name, params, caller, opts) do
sql_name = Keyword.fetch!(opts, :for)
{query, args} = build_query(params, caller)
defp macro(macro, name, params, caller, opts) do
body =
case Keyword.fetch(opts, :do) do
{:ok, ast} ->
EctoFunction.BodyBuilder.build(ast, params)

quote do
defmacro unquote(name)(unquote_splicing(params)) do
unquote(body(sql_name, query, args))
end
end
end
_ ->
sql_name = Keyword.fetch!(opts, :for)
{query, args} = build_query(params, caller)

defp body(name, query, args) do
fcall = "#{name}(#{query})"
args = Enum.map(args, &{:unquote, [], [&1]})
fcall = "#{sql_name}(#{query})"

{:quote, [], [[do: {:fragment, [], [fcall | args]}]]}
quote bind_quoted: [args: [fcall | args]] do
quote do: fragment(unquote_splicing(args))
end
end

quote do
unquote(macro)(unquote(name)(unquote_splicing(params)), do: unquote(body))
end
end

defp build_query(args, caller) do
Expand Down
45 changes: 45 additions & 0 deletions lib/ecto_function/body_builder.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
defmodule EctoFunction.BodyBuilder do
def build(ast, params) do
params = Enum.map(params, &extract_arg/1)
{fragment, args} = build_fragment(ast, params)

quote bind_quoted: [args: [fragment | args]] do
quote do: fragment(unquote_splicing(args))
end
end

defp build_fragment({:cond, _env, [[do: cases]]}, params) do
{fragment, args} = condition_fragment(cases, params, [], [])

{"CASE #{fragment} END", args}
end

defp condition_fragment([], _, fragment, args),
do: {Enum.reverse(fragment), Enum.reverse(args)}

defp condition_fragment([{:->, _, [[true], result]}], params, fragment, args) do
result = unquote_params(result, params)
condition_fragment([], params, [" ELSE ?" | fragment], [Macro.escape(result, unquote: true) | args])
end

defp condition_fragment([{:->, _env, [[condition], result]} | rest], params, fragment, args) do
condition = unquote_params(condition, params)
result = unquote_params(result, params)
condition_fragment(rest, params, [" WHEN ? THEN ?" | fragment], [Macro.escape(result, unquote: true), Macro.escape(condition, unquote: true)] ++ args)
end

defp unquote_params(ast, params) do
Macro.postwalk(ast, fn
{name, _, atom} = entry when is_atom(atom) ->
if name in params do
{:unquote, [], [entry]}
else
entry
end
other -> other
end)
end

defp extract_arg({:\\, _, [{name, _, _}, _]}), do: name
defp extract_arg({name, _, _}), do: name
end
63 changes: 14 additions & 49 deletions test/ecto_function_test.exs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
defmodule Functions do
import Ecto.Function

defqueryfunc cbrt(dp)
defq cbrt(dp)

defqueryfunc sqrt / 1
defq sqrt(a)

defqueryfunc regr_syy(y, x \\ 0)
defq regr_syy(y, x \\ 0)

defqueryfunc regr_x(y \\ 0, x), for: "regr_sxx"
defq regr_x(y \\ 0, x), for: "regr_sxx"
end

defmodule Ecto.FunctionTest do
Expand All @@ -30,8 +30,8 @@ defmodule Ecto.FunctionTest do
end

describe "compilation" do
setup do
mod = String.to_atom("Elixir.Test#{System.unique_integer([:positive])}")
setup ctx do
mod = Module.concat([__MODULE__, "Test", "Example#{ctx.line}"])

{:ok, mod: mod}
end
Expand All @@ -41,38 +41,20 @@ defmodule Ecto.FunctionTest do
import Ecto.Function

defmodule :'#{mod}' do
defqueryfunc test(a, b)
defq test(a, b)
end
"""

assert [{^mod, _}] = Code.compile_string(code)
assert macro_exported?(mod, :test, 2)
end

test "with defined macro using slashed syntax compiles", %{mod: mod} do
code = """
import Ecto.Function

defmodule :'#{mod}' do
defqueryfunc test/2
end
"""

log =
capture_log(fn ->
assert [{^mod, _}] = Code.compile_string(code)
end)

assert log =~ "func/arity syntax is deprecated"
assert macro_exported?(mod, :test, 2)
end

test "with default params", %{mod: mod} do
code = """
import Ecto.Function

defmodule :'#{mod}' do
defqueryfunc test(a, b \\\\ 1)
defq test(a, b \\\\ 1)
end
"""

Expand All @@ -81,29 +63,12 @@ defmodule Ecto.FunctionTest do
assert macro_exported?(mod, :test, 2)
end

test "generated code" do
result_ast =
Macro.expand_once(
quote do
Ecto.Function.defqueryfunc(test(a, b))
end,
__ENV__
)

assert {:defmacro, _, macro} = result_ast
assert [{:test, _, [{:a, _, _}, {:b, _, _}]}, [do: body]] = macro
assert {:quote, _, [[do: quoted]]} = body
assert {:fragment, _, [sql_string | params]} = quoted
assert sql_string =~ ~r/test\(\?,\s*\?\)/
for param <- params, do: assert({:unquote, _, _} = param)
end

test "do not compiles when params aren't correct", %{mod: mod} do
code = """
import Ecto.Function

defmodule :'#{mod}' do
defqueryfunc test(a, foo(funky))
defq test(a, foo(funky))
end
"""

Expand All @@ -119,7 +84,7 @@ defmodule Ecto.FunctionTest do
import Ecto.Function

defmodule :'#{mod}' do
defqueryfunc "foo"
defq "foo"
end
"""

Expand All @@ -133,9 +98,9 @@ defmodule Ecto.FunctionTest do
import Ecto.Function

defmodule :'#{mod}' do
defqueryfunc foo
defqueryfunc bar/0
defqueryfunc baz()
defq foo
defq baz()
defqp bar
end
"""

Expand All @@ -145,8 +110,8 @@ defmodule Ecto.FunctionTest do
end)

assert macro_exported?(mod, :foo, 0)
assert macro_exported?(mod, :bar, 0)
assert macro_exported?(mod, :baz, 0)
refute macro_exported?(mod, :bar, 0)
end
end

Expand Down