Skip to content

Commit 1ff4198

Browse files
committed
Handle has_one and has_many associations
Ecto.Repo.insert! only handles associations when changeset is passed as argument. Prior to insertion, Ecto.Model is thus converted to Ecto.Changeset.
1 parent 262a44d commit 1ff4198

File tree

4 files changed

+172
-6
lines changed

4 files changed

+172
-6
lines changed

lib/ex_machina/ecto.ex

+43-5
Original file line numberDiff line numberDiff line change
@@ -103,17 +103,55 @@ defmodule ExMachina.Ecto do
103103
ExMachina.build(module, factory_name)
104104
end
105105

106+
defp get_assocs(%{__struct__: struct}) do
107+
for a <- struct.__schema__(:associations) do
108+
{a, struct.__schema__(:association, a)}
109+
end
110+
end
111+
112+
defp belongs_to_assocs(model) do
113+
for {a, %Ecto.Association.BelongsTo{}} <- get_assocs(model), do: a
114+
end
115+
116+
defp not_loaded_assocs(model) do
117+
for {a, %Ecto.Association.Has{}} <- get_assocs(model),
118+
!Ecto.Association.loaded?(Map.get(model, a)),
119+
do: a
120+
end
121+
122+
defp reset_associations(target, source) do
123+
target
124+
|> belongs_to_assocs
125+
|> Enum.reduce(target, fn(a, target) -> Map.put(target, a, Map.get(source, a)) end)
126+
end
127+
128+
defp convert_to_changes(record) do
129+
record
130+
|> Map.from_struct
131+
|> Map.delete(:__meta__)
132+
# drop fields for `belongs_to` assocs as they cannot be handled by changeset
133+
|> Map.drop(belongs_to_assocs(record))
134+
|> Map.drop(not_loaded_assocs(record))
135+
end
136+
106137
@doc """
107138
Saves a record and all associated records using `Repo.insert!`
108139
"""
109-
def save_record(module, repo, record) do
110-
record
111-
|> associate_records(module)
140+
def save_record(module, repo, %{__struct__: model} = record) do
141+
record = record |> associate_records(module)
142+
changes = record |> convert_to_changes
143+
144+
struct(model)
145+
|> Ecto.Changeset.change(changes)
112146
|> repo.insert!
147+
|> reset_associations(record)
148+
end
149+
def save_record(_, _ , record) do
150+
raise ArgumentError, "#{inspect record} is not Ecto model."
113151
end
114152

115-
defp associate_records(built_record = %{__struct__: struct}, module) do
116-
association_names = struct.__schema__(:associations)
153+
defp associate_records(built_record, module) do
154+
association_names = belongs_to_assocs(built_record)
117155

118156
Enum.reduce association_names, built_record, fn(association_name, record) ->
119157
case association = Map.get(record, association_name) do
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
defmodule ExMachina.TestRepo.Migrations.CreatePackagesAndInvoices do
2+
use Ecto.Migration
3+
4+
def change do
5+
create table(:invoices) do
6+
add :title, :string
7+
add :package_id, :integer
8+
end
9+
create table(:package_statuses) do
10+
add :status, :string
11+
add :package_id, :integer
12+
end
13+
create table(:packages) do
14+
add :description, :string
15+
end
16+
end
17+
end
+105
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
defmodule ExMachina.EctoHasManyTest do
2+
use ExUnit.Case, async: false
3+
alias ExMachina.TestRepo
4+
5+
setup_all do
6+
Ecto.Adapters.SQL.begin_test_transaction(TestRepo, [])
7+
on_exit fn -> Ecto.Adapters.SQL.rollback_test_transaction(TestRepo, []) end
8+
:ok
9+
end
10+
11+
setup do
12+
Ecto.Adapters.SQL.restart_test_transaction(TestRepo, [])
13+
:ok
14+
end
15+
16+
defmodule Package do
17+
use Ecto.Model
18+
schema "packages" do
19+
field :description, :string
20+
has_many :statuses, ExMachina.EctoHasManyTest.PackageStatus
21+
end
22+
end
23+
24+
defmodule PackageStatus do
25+
use Ecto.Model
26+
schema "package_statuses" do
27+
field :status, :string
28+
belongs_to :package, Package
29+
end
30+
end
31+
32+
defmodule Invoice do
33+
use Ecto.Model
34+
schema "invoices" do
35+
field :title, :string
36+
belongs_to :package, Package
37+
end
38+
end
39+
40+
defmodule Factory do
41+
use ExMachina.Ecto, repo: TestRepo
42+
43+
factory(:invalid_package) do
44+
%Package{
45+
description: "Invalid package without any statuses"
46+
}
47+
end
48+
49+
factory(:package) do
50+
%Package{
51+
description: "Package that just got ordered",
52+
statuses: [
53+
%PackageStatus{status: "ordered"}
54+
]
55+
}
56+
end
57+
58+
factory(:shipped_package) do
59+
%Package{
60+
description: "Package that got shipped",
61+
statuses: [
62+
%PackageStatus{status: "ordered"},
63+
%PackageStatus{status: "sent"},
64+
%PackageStatus{status: "shipped"}
65+
]
66+
}
67+
end
68+
69+
factory(:invoice) do
70+
%Invoice{
71+
title: "Invoice for shipped package",
72+
package: assoc(:package, factory: :shipped_package)
73+
}
74+
end
75+
end
76+
77+
test "create/1 creates model with `has_many` associations" do
78+
package = Factory.create(:package)
79+
80+
assert %{statuses: [%{status: "ordered"}]} = package
81+
end
82+
83+
test "create/2 creates model with overriden `has_many` associations" do
84+
statuses = [
85+
%PackageStatus{status: "ordered"},
86+
%PackageStatus{status: "delayed"}
87+
]
88+
package = Factory.create :package,
89+
description: "Delayed package",
90+
statuses: statuses
91+
92+
assert %{statuses: [%{status: "ordered"}, %{status: "delayed"}]} = package
93+
end
94+
95+
test "create/1 creates model without `has_many` association specified" do
96+
package = Factory.create(:invalid_package)
97+
assert package
98+
end
99+
100+
test "create/1 creates model with `belongs_to` having `has_many` associations" do
101+
invoice = Factory.create(:invoice)
102+
103+
assert %{title: "Invoice for shipped package", package_id: 1} = invoice
104+
end
105+
end

test/ex_machina/ecto_test.exs

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
defmodule ExMachina.EctoTest do
2-
use ExUnit.Case, async: true
2+
use ExUnit.Case, async: false
33
alias ExMachina.TestRepo
44

55
setup_all do
@@ -147,6 +147,12 @@ defmodule ExMachina.EctoTest do
147147
end
148148
end
149149

150+
test "save_record/1 raises unless Ecto.Model is passed" do
151+
assert_raise ArgumentError, ~r"not Ecto model", fn ->
152+
Factory.save_record(%{foo: "bar"})
153+
end
154+
end
155+
150156
test "assoc/3 returns the passed in key if it exists" do
151157
existing_account = %{id: 1, plan_type: "free"}
152158
attrs = %{account: existing_account}

0 commit comments

Comments
 (0)