diff --git a/lib/lutaml/xml/transformation/element_builder.rb b/lib/lutaml/xml/transformation/element_builder.rb index 8373ef20..064b7929 100644 --- a/lib/lutaml/xml/transformation/element_builder.rb +++ b/lib/lutaml/xml/transformation/element_builder.rb @@ -140,13 +140,21 @@ def create_nested_model_element(rule, value, options, register, actual_class = resolve_polymorphic_class(rule, value, is_polymorphic, is_polymorphic_subtype) + # Dispatch on value's actual class when it is a Serializable that + # differs from the declared attribute_type. This covers ad-hoc + # polymorphism (caller assigns an unrelated Serializable to a typed + # slot) and prevents the wrong transformation being applied + # silently. Subsumes is_polymorphic_subtype; preserved here for + # the resolve_polymorphic_class call above. + dispatch_on_value_class = union || is_polymorphic || + (value.is_a?(Lutaml::Model::Serialize) && + value.class != rule.attribute_type) + # Get transformation for the actual class. Unions resolve the member # from the value's own class, like polymorphism. - child_transformation = if union - value.class.transformation_for(:xml, register) - elsif is_polymorphic || is_polymorphic_subtype - actual_class.transformation_for(:xml, - register) + child_transformation = if dispatch_on_value_class + value.class.transformation_for(:xml, + register) else rule.child_transformation || actual_class.transformation_for( :xml, register diff --git a/spec/lutaml/xml/cross_class_dispatch_spec.rb b/spec/lutaml/xml/cross_class_dispatch_spec.rb new file mode 100644 index 00000000..2336bd2e --- /dev/null +++ b/spec/lutaml/xml/cross_class_dispatch_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_string: true + +require "spec_helper" + +# When a caller assigns an unrelated Lutaml::Model::Serializable (not a +# declared subtype) to a typed slot, serialisation must use the value's own +# class mapping rules. The prior behaviour reused the declared +# attribute_type's child_transformation regardless of value.class, which +# silently applied the wrong rules and raised NoMethodError on whichever +# Ruby attribute name differed. +# +# See BUGREPORT.element-builder-dispatch-on-value-class.md for the original +# analysis and sts-ruby PR #47 for the downstream trigger. +RSpec.describe "ElementBuilder cross-class dispatch" do + let(:declared_type) do + Class.new(Lutaml::Model::Serializable) do + attribute :name, :string + + xml do + root "declared" + map_element "name", to: :name + end + end + end + + let(:actual_type) do + Class.new(Lutaml::Model::Serializable) do + attribute :different_name, :string + + xml do + root "actual" + map_element "different-name", to: :different_name + end + end + end + + let(:holder_class) do + declared = declared_type + Class.new(Lutaml::Model::Serializable) do + attribute :child, declared + + xml do + root "holder" + map_element "child", to: :child + end + end + end + + it "serialises an unrelated Serializable using its own mapping" do + holder = holder_class.new + holder.child = actual_type.new(different_name: "x") + xml = holder.to_xml + + parsed = Nokogiri::XML(xml) + expect(parsed.at_css("different-name").content).to eq("x") + end + + it "preserves the declared-type fast path for matching classes" do + holder = holder_class.new + holder.child = declared_type.new(name: "y") + xml = holder.to_xml + + parsed = Nokogiri::XML(xml) + expect(parsed.at_css("name").content).to eq("y") + end + + it "round-trips the XML output (parsing yields declared type by design)" do + # Deserialisation cannot infer the runtime-assigned class without a + # discriminator; it instantiates the declared type. The XML round-trip + # itself must still be lossless. + holder = holder_class.new + holder.child = actual_type.new(different_name: "round-trip") + xml = holder.to_xml + + reparsed = holder_class.from_xml(xml) + expect(reparsed.child).to be_a(declared_type) + expect(xml).to include("round-trip") + end +end