Repository: elixir-ecto/ecto
Pull request #2428 (2018-02-16 14:31:30)

Support passing `on` to join/5 as option and soft-deprecate raw argument 
Refs #2389 
Refs #2411 

This PR is attempt at stage one of plan to implement named joins in Ecto, as laid out by @josevalim in https://github.com/elixir-ecto/ecto/pull/2411#issuecomment-364642910.

The actual changes to the logic are implemented in the [first commit](https://github.com/elixir-ecto/ecto/commit/96d4c64b2fc544769c6395d88cb6082db076e41a). The [second commit](https://github.com/elixir-ecto/ecto/commit/ac84d0a2f9f99db71045c31679e07bd98627970f) contains exclusively updates to the new syntax across tests and docs.

Feedback appreciated.

josevalim: Just two comments and we should be good to go.

Patch:
diff --git a/lib/ecto/query.ex b/lib/ecto/query.ex
index 530bc373..df741518 100644
--- a/lib/ecto/query.ex
+++ b/lib/ecto/query.ex
@@ -730,7 +730,7 @@ defmodule Ecto.Query do
   ## Expressions examples
 
       Comment
-      |> join(:inner, [c], p in Post, c.post_id == p.id)
+      |> join(:inner, [c], p in Post, on: c.post_id == p.id)
       |> select([c, p], {p.title, c.text})
 
       Post
@@ -738,7 +738,7 @@ defmodule Ecto.Query do
       |> select([p, c], {p, c})
 
       Post
-      |> join(:left, [p], c in Comment, c.post_id == p.id and c.is_visible == true)
+      |> join(:left, [p], c in Comment, on: c.post_id == p.id and c.is_visible == true)
       |> select([p, c], {p, c})
 
   ## Joining with fragments
@@ -757,12 +757,40 @@ defmodule Ecto.Query do
       |> select([g, gs], {g.name, gs.sold_on})
 
   """
-  defmacro join(query, qual, binding \\ [], expr, on \\ nil) do
+  defmacro join(query, qual, binding \\ [], expr, opts \\ []) do
+    opts =
+      case parse_join_opts(opts) do
+        {:opts, opts} ->
+          opts
+        {:expr, on_expr} ->
+          IO.warn "Passing raw `on` expression as the last argument to Ecto.Query.join/5 is deprecated. " <>
+            "Please use :on keyword option instead."
+          [on: on_expr]
+      end
+
     query
-    |> Join.build(qual, binding, expr, on, nil, __CALLER__)
+    |> Join.build(qual, binding, expr, opts[:on], nil, __CALLER__)
     |> elem(0)
   end
 
+  @join_opts [:on]
+
+  defp parse_join_opts([]), do: {:opts, []}
+  defp parse_join_opts(list) when is_list(list) do
+    opts = Keyword.take(list, @join_opts)
+
+    case {list, opts} do
+      {list, []} ->
+        {:expr, list}
+      {opts, opts} ->
+        {:opts, opts}
+      {list, _} ->
+        raise ArgumentError, "invalid option(s) passed to Ecto.Query.join/5: " <>
+          "#{inspect(Keyword.keys(list) -- @join_opts)}. Valid options: #{inspect @join_opts}."
+    end
+  end
+  defp parse_join_opts(expr), do: {:expr, expr}
+
   @doc """
   A select query expression.
 


Test patch:
diff --git a/test/ecto/adapters/mysql_test.exs b/test/ecto/adapters/mysql_test.exs
index 2fce873d..c459428c 100644
--- a/test/ecto/adapters/mysql_test.exs
+++ b/test/ecto/adapters/mysql_test.exs
@@ -316,8 +316,8 @@ defmodule Ecto.Adapters.MySQLTest do
   test "interpolated values" do
     query = Schema
             |> select([m], {m.id, ^0})
-            |> join(:inner, [], Schema2, fragment("?", ^true))
-            |> join(:inner, [], Schema2, fragment("?", ^false))
+            |> join(:inner, [], Schema2, on: fragment("?", ^true))
+            |> join(:inner, [], Schema2, on: fragment("?", ^false))
             |> where([], fragment("?", ^true))
             |> where([], fragment("?", ^false))
             |> having([], fragment("?", ^true))
@@ -358,7 +358,7 @@ defmodule Ecto.Adapters.MySQLTest do
     assert update_all(query) ==
            ~s{UPDATE `schema` AS s0 SET s0.`x` = ?}
 
-    query = Schema |> join(:inner, [p], q in Schema2, p.x == q.z)
+    query = Schema |> join(:inner, [p], q in Schema2, on: p.x == q.z)
                   |> update([_], set: [x: 0]) |> normalize(:update_all)
     assert update_all(query) ==
            ~s{UPDATE `schema` AS s0, `schema2` AS s1 SET s0.`x` = 0 WHERE (s0.`x` = s1.`z`)}
@@ -384,7 +384,7 @@ defmodule Ecto.Adapters.MySQLTest do
     assert delete_all(query) ==
            ~s{DELETE s0.* FROM `schema` AS s0 WHERE (s0.`x` = 123)}
 
-    query = Schema |> join(:inner, [p], q in Schema2, p.x == q.z) |> normalize
+    query = Schema |> join(:inner, [p], q in Schema2, on: p.x == q.z) |> normalize
     assert delete_all(query) ==
            ~s{DELETE s0.* FROM `schema` AS s0 INNER JOIN `schema2` AS s1 ON s0.`x` = s1.`z`}
 
@@ -402,45 +402,45 @@ defmodule Ecto.Adapters.MySQLTest do
   ## Joins
 
   test "join" do
-    query = Schema |> join(:inner, [p], q in Schema2, p.x == q.z) |> select([], true) |> normalize
+    query = Schema |> join(:inner, [p], q in Schema2, on: p.x == q.z) |> select([], true) |> normalize
     assert all(query) ==
            ~s{SELECT TRUE FROM `schema` AS s0 INNER JOIN `schema2` AS s1 ON s0.`x` = s1.`z`}
 
-    query = Schema |> join(:inner, [p], q in Schema2, p.x == q.z)
-                  |> join(:inner, [], Schema, true) |> select([], true) |> normalize
+    query = Schema |> join(:inner, [p], q in Schema2, on: p.x == q.z)
+                  |> join(:inner, [], Schema, on: true) |> select([], true) |> normalize
     assert all(query) ==
            ~s{SELECT TRUE FROM `schema` AS s0 INNER JOIN `schema2` AS s1 ON s0.`x` = s1.`z` } <>
            ~s{INNER JOIN `schema` AS s2 ON TRUE}
   end
 
   test "join with nothing bound" do
-    query = Schema |> join(:inner, [], q in Schema2, q.z == q.z) |> select([], true) |> normalize
+    query = Schema |> join(:inner, [], q in Schema2, on: q.z == q.z) |> select([], true) |> normalize
     assert all(query) ==
            ~s{SELECT TRUE FROM `schema` AS s0 INNER JOIN `schema2` AS s1 ON s1.`z` = s1.`z`}
   end
 
   test "join without schema" do
-    query = "posts" |> join(:inner, [p], q in "comments", p.x == q.z) |> select([], true) |> normalize
+    query = "posts" |> join(:inner, [p], q in "comments", on: p.x == q.z) |> select([], true) |> normalize
     assert all(query) ==
            ~s{SELECT TRUE FROM `posts` AS p0 INNER JOIN `comments` AS c1 ON p0.`x` = c1.`z`}
   end
 
   test "join with subquery" do
     posts = subquery("posts" |> where(title: ^"hello") |> select([r], %{x: r.x, y: r.y}))
-    query = "comments" |> join(:inner, [c], p in subquery(posts), true) |> select([_, p], p.x) |> normalize
+    query = "comments" |> join(:inner, [c], p in subquery(posts), on: true) |> select([_, p], p.x) |> normalize
     assert all(query) ==
            ~s{SELECT s1.`x` FROM `comments` AS c0 } <>
            ~s{INNER JOIN (SELECT p0.`x` AS `x`, p0.`y` AS `y` FROM `posts` AS p0 WHERE (p0.`title` = ?)) AS s1 ON TRUE}
 
     posts = subquery("posts" |> where(title: ^"hello") |> select([r], %{x: r.x, z: r.y}))
-    query = "comments" |> join(:inner, [c], p in subquery(posts), true) |> select([_, p], p) |> normalize
+    query = "comments" |> join(:inner, [c], p in subquery(posts), on: true) |> select([_, p], p) |> normalize
     assert all(query) ==
            ~s{SELECT s1.`x`, s1.`z` FROM `comments` AS c0 } <>
            ~s{INNER JOIN (SELECT p0.`x` AS `x`, p0.`y` AS `z` FROM `posts` AS p0 WHERE (p0.`title` = ?)) AS s1 ON TRUE}
   end
 
   test "join with prefix" do
-    query = Schema |> join(:inner, [p], q in Schema2, p.x == q.z) |> select([], true) |> normalize
+    query = Schema |> join(:inner, [p], q in Schema2, on: p.x == q.z) |> select([], true) |> normalize
     assert all(%{query | prefix: "prefix"}) ==
            ~s{SELECT TRUE FROM `prefix`.`schema` AS s0 INNER JOIN `prefix`.`schema2` AS s1 ON s0.`x` = s1.`z`}
   end
@@ -459,7 +459,7 @@ defmodule Ecto.Adapters.MySQLTest do
 
   test "join with fragment and on defined" do
     query = Schema
-            |> join(:inner, [p], q in fragment("SELECT * FROM schema2"), q.id == p.id)
+            |> join(:inner, [p], q in fragment("SELECT * FROM schema2"), on: q.id == p.id)
             |> select([p], {p.id, ^0})
             |> normalize
     assert all(query) ==
diff --git a/test/ecto/adapters/postgres_test.exs b/test/ecto/adapters/postgres_test.exs
index 860983e6..2d0bb963 100644
--- a/test/ecto/adapters/postgres_test.exs
+++ b/test/ecto/adapters/postgres_test.exs
@@ -359,8 +359,8 @@ defmodule Ecto.Adapters.PostgresTest do
   test "interpolated values" do
     query = "schema"
             |> select([m], {m.id, ^true})
-            |> join(:inner, [], Schema2, fragment("?", ^true))
-            |> join(:inner, [], Schema2, fragment("?", ^false))
+            |> join(:inner, [], Schema2, on: fragment("?", ^true))
+            |> join(:inner, [], Schema2, on: fragment("?", ^false))
             |> where([], fragment("?", ^true))
             |> where([], fragment("?", ^false))
             |> having([], fragment("?", ^true))
@@ -429,7 +429,7 @@ defmodule Ecto.Adapters.PostgresTest do
     assert update_all(query) ==
            ~s{UPDATE "schema" AS s0 SET "x" = $1}
 
-    query = Schema |> join(:inner, [p], q in Schema2, p.x == q.z)
+    query = Schema |> join(:inner, [p], q in Schema2, on: p.x == q.z)
                   |> update([_], set: [x: 0]) |> normalize(:update_all)
     assert update_all(query) ==
            ~s{UPDATE "schema" AS s0 SET "x" = 0 FROM "schema2" AS s1 WHERE (s0."x" = s1."z")}
@@ -471,7 +471,7 @@ defmodule Ecto.Adapters.PostgresTest do
     assert delete_all(query) ==
            ~s{DELETE FROM "schema" AS s0 WHERE (s0."x" = 123)}
 
-    query = Schema |> join(:inner, [p], q in Schema2, p.x == q.z) |> normalize
+    query = Schema |> join(:inner, [p], q in Schema2, on: p.x == q.z) |> normalize
     assert delete_all(query) ==
            ~s{DELETE FROM "schema" AS s0 USING "schema2" AS s1 WHERE (s0."x" = s1."z")}
 
@@ -497,45 +497,45 @@ defmodule Ecto.Adapters.PostgresTest do
   ## Joins
 
   test "join" do
-    query = Schema |> join(:inner, [p], q in Schema2, p.x == q.z) |> select([], true) |> normalize
+    query = Schema |> join(:inner, [p], q in Schema2, on: p.x == q.z) |> select([], true) |> normalize
     assert all(query) ==
            ~s{SELECT TRUE FROM "schema" AS s0 INNER JOIN "schema2" AS s1 ON s0."x" = s1."z"}
 
-    query = Schema |> join(:inner, [p], q in Schema2, p.x == q.z)
-                  |> join(:inner, [], Schema, true) |> select([], true) |> normalize
+    query = Schema |> join(:inner, [p], q in Schema2, on: p.x == q.z)
+                  |> join(:inner, [], Schema, on: true) |> select([], true) |> normalize
     assert all(query) ==
            ~s{SELECT TRUE FROM "schema" AS s0 INNER JOIN "schema2" AS s1 ON s0."x" = s1."z" } <>
            ~s{INNER JOIN "schema" AS s2 ON TRUE}
   end
 
   test "join with nothing bound" do
-    query = Schema |> join(:inner, [], q in Schema2, q.z == q.z) |> select([], true) |> normalize
+    query = Schema |> join(:inner, [], q in Schema2, on: q.z == q.z) |> select([], true) |> normalize
     assert all(query) ==
            ~s{SELECT TRUE FROM "schema" AS s0 INNER JOIN "schema2" AS s1 ON s1."z" = s1."z"}
   end
 
   test "join without schema" do
-    query = "posts" |> join(:inner, [p], q in "comments", p.x == q.z) |> select([], true) |> normalize
+    query = "posts" |> join(:inner, [p], q in "comments", on: p.x == q.z) |> select([], true) |> normalize
     assert all(query) ==
            ~s{SELECT TRUE FROM "posts" AS p0 INNER JOIN "comments" AS c1 ON p0."x" = c1."z"}
   end
 
   test "join with subquery" do
     posts = subquery("posts" |> where(title: ^"hello") |> select([r], %{x: r.x, y: r.y}))
-    query = "comments" |> join(:inner, [c], p in subquery(posts), true) |> select([_, p], p.x) |> normalize
+    query = "comments" |> join(:inner, [c], p in subquery(posts), on: true) |> select([_, p], p.x) |> normalize
     assert all(query) ==
            ~s{SELECT s1."x" FROM "comments" AS c0 } <>
            ~s{INNER JOIN (SELECT p0."x" AS "x", p0."y" AS "y" FROM "posts" AS p0 WHERE (p0."title" = $1)) AS s1 ON TRUE}
 
     posts = subquery("posts" |> where(title: ^"hello") |> select([r], %{x: r.x, z: r.y}))
-    query = "comments" |> join(:inner, [c], p in subquery(posts), true) |> select([_, p], p) |> normalize
+    query = "comments" |> join(:inner, [c], p in subquery(posts), on: true) |> select([_, p], p) |> normalize
     assert all(query) ==
            ~s{SELECT s1."x", s1."z" FROM "comments" AS c0 } <>
            ~s{INNER JOIN (SELECT p0."x" AS "x", p0."y" AS "z" FROM "posts" AS p0 WHERE (p0."title" = $1)) AS s1 ON TRUE}
   end
 
   test "join with prefix" do
-    query = Schema |> join(:inner, [p], q in Schema2, p.x == q.z) |> select([], true) |> normalize
+    query = Schema |> join(:inner, [p], q in Schema2, on: p.x == q.z) |> select([], true) |> normalize
     assert all(%{query | prefix: "prefix"}) ==
            ~s{SELECT TRUE FROM "prefix"."schema" AS s0 INNER JOIN "prefix"."schema2" AS s1 ON s0."x" = s1."z"}
   end
@@ -554,7 +554,7 @@ defmodule Ecto.Adapters.PostgresTest do
 
   test "join with fragment and on defined" do
     query = Schema
-            |> join(:inner, [p], q in fragment("SELECT * FROM schema2"), q.id == p.id)
+            |> join(:inner, [p], q in fragment("SELECT * FROM schema2"), on: q.id == p.id)
             |> select([p], {p.id, ^0})
             |> normalize
     assert all(query) ==
@@ -598,7 +598,7 @@ defmodule Ecto.Adapters.PostgresTest do
   describe "query interpolation parameters" do
     test "self join on subquery" do
       subquery = select(Schema, [r], %{x: r.x, y: r.y})
-      query = subquery |> join(:inner, [c], p in subquery(subquery), true) |> normalize
+      query = subquery |> join(:inner, [c], p in subquery(subquery), on: true) |> normalize
       assert all(query) ==
              ~s{SELECT s0."x", s0."y" FROM "schema" AS s0 INNER JOIN } <>
              ~s{(SELECT s0."x" AS "x", s0."y" AS "y" FROM "schema" AS s0) } <>
@@ -607,7 +607,7 @@ defmodule Ecto.Adapters.PostgresTest do
 
     test "self join on subquery with fragment" do
       subquery = select(Schema, [r], %{string: fragment("downcase(?)", ^"string")})
-      query = subquery |> join(:inner, [c], p in subquery(subquery), true) |> normalize
+      query = subquery |> join(:inner, [c], p in subquery(subquery), on: true) |> normalize
       assert all(query) ==
              ~s{SELECT downcase($1) FROM "schema" AS s0 INNER JOIN } <>
              ~s{(SELECT downcase($2) AS "string" FROM "schema" AS s0) } <>
@@ -618,7 +618,7 @@ defmodule Ecto.Adapters.PostgresTest do
       subquery = select(Schema, [r], %{x: ^999, w: ^888})
       query = Schema
               |> select([r], %{y: ^666})
-              |> join(:inner, [c], p in subquery(subquery), true)
+              |> join(:inner, [c], p in subquery(subquery), on: true)
               |> where([a, b], a.x == ^111)
               |> normalize
 
diff --git a/test/ecto/query/builder/join_test.exs b/test/ecto/query/builder/join_test.exs
index 3bbb287c..34fe9f80 100644
--- a/test/ecto/query/builder/join_test.exs
+++ b/test/ecto/query/builder/join_test.exs
@@ -15,12 +15,12 @@ defmodule Ecto.Query.Builder.JoinTest do
   test "expands macros as sources" do
     left = "left"
     right = "right"
-    assert %{joins: [_]} = join("posts", :inner, [p], c in join_macro(^left, ^right), true)
+    assert %{joins: [_]} = join("posts", :inner, [p], c in join_macro(^left, ^right), on: true)
   end
 
   test "accepts keywords on :on" do
     assert %{joins: [join]} =
-            join("posts", :inner, [p], c in "comments", [post_id: p.id, public: true])
+            join("posts", :inner, [p], c in "comments", on: [post_id: p.id, public: true])
     assert Macro.to_string(join.on.expr) ==
            "&1.post_id() == &0.id() and &1.public() == %Ecto.Query.Tagged{tag: nil, type: {1, :public}, value: true}"
     assert join.on.params == []
@@ -30,63 +30,63 @@ defmodule Ecto.Query.Builder.JoinTest do
     qual = :left
     source = "comments"
     assert %{joins: [%{source: {"comments", nil}}]} =
-            join("posts", qual, [p], c in ^source, true)
+            join("posts", qual, [p], c in ^source, on: true)
 
     qual = :right
     source = Comment
     assert %{joins: [%{source: {nil, Comment}}]} =
-            join("posts", qual, [p], c in ^source, true)
+            join("posts", qual, [p], c in ^source, on: true)
 
     qual = :right
     source = {"user_comments", Comment}
     assert %{joins: [%{source: {"user_comments", Comment}}]} =
-            join("posts", qual, [p], c in ^source, true)
+            join("posts", qual, [p], c in ^source, on: true)
 
     qual = :inner
     source = from c in "comments", where: c.public
     assert %{joins: [%{source: %Ecto.Query{from: {"comments", nil}}}]} =
-            join("posts", qual, [p], c in ^source, true)
+            join("posts", qual, [p], c in ^source, on: true)
   end
 
   test "accepts interpolation on :on" do
     assert %{joins: [join]} =
-            join("posts", :inner, [p], c in "comments", ^[post_id: 1, public: true])
+            join("posts", :inner, [p], c in "comments", on: ^[post_id: 1, public: true])
     assert Macro.to_string(join.on.expr) == "&1.post_id() == ^0 and &1.public() == ^1"
     assert join.on.params == [{1, {1, :post_id}}, {true, {1, :public}}]
 
     dynamic = dynamic([p, c], c.post_id == p.id and c.public == ^true)
     assert %{joins: [join]} =
-            join("posts", :inner, [p], c in "comments", ^dynamic)
+            join("posts", :inner, [p], c in "comments", on: ^dynamic)
     assert Macro.to_string(join.on.expr) == "&1.post_id() == &0.id() and &1.public() == ^0"
     assert join.on.params == [{true, {1, :public}}]
   end
 
   test "accepts interpolation on assoc/2 field" do
     assoc = :comments
-    join("posts", :left, [p], c in assoc(p, ^assoc), true)
+    join("posts", :left, [p], c in assoc(p, ^assoc), on: true)
   end
 
   test "accepts subqueries" do
     subquery = "comments"
-    join("posts", :left, [p], c in subquery(subquery), true)
-    join("posts", :left, [p], c in subquery(subquery, prefix: "sample"), true)
+    join("posts", :left, [p], c in subquery(subquery), on: true)
+    join("posts", :left, [p], c in subquery(subquery, prefix: "sample"), on: true)
   end
 
   test "accepts interpolated binary as unsafe fragment" do
-    join("posts", :left, [p], c in unsafe_fragment(^"comments"), true)
-    join("posts", :left, [p], c in unsafe_fragment(^"?", 1), true)
+    join("posts", :left, [p], c in unsafe_fragment(^"comments"), on: true)
+    join("posts", :left, [p], c in unsafe_fragment(^"?", 1), on: true)
   end
 
   test "raises on non interpolated argument" do
     assert_raise Ecto.Query.CompileError, ~r/unsafe_fragment\(...\) expects the first argument/, fn ->
       escape(quote do
-        join("posts", :left, [p], c in unsafe_fragment("comments"), true)
+        join("posts", :left, [p], c in unsafe_fragment("comments"), on: true)
       end, [], __ENV__)
     end
 
     assert_raise Ecto.Query.CompileError, ~r/unsafe_fragment\(...\) expects the first argument/, fn ->
       escape(quote do
-        join("posts", :left, [p], c in unsafe_fragment(["$eq": "foo"]), true)
+        join("posts", :left, [p], c in unsafe_fragment(["$eq": "foo"]), on: true)
       end, [], __ENV__)
     end
   end
@@ -94,7 +94,7 @@ defmodule Ecto.Query.Builder.JoinTest do
   test "raises on invalid interpolated unsafe fragments" do
     frag = ["comments": "authors"]
     assert_raise ArgumentError, ~r/unsafe_fragment\(...\) expects the first argument/, fn ->
-      join("posts", :left, [p], c in unsafe_fragment(^frag), true)
+      join("posts", :left, [p], c in unsafe_fragment(^frag), on: true)
     end
   end
 
@@ -102,14 +102,14 @@ defmodule Ecto.Query.Builder.JoinTest do
     assert_raise ArgumentError,
                  ~r/invalid join qualifier `:whatever`/, fn ->
       qual = :whatever
-      join("posts", qual, [p], c in "comments", true)
+      join("posts", qual, [p], c in "comments", on: true)
     end
   end
 
   test "raises on invalid interpolation" do
     assert_raise Protocol.UndefinedError, fn ->
       source = 123
-      join("posts", :left, [p], c in ^source, true)
+      join("posts", :left, [p], c in ^source, on: true)
     end
   end
 
@@ -119,4 +119,12 @@ defmodule Ecto.Query.Builder.JoinTest do
       escape(quote do assoc(join_var, field_var) end, nil, nil)
     end
   end
+
+  test "raises on mix of valid and invalid options passed to join/5" do
+    assert_raise ArgumentError, ~r/invalid option\(s\) passed/, fn ->
+      escape(quote do
+        join("posts", :left, [p], c in "comments", on: true, foo: :bar)
+      end, [], __ENV__)
+    end
+  end
 end
diff --git a/test/ecto/query_test.exs b/test/ecto/query_test.exs
index 39c8f0db..58dd7fca 100644
--- a/test/ecto/query_test.exs
+++ b/test/ecto/query_test.exs
@@ -138,11 +138,11 @@ defmodule Ecto.QueryTest do
 
     test "can be added through joins" do
       from(c in "comments", join: p in "posts", select: {p.title, c.text})
-      "comments" |> join(:inner, [c], p in "posts", true) |> select([c, p], {p.title, c.text})
+      "comments" |> join(:inner, [c], p in "posts", on: true) |> select([c, p], {p.title, c.text})
     end
 
     test "can be added through joins with a counter" do
-      base = join("comments", :inner, [c], p in "posts", true)
+      base = join("comments", :inner, [c], p in "posts", on: true)
       assert select(base, [{p, 1}], p) == select(base, [c, p], p)
     end
 
@@ -196,7 +196,7 @@ defmodule Ecto.QueryTest do
       query =
         "posts"
         |> join(:inner, [], "comments")
-        |> join(:inner, [..., c], v in "votes", c.id == v.id)
+        |> join(:inner, [..., c], v in "votes", on: c.id == v.id)
 
       assert hd(tl(query.joins)).on.expr ==
              {:==, [], [