From 4899bccef95a06627768b51a24210ae12500765e Mon Sep 17 00:00:00 2001 From: Ivan Prignano Date: Thu, 11 Jun 2026 10:19:38 +0200 Subject: [PATCH 1/4] Deduplicate resource objects in `included`s --- lib/jsonapi_plug/normalizer.ex | 63 ++++++++++++++++++++++++- test/jsonapi_plug/normalizer_test.exs | 67 +++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 test/jsonapi_plug/normalizer_test.exs diff --git a/lib/jsonapi_plug/normalizer.ex b/lib/jsonapi_plug/normalizer.ex index 3cfd9d3..7cf86ae 100644 --- a/lib/jsonapi_plug/normalizer.ex +++ b/lib/jsonapi_plug/normalizer.ex @@ -2,7 +2,7 @@ defmodule JSONAPIPlug.Normalizer do @moduledoc """ Transforms user data to and from a `JSON:API` Document. - The default implementation transforms `JSON:API`documents in requests to an ecto + The default implementation transforms `JSON:API` documents in requests to an ecto friendly format and expects `Ecto.Schema` instances when rendering data in responses. The data it produces is stored under the `:params` key of the `JSONAPIPlug` struct that will be stored in the `Plug.Conn` private assign `:jsonapi_plug`. @@ -307,9 +307,70 @@ defmodule JSONAPIPlug.Normalizer do included: normalize_included(MapSet.new(), conn, resource_or_resources, options) |> MapSet.to_list() + |> dedupe_included() } end + defp dedupe_included(included) do + {order, by_identity} = + Enum.reduce(included, {[], %{}}, fn %ResourceObject{} = resource_object, {order, acc} -> + key = {resource_object.type, resource_object.id || resource_object.lid} + + case acc do + %{^key => existing} -> + {order, Map.put(acc, key, merge_resource_objects(existing, resource_object))} + + _ -> + {[key | order], Map.put(acc, key, resource_object)} + end + end) + + order + |> Enum.reverse() + |> Enum.map(&Map.fetch!(by_identity, &1)) + end + + defp merge_resource_objects(%ResourceObject{} = base, %ResourceObject{} = incoming) do + %ResourceObject{ + base + | attributes: Map.merge(base.attributes || %{}, incoming.attributes || %{}), + relationships: merge_relationships(base.relationships, incoming.relationships), + links: merge_optional(base.links, incoming.links), + meta: merge_optional(base.meta, incoming.meta) + } + end + + defp merge_relationships(base, incoming) do + Map.merge(base || %{}, incoming || %{}, fn _name, base_relationship, incoming_relationship -> + merge_relationship(base_relationship, incoming_relationship) + end) + end + + defp merge_relationship( + %RelationshipObject{data: base_data} = base_relationship, + %RelationshipObject{data: incoming_data} + ) + when is_list(base_data) and is_list(incoming_data) do + %RelationshipObject{base_relationship | data: Enum.uniq(base_data ++ incoming_data)} + end + + defp merge_relationship( + %RelationshipObject{data: nil}, + %RelationshipObject{} = incoming_relationship + ), + do: incoming_relationship + + defp merge_relationship(%RelationshipObject{} = base_relationship, %RelationshipObject{}), + do: base_relationship + + defp merge_optional(nil, other), do: other + defp merge_optional(value, nil), do: value + + defp merge_optional(base, incoming) when is_map(base) and is_map(incoming), + do: Map.merge(base, incoming) + + defp merge_optional(_base, incoming), do: incoming + defp normalize_data(_conn, nil, _options), do: nil defp normalize_data(conn, resources, options) when is_list(resources), diff --git a/test/jsonapi_plug/normalizer_test.exs b/test/jsonapi_plug/normalizer_test.exs new file mode 100644 index 0000000..d01c35f --- /dev/null +++ b/test/jsonapi_plug/normalizer_test.exs @@ -0,0 +1,67 @@ +defmodule JSONAPIPlug.NormalizerTest do + use ExUnit.Case + import Plug.Test + + alias JSONAPIPlug.{Document, Document.ResourceObject} + alias JSONAPIPlug.TestSupport.Plugs.UnderscoringPostPlug + alias JSONAPIPlug.TestSupport.Resources.{Comment, Company, Industry, Post, Tag, User} + + defp included_with_id(%Document{included: included}, type, id), + do: Enum.filter(included, &(&1.type == type and &1.id == id)) + + describe "normalize included deduplication" do + test "collapses a resource reached via multiple include paths into a single object" do + conn = + conn(:get, "/?include=author.company,best_comments.user") |> UnderscoringPostPlug.call([]) + + author = %User{id: 1, first_name: "Ada", company: %Company{id: 9, name: "Initech"}} + comment = %Comment{id: 5, body: "hi", user: %User{id: 1, first_name: "Ada"}} + post = %Post{id: 1, text: "Hello", author: author, best_comments: [comment]} + + document = JSONAPIPlug.render(conn, post) + + assert [%ResourceObject{relationships: relationships}] = + included_with_id(document, "user", "1") + + assert Map.has_key?(relationships, "company") + assert [%ResourceObject{}] = included_with_id(document, "company", "9") + end + + test "leaves a document without duplicate resources untouched" do + conn = conn(:get, "/?include=author,best_comments") |> UnderscoringPostPlug.call([]) + + post = %Post{ + id: 1, + text: "Hello", + author: %User{id: 1, first_name: "Ada"}, + best_comments: [%Comment{id: 5, body: "hi"}] + } + + assert %Document{included: included} = JSONAPIPlug.render(conn, post) + + assert included |> Enum.map(&{&1.type, &1.id}) |> Enum.sort() == [ + {"comment", "5"}, + {"user", "1"} + ] + end + + test "merges to-many relationship identifiers into one" do + conn = + conn(:get, "/?include=author.company.industry.tags,other_user.company.industry.tags") + |> UnderscoringPostPlug.call([]) + + industry_a = %Industry{id: 7, name: "Tech", tags: [%Tag{id: 100, name: "a"}]} + industry_b = %Industry{id: 7, name: "Tech", tags: [%Tag{id: 200, name: "b"}]} + author = %User{id: 1, company: %Company{id: 9, industry: industry_a}} + other_user = %User{id: 2, company: %Company{id: 10, industry: industry_b}} + post = %Post{id: 1, text: "Hello", author: author, other_user: other_user} + + document = JSONAPIPlug.render(conn, post) + + assert [%ResourceObject{relationships: %{"tags" => tags}}] = + included_with_id(document, "industry", "7") + + assert tags.data |> Enum.map(& &1.id) |> Enum.sort() == ["100", "200"] + end + end +end From 9b6938e877a186409c2f083b78bbd060396f3dfc Mon Sep 17 00:00:00 2001 From: Ivan Prignano Date: Mon, 15 Jun 2026 10:58:52 +0200 Subject: [PATCH 2/4] Switch to Map.update() approach --- lib/jsonapi_plug/normalizer.ex | 35 +++++++++------------------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/lib/jsonapi_plug/normalizer.ex b/lib/jsonapi_plug/normalizer.ex index 7cf86ae..5007dfe 100644 --- a/lib/jsonapi_plug/normalizer.ex +++ b/lib/jsonapi_plug/normalizer.ex @@ -305,29 +305,14 @@ defmodule JSONAPIPlug.Normalizer do data: normalize_data(conn, resource_or_resources, options), links: normalize_links(conn, resource_or_resources, links || %{}, options), included: - normalize_included(MapSet.new(), conn, resource_or_resources, options) - |> MapSet.to_list() - |> dedupe_included() + normalize_included(%{}, conn, resource_or_resources, options) + |> Map.values() } end - defp dedupe_included(included) do - {order, by_identity} = - Enum.reduce(included, {[], %{}}, fn %ResourceObject{} = resource_object, {order, acc} -> - key = {resource_object.type, resource_object.id || resource_object.lid} - - case acc do - %{^key => existing} -> - {order, Map.put(acc, key, merge_resource_objects(existing, resource_object))} - - _ -> - {[key | order], Map.put(acc, key, resource_object)} - end - end) - - order - |> Enum.reverse() - |> Enum.map(&Map.fetch!(by_identity, &1)) + defp put_included(included, %ResourceObject{} = resource_object) do + key = {resource_object.type, resource_object.id || resource_object.lid} + Map.update(included, key, resource_object, &merge_resource_objects(&1, resource_object)) end defp merge_resource_objects(%ResourceObject{} = base, %ResourceObject{} = incoming) do @@ -533,8 +518,9 @@ defmodule JSONAPIPlug.Normalizer do case {related_loaded?, related_many, related_data} do {true, true, related_data} when is_list(related_data) -> - MapSet.new(related_data, &normalize_resource(conn, &1, options)) - |> MapSet.union(included) + Enum.reduce(related_data, included, fn related, included -> + put_included(included, normalize_resource(conn, related, options)) + end) {true, _related_many, related_data} when is_list(related_data) -> raise InvalidDocument, @@ -547,10 +533,7 @@ defmodule JSONAPIPlug.Normalizer do reference: nil {true, _related_many, _related_data} -> - MapSet.put( - included, - normalize_resource(conn, related_data, options) - ) + put_included(included, normalize_resource(conn, related_data, options)) {false, _related_many, _related_data} -> included From 662ccb69f12a468b989a917a5faaf74908790676 Mon Sep 17 00:00:00 2001 From: Ivan Prignano Date: Mon, 15 Jun 2026 12:20:48 +0200 Subject: [PATCH 3/4] Don't merge attrs, links and meta --- lib/jsonapi_plug/normalizer.ex | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/jsonapi_plug/normalizer.ex b/lib/jsonapi_plug/normalizer.ex index 5007dfe..f96faed 100644 --- a/lib/jsonapi_plug/normalizer.ex +++ b/lib/jsonapi_plug/normalizer.ex @@ -318,10 +318,7 @@ defmodule JSONAPIPlug.Normalizer do defp merge_resource_objects(%ResourceObject{} = base, %ResourceObject{} = incoming) do %ResourceObject{ base - | attributes: Map.merge(base.attributes || %{}, incoming.attributes || %{}), - relationships: merge_relationships(base.relationships, incoming.relationships), - links: merge_optional(base.links, incoming.links), - meta: merge_optional(base.meta, incoming.meta) + | relationships: merge_relationships(base.relationships, incoming.relationships) } end From 1b46ca18a33962017af19b0cf7410d00bf51ea1c Mon Sep 17 00:00:00 2001 From: Ivan Prignano Date: Tue, 16 Jun 2026 17:51:34 +0200 Subject: [PATCH 4/4] Remove unused functions --- lib/jsonapi_plug/normalizer.ex | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lib/jsonapi_plug/normalizer.ex b/lib/jsonapi_plug/normalizer.ex index f96faed..fac0e67 100644 --- a/lib/jsonapi_plug/normalizer.ex +++ b/lib/jsonapi_plug/normalizer.ex @@ -345,14 +345,6 @@ defmodule JSONAPIPlug.Normalizer do defp merge_relationship(%RelationshipObject{} = base_relationship, %RelationshipObject{}), do: base_relationship - defp merge_optional(nil, other), do: other - defp merge_optional(value, nil), do: value - - defp merge_optional(base, incoming) when is_map(base) and is_map(incoming), - do: Map.merge(base, incoming) - - defp merge_optional(_base, incoming), do: incoming - defp normalize_data(_conn, nil, _options), do: nil defp normalize_data(conn, resources, options) when is_list(resources),