From 2c76f192af7735e15b941e2aa3e622757cbf1563 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Thu, 20 Aug 2026 11:14:01 +0200 Subject: [PATCH 1/3] fix(agent): stop the update route from writing foreign keys from relationships --- .../routes/resources/update.rb | 2 + .../routes/resources/update_spec.rb | 95 ++++++++++++++----- 2 files changed, 75 insertions(+), 22 deletions(-) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/update.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/update.rb index c4c498715..f78e71fd3 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/update.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/update.rb @@ -23,6 +23,8 @@ def handle_request(args = {}) filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new( condition_tree: ConditionTree::ConditionTreeFactory.intersect([condition_tree, scope]) ) + payload = args[:params][:data] + payload.delete(:relationships) if payload.is_a?(Hash) data = format_attributes(args, context.collection) context.collection.update(context.caller, filter, data) records = context.collection.list(context.caller, filter, ProjectionFactory.all(context.collection)) diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/update_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/update_spec.rb index e9fee6528..1e3113ace 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/update_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/update_spec.rb @@ -101,24 +101,25 @@ def respond_to?(arg) end end - describe 'with polymorphic many to one relation' do - it 'call update with polymorphic foreign key and type' do - collection_company = build_collection( - name: 'company', + describe 'with relationships in the payload' do + let(:collection_creator) do + build_collection( + name: 'creator', schema: { fields: { 'id' => ColumnSchema.new( column_type: 'Number', is_primary_key: true, filter_operators: [Operators::IN, Operators::EQUAL] - ), - 'name' => ColumnSchema.new(column_type: 'String') + ) } } ) + end - collection_member = build_collection( - name: 'member', + let(:collection_experience) do + build_collection( + name: 'experience', schema: { fields: { 'id' => ColumnSchema.new( @@ -126,37 +127,87 @@ def respond_to?(arg) is_primary_key: true, filter_operators: [Operators::IN, Operators::EQUAL] ), + 'name' => ColumnSchema.new(column_type: 'String'), + 'creator_id' => ColumnSchema.new(column_type: 'Number'), + 'creator' => Relations::ManyToOneSchema.new( + foreign_collection: 'creator', + foreign_key: 'creator_id', + foreign_key_target: 'id' + ), 'memberable_id' => ColumnSchema.new(column_type: 'Number'), 'memberable_type' => ColumnSchema.new(column_type: 'String'), 'memberable' => Relations::PolymorphicManyToOneSchema.new( - foreign_collections: ['company'], + foreign_collections: ['creator'], foreign_key: 'memberable_id', foreign_key_type_field: 'memberable_type', - foreign_key_targets: { 'company' => 'id' } + foreign_key_targets: { 'creator' => 'id' } ) } } ) + end - @datasource.add_collection(collection_company) - @datasource.add_collection(collection_member) + before do + @datasource.add_collection(collection_creator) + @datasource.add_collection(collection_experience) + args[:params]['collection_name'] = 'experience' + args[:params]['id'] = '1' + allow(@datasource.get_collection('experience')) + .to receive_messages(list: [{ 'id' => 1, 'name' => 'edited' }], update: true) + end + it 'does not write a many to one foreign key sent as data null' do args[:params][:data] = { - attributes: {}, - relationships: { 'memberable' => { 'data' => { 'type' => 'Company', 'id' => 5 } } } + attributes: { 'name' => 'edited' }, + relationships: { 'creator' => { 'data' => nil } } } - args[:params]['collection_name'] = 'member' - args[:params]['id'] = '1' - member = { 'id' => 1, 'memberable_id' => 5, 'memberable_type' => 'Company' } - allow(@datasource.get_collection('member')).to receive_messages(list: [member], update: true) update.handle_request(args) - expect(@datasource.get_collection('member')).to have_received(:update) do |caller, filter, data| - expect(caller).to be_instance_of(Components::Caller) - expect(data).to eq({ 'memberable_id' => 5, 'memberable_type' => 'Company' }) - expect(filter.condition_tree.to_h).to eq(field: 'id', operator: Operators::EQUAL, value: 1) + + expect(@datasource.get_collection('experience')).to have_received(:update) do |_caller, _filter, data| + expect(data).to eq({ 'name' => 'edited' }) + end + end + + it 'does not write a polymorphic foreign key nor its type field' do + args[:params][:data] = { + attributes: { 'name' => 'edited' }, + relationships: { 'memberable' => { 'data' => { 'type' => 'Creator', 'id' => 5 } } } + } + + update.handle_request(args) + + expect(@datasource.get_collection('experience')).to have_received(:update) do |_caller, _filter, data| + expect(data).to eq({ 'name' => 'edited' }) end end + + it 'keeps the attributes of a payload mixing touched and untouched relations' do + args[:params][:data] = { + attributes: { 'name' => 'edited' }, + relationships: { + 'creator' => { 'data' => nil }, + 'memberable' => { 'data' => { 'type' => 'Creator', 'id' => 5 } } + } + } + + update.handle_request(args) + + expect(@datasource.get_collection('experience')).to have_received(:update) do |_caller, _filter, data| + expect(data).to eq({ 'name' => 'edited' }) + end + end + + it 'drops the relationships from the payload it forwards' do + args[:params][:data] = { + attributes: { 'name' => 'edited' }, + relationships: { 'creator' => { 'data' => nil } } + } + + update.handle_request(args) + + expect(args[:params][:data]).not_to have_key(:relationships) + end end end end From 4f1372e4068b704ea59a4e01d8864f3b1dc5ae00 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Thu, 20 Aug 2026 14:36:51 +0200 Subject: [PATCH 2/3] fix(agent): let the relationships route clear a polymorphic many to one --- .../routes/abstract_related_route.rb | 4 +++- .../routes/resources/related/update_related.rb | 2 +- .../resources/related/update_related_spec.rb | 16 ++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_related_route.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_related_route.rb index b44a391d1..8ac695325 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_related_route.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/abstract_related_route.rb @@ -6,7 +6,9 @@ def build(args = {}) relation = context.collection.schema[:fields][args[:params]['relation_name']] context.child_collection = if relation.type == 'PolymorphicManyToOne' - context.datasource.get_collection(args[:params]['data']['type']) + if (type = args.dig(:params, 'data', 'type')) + context.datasource.get_collection(type) + end else context.datasource.get_collection(relation.foreign_collection) end diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/related/update_related.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/related/update_related.rb index 8602d8f04..884fca495 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/related/update_related.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/related/update_related.rb @@ -89,7 +89,7 @@ def update_polymorphic_many_to_one(relation, parent_primary_key_values, linked_p ) end - polymorphic_type = context.child_collection.name.gsub('__', '::') + polymorphic_type = context.child_collection&.name&.gsub('__', '::') context.collection.update( context.caller, scoped_fk_owner_filter(parent_primary_key_values, context), diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/related/update_related_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/related/update_related_spec.rb index 23261908e..029088e61 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/related/update_related_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/related/update_related_spec.rb @@ -194,6 +194,22 @@ module Related expect(result).to eq({ content: nil, status: 204 }) end + it 'clears the foreign key and its type field on a polymorphic_many_to_one relation' do + allow(@datasource.get_collection('address')).to receive(:update).and_return(true) + + args[:params]['collection_name'] = 'address' + args[:params]['relation_name'] = 'addressable' + args[:params]['data'] = nil + args[:params]['id'] = 1 + + result = update.handle_request(args) + + expect(@datasource.get_collection('address')).to have_received(:update) do |_caller, _filter, data| + expect(data).to eq({ 'addressable_id' => nil, 'addressable_type' => nil }) + end + expect(result).to eq({ content: nil, status: 204 }) + end + it 'call handle_request on a one_to_one relation' do allow(permissions).to receive(:get_scope) .and_return(Nodes::ConditionTreeLeaf.new('author_id', Operators::NOT_EQUAL, 99)) From 9e782d42cc2b934a94a38ab4b7da8864d1cd90c4 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Thu, 20 Aug 2026 14:36:51 +0200 Subject: [PATCH 3/3] refactor(agent): name the relationships drop and document why --- .../forest_admin_agent/routes/resources/update.rb | 12 ++++++++++-- .../routes/resources/update_spec.rb | 15 ++------------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/update.rb b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/update.rb index f78e71fd3..507b0f23e 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/update.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/routes/resources/update.rb @@ -23,8 +23,7 @@ def handle_request(args = {}) filter = ForestAdminDatasourceToolkit::Components::Query::Filter.new( condition_tree: ConditionTree::ConditionTreeFactory.intersect([condition_tree, scope]) ) - payload = args[:params][:data] - payload.delete(:relationships) if payload.is_a?(Hash) + drop_relationships!(args) data = format_attributes(args, context.collection) context.collection.update(context.caller, filter, data) records = context.collection.list(context.caller, filter, ProjectionFactory.all(context.collection)) @@ -39,6 +38,15 @@ def handle_request(args = {}) ) } end + + private + + # The frontend writes relations through PUT /:collection/:id/relationships/:name, which fires + # before this route. Honouring the relationships block here would turn the untouched relations + # it always resends as `data: null` into foreign keys set to nil. Parity with agent-nodejs. + def drop_relationships!(args) + args[:params][:data].delete(:relationships) + end end end end diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/update_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/update_spec.rb index 1e3113ace..47534d9a7 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/update_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/routes/resources/update_spec.rb @@ -172,7 +172,7 @@ def respond_to?(arg) it 'does not write a polymorphic foreign key nor its type field' do args[:params][:data] = { attributes: { 'name' => 'edited' }, - relationships: { 'memberable' => { 'data' => { 'type' => 'Creator', 'id' => 5 } } } + relationships: { 'memberable' => { 'data' => { 'type' => 'creator', 'id' => 5 } } } } update.handle_request(args) @@ -187,7 +187,7 @@ def respond_to?(arg) attributes: { 'name' => 'edited' }, relationships: { 'creator' => { 'data' => nil }, - 'memberable' => { 'data' => { 'type' => 'Creator', 'id' => 5 } } + 'memberable' => { 'data' => { 'type' => 'creator', 'id' => 5 } } } } @@ -197,17 +197,6 @@ def respond_to?(arg) expect(data).to eq({ 'name' => 'edited' }) end end - - it 'drops the relationships from the payload it forwards' do - args[:params][:data] = { - attributes: { 'name' => 'edited' }, - relationships: { 'creator' => { 'data' => nil } } - } - - update.handle_request(args) - - expect(args[:params][:data]).not_to have_key(:relationships) - end end end end