Skip to content

Commit 27c9fa4

Browse files
authored
fix: manual relationship implies no_attributes? true (#2562)
Adds a transformer on relationships.{has_one, has_many} DSLs to automatically set `no_attributes?` to `true` if `manual` is set. Reworks logic in places that use `no_attributes?` and `manual` together to take this new implication into consideration. (read.ex, relationships.ex, and schema.ex) Adds some tests to ensure that this implication holds. Also adds regression tests to ensure that `select` callback for manual relationships is respected.
1 parent 66068b5 commit 27c9fa4

File tree

9 files changed

+151
-26
lines changed

9 files changed

+151
-26
lines changed

documentation/dsls/DSL-Ash.Resource.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,7 @@ end
442442

443443
| Name | Type | Default | Docs |
444444
|------|------|---------|------|
445-
| [`manual`](#relationships-has_one-manual){: #relationships-has_one-manual } | `(any, any -> any) \| module` | | A module that implements `Ash.Resource.ManualRelationship`. Also accepts a 2 argument function that takes the source records and the context. |
445+
| [`manual`](#relationships-has_one-manual){: #relationships-has_one-manual } | `(any, any -> any) \| module` | | A module that implements `Ash.Resource.ManualRelationship`. Also accepts a 2 argument function that takes the source records and the context. Setting this will automatically set `no_attributes?` to `true`. |
446446
| [`no_attributes?`](#relationships-has_one-no_attributes?){: #relationships-has_one-no_attributes? } | `boolean` | | All existing entities are considered related, i.e this relationship is not based on any fields, and `source_attribute` and `destination_attribute` are ignored. See the See the [relationships guide](/documentation/topics/resources/relationships.md) for more. |
447447
| [`allow_nil?`](#relationships-has_one-allow_nil?){: #relationships-has_one-allow_nil? } | `boolean` | `true` | Marks the relationship as required. Has no effect on validations, but can inform extensions that there will always be a related entity. |
448448
| [`from_many?`](#relationships-has_one-from_many?){: #relationships-has_one-from_many? } | `boolean` | `false` | Signal that this relationship is actually a `has_many` where the first record is given via the `sort`. This will allow data layers to properly deduplicate when necessary. |
@@ -544,7 +544,7 @@ end
544544

545545
| Name | Type | Default | Docs |
546546
|------|------|---------|------|
547-
| [`manual`](#relationships-has_many-manual){: #relationships-has_many-manual } | `(any, any -> any) \| module` | | A module that implements `Ash.Resource.ManualRelationship`. Also accepts a 2 argument function that takes the source records and the context. |
547+
| [`manual`](#relationships-has_many-manual){: #relationships-has_many-manual } | `(any, any -> any) \| module` | | A module that implements `Ash.Resource.ManualRelationship`. Also accepts a 2 argument function that takes the source records and the context. Setting this will automatically set `no_attributes?` to `true`. |
548548
| [`no_attributes?`](#relationships-has_many-no_attributes?){: #relationships-has_many-no_attributes? } | `boolean` | | All existing entities are considered related, i.e this relationship is not based on any fields, and `source_attribute` and `destination_attribute` are ignored. See the See the [relationships guide](/documentation/topics/resources/relationships.md) for more. |
549549
| [`limit`](#relationships-has_many-limit){: #relationships-has_many-limit } | `integer` | | An integer to limit entries from loaded relationship. |
550550
| [`description`](#relationships-has_many-description){: #relationships-has_many-description } | `String.t` | | An optional description for the relationship |

lib/ash/actions/read/read.ex

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1499,15 +1499,12 @@ defmodule Ash.Actions.Read do
14991499
[]
15001500
else
15011501
case Ash.Resource.Info.relationship(query.resource, name) do
1502+
%{manual: {module, opts}} ->
1503+
module.select(opts)
1504+
15021505
%{no_attributes?: true} ->
15031506
[]
15041507

1505-
%{manual: {module, opts}, source_attribute: source_attribute} ->
1506-
fields =
1507-
module.select(opts)
1508-
1509-
[source_attribute | fields]
1510-
15111508
%{source_attribute: source_attribute} ->
15121509
[source_attribute]
15131510
end

lib/ash/actions/read/relationships.ex

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -999,11 +999,10 @@ defmodule Ash.Actions.Read.Relationships do
999999

10001000
defp select_destination_attribute(related_query, relationship) do
10011001
if Map.get(relationship, :no_attributes?) ||
1002-
(Map.get(relationship, :manual) &&
1003-
!Ash.Resource.Info.attribute(
1004-
relationship.destination,
1005-
relationship.destination_attribute
1006-
)) do
1002+
!Ash.Resource.Info.attribute(
1003+
relationship.destination,
1004+
relationship.destination_attribute
1005+
) do
10071006
related_query
10081007
else
10091008
Ash.Query.ensure_selected(related_query, [relationship.destination_attribute])

lib/ash/resource/relationships/has_many.ex

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ defmodule Ash.Resource.Relationships.HasMany do
9393
def manual(module) when is_atom(module), do: {:ok, {module, []}}
9494

9595
def transform(relationship) do
96-
{:ok, relationship |> Ash.Resource.Actions.Read.concat_filters()}
96+
{:ok,
97+
relationship
98+
|> Ash.Resource.Actions.Read.concat_filters()
99+
|> manual_implies_no_attributes()}
97100
end
98101
end

lib/ash/resource/relationships/has_one.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ defmodule Ash.Resource.Relationships.HasOne do
9999
{:ok,
100100
relationship
101101
|> Ash.Resource.Actions.Read.concat_filters()
102-
|> Map.put(:from_many?, relationship.from_many? || not is_nil(relationship.sort))}
102+
|> Map.put(:from_many?, relationship.from_many? || not is_nil(relationship.sort))
103+
|> manual_implies_no_attributes()}
103104
end
104105
end

lib/ash/resource/relationships/shared_options.ex

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,12 @@ defmodule Ash.Resource.Relationships.SharedOptions do
145145
{:spark_function_behaviour, Ash.Resource.ManualRelationship,
146146
{Ash.Resource.ManualRelationship.Function, 2}},
147147
doc: """
148-
A module that implements `Ash.Resource.ManualRelationship`. Also accepts a 2 argument function that takes the source records and the context.
148+
A module that implements `Ash.Resource.ManualRelationship`. Also accepts a 2 argument function that takes the source records and the context. Setting this will automatically set `no_attributes?` to `true`.
149149
"""}
150150
end
151+
152+
def manual_implies_no_attributes(relationship) do
153+
relationship
154+
|> Map.put(:no_attributes?, relationship.no_attributes? || not is_nil(relationship.manual))
155+
end
151156
end

lib/ash/resource/schema.ex

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -223,12 +223,6 @@ defmodule Ash.Schema do
223223
%{no_attributes?: true} ->
224224
:ok
225225

226-
%{manual?: true} ->
227-
:ok
228-
229-
%{manual: manual} when not is_nil(manual) ->
230-
:ok
231-
232226
%{type: :belongs_to} ->
233227
belongs_to relationship.name, relationship.destination,
234228
define_field: false,

test/actions/has_many_test.exs

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,38 @@ defmodule Ash.Test.Actions.HasManyTest do
4848

4949
require Ash.Query
5050

51-
def load(records, _opts, %{query: query, actor: actor, authorize?: authorize?}) do
51+
def select(opts) do
52+
if opts[:select_likes?] do
53+
[:likes]
54+
else
55+
[]
56+
end
57+
end
58+
59+
def load(records, opts, %{
60+
query: query,
61+
actor: actor,
62+
authorize?: authorize?,
63+
relationship: relationship
64+
}) do
5265
post_ids = Enum.map(records, & &1.id)
5366

67+
case relationship.name do
68+
:meow_comments ->
69+
assert opts[:select_likes?] == true
70+
71+
:meow_comments_no_likes ->
72+
assert !opts[:select_likes?]
73+
end
74+
75+
Enum.each(records, fn record ->
76+
if opts[:select_likes?] do
77+
assert is_nil(record.likes) or is_integer(record.likes)
78+
else
79+
assert not is_nil(record.likes) and not is_integer(record.likes)
80+
end
81+
end)
82+
5483
{:ok,
5584
query
5685
|> Ash.Query.filter(post_id in ^post_ids)
@@ -116,6 +145,8 @@ defmodule Ash.Test.Actions.HasManyTest do
116145
attribute :title, :string, public?: true
117146
attribute :tenant_id, :string, public?: true
118147

148+
attribute :likes, :integer, public?: true
149+
119150
create_timestamp :inserted_at, public?: true
120151
end
121152

@@ -127,7 +158,11 @@ defmodule Ash.Test.Actions.HasManyTest do
127158
end
128159

129160
has_many :meow_comments, Comment do
130-
manual MeowCommentRelationship
161+
manual({MeowCommentRelationship, [select_likes?: true]})
162+
end
163+
164+
has_many :meow_comments_no_likes, Comment do
165+
manual({MeowCommentRelationship, [select_likes?: false]})
131166
end
132167

133168
has_many :meow_list_comments, Comment do
@@ -265,7 +300,8 @@ defmodule Ash.Test.Actions.HasManyTest do
265300
post =
266301
Post
267302
|> Ash.Changeset.for_create(:create, %{
268-
title: "buz"
303+
title: "buz",
304+
likes: 1337
269305
})
270306
|> Ash.create!()
271307

@@ -274,8 +310,9 @@ defmodule Ash.Test.Actions.HasManyTest do
274310
|> Ash.Changeset.for_update(:add_comment, %{
275311
comment: %{content: "meow"}
276312
})
313+
|> Ash.Changeset.deselect(:likes)
277314
|> Ash.update!()
278-
|> Ash.load!(:meow_comments)
315+
|> Ash.load!([:meow_comments])
279316

280317
assert length(post.meow_comments) == 1
281318

@@ -305,6 +342,14 @@ defmodule Ash.Test.Actions.HasManyTest do
305342
|> Ash.load!(:meow_comments)
306343

307344
assert length(post.meow_comments) == 1
345+
346+
post
347+
|> Ash.Changeset.for_update(:add_comment, %{
348+
comment: %{content: "meow"}
349+
})
350+
|> Ash.Changeset.deselect(:likes)
351+
|> Ash.update!()
352+
|> Ash.load!([:meow_comments_no_likes])
308353
end
309354

310355
test "raise on an invalid manual relationship query" do

test/actions/load_test.exs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,62 @@ defmodule Ash.Test.Actions.LoadTest do
582582
end
583583
end
584584

585+
defmodule PostSecretRelationship do
586+
@moduledoc false
587+
use Ash.Resource.ManualRelationship
588+
589+
require Ash.Query
590+
591+
@impl true
592+
def load(records, _opts, _context) do
593+
posts_with_secrets =
594+
Post
595+
|> Ash.Query.for_read(:all_access)
596+
|> Ash.Query.filter(
597+
exists(Ash.Test.Actions.LoadTest.PostSecret, contains(parent(secret), secret))
598+
)
599+
|> Ash.read!()
600+
601+
Enum.map(records, fn record ->
602+
Enum.filter(posts_with_secrets, fn post ->
603+
String.contains?(
604+
post.secret,
605+
record.secret
606+
)
607+
end)
608+
end)
609+
end
610+
end
611+
612+
defmodule PostSecret do
613+
@moduledoc false
614+
use Ash.Resource,
615+
domain: Domain,
616+
data_layer: Ash.DataLayer.Ets
617+
618+
ets do
619+
private?(true)
620+
end
621+
622+
actions do
623+
defaults([:read, create: :*])
624+
end
625+
626+
attributes do
627+
attribute :secret, :string do
628+
public? true
629+
primary_key? true
630+
allow_nil? false
631+
end
632+
end
633+
634+
relationships do
635+
has_many :posts_with_secret, Post do
636+
manual PostSecretRelationship
637+
end
638+
end
639+
end
640+
585641
describe "loads" do
586642
setup do
587643
start_supervised(
@@ -1378,6 +1434,31 @@ defmodule Ash.Test.Actions.LoadTest do
13781434
assert Ash.load!(event_log, :author).author.id == author.id
13791435
assert Ash.load!(event_log2, :author).author.id == author2.id
13801436
end
1437+
1438+
test "it allows loading manual relationships with non :id pkey, regardless of source_attribute" do
1439+
Post
1440+
|> Ash.Changeset.for_create(:create, %{title: "post1", secret: "50"})
1441+
|> Ash.create!(authorize?: false)
1442+
1443+
post2 =
1444+
Post
1445+
|> Ash.Changeset.for_create(:create, %{title: "post2", secret: "42"})
1446+
|> Ash.create!(authorize?: false)
1447+
1448+
post3 =
1449+
Post
1450+
|> Ash.Changeset.for_create(:create, %{title: "post3", secret: "4"})
1451+
|> Ash.create!(authorize?: false)
1452+
1453+
post_secret1 =
1454+
PostSecret
1455+
|> Ash.Changeset.for_create(:create, %{secret: "4"})
1456+
|> Ash.create!()
1457+
|> Ash.load!([:posts_with_secret])
1458+
1459+
assert Enum.sort(Enum.map(post_secret1.posts_with_secret, & &1.secret)) ==
1460+
Enum.sort([post2.secret, post3.secret])
1461+
end
13811462
end
13821463

13831464
describe "forbidden lazy loads" do

0 commit comments

Comments
 (0)