Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions lib/lutaml/xml/transformation/element_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions spec/lutaml/xml/cross_class_dispatch_spec.rb
Original file line number Diff line number Diff line change
@@ -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("<different-name>round-trip</different-name>")
end
end
Loading