diff --git a/packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_serializer.rb b/packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_serializer.rb index 1da0e4c89..4b3e6a0af 100644 --- a/packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_serializer.rb +++ b/packages/forest_admin_agent/lib/forest_admin_agent/serializer/forest_serializer.rb @@ -93,6 +93,27 @@ def add_to_one_association(name, options = {}, &block) @to_one_associations[name] = format_field(name, options) end + # A PolymorphicManyToOne can't be SQL-joined, so list projections return a phantom object with no + # primary key. Rebuild the id from the foreign key on the owner record. + def has_one_relationship(attribute_name, attr_data) + object = super + return object if object.nil? # relation not projected (e.g. update/store responses) + + field = ForestAdminAgent::Facades::Container.datasource + .get_collection(@options[:class_name].gsub('::', '__')) + .schema[:fields][attribute_name.to_s] + return object unless field&.type == 'PolymorphicManyToOne' + + foreign_key = @object[field.foreign_key] + foreign_type = @object[field.foreign_key_type_field] + return nil if foreign_key.nil? || foreign_type.nil? + + # foreign_key_targets is keyed by formatted collection names (Admin::User => Admin__User), + # but foreign_type is the raw AR type column value, so format it before the lookup. + primary_key = (field.foreign_key_targets || {})[foreign_type.gsub('::', '__')] || 'id' + (object.is_a?(Hash) ? object : {}).merge(primary_key => foreign_key) + end + def has_one_relationships return {} if @to_one_associations.nil? data = {} diff --git a/packages/forest_admin_agent/spec/lib/forest_admin_agent/serializer/forest_serializer_spec.rb b/packages/forest_admin_agent/spec/lib/forest_admin_agent/serializer/forest_serializer_spec.rb index 984fd2e2b..4ca18912d 100644 --- a/packages/forest_admin_agent/spec/lib/forest_admin_agent/serializer/forest_serializer_spec.rb +++ b/packages/forest_admin_agent/spec/lib/forest_admin_agent/serializer/forest_serializer_spec.rb @@ -34,6 +34,17 @@ module Serializer } ) + # Namespaced model (Admin::User => Admin__User) with a custom primary key + admin_user_collection = build_collection( + name: 'Admin__User', + schema: { + fields: { + 'reference' => ColumnSchema.new(column_type: 'String', is_primary_key: true, filter_operators: [Operators::EQUAL]), + 'name' => ColumnSchema.new(column_type: 'String') + } + } + ) + document_collection = build_collection( name: 'Document', schema: { @@ -45,8 +56,8 @@ module Serializer 'documentable' => PolymorphicManyToOneSchema.new( foreign_key: 'documentable_id', foreign_key_type_field: 'documentable_type', - foreign_collections: %w[Car User], - foreign_key_targets: { 'Car' => 'id', 'User' => 'id' } + foreign_collections: %w[Car User Admin__User], + foreign_key_targets: { 'Car' => 'id', 'User' => 'id', 'Admin__User' => 'reference' } ) } } @@ -55,6 +66,7 @@ module Serializer allow(ForestAdminAgent::Builder::AgentFactory.instance).to receive(:send_schema).and_return(nil) datasource.add_collection(car_collection) datasource.add_collection(user_collection) + datasource.add_collection(admin_user_collection) datasource.add_collection(document_collection) ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(datasource) ForestAdminAgent::Builder::AgentFactory.instance.build @@ -77,6 +89,74 @@ module Serializer expect(relationship['data']['id']).to eq('10') end + it 'builds the linkage id from the foreign key when the related record is a phantom (issue #332)' do + # The AR datasource skips polymorphic relations while building the SELECT, so the related + # object comes back without a primary key. The id must still be built from the owner FK. + record = { + 'id' => 1, + 'title' => 'Registration Certificate', + 'documentable_id' => 10, + 'documentable_type' => 'Car', + 'documentable' => { '*' => nil } + } + + result = JSONAPI::Serializer.serialize(record, class_name: 'Document', serializer: described_class) + + relationship = result['data']['relationships']['documentable'] + expect(relationship['data']['type']).to eq('Car') + expect(relationship['data']['id']).to eq('10') + end + + it 'builds the included document id and self-link from the foreign key (issue #332)' do + record = { + 'id' => 1, + 'title' => 'Registration Certificate', + 'documentable_id' => 10, + 'documentable_type' => 'Car', + 'documentable' => { '*' => nil } + } + + result = JSONAPI::Serializer.serialize( + record, class_name: 'Document', serializer: described_class, include: 'documentable' + ) + + included = result['included'].find { |r| r['type'] == 'Car' } + expect(included['id']).to eq('10') + expect(included['links']['self']).to eq('/forest/Car/10') + end + + it 'builds the linkage id for a namespaced target with a custom primary key (issue #332)' do + # foreign_key_targets is keyed by the formatted name (Admin__User), but the type column + # stores the raw class name (Admin::User); the lookup must reconcile the two. + record = { + 'id' => 4, + 'title' => 'Audit Log', + 'documentable_id' => 'ref-42', + 'documentable_type' => 'Admin::User', + 'documentable' => { '*' => nil } + } + + result = JSONAPI::Serializer.serialize(record, class_name: 'Document', serializer: described_class) + + relationship = result['data']['relationships']['documentable'] + expect(relationship['data']['type']).to eq('Admin__User') + expect(relationship['data']['id']).to eq('ref-42') + end + + it 'omits the data key for an unlinked polymorphic relation (issue #332)' do + record = { + 'id' => 3, + 'title' => 'Orphan', + 'documentable_id' => nil, + 'documentable_type' => nil, + 'documentable' => { '*' => nil } + } + + result = JSONAPI::Serializer.serialize(record, class_name: 'Document', serializer: described_class) + + expect(result['data']['relationships']['documentable']).not_to have_key('data') + end + it 'serializes a polymorphic belongs_to relation pointing to User' do record = { 'id' => 2,