From 66d2f444431103d8953c068b88c2233fa2442526 Mon Sep 17 00:00:00 2001 From: Riccardo Binetti Date: Tue, 6 Feb 2024 15:46:09 +0100 Subject: [PATCH] feat: add Relay ID translation in mutation and queries (#109) Adds a new option for queries and mutations that defines which arguments or attributes will use a global Relay ID and their type. This allows automatically decoding them before hitting their action. This paves the way to automatic translation derived from the arguments, which will be implemented subsequently. --------- Co-authored-by: Zach Daniel --- .formatter.exs | 1 + .../dsls/DSL:-AshGraphql.Resource.md | 14 + documentation/topics/relay.md | 31 ++ lib/graphql/id_translator.ex | 67 +++++ lib/resource/mutation.ex | 24 +- lib/resource/query.ex | 8 + lib/resource/resource.ex | 23 +- test/relay_ids_test.exs | 274 ++++++++++++++++++ test/support/relay_ids/resources/post.ex | 9 +- test/support/relay_ids/resources/user.ex | 7 + 10 files changed, 455 insertions(+), 3 deletions(-) create mode 100644 lib/graphql/id_translator.ex diff --git a/.formatter.exs b/.formatter.exs index dfbae687..4e192992 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -41,6 +41,7 @@ spark_locals_without_parens = [ read_one: 3, relationships: 1, relay?: 1, + relay_id_translations: 1, root_level_errors?: 1, show_metadata: 1, show_raised_errors?: 1, diff --git a/documentation/dsls/DSL:-AshGraphql.Resource.md b/documentation/dsls/DSL:-AshGraphql.Resource.md index 522b325c..76f86019 100644 --- a/documentation/dsls/DSL:-AshGraphql.Resource.md +++ b/documentation/dsls/DSL:-AshGraphql.Resource.md @@ -127,6 +127,7 @@ get :get_post, :read | [`metadata_types`](#graphql-queries-get-metadata_types){: #graphql-queries-get-metadata_types } | `keyword` | `[]` | Type overrides for metadata fields on the read action. | | [`show_metadata`](#graphql-queries-get-show_metadata){: #graphql-queries-get-show_metadata } | `list(atom)` | | The metadata attributes to show. Defaults to all. | | [`as_mutation?`](#graphql-queries-get-as_mutation?){: #graphql-queries-get-as_mutation? } | `boolean` | `false` | Places the query in the `mutations` key instead. Not typically necessary, but is often paired with `as_mutation?`. See the [the guide](/documentation/topics/modifying-the-resolution.html) for more. | +| [`relay_id_translations`](#graphql-queries-get-relay_id_translations){: #graphql-queries-get-relay_id_translations } | `keyword` | `[]` | A keyword list indicating arguments or attributes that have to be translated from global Relay IDs to internal IDs. See the [Relay guide](/documentation/topics/relay.md#translating-relay-global-ids-passed-as-arguments) for more. | @@ -169,6 +170,7 @@ read_one :current_user, :current_user | [`metadata_types`](#graphql-queries-read_one-metadata_types){: #graphql-queries-read_one-metadata_types } | `keyword` | `[]` | Type overrides for metadata fields on the read action. | | [`show_metadata`](#graphql-queries-read_one-show_metadata){: #graphql-queries-read_one-show_metadata } | `list(atom)` | | The metadata attributes to show. Defaults to all. | | [`as_mutation?`](#graphql-queries-read_one-as_mutation?){: #graphql-queries-read_one-as_mutation? } | `boolean` | `false` | Places the query in the `mutations` key instead. Not typically necessary, but is often paired with `as_mutation?`. See the [the guide](/documentation/topics/modifying-the-resolution.html) for more. | +| [`relay_id_translations`](#graphql-queries-read_one-relay_id_translations){: #graphql-queries-read_one-relay_id_translations } | `keyword` | `[]` | A keyword list indicating arguments or attributes that have to be translated from global Relay IDs to internal IDs. See the [Relay guide](/documentation/topics/relay.md#translating-relay-global-ids-passed-as-arguments) for more. | @@ -215,6 +217,7 @@ list :list_posts_paginated, :read, relay?: true | [`metadata_types`](#graphql-queries-list-metadata_types){: #graphql-queries-list-metadata_types } | `keyword` | `[]` | Type overrides for metadata fields on the read action. | | [`show_metadata`](#graphql-queries-list-show_metadata){: #graphql-queries-list-show_metadata } | `list(atom)` | | The metadata attributes to show. Defaults to all. | | [`as_mutation?`](#graphql-queries-list-as_mutation?){: #graphql-queries-list-as_mutation? } | `boolean` | `false` | Places the query in the `mutations` key instead. Not typically necessary, but is often paired with `as_mutation?`. See the [the guide](/documentation/topics/modifying-the-resolution.html) for more. | +| [`relay_id_translations`](#graphql-queries-list-relay_id_translations){: #graphql-queries-list-relay_id_translations } | `keyword` | `[]` | A keyword list indicating arguments or attributes that have to be translated from global Relay IDs to internal IDs. See the [Relay guide](/documentation/topics/relay.md#translating-relay-global-ids-passed-as-arguments) for more. | @@ -247,7 +250,11 @@ action :check_status, :check_status |------|------|---------|------| | [`name`](#graphql-queries-action-name){: #graphql-queries-action-name } | `atom` | `:get` | The name to use for the query. | | [`action`](#graphql-queries-action-action){: #graphql-queries-action-action .spark-required} | `atom` | | The action to use for the query. | +### Options +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`relay_id_translations`](#graphql-queries-action-relay_id_translations){: #graphql-queries-action-relay_id_translations } | `keyword` | `[]` | A keyword list indicating arguments or attributes that have to be translated from global Relay IDs to internal IDs. See the [Relay guide](/documentation/topics/relay.md#translating-relay-global-ids-passed-as-arguments) for more. | @@ -312,6 +319,7 @@ create :create_post, :create | [`upsert?`](#graphql-mutations-create-upsert?){: #graphql-mutations-create-upsert? } | `boolean` | `false` | Whether or not to use the `upsert?: true` option when calling `YourApi.create/2`. | | [`upsert_identity`](#graphql-mutations-create-upsert_identity){: #graphql-mutations-create-upsert_identity } | `atom` | `false` | Which identity to use for the upsert | | [`modify_resolution`](#graphql-mutations-create-modify_resolution){: #graphql-mutations-create-modify_resolution } | `mfa` | | An MFA that will be called with the resolution, the query, and the result of the action as the first three arguments. See the [the guide](/documentation/topics/modifying-the-resolution.html) for more. | +| [`relay_id_translations`](#graphql-mutations-create-relay_id_translations){: #graphql-mutations-create-relay_id_translations } | `keyword` | `[]` | A keyword list indicating arguments or attributes that have to be translated from global Relay IDs to internal IDs. See the [Relay guide](/documentation/topics/relay.md#translating-relay-global-ids-passed-as-arguments) for more. | @@ -350,6 +358,7 @@ update :update_post, :update |------|------|---------|------| | [`identity`](#graphql-mutations-update-identity){: #graphql-mutations-update-identity } | `atom` | | The identity to use to fetch the record to be updated. Use `false` if no identity is required. | | [`read_action`](#graphql-mutations-update-read_action){: #graphql-mutations-update-read_action } | `atom` | | The read action to use to fetch the record to be updated. Defaults to the primary read action. | +| [`relay_id_translations`](#graphql-mutations-update-relay_id_translations){: #graphql-mutations-update-relay_id_translations } | `keyword` | `[]` | A keyword list indicating arguments or attributes that have to be translated from global Relay IDs to internal IDs. See the [Relay guide](/documentation/topics/relay.md#translating-relay-global-ids-passed-as-arguments) for more. | @@ -388,6 +397,7 @@ destroy :destroy_post, :destroy |------|------|---------|------| | [`read_action`](#graphql-mutations-destroy-read_action){: #graphql-mutations-destroy-read_action } | `atom` | | The read action to use to fetch the record to be destroyed. Defaults to the primary read action. | | [`identity`](#graphql-mutations-destroy-identity){: #graphql-mutations-destroy-identity } | `atom` | | The identity to use to fetch the record to be destroyed. Use `false` if no identity is required. | +| [`relay_id_translations`](#graphql-mutations-destroy-relay_id_translations){: #graphql-mutations-destroy-relay_id_translations } | `keyword` | `[]` | A keyword list indicating arguments or attributes that have to be translated from global Relay IDs to internal IDs. See the [Relay guide](/documentation/topics/relay.md#translating-relay-global-ids-passed-as-arguments) for more. | @@ -420,7 +430,11 @@ action :check_status, :check_status |------|------|---------|------| | [`name`](#graphql-mutations-action-name){: #graphql-mutations-action-name } | `atom` | `:get` | The name to use for the query. | | [`action`](#graphql-mutations-action-action){: #graphql-mutations-action-action .spark-required} | `atom` | | The action to use for the query. | +### Options +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`relay_id_translations`](#graphql-mutations-action-relay_id_translations){: #graphql-mutations-action-relay_id_translations } | `keyword` | `[]` | A keyword list indicating arguments or attributes that have to be translated from global Relay IDs to internal IDs. See the [Relay guide](/documentation/topics/relay.md#translating-relay-global-ids-passed-as-arguments) for more. | diff --git a/documentation/topics/relay.md b/documentation/topics/relay.md index 938a6055..b4894368 100644 --- a/documentation/topics/relay.md +++ b/documentation/topics/relay.md @@ -25,3 +25,34 @@ use AshGraphql, relay_ids?: true ``` This allows refetching a node using the `node` query and passing its global ID. + +### Translating Relay Global IDs passed as arguments + +When `relay_ids?: true` is passed, users of the API will have access only to the global IDs, so they +will also need to use them when an ID is required as argument. You actions, though, internally use the +normal IDs defined by the data layer. + +To handle the translation between the two ID domains, you can use the `relay_id_translations` +option. With this, you can define a list of arguments that will be translated from Relay global IDs +to internal IDs. + +For example, if you have a `Post` resource with an action to create a post associated with an +author: + +```elixir +create :create do + argument :author_id, :uuid + + # Do stuff with author_id +end +``` + +You can add this to the mutation connected to that action: + +```elixir +mutations do + create :create_post, :create do + relay_id_translations [input: [author_id: :user]] + end +end +``` diff --git a/lib/graphql/id_translator.ex b/lib/graphql/id_translator.ex new file mode 100644 index 00000000..32fa3748 --- /dev/null +++ b/lib/graphql/id_translator.ex @@ -0,0 +1,67 @@ +defmodule AshGraphql.Graphql.IdTranslator do + @moduledoc false + + def translate_relay_ids(%{state: :unresolved} = resolution, relay_id_translations) do + arguments = + Enum.reduce(relay_id_translations, resolution.arguments, &process/2) + + %{resolution | arguments: arguments} + end + + def translate_relay_ids(resolution, _relay_id_translations) do + resolution + end + + defp process({field, nested_translations}, args) when is_list(nested_translations) do + case Map.get(args, field) do + subtree when is_map(subtree) -> + new_subtree = Enum.reduce(nested_translations, subtree, &process/2) + Map.put(args, field, new_subtree) + + elements when is_list(elements) -> + new_elements = + Enum.map(elements, fn element -> + Enum.reduce(nested_translations, element, &process/2) + end) + + Map.put(args, field, new_elements) + + _ -> + args + end + end + + defp process({field, type}, args) when is_atom(type) do + case Map.get(args, field) do + id when is_binary(id) -> + case AshGraphql.Resource.decode_relay_id(id) do + {:ok, %{type: ^type, id: decoded_id}} -> + Map.put(args, field, decoded_id) + + _ -> + # If we fail to decode for the correct type, we just skip translation + # This will be marked as an invalid input down the line + args + end + + [id | _] = ids when is_binary(id) -> + decoded_ids = + Enum.map(ids, fn id -> + case AshGraphql.Resource.decode_relay_id(id) do + {:ok, %{type: ^type, id: decoded_id}} -> + decoded_id + + _ -> + # If we fail to decode for the correct type, we just skip translation + # This will be marked as an invalid input down the line + id + end + end) + + Map.put(args, field, decoded_ids) + + _ -> + args + end + end +end diff --git a/lib/resource/mutation.ex b/lib/resource/mutation.ex index d8503ca6..46ee9e3b 100644 --- a/lib/resource/mutation.ex +++ b/lib/resource/mutation.ex @@ -8,7 +8,8 @@ defmodule AshGraphql.Resource.Mutation do :read_action, :upsert?, :upsert_identity, - :modify_resolution + :modify_resolution, + :relay_id_translations ] @create_schema [ @@ -37,6 +38,13 @@ defmodule AshGraphql.Resource.Mutation do doc: """ An MFA that will be called with the resolution, the query, and the result of the action as the first three arguments. See the [the guide](/documentation/topics/modifying-the-resolution.html) for more. """ + ], + relay_id_translations: [ + type: :keyword_list, + doc: """ + A keyword list indicating arguments or attributes that have to be translated from global Relay IDs to internal IDs. See the [Relay guide](/documentation/topics/relay.md#translating-relay-global-ids-passed-as-arguments) for more. + """, + default: [] ] ] @@ -61,6 +69,13 @@ defmodule AshGraphql.Resource.Mutation do type: :atom, doc: "The read action to use to fetch the record to be updated. Defaults to the primary read action." + ], + relay_id_translations: [ + type: :keyword_list, + doc: """ + A keyword list indicating arguments or attributes that have to be translated from global Relay IDs to internal IDs. See the [Relay guide](/documentation/topics/relay.md#translating-relay-global-ids-passed-as-arguments) for more. + """, + default: [] ] ] @@ -85,6 +100,13 @@ defmodule AshGraphql.Resource.Mutation do doc: """ The identity to use to fetch the record to be destroyed. Use `false` if no identity is required. """ + ], + relay_id_translations: [ + type: :keyword_list, + doc: """ + A keyword list indicating arguments or attributes that have to be translated from global Relay IDs to internal IDs. See the [Relay guide](/documentation/topics/relay.md#translating-relay-global-ids-passed-as-arguments) for more. + """, + default: [] ] ] diff --git a/lib/resource/query.ex b/lib/resource/query.ex index 9a597da6..377bc60a 100644 --- a/lib/resource/query.ex +++ b/lib/resource/query.ex @@ -7,6 +7,7 @@ defmodule AshGraphql.Resource.Query do :identity, :allow_nil?, :modify_resolution, + :relay_id_translations, as_mutation?: false, metadata_names: [], metadata_types: [], @@ -52,6 +53,13 @@ defmodule AshGraphql.Resource.Query do doc: """ Places the query in the `mutations` key instead. Not typically necessary, but is often paired with `as_mutation?`. See the [the guide](/documentation/topics/modifying-the-resolution.html) for more. """ + ], + relay_id_translations: [ + type: :keyword_list, + doc: """ + A keyword list indicating arguments or attributes that have to be translated from global Relay IDs to internal IDs. See the [Relay guide](/documentation/topics/relay.md#translating-relay-global-ids-passed-as-arguments) for more. + """, + default: [] ] ] diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 85b36166..54e953a6 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -57,12 +57,19 @@ defmodule AshGraphql.Resource do type: :atom, doc: "The action to use for the query.", required: true + ], + relay_id_translations: [ + type: :keyword_list, + doc: """ + A keyword list indicating arguments or attributes that have to be translated from global Relay IDs to internal IDs. See the [Relay guide](/documentation/topics/relay.md#translating-relay-global-ids-passed-as-arguments) for more. + """, + default: [] ] ] defmodule Action do @moduledoc "Represents a configured generic action" - defstruct [:type, :name, :action] + defstruct [:type, :name, :action, :relay_id_translations] end @action %Spark.Dsl.Entity{ @@ -499,6 +506,7 @@ defmodule AshGraphql.Resource do identifier: name, middleware: action_middleware ++ + id_translation_middleware(query.relay_id_translations, relay_ids?) ++ [ {{AshGraphql.Graphql.Resolver, :resolve}, {api, resource, query, false}} ], @@ -520,6 +528,7 @@ defmodule AshGraphql.Resource do identifier: query.name, middleware: action_middleware ++ + id_translation_middleware(query.relay_id_translations, relay_ids?) ++ [ {{AshGraphql.Graphql.Resolver, :resolve}, {api, resource, query, relay_ids?}} ], @@ -569,6 +578,7 @@ defmodule AshGraphql.Resource do identifier: name, middleware: action_middleware ++ + id_translation_middleware(query.relay_id_translations, relay_ids?) ++ [ {{AshGraphql.Graphql.Resolver, :resolve}, {api, resource, query, true}} ], @@ -593,6 +603,7 @@ defmodule AshGraphql.Resource do identifier: mutation.name, middleware: action_middleware ++ + id_translation_middleware(mutation.relay_id_translations, relay_ids?) ++ [ {{AshGraphql.Graphql.Resolver, :mutate}, {api, resource, mutation, relay_ids?}} ], @@ -638,6 +649,7 @@ defmodule AshGraphql.Resource do identifier: mutation.name, middleware: action_middleware ++ + id_translation_middleware(mutation.relay_id_translations, relay_ids?) ++ [ {{AshGraphql.Graphql.Resolver, :mutate}, {api, resource, mutation, relay_ids?}} ], @@ -691,6 +703,7 @@ defmodule AshGraphql.Resource do identifier: mutation.name, middleware: action_middleware ++ + id_translation_middleware(mutation.relay_id_translations, relay_ids?) ++ [ {{AshGraphql.Graphql.Resolver, :mutate}, {api, resource, mutation, relay_ids?}} ], @@ -894,6 +907,14 @@ defmodule AshGraphql.Resource do end) end + defp id_translation_middleware(relay_id_translations, true) do + [{{AshGraphql.Graphql.IdTranslator, :translate_relay_ids}, relay_id_translations}] + end + + defp id_translation_middleware(_relay_id_translations, _relay_ids?) do + [] + end + # sobelow_skip ["DOS.StringToAtom"] defp metadata_field(resource, mutation, schema) do metadata_fields = diff --git a/test/relay_ids_test.exs b/test/relay_ids_test.exs index 757fe14c..d23c2cda 100644 --- a/test/relay_ids_test.exs +++ b/test/relay_ids_test.exs @@ -257,4 +257,278 @@ defmodule AshGraphql.RelayIdsTest do |> AshGraphql.Resource.decode_relay_id() end end + + describe "relay ID translation" do + test "works with create mutations" do + author_id = + User + |> Ash.Changeset.for_create(:create, %{name: "Fred"}) + |> Api.create!() + |> AshGraphql.Resource.encode_relay_id() + + resp = + """ + mutation SimpleCreatePost($input: SimpleCreatePostInput) { + simpleCreatePost(input: $input) { + result { + text + author { + id + } + } + errors { + message + } + } + } + """ + |> Absinthe.run(Schema, + variables: %{ + "input" => %{ + "text" => "foo", + "author_id" => author_id + } + } + ) + + assert {:ok, result} = resp + + refute Map.has_key?(result, :errors) + + assert %{ + data: %{ + "simpleCreatePost" => %{ + "result" => %{ + "text" => "foo", + "author" => %{ + "id" => ^author_id + } + } + } + } + } = result + end + + test "works in update mutations" do + author_id = + User + |> Ash.Changeset.for_create(:create, %{name: "Fred"}) + |> Api.create!() + |> AshGraphql.Resource.encode_relay_id() + + post_id = + Post + |> Ash.Changeset.for_create(:create, %{text: "foo"}) + |> Api.create!() + |> AshGraphql.Resource.encode_relay_id() + + resp = + """ + mutation AssignAuthor($id: ID!, $input: AssignAuthorInput) { + assignAuthor(id: $id, input: $input) { + result { + text + author { + id + } + } + errors { + message + } + } + } + """ + |> Absinthe.run(Schema, + variables: %{ + "id" => post_id, + "input" => %{ + "author_id" => author_id + } + } + ) + + assert {:ok, result} = resp + + refute Map.has_key?(result, :errors) + + assert %{ + data: %{ + "assignAuthor" => %{ + "result" => %{ + "author" => %{ + "id" => ^author_id + } + } + } + } + } = result + end + + test "works with lists" do + author_id = + User + |> Ash.Changeset.for_create(:create, %{name: "Fred"}) + |> Api.create!() + |> AshGraphql.Resource.encode_relay_id() + + post_ids = + Enum.map(1..5, fn i -> + Post + |> Ash.Changeset.for_create(:create, %{text: "foo #{i}"}) + |> Api.create!() + |> AshGraphql.Resource.encode_relay_id() + end) + + resp = + """ + mutation AssignPosts($id: ID!, $input: AssignPostsInput) { + assignPosts(id: $id, input: $input) { + result { + posts { + id + } + } + errors { + message + } + } + } + """ + |> Absinthe.run(Schema, + variables: %{ + "id" => author_id, + "input" => %{ + "post_ids" => post_ids + } + } + ) + + assert {:ok, result} = resp + + refute Map.has_key?(result, :errors) + + assert %{ + data: %{ + "assignPosts" => %{ + "result" => %{ + "posts" => posts + } + } + } + } = result + + assert length(posts) == 5 + Enum.each(posts, fn post -> assert post["id"] in post_ids end) + end + + test "rejects invalid IDs" do + author_id = + User + |> Ash.Changeset.for_create(:create, %{name: "Fred"}) + |> Api.create!() + |> AshGraphql.Resource.encode_relay_id() + + post_ids = + Enum.map(1..5, fn i -> + Post + |> Ash.Changeset.for_create(:create, %{text: "foo #{i}"}) + |> Api.create!() + |> AshGraphql.Resource.encode_relay_id() + end) + + post_ids = ["invalid_id" | post_ids] + + resp = + """ + mutation AssignPosts($id: ID!, $input: AssignPostsInput) { + assignPosts(id: $id, input: $input) { + result { + posts { + id + } + } + errors { + fields + message + } + } + } + """ + |> Absinthe.run(Schema, + variables: %{ + "id" => author_id, + "input" => %{ + "post_ids" => post_ids + } + } + ) + + assert {:ok, result} = resp + + assert %{ + data: %{ + "assignPosts" => %{ + "result" => nil, + "errors" => [ + %{ + "fields" => ["post_ids"], + "message" => "is invalid" + } + ] + } + } + } = result + end + + test "rejects IDs for another type" do + author_id = + User + |> Ash.Changeset.for_create(:create, %{name: "Fred"}) + |> Api.create!() + |> AshGraphql.Resource.encode_relay_id() + + post_ids = [author_id] + + resp = + """ + mutation AssignPosts($id: ID!, $input: AssignPostsInput) { + assignPosts(id: $id, input: $input) { + result { + posts { + id + } + } + errors { + fields + message + } + } + } + """ + |> Absinthe.run(Schema, + variables: %{ + "id" => author_id, + "input" => %{ + "post_ids" => post_ids + } + } + ) + + assert {:ok, result} = resp + + assert %{ + data: %{ + "assignPosts" => %{ + "result" => nil, + "errors" => [ + %{ + "fields" => ["post_ids"], + "message" => "is invalid" + } + ] + } + } + } = result + end + end end diff --git a/test/support/relay_ids/resources/post.ex b/test/support/relay_ids/resources/post.ex index fb950e19..e623caf3 100644 --- a/test/support/relay_ids/resources/post.ex +++ b/test/support/relay_ids/resources/post.ex @@ -16,8 +16,9 @@ defmodule AshGraphql.Test.RelayIds.Post do end mutations do - create :simple_create_post, :create + create :simple_create_post, :create, relay_id_translations: [input: [author_id: :user]] update :update_post, :update + update :assign_author, :assign_author, relay_id_translations: [input: [author_id: :user]] destroy :delete_post, :destroy end end @@ -31,6 +32,12 @@ defmodule AshGraphql.Test.RelayIds.Post do change(set_attribute(:author_id, arg(:author_id))) end + + update :assign_author do + argument(:author_id, :uuid) + + change(set_attribute(:author_id, arg(:author_id))) + end end attributes do diff --git a/test/support/relay_ids/resources/user.ex b/test/support/relay_ids/resources/user.ex index 577ff111..5323f41c 100644 --- a/test/support/relay_ids/resources/user.ex +++ b/test/support/relay_ids/resources/user.ex @@ -14,11 +14,18 @@ defmodule AshGraphql.Test.RelayIds.User do mutations do create :create_user, :create + update :assign_posts, :assign_posts, relay_id_translations: [input: [post_ids: :post]] end end actions do defaults([:create, :update, :destroy, :read]) + + update :assign_posts do + argument(:post_ids, {:array, :uuid}) + + change(manage_relationship(:post_ids, :posts, value_is_key: :id, type: :append_and_remove)) + end end attributes do