Skip to content

Commit 831284d

Browse files
authored
docs: manual relationship via list docs (#2550)
* improvement: manual load can return list without :ok This is a small improvement to commit 4e24d0f which will enable users to simply return a list from the manual relate without wrapping it as `{:ok, list}` This is more in line with how calculations can return a list with or without :ok wrapping the result.
1 parent 949f30d commit 831284d

File tree

3 files changed

+101
-10
lines changed

3 files changed

+101
-10
lines changed

documentation/topics/resources/relationships.md

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ Using Ash to get the destination records is ideal, so you can authorize access
433433
like normal but if you need to use a raw ecto query here, you can. As long as
434434
you return the right structure.
435435

436-
The `TicketsAboveThreshold` module is implemented as follows.
436+
The `TicketsAboveThreshold` module is implemented as follows, using `Ash.Resource.ManualRelationship`.
437437

438438
```elixir
439439
defmodule Helpdesk.Support.Ticket.Relationships.TicketsAboveThreshold do
@@ -444,17 +444,35 @@ defmodule Helpdesk.Support.Ticket.Relationships.TicketsAboveThreshold do
444444
# Use existing records to limit results
445445
rep_ids = Enum.map(records, & &1.id)
446446

447-
{:ok,
447+
# Build a map of the items grouped by the primary key of the source, i.e representative.id => [...tickets above threshold]
448+
tickets_by_rep =
448449
query
449450
|> Ash.Query.filter(representative_id in ^rep_ids)
450451
|> Ash.Query.filter(priority > representative.priority_threshold)
451452
|> Ash.read!(Ash.Context.to_opts(context))
452-
# Return the items grouped by the primary key of the source, i.e representative.id => [...tickets above threshold]
453-
|> Enum.group_by(& &1.representative_id)}
453+
|> Enum.group_by(& &1.representative_id)
454+
455+
# Return the tickets to relate to each representative in `records`
456+
Enum.map(records, &Map.get(tickets_by_rep, &1.id))
454457
end
455458
end
456459
```
457460

461+
Notice that each item of the resulting list at a given index ends up being related to the record from the input list at the same index.
462+
In other words, for an input record list `[r1, r2, ...]`, and an output list of `[s1, s2, ...]` the resulting relationships are `r1 -> s1, r2 -> s2, ...`.
463+
464+
This is similar to how [Module Calculations](documentation/topics/resources/calculations.md#module-calculations) are done.
465+
466+
> ### Returning a map? {: .info}
467+
> At Ash version 3.14.1 and earlier, instead of returning a list from the `load/3` callback,
468+
> it was necessary to return a map of input record ids to related records.
469+
>
470+
> Returning a map is still supported for backwards compatibility,
471+
> but due to performance issues in certain cases it is recommended to return a list instead.
472+
> Though it is supported, you should consider returning a map here as deprecated. Doing so may become unsupported in a future version.
473+
>
474+
> See issue [#2505](https://github.com/ash-project/ash/issues/2505) for more information.
475+
458476
### Reusing the Query
459477

460478
Since you likely want to support things like filtering your relationship when
@@ -496,15 +514,17 @@ defmodule Helpdesk.Support.Ticket.Relationships.TicketsAboveThreshold do
496514
def load(records, _opts, %{query: query, actor: actor, authorize?: authorize?}) do
497515
rep_ids = Enum.map(records, & &1.id)
498516

499-
{:ok,
517+
tickets_by_rep =
500518
query
501519
# If this isn't added, representative_id would be set to %Ash.NotLoaded, causing the
502520
# Enum.group_by call below to fail mapping results to the correct records.
503521
|> Ash.Query.ensure_selected([:representative_id])
504522
|> Ash.Query.filter(representative_id in ^rep_ids)
505523
|> Ash.Query.filter(priority > representative.priority_threshold)
506524
|> Helpdesk.Support.read!(actor: actor, authorize?: authorize?)
507-
|> Enum.group_by(& &1.representative_id)}
525+
|> Enum.group_by(& &1.representative_id)
526+
527+
Enum.map(records, &Map.get(tickets_by_rep, &1.id))
508528
end
509529
end
510530
```
@@ -518,13 +538,19 @@ need and then apply the query to them in memory.
518538
```elixir
519539
def load(records, _opts, %{query: query, ..}) do
520540
# fetch the data from the other source, which is capable of sorting
521-
data = get_other_data(data, query.sort)
541+
# (assume this returns a map of record ids => data to relate)
542+
data_by_record_id = get_other_data(records, query.sort)
522543

523-
query
524544
# unset the sort since we already applied that
525-
|> Ash.Query.unset([:sort])
545+
query=
546+
query
547+
|> Ash.Query.unset([:sort])
548+
526549
# apply the query in memory (filtering, distinct, limit, offset)
527-
|> Ash.Query.apply_to(data)
550+
Enum.map(records, fn record ->
551+
data = data_by_record_id[record.id]
552+
Ash.Query.apply_to(query, data)
553+
end)
528554
end
529555
```
530556

lib/ash/actions/read/relationships.ex

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,11 @@ defmodule Ash.Actions.Read.Relationships do
504504
domain: related_query.domain,
505505
tenant: related_query.tenant
506506
})
507+
|> case do
508+
{:ok, result} -> {:ok, result}
509+
{:error, error} -> {:error, error}
510+
result when is_list(result) -> {:ok, result}
511+
end
507512
|> case do
508513
{:ok, result_records} ->
509514
result_records

test/actions/has_many_test.exs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,27 @@ defmodule Ash.Test.Actions.HasManyTest do
6060
end
6161
end
6262

63+
defmodule MeowCommentListRelationship do
64+
use Ash.Resource.ManualRelationship
65+
66+
require Ash.Query
67+
68+
def load(records, _opts, %{query: query, actor: actor, authorize?: authorize?}) do
69+
# Same as MeowCommentRelationship, but use the list form return value
70+
post_ids = Enum.map(records, & &1.id)
71+
72+
grouped_comments =
73+
query
74+
|> Ash.Query.filter(post_id in ^post_ids)
75+
|> Ash.Query.filter(content == "meow")
76+
|> Ash.read!(actor: actor, authorize?: authorize?)
77+
|> Enum.group_by(& &1.post_id)
78+
79+
# Need to use Ash.CiString.value here since comment's post_id is defined as a uuid
80+
Enum.map(records, &Map.get(grouped_comments, Ash.CiString.value(&1.id)))
81+
end
82+
end
83+
6384
defmodule Post do
6485
@moduledoc false
6586
use Ash.Resource,
@@ -108,6 +129,10 @@ defmodule Ash.Test.Actions.HasManyTest do
108129
has_many :meow_comments, Comment do
109130
manual MeowCommentRelationship
110131
end
132+
133+
has_many :meow_list_comments, Comment do
134+
manual MeowCommentListRelationship
135+
end
111136
end
112137
end
113138

@@ -300,6 +325,41 @@ defmodule Ash.Test.Actions.HasManyTest do
300325
end
301326
end
302327

328+
test "manual relationships can return a list" do
329+
post1 =
330+
Post
331+
|> Ash.Changeset.for_create(:create, %{
332+
title: "fiz"
333+
})
334+
|> Ash.create!()
335+
336+
post2 =
337+
Post
338+
|> Ash.Changeset.for_create(:create, %{
339+
title: "buz"
340+
})
341+
|> Ash.create!()
342+
|> Ash.Changeset.for_update(:add_comment, %{
343+
comment: %{content: "meow"}
344+
})
345+
|> Ash.update!()
346+
|> Ash.Changeset.for_update(:add_comment, %{
347+
comment: %{content: "bar"}
348+
})
349+
|> Ash.update!()
350+
|> Ash.Changeset.for_update(:add_comment, %{
351+
comment: %{content: "meow"}
352+
})
353+
|> Ash.update!()
354+
355+
[post1, post2] =
356+
[post1, post2]
357+
|> Ash.load!(:meow_list_comments)
358+
359+
assert length(post1.meow_list_comments) == 0
360+
assert length(post2.meow_list_comments) == 2
361+
end
362+
303363
test "expr within relationship - 2" do
304364
tenant_id = Ash.UUID.generate()
305365

0 commit comments

Comments
 (0)