Skip to content

Commit b2a5bf7

Browse files
soundmonsterLeo B
authored and
Leo B
committed
Support associations on composite foreign keys
1 parent 99d19b4 commit b2a5bf7

17 files changed

+945
-178
lines changed

Diff for: Earthfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ integration-test-base:
6060
apk del .build-dependencies && rm -f msodbcsql*.sig mssql-tools*.apk
6161
ENV PATH="/opt/mssql-tools/bin:${PATH}"
6262

63-
GIT CLONE https://github.com/elixir-ecto/ecto_sql.git /src/ecto_sql
63+
GIT CLONE --branch composite_foreign_keys https://github.com/soundmonster/ecto_sql.git /src/ecto_sql
6464
WORKDIR /src/ecto_sql
6565
RUN mix deps.get
6666

Diff for: integration_test/cases/assoc.exs

+63
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ defmodule Ecto.Integration.AssocTest do
1010
alias Ecto.Integration.PostUser
1111
alias Ecto.Integration.Comment
1212
alias Ecto.Integration.Permalink
13+
alias Ecto.Integration.CompositePk
14+
alias Ecto.Integration.OneToOneCompositePk
1315

1416
test "has_many assoc" do
1517
p1 = TestRepo.insert!(%Post{title: "1"})
@@ -42,6 +44,22 @@ defmodule Ecto.Integration.AssocTest do
4244
assert l3.id == lid3
4345
end
4446

47+
test "has_one assoc with composite key" do
48+
c11 = TestRepo.insert!(%CompositePk{a: 1, b: 1, name: "11"})
49+
_c12 = TestRepo.insert!(%CompositePk{a: 1, b: 2, name: "12"})
50+
c22 = TestRepo.insert!(%CompositePk{a: 2, b: 2, name: "22"})
51+
52+
%OneToOneCompositePk{id: id_o11} = TestRepo.insert!(%OneToOneCompositePk{composite_a: 1, composite_b: 1})
53+
%OneToOneCompositePk{} = TestRepo.insert!(%OneToOneCompositePk{composite_a: 1, composite_b: 2})
54+
%OneToOneCompositePk{id: id_o22} = TestRepo.insert!(%OneToOneCompositePk{composite_a: 2, composite_b: 2})
55+
56+
require Util
57+
[o11, o22] = TestRepo.all(Ecto.assoc([c11, c22], :one_to_one_composite_pk))
58+
assert o11.id == id_o11
59+
assert o22.id == id_o22
60+
end
61+
62+
4563
test "belongs_to assoc" do
4664
%Post{id: pid1} = TestRepo.insert!(%Post{title: "1"})
4765
%Post{id: pid2} = TestRepo.insert!(%Post{title: "2"})
@@ -55,6 +73,22 @@ defmodule Ecto.Integration.AssocTest do
5573
assert p2.id == pid2
5674
end
5775

76+
test "belongs_to assoc with composite key" do
77+
TestRepo.insert!(%CompositePk{a: 2, b: 1, name: "foo"})
78+
TestRepo.insert!(%CompositePk{a: 2, b: 2, name: "bar"})
79+
TestRepo.insert!(%CompositePk{a: 2, b: 3, name: "unused"})
80+
81+
p1 = TestRepo.insert!(%Post{title: "first", composite_a: 2, composite_b: 1})
82+
p2 = TestRepo.insert!(%Post{title: "none"})
83+
p3 = TestRepo.insert!(%Post{title: "second", composite_a: 2, composite_b: 2})
84+
85+
assert [c1, c2] = TestRepo.all Ecto.assoc([p1, p2, p3], :composite)
86+
assert c1.a == 2
87+
assert c1.b == 1
88+
assert c2.a == 2
89+
assert c2.b == 2
90+
end
91+
5892
test "has_many through assoc" do
5993
p1 = TestRepo.insert!(%Post{})
6094
p2 = TestRepo.insert!(%Post{})
@@ -725,6 +759,27 @@ defmodule Ecto.Integration.AssocTest do
725759
assert perma.post_id == nil
726760
end
727761

762+
test "belongs_to changeset assoc on composite key" do
763+
changeset =
764+
%CompositePk{a: 1, b: 2}
765+
|> Ecto.Changeset.change()
766+
|> Ecto.Changeset.put_assoc(:posts, [%Post{title: "1"}])
767+
768+
composite = TestRepo.insert!(changeset)
769+
assert [post] = composite.posts
770+
assert post.id
771+
assert post.composite_a == composite.a
772+
assert post.composite_b == composite.b
773+
assert post.title == "1"
774+
775+
composite = TestRepo.get_by! from(CompositePk, preload: [:posts]), [a: composite.a, b: composite.b]
776+
assert [%Post{title: "1"}] = composite.posts
777+
778+
post = TestRepo.get! from(Post, preload: [:composite]), post.id
779+
assert post.composite.a == 1
780+
assert post.composite.b == 2
781+
end
782+
728783
test "inserting struct with associations" do
729784
tree = %Permalink{
730785
url: "root",
@@ -750,6 +805,14 @@ defmodule Ecto.Integration.AssocTest do
750805
assert Enum.all?(tree.post.comments, & &1.id)
751806
end
752807

808+
test "inserting struct with associations on composite keys" do
809+
# creates nested belongs_to
810+
%Post{composite: composite} =
811+
TestRepo.insert! %Post{title: "1", composite: %CompositePk{a: 1, b: 2, name: "name"}}
812+
813+
assert %CompositePk{a: 1, b: 2, name: "name"} = composite
814+
end
815+
753816
test "inserting struct with empty associations" do
754817
permalink = TestRepo.insert!(%Permalink{url: "root", post: nil})
755818
assert permalink.post == nil

Diff for: integration_test/cases/preload.exs

+76
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ defmodule Ecto.Integration.PreloadTest do
66

77
alias Ecto.Integration.Post
88
alias Ecto.Integration.Comment
9+
alias Ecto.Integration.CompositePk
910
alias Ecto.Integration.Item
1011
alias Ecto.Integration.Permalink
1112
alias Ecto.Integration.User
@@ -341,6 +342,25 @@ defmodule Ecto.Integration.PreloadTest do
341342
assert [] = pe3.comments
342343
end
343344

345+
test "preload composite foreign key with function" do
346+
c11 = TestRepo.insert!(%CompositePk{a: 1, b: 1, name: "11"})
347+
c12 = TestRepo.insert!(%CompositePk{a: 1, b: 2, name: "12"})
348+
c22 = TestRepo.insert!(%CompositePk{a: 2, b: 2, name: "22"})
349+
c33 = TestRepo.insert!(%CompositePk{a: 3, b: 3, name: "33"})
350+
351+
TestRepo.insert!(%Post{title: "1", composite_a: 1, composite_b: 1})
352+
TestRepo.insert!(%Post{title: "2", composite_a: 1, composite_b: 1})
353+
TestRepo.insert!(%Post{title: "3", composite_a: 1, composite_b: 2})
354+
TestRepo.insert!(%Post{title: "4", composite_a: 2, composite_b: 2})
355+
356+
assert [ce12, ce11, ce33, ce22] = TestRepo.preload([c12, c11, c33, c22],
357+
posts: fn _ -> TestRepo.all(Post) end)
358+
assert [%Post{title: "1"}, %Post{title: "2"}] = ce11.posts
359+
assert [%Post{title: "3"}] = ce12.posts
360+
assert [%Post{title: "4"}] = ce22.posts
361+
assert [] = ce33.posts
362+
end
363+
344364
test "preload many_to_many with function" do
345365
p1 = TestRepo.insert!(%Post{title: "1"})
346366
p2 = TestRepo.insert!(%Post{title: "2"})
@@ -389,6 +409,50 @@ defmodule Ecto.Integration.PreloadTest do
389409
assert p3.users == [%{id: uid1}, %{id: uid4}]
390410
end
391411

412+
test "preload many_to_many on composite foreign keys with function" do
413+
c11 = TestRepo.insert!(%CompositePk{a: 1, b: 1, name: "11"})
414+
c12 = TestRepo.insert!(%CompositePk{a: 1, b: 2, name: "12"})
415+
c22 = TestRepo.insert!(%CompositePk{a: 2, b: 2, name: "22"})
416+
417+
TestRepo.insert_all "composite_pk_composite_pk", [[a_1: 1, b_1: 1, a_2: 1, b_2: 2],
418+
[a_1: 1, b_1: 1, a_2: 2, b_2: 2],
419+
[a_1: 1, b_1: 2, a_2: 1, b_2: 1],
420+
[a_1: 2, b_1: 2, a_2: 2, b_2: 2]]
421+
422+
wrong_preloader = fn composite_ids ->
423+
composite_ids_a = Enum.map(composite_ids, &Enum.at(&1, 0))
424+
composite_ids_b = Enum.map(composite_ids, &Enum.at(&1, 1))
425+
TestRepo.all(
426+
from c in CompositePk,
427+
join: cc in "composite_pk_composite_pk",
428+
where: cc.a_1 in ^composite_ids_a and cc.b_1 in ^composite_ids_b and cc.a_2 == c.a and cc.b_2 == c.b,
429+
order_by: [c.a, c.b],
430+
select: map(c, [:name])
431+
)
432+
end
433+
434+
assert_raise RuntimeError, ~r/invalid custom preload for `composites` on `Ecto.Integration.CompositePk`/, fn ->
435+
TestRepo.preload([c11, c12, c22], composites: wrong_preloader)
436+
end
437+
438+
right_preloader = fn composite_ids ->
439+
composite_ids_a = Enum.map(composite_ids, &Enum.at(&1, 0))
440+
composite_ids_b = Enum.map(composite_ids, &Enum.at(&1, 1))
441+
TestRepo.all(
442+
from c in CompositePk,
443+
join: cc in "composite_pk_composite_pk",
444+
where: cc.a_1 in ^composite_ids_a and cc.b_1 in ^composite_ids_b and cc.a_2 == c.a and cc.b_2 == c.b,
445+
order_by: [c.a, c.b],
446+
select: {[cc.a_1, cc.b_1], map(c, [:name])}
447+
)
448+
end
449+
450+
[c11, c12, c22] = TestRepo.preload([c11, c12, c22], composites: right_preloader)
451+
assert c11.composites == [%{name: "12"}, %{name: "22"}]
452+
assert c12.composites == [%{name: "11"}]
453+
assert c22.composites == [%{name: "22"}]
454+
end
455+
392456
test "preload with query" do
393457
p1 = TestRepo.insert!(%Post{title: "1"})
394458
p2 = TestRepo.insert!(%Post{title: "2"})
@@ -604,6 +668,18 @@ defmodule Ecto.Integration.PreloadTest do
604668
assert TestRepo.preload(updated, [:author], force: true).author == nil
605669
end
606670

671+
test "preload raises with association over composite foreign key is set but without id" do
672+
p1 = TestRepo.insert!(%Post{title: "1"})
673+
c11 = TestRepo.insert!(%CompositePk{a: 1, b: 1, name: "11"})
674+
updated = %{p1 | composite: c11, composite_a: nil, composite_b: nil}
675+
676+
assert ExUnit.CaptureLog.capture_log(fn ->
677+
assert TestRepo.preload(updated, [:composite]).composite == c11
678+
end) =~ ~r/its association keys `\[composite_a, composite_b\]` are nil/
679+
680+
assert TestRepo.preload(updated, [:composite], force: true).composite == nil
681+
end
682+
607683
test "preload skips already loaded for cardinality one" do
608684
%Post{id: pid} = TestRepo.insert!(%Post{title: "1"})
609685

Diff for: integration_test/cases/repo.exs

+17
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,23 @@ defmodule Ecto.Integration.RepoTest do
152152
assert TestRepo.all(PostUserCompositePk) == []
153153
end
154154

155+
@tag :composite_pk
156+
test "insert, update and delete with assoc over composite foreign key" do
157+
composite = TestRepo.insert!(%CompositePk{a: 1, b: 2, name: "name"})
158+
post = TestRepo.insert!(%Post{title: "post title", composite: composite})
159+
160+
assert post.composite_a == 1
161+
assert post.composite_b == 2
162+
assert TestRepo.get_by!(CompositePk, [a: 1, b: 2]) == composite
163+
164+
post = post |> Ecto.Changeset.change(composite: nil) |> TestRepo.update!
165+
assert is_nil(post.composite_a)
166+
assert is_nil(post.composite_b)
167+
168+
TestRepo.delete!(post)
169+
assert TestRepo.all(CompositePk) == [composite]
170+
end
171+
155172
@tag :invalid_prefix
156173
test "insert, update and delete with invalid prefix" do
157174
post = TestRepo.insert!(%Post{})

Diff for: integration_test/support/schemas.exs

+26-1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ defmodule Ecto.Integration.Post do
5454
has_one :update_permalink, Ecto.Integration.Permalink, foreign_key: :post_id, on_delete: :delete_all, on_replace: :update
5555
has_many :comments_authors, through: [:comments, :author]
5656
belongs_to :author, Ecto.Integration.User
57+
belongs_to :composite, Ecto.Integration.CompositePk,
58+
foreign_key: [:composite_a, :composite_b], references: [:a, :b], type: [:integer, :integer], on_replace: :nilify
5759
many_to_many :users, Ecto.Integration.User,
5860
join_through: "posts_users", on_delete: :delete_all, on_replace: :delete
5961
many_to_many :ordered_users, Ecto.Integration.User, join_through: "posts_users", preload_order: [desc: :name]
@@ -63,7 +65,7 @@ defmodule Ecto.Integration.Post do
6365
join_through: Ecto.Integration.PostUserCompositePk
6466
has_many :users_comments, through: [:users, :comments]
6567
has_many :comments_authors_permalinks, through: [:comments_authors, :permalink]
66-
has_one :post_user_composite_pk, Ecto.Integration.PostUserCompositePk
68+
has_many :post_user_composite_pk, Ecto.Integration.PostUserCompositePk
6769
timestamps()
6870
end
6971

@@ -291,6 +293,12 @@ defmodule Ecto.Integration.CompositePk do
291293
field :a, :integer, primary_key: true
292294
field :b, :integer, primary_key: true
293295
field :name, :string
296+
has_many :posts, Ecto.Integration.Post, foreign_key: [:composite_a, :composite_b], references: [:a, :b]
297+
many_to_many :composites, Ecto.Integration.CompositePk,
298+
join_through: "composite_pk_composite_pk", join_keys: [[a_1: :a, b_1: :b], [a_2: :a, b_2: :b]],
299+
on_delete: :delete_all, on_replace: :delete
300+
has_one :one_to_one_composite_pk, Ecto.Integration.OneToOneCompositePk,
301+
foreign_key: [:composite_a, :composite_b], references: [:a, :b]
294302
end
295303
def changeset(schema, params) do
296304
cast(schema, params, ~w(a b name)a)
@@ -329,6 +337,23 @@ defmodule Ecto.Integration.PostUserCompositePk do
329337
end
330338
end
331339

340+
defmodule Ecto.Integration.OneToOneCompositePk do
341+
@moduledoc """
342+
This module is used to test:
343+
344+
* Composite primary keys for 2 has_one fields
345+
346+
"""
347+
use Ecto.Integration.Schema
348+
349+
schema "one_to_one_composite_pk" do
350+
belongs_to :composite, Ecto.Integration.CompositePk,
351+
foreign_key: [:composite_a, :composite_b], references: [:a, :b], type: [:integer, :integer], on_replace: :nilify
352+
timestamps()
353+
end
354+
end
355+
356+
332357
defmodule Ecto.Integration.Usec do
333358
@moduledoc """
334359
This module is used to test:

Diff for: lib/ecto.ex

+6-4
Original file line numberDiff line numberDiff line change
@@ -510,10 +510,12 @@ defmodule Ecto do
510510
refl = %{owner_key: owner_key} = Ecto.Association.association_from_schema!(schema, assoc)
511511

512512
values =
513-
Enum.uniq for(struct <- structs,
514-
assert_struct!(schema, struct),
515-
key = Map.fetch!(struct, owner_key),
516-
do: key)
513+
structs
514+
|> Enum.filter(&assert_struct!(schema, &1))
515+
|> Enum.map(fn struct ->
516+
Enum.map(owner_key, &Map.fetch!(struct, &1))
517+
end)
518+
|> Enum.uniq
517519

518520
case assocs do
519521
[] ->

0 commit comments

Comments
 (0)