Repository: elixir-ecto/ecto
Pull request #4630 (2025-06-23 16:31:04)

Fix issue with macro expansion in `over` clause's `order_by`
Fixes a regression that prevents some queries from compiling correctly.

## Current behavior

Given the following setup:

```elixir
defmodule MyQueryUtils do
  defmacro foo(table) do
    quote(do: unquote(table).priority > 0)
  end
end

defmodule Post do
  use Ecto.Schema

  @primary_key false
  schema "posts" do
    field :title, :string
    field :priority, :integer
  end
end
```

This query no longer compiles:

```elixir
import MyQueryUtils
import Ecto.Query

select(Post, [o], %{row_number: over(row_number(), order_by: [desc: foo(o)])})
```

With the following error:

```
** (KeyError) key :module not found in: {#Macro.Env<
   aliases: [],
   ...
 >, #Function<4.25497058/5 in Ecto.Query.Builder.Select.escape_expansion>}

If you are using the dot syntax, such as map.field, make sure the left-hand side of the dot is a map
    (elixir 1.18.3) lib/macro/env.ex:539: Macro.Env.expand_import/5
    (elixir 1.18.3) lib/macro.ex:1877: Macro.do_expand_once/2
    (elixir 1.18.3) lib/macro.ex:1824: Macro.expand_once/2
    (ecto 3.13.1) lib/ecto/query/builder.ex:1101: Ecto.Query.Builder.try_expansion/5
    (ecto 3.13.1) lib/ecto/query/builder/order_by.ex:79: Ecto.Query.Builder.OrderBy.do_escape/5
    (elixir 1.18.3) lib/enum.ex:1316: anonymous fn/3 in Enum.flat_map_reduce/3
    (elixir 1.18.3) lib/enum.ex:4968: Enumerable.List.reduce/3
    (elixir 1.18.3) lib/enum.ex:1315: Enum.flat_map_reduce/3
```

greg-rychlewski: One tiny comment then it's good to go! Thank you

Patch:
diff --git a/lib/ecto/query/builder/order_by.ex b/lib/ecto/query/builder/order_by.ex
index 88ac2f83..25f04e0a 100644
--- a/lib/ecto/query/builder/order_by.ex
+++ b/lib/ecto/query/builder/order_by.ex
@@ -75,13 +75,13 @@ defmodule Ecto.Query.Builder.OrderBy do
 
   defp do_escape({dir, expr}, params_acc, kind, vars, env) do
     fun = &escape_expansion(kind, &1, &2, &3, &4, &5)
-    {ast, params_acc} = Builder.escape(expr, :any, params_acc, vars, {env, fun})
+    {ast, params_acc} = Builder.escape(expr, :any, params_acc, vars, {get_env(env), fun})
     {[{quoted_dir!(kind, dir), ast}], params_acc}
   end
 
   defp do_escape(expr, params_acc, kind, vars, env) do
     fun = &escape_expansion(kind, &1, &2, &3, &4, &5)
-    {ast, params_acc} = Builder.escape(expr, :any, params_acc, vars, {env, fun})
+    {ast, params_acc} = Builder.escape(expr, :any, params_acc, vars, {get_env(env), fun})
 
     if is_list(ast) do
       {ast, params_acc}
@@ -90,6 +90,9 @@ defmodule Ecto.Query.Builder.OrderBy do
     end
   end
 
+  defp get_env({env, _}), do: env
+  defp get_env(env), do: env
+
   defp escape_expansion(kind, expr, _type, params_acc, vars, env) when is_list(expr) do
     escape(kind, expr, params_acc, vars, env)
   end


Test patch:
diff --git a/test/ecto/query/builder/select_test.exs b/test/ecto/query/builder/select_test.exs
index e27adbbc..7dec8c5c 100644
--- a/test/ecto/query/builder/select_test.exs
+++ b/test/ecto/query/builder/select_test.exs
@@ -174,6 +174,53 @@ defmodule Ecto.Query.Builder.SelectTest do
       assert %{alias: _} = query.select.aliases
     end
 
+    defmacro my_custom_field(p) do
+      quote do
+        fragment("lower(?)", unquote(p).title)
+      end
+    end
+
+    defmacro my_complex_order(p) do
+      quote do
+        [desc: unquote(p).id, asc: my_custom_field(unquote(p)), asc: nth_value(unquote(p).links, 1)]
+      end
+    end
+
+    test "supports macro expansion in over/2" do
+      query = from p in "posts", select: %{row_number: over(row_number(), order_by: [desc: my_custom_field(p)])}
+
+      assert {:%{}, [],
+       [
+         row_number:
+           {:over, [],
+            [
+              {:row_number, [], []},
+              [
+                order_by: [
+                  desc: {:fragment, [], [raw: "lower(", expr: _, raw: ")"]}
+                ]
+              ]
+            ]}
+       ]} = query.select.expr
+
+      query = from p in "posts", select: %{row_number: over(row_number(), order_by: my_complex_order(p))}
+      assert {:%{}, [],
+        [
+          row_number:
+            {:over, [],
+              [
+                {:row_number, [], []},
+                [
+                  order_by: [
+                    desc: _,
+                    asc: {:fragment, [], [raw: "lower(", expr: _, raw: ")"]},
+                    asc: {:nth_value, [], _}
+                  ]
+                ]
+              ]}
+        ]} = query.select.expr
+    end
+
     test "raises if name given to selected_as/2 is not an atom" do
       message = "expected literal atom or interpolated value in selected_as/2, got: `\"ident\"`"