From 4596298501bcdcdc77d36d240e0c26d4660a30ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Niemier?= Date: Tue, 15 Jun 2021 11:40:37 +0200 Subject: [PATCH] feat: experiment with supporting full-body query functions --- lib/ecto_function.ex | 88 +++++++++++++------------------ lib/ecto_function/body_builder.ex | 45 ++++++++++++++++ test/ecto_function_test.exs | 63 +++++----------------- 3 files changed, 96 insertions(+), 100 deletions(-) create mode 100644 lib/ecto_function/body_builder.ex diff --git a/lib/ecto_function.ex b/lib/ecto_function.ex index 491fa2c..1066bf9 100644 --- a/lib/ecto_function.ex +++ b/lib/ecto_function.ex @@ -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: @@ -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 @@ -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 diff --git a/lib/ecto_function/body_builder.ex b/lib/ecto_function/body_builder.ex new file mode 100644 index 0000000..5a1ff22 --- /dev/null +++ b/lib/ecto_function/body_builder.ex @@ -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 diff --git a/test/ecto_function_test.exs b/test/ecto_function_test.exs index a61654d..0a76461 100644 --- a/test/ecto_function_test.exs +++ b/test/ecto_function_test.exs @@ -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 @@ -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 @@ -41,7 +41,7 @@ defmodule Ecto.FunctionTest do import Ecto.Function defmodule :'#{mod}' do - defqueryfunc test(a, b) + defq test(a, b) end """ @@ -49,30 +49,12 @@ defmodule Ecto.FunctionTest do 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 """ @@ -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 """ @@ -119,7 +84,7 @@ defmodule Ecto.FunctionTest do import Ecto.Function defmodule :'#{mod}' do - defqueryfunc "foo" + defq "foo" end """ @@ -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 """ @@ -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