lutaml-model 0.8.5 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-tests.yml +4 -1
  3. data/.rubocop_todo.yml +97 -22
  4. data/docs/_migrations/0-8-0-namespace-restructuring.adoc +90 -0
  5. data/lib/lutaml/model/version.rb +1 -1
  6. data/lib/lutaml/xml/adapter/adapter_helpers.rb +1 -42
  7. data/lib/lutaml/xml/adapter/base_adapter.rb +48 -458
  8. data/lib/lutaml/xml/adapter/namespace_data.rb +0 -17
  9. data/lib/lutaml/xml/adapter/namespace_uri_collector.rb +71 -0
  10. data/lib/lutaml/xml/adapter/nokogiri_adapter.rb +5 -1110
  11. data/lib/lutaml/xml/adapter/oga_adapter.rb +6 -846
  12. data/lib/lutaml/xml/adapter/ox_adapter.rb +7 -884
  13. data/lib/lutaml/xml/adapter/plan_based_builder.rb +929 -0
  14. data/lib/lutaml/xml/adapter/rexml_adapter.rb +10 -864
  15. data/lib/lutaml/xml/adapter/xml_parser.rb +86 -0
  16. data/lib/lutaml/xml/adapter/xml_serializer.rb +291 -0
  17. data/lib/lutaml/xml/adapter.rb +0 -1
  18. data/lib/lutaml/xml/adapter_element.rb +7 -1
  19. data/lib/lutaml/xml/builder/base.rb +0 -1
  20. data/lib/lutaml/xml/data_model.rb +9 -1
  21. data/lib/lutaml/xml/document.rb +3 -1
  22. data/lib/lutaml/xml/element.rb +13 -10
  23. data/lib/lutaml/xml/schema/xsd/schema.rb +8 -5
  24. data/lib/lutaml/xml/serialization/format_conversion.rb +19 -42
  25. data/lib/lutaml/xml/serialization/instance_methods.rb +26 -35
  26. data/lib/lutaml/xml/transformation/custom_method_wrapper.rb +34 -55
  27. data/lib/lutaml/xml/transformation/rule_applier.rb +1 -1
  28. data/lib/lutaml/xml/xml_element.rb +24 -20
  29. data/spec/lutaml/xml/adapter/base_adapter_regression_spec.rb +151 -0
  30. data/spec/lutaml/xml/adapter/order_spec.rb +150 -0
  31. data/spec/lutaml/xml/clear_parse_state_spec.rb +139 -0
  32. data/spec/lutaml/xml/doubly_defined_namespace_spec.rb +0 -2
  33. data/spec/lutaml/xml/schema/compiler_spec.rb +75 -69
  34. data/spec/lutaml/xml/schema/xsd/schema_mapping_spec.rb +20 -0
  35. data/spec/lutaml/xml/schema/xsd/spec_helper.rb +1 -0
  36. data/spec/lutaml/xml/transformation/custom_method_wrapper_spec.rb +213 -14
  37. metadata +9 -3
  38. data/lib/lutaml/xml/adapter/xml_serialization.rb +0 -145
@@ -19,15 +19,24 @@ module Lutaml
19
19
  attr_accessor :element_order, :attribute_order, :schema_location,
20
20
  :encoding, :doctype
21
21
 
22
- # Store pre-collected namespace data for lazy plan building.
23
- # This is a plain Hash (no adapter objects) collected during from_xml.
24
- attr_accessor :pending_namespace_data
25
-
26
22
  # Store root element reference for truly lazy plan building.
27
23
  # Set in :lazy mode during deserialization; consumed on first to_xml.
28
24
  # Released after plan is built to allow GC of the DOM tree.
29
25
  attr_accessor :pending_plan_root_element
30
26
 
27
+ # Clear all internal XML parse state on this instance.
28
+ #
29
+ # Call before re-serializing a previously-parsed instance when the
30
+ # serialization context has changed (e.g. different namespace config,
31
+ # different XML document structure).
32
+ #
33
+ # @return [self]
34
+ def clear_xml_parse_state!
35
+ @import_declaration_plan = nil
36
+ @pending_plan_root_element = nil
37
+ self
38
+ end
39
+
31
40
  # XML namespace metadata for doubly-defined and alias support.
32
41
  # These carry information from deserialization to serialization.
33
42
  # Accessor methods use the @__ prefixed ivars for backward compatibility.
@@ -74,8 +83,8 @@ module Lutaml
74
83
  # Build or return the cached declaration plan.
75
84
  #
76
85
  # When import_declaration_plan: :lazy (default), builds the plan from
77
- # pre-collected namespace data on first call. No-op when no pending
78
- # data exists (:eager already set, :skip, or programmatic creation).
86
+ # the stored element reference on first call. No-op when no pending
87
+ # element exists (:eager already set, :skip, or programmatic creation).
79
88
  #
80
89
  # @return [DeclarationPlan, nil] The plan or nil
81
90
  def import_declaration_plan
@@ -178,8 +187,7 @@ module Lutaml
178
187
 
179
188
  # Extend INTERNAL_ATTRIBUTES with XML-specific ones
180
189
  def pretty_print_instance_variables
181
- xml_internals = %i[@import_declaration_plan @xml_input_namespaces
182
- @pending_namespace_data @pending_plan_root_element
190
+ xml_internals = %i[@import_declaration_plan @pending_plan_root_element
183
191
  @__xml_namespace_prefix
184
192
  @__xml_ns_prefixes @__xml_original_namespace_uri
185
193
  @xml_declaration @raw_schema_location]
@@ -232,11 +240,6 @@ module Lutaml
232
240
  options[:xml_declaration] = @xml_declaration
233
241
  end
234
242
 
235
- # Pass input namespaces for Namespace Preservation
236
- if instance_variable_defined?(:@xml_input_namespaces) && @xml_input_namespaces&.any?
237
- options[:input_namespaces] = @xml_input_namespaces
238
- end
239
-
240
243
  # Pass stored DeclarationPlan for format preservation.
241
244
  if import_declaration_plan
242
245
  options[:stored_xml_declaration_plan] = import_declaration_plan
@@ -253,39 +256,27 @@ module Lutaml
253
256
 
254
257
  private
255
258
 
256
- # Build declaration plan from pre-collected namespace data or stored
257
- # element reference (lazy mode). Called by xml_declaration_plan getter
258
- # on first access.
259
+ # Build declaration plan from stored element reference (lazy mode).
260
+ # Called by import_declaration_plan getter on first access.
259
261
  # @return [DeclarationPlan, nil]
260
262
  def build_pending_declaration_plan
261
- # Truly lazy: build namespace data from stored element reference
262
- if @pending_plan_root_element
263
- element = @pending_plan_root_element
264
- @pending_plan_root_element = nil # Release reference (allows GC of DOM)
265
- ns_data = Lutaml::Xml::ModelTransform.collect_element_namespaces(element)
266
- if ns_data && !ns_data.empty?
267
- xml_mapping = self.class.mappings_for(:xml)
268
- return Lutaml::Xml::DeclarationPlan.from_input_with_locations(ns_data,
269
- xml_mapping)
270
- end
271
- return nil
272
- end
263
+ return nil unless @pending_plan_root_element
273
264
 
274
- # Fallback: use pre-collected namespace data (eager-lazy hybrid)
275
- ns = @pending_namespace_data
276
- return nil unless ns
265
+ element = @pending_plan_root_element
266
+ @pending_plan_root_element = nil
267
+ ns_data = Lutaml::Xml::ModelTransform.collect_element_namespaces(element)
268
+ return nil unless ns_data && !ns_data.empty?
277
269
 
278
- @pending_namespace_data = nil
279
270
  xml_mapping = self.class.mappings_for(:xml)
280
- Lutaml::Xml::DeclarationPlan.from_input_with_locations(ns,
271
+ Lutaml::Xml::DeclarationPlan.from_input_with_locations(ns_data,
281
272
  xml_mapping)
282
273
  end
283
274
 
284
275
  def set_ordering(attrs)
285
- return unless attrs.respond_to?(:item_order)
276
+ return unless attrs.is_a?(Lutaml::Xml::XmlElement)
286
277
 
287
278
  @element_order = attrs.item_order
288
- @attribute_order = attrs.attribute_order if attrs.respond_to?(:attribute_order)
279
+ @attribute_order = attrs.attribute_order
289
280
  end
290
281
 
291
282
  def set_schema_location(attrs)
@@ -13,14 +13,10 @@ module Lutaml
13
13
  # is called with a block, the new element becomes the context for the duration
14
14
  # of the block, allowing nested element creation.
15
15
  class CustomMethodWrapper
16
- # Initialize the wrapper
17
- #
18
16
  # @param parent [XmlDataModel::XmlElement] Parent element to add children to
19
- # @param rule [CompiledRule] The transformation rule
20
- def initialize(parent, rule)
17
+ def initialize(parent)
21
18
  @parent = parent
22
- @rule = rule
23
- @context_stack = [parent] # Stack of context elements for nested creation
19
+ @context_stack = [parent]
24
20
  end
25
21
 
26
22
  # Get the current context element (top of stack)
@@ -70,9 +66,12 @@ module Lutaml
70
66
 
71
67
  if element_or_string.is_a?(String)
72
68
  add_xml_fragment_or_raw_content(parent, element_or_string)
73
- else
74
- # Add as child element
69
+ elsif element_or_string.is_a?(::Lutaml::Xml::DataModel::XmlElement)
75
70
  parent.add_child(element_or_string)
71
+ else
72
+ raise TypeError,
73
+ "add_element expects a String or XmlElement, got " \
74
+ "#{element_or_string.class}. Call .to_xml on the element first."
76
75
  end
77
76
  element_or_string
78
77
  end
@@ -81,7 +80,7 @@ module Lutaml
81
80
  require "moxml" unless defined?(Moxml)
82
81
  fragment_doc = Moxml.new.parse(fragment_string, fragment: true)
83
82
  add_fragment_children_to_parent(fragment_doc, parent)
84
- rescue LoadError, StandardError
83
+ rescue LoadError
85
84
  append_raw_content(parent, fragment_string)
86
85
  end
87
86
 
@@ -135,12 +134,12 @@ module Lutaml
135
134
 
136
135
  # Add text to element (mimics old adapter API)
137
136
  #
138
- # @param element [XmlDataModel::XmlElement, CustomMethodWrapper, nil] Element to add text to
137
+ # @param element [XmlDataModel::XmlElement, CustomMethodWrapper, nil]
138
+ # Element to add text to. When the wrapper itself or nil is passed,
139
+ # text is added to the current context element.
139
140
  # @param text [String] Text content
140
141
  def add_text(element, text)
141
- # Handle case where element is the wrapper itself (for content mapping)
142
- # or when element is nil (add to current context)
143
- target = if element.is_a?(CustomMethodWrapper) || element.nil?
142
+ target = if element == self || element.nil?
144
143
  current_context
145
144
  else
146
145
  element
@@ -169,32 +168,14 @@ module Lutaml
169
168
  # @yield [ElementWrapper] The created element for customization
170
169
  # @return [XmlElement] The created element
171
170
  def create_and_add_element(name, attributes: {})
172
- # Create XmlDataModel element
173
- element = Lutaml::Xml::DataModel::XmlElement.new(name)
174
-
175
- # Add attributes if provided
176
- attributes&.each do |attr_name, attr_value|
177
- attr = Lutaml::Xml::DataModel::XmlAttribute.new(
178
- attr_name.to_s, attr_value.to_s
179
- )
180
- element.add_attribute(attr)
181
- end
182
-
183
- # Add to current context
171
+ element = self.class.build_element(name, attributes)
184
172
  current_context.add_child(element)
185
173
 
186
174
  if block_given?
187
- # Push this element as the new context for nested operations
188
175
  push_context(element)
189
-
190
176
  begin
191
- # Create wrapper for the element
192
- wrapped_element = ElementWrapper.new(element, self)
193
-
194
- # Yield for customization (e.g., adding text, more nested elements)
195
- yield wrapped_element
177
+ yield ElementWrapper.new(element, self)
196
178
  ensure
197
- # Restore previous context
198
179
  pop_context
199
180
  end
200
181
  end
@@ -216,13 +197,7 @@ module Lutaml
216
197
  # @param text [String] Text content
217
198
  # @param cdata [Boolean, Hash] Whether to use CDATA (true or {cdata: true})
218
199
  def add_text(_self, text, cdata: false)
219
- # Handle both cdata: true and cdata: {cdata: true} formats
220
- use_cdata = if cdata.is_a?(Hash)
221
- cdata[:cdata] || false
222
- else
223
- cdata
224
- end
225
-
200
+ use_cdata = cdata.is_a?(Hash) ? cdata[:cdata] || false : cdata
226
201
  @element.text_content = text
227
202
  @element.cdata = use_cdata
228
203
  end
@@ -234,29 +209,33 @@ module Lutaml
234
209
  # @yield [ElementWrapper] The created element for customization
235
210
  # @return [XmlElement] The created element
236
211
  def create_and_add_element(name, attributes: {})
237
- # Create XmlDataModel element
238
- child = Lutaml::Xml::DataModel::XmlElement.new(name)
239
-
240
- # Add attributes if provided
241
- attributes&.each do |attr_name, attr_value|
242
- attr = Lutaml::Xml::DataModel::XmlAttribute.new(
243
- attr_name.to_s, attr_value.to_s
244
- )
245
- child.add_attribute(attr)
246
- end
247
-
248
- # Add to this element
212
+ child = CustomMethodWrapper.build_element(name, attributes)
249
213
  @element.add_child(child)
250
214
 
251
215
  if block_given?
252
- # Wrap the child and yield
253
- wrapped_child = ElementWrapper.new(child, @parent_wrapper)
254
- yield wrapped_child
216
+ yield ElementWrapper.new(child, @parent_wrapper)
255
217
  end
256
218
 
257
219
  child
258
220
  end
259
221
  end
222
+
223
+ # Shared factory: create an XmlElement with optional attributes.
224
+ # Public so ElementWrapper can call it without an instance.
225
+ #
226
+ # @param name [String] Element name
227
+ # @param attributes [Hash] Optional attributes
228
+ # @return [DataModel::XmlElement]
229
+ def self.build_element(name, attributes)
230
+ element = Lutaml::Xml::DataModel::XmlElement.new(name)
231
+ attributes&.each do |attr_name, attr_value|
232
+ attr = Lutaml::Xml::DataModel::XmlAttribute.new(
233
+ attr_name.to_s, attr_value.to_s
234
+ )
235
+ element.add_attribute(attr)
236
+ end
237
+ element
238
+ end
260
239
  end
261
240
  end
262
241
  end
@@ -239,7 +239,7 @@ register_id)
239
239
  # @param model_class [Class] The model class
240
240
  # @param model_instance [Object] The model instance
241
241
  def apply_custom_method(parent, rule, model_class, model_instance)
242
- wrapper = ::Lutaml::Xml::CustomMethodWrapper.new(parent, rule)
242
+ wrapper = ::Lutaml::Xml::CustomMethodWrapper.new(parent)
243
243
  mapper_instance = model_class.new
244
244
  mapper_instance.send(rule.custom_methods[:to], model_instance,
245
245
  parent, wrapper)
@@ -257,33 +257,26 @@ module Lutaml
257
257
  return @order_cache if @order_cache
258
258
 
259
259
  @order_cache = children.filter_map do |child|
260
- if child.text?
260
+ if child.cdata?
261
+ Lutaml::Xml::Element.new("Text", "#cdata-section",
262
+ text_content: child.text,
263
+ node_type: :cdata)
264
+ elsif child.text?
261
265
  next if child.text.nil?
262
266
 
263
- # For text nodes:
264
- # - name is "text" for backward compatibility with tests
265
- # - text_content contains the actual text for round-trip serialization
266
- # - node_type explicitly marks this as a text node
267
267
  Lutaml::Xml::Element.new("Text", "text",
268
268
  text_content: child.text,
269
269
  node_type: :text)
270
- elsif child.cdata?
271
- # For CDATA sections:
272
- # - name is "#cdata-section" for backward compatibility
273
- # - text_content contains the actual CDATA content
274
- # - node_type explicitly marks this as CDATA
275
- Lutaml::Xml::Element.new("Text", "#cdata-section",
276
- text_content: child.text,
277
- node_type: :cdata)
278
270
  elsif child.comment?
279
271
  Lutaml::Xml::Element.new("Comment", "comment",
280
272
  text_content: child.text,
281
273
  node_type: :comment)
274
+ elsif child.processing_instruction?
275
+ Lutaml::Xml::Element.new("ProcessingInstruction",
276
+ child.unprefixed_name,
277
+ text_content: child.text,
278
+ node_type: :processing_instruction)
282
279
  else
283
- # For regular elements:
284
- # - name is the actual element name
285
- # - node_type explicitly marks this as an element
286
- # - namespace_uri and namespace_prefix preserve namespace info for rule matching
287
280
  Lutaml::Xml::Element.new("Element", child.unprefixed_name,
288
281
  node_type: :element,
289
282
  namespace_uri: child.namespace_uri,
@@ -303,14 +296,14 @@ module Lutaml
303
296
 
304
297
  def text
305
298
  return @text if children.empty?
306
- return text_children.map(&:text) if children.count > 1
299
+ return text_children.map(&:text) if content_bearing_children_count > 1
307
300
 
308
301
  text_children.map(&:text).join
309
302
  end
310
303
 
311
304
  def cdata
312
305
  return @text if children.empty?
313
- return cdata_children.map(&:text) if children.count > 1
306
+ return cdata_children.map(&:text) if content_bearing_children_count > 1
314
307
 
315
308
  cdata_children.map(&:text).join
316
309
  end
@@ -330,7 +323,8 @@ module Lutaml
330
323
 
331
324
  @element_children = children.reject do |child|
332
325
  child.is_a?(String) || child.is_a?(Symbol) ||
333
- (child.is_a?(XmlElement) && child.text?)
326
+ (child.is_a?(XmlElement) &&
327
+ (child.text? || child.processing_instruction?))
334
328
  end
335
329
  end
336
330
 
@@ -389,6 +383,8 @@ module Lutaml
389
383
 
390
384
  @children_index = {}
391
385
  @children.each do |child|
386
+ next if child.is_a?(XmlElement) && child.processing_instruction?
387
+
392
388
  key = child.namespaced_name
393
389
  @children_index[key] ||= []
394
390
  @children_index[key] << child
@@ -432,6 +428,14 @@ module Lutaml
432
428
 
433
429
  private
434
430
 
431
+ # Count children that bear content (excludes processing instructions).
432
+ # Used to determine if content is mixed (multiple content nodes).
433
+ def content_bearing_children_count
434
+ children.count do |child|
435
+ !child.is_a?(XmlElement) || !child.processing_instruction?
436
+ end
437
+ end
438
+
435
439
  # Backward compatibility: infer node_type from name
436
440
  # This allows old code that doesn't pass node_type to still work
437
441
  def infer_node_type_from_name(name)
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/model"
5
+ require "lutaml/xml"
6
+ require "lutaml/xml/adapter/nokogiri_adapter"
7
+ require "lutaml/xml/adapter/ox_adapter"
8
+ require "lutaml/xml/adapter/oga_adapter"
9
+ require "lutaml/xml/adapter/rexml_adapter"
10
+
11
+ # Regression tests for BaseAdapter refactoring.
12
+ # Each test guards a specific bug fix to prevent re-introduction.
13
+ # Run against all 4 adapters to ensure consistent behavior.
14
+ RSpec.shared_examples "base adapter regressions" do |adapter_class|
15
+ let(:adapter) { adapter_class }
16
+
17
+ describe "CDATA preservation in mixed content" do
18
+ let(:xml_with_cdata) do
19
+ "<root><![CDATA[Hello]]><b>world</b><![CDATA[!]]></root>"
20
+ end
21
+
22
+ it "preserves CDATA wrapping for mixed content string nodes" do
23
+ doc = adapter.parse(xml_with_cdata)
24
+ output = doc.to_xml
25
+
26
+ expect(output).to include("<![CDATA[Hello]]>")
27
+ expect(output).to include("<![CDATA[!]]>")
28
+ end
29
+ end
30
+
31
+ describe "XML comment preservation in serialization" do
32
+ let(:xml_with_comment) do
33
+ "<root><a/><!--middle comment--><b/></root>"
34
+ end
35
+
36
+ it "preserves comments during round-trip" do
37
+ doc = adapter.parse(xml_with_comment)
38
+ output = doc.to_xml
39
+
40
+ expect(output).to include("<!--middle comment-->")
41
+ end
42
+ end
43
+
44
+ describe "processing instructions do not affect text shape" do
45
+ let(:xml_with_pi_and_text) do
46
+ "<root>hello<?pi data?></root>"
47
+ end
48
+
49
+ let(:xml_with_only_pi) do
50
+ "<root><?pi data?></root>"
51
+ end
52
+
53
+ it "returns text as a string when PI is alongside text" do
54
+ doc = adapter.parse(xml_with_pi_and_text)
55
+
56
+ # text should be a string, not an array —
57
+ # PIs should not make plain text look like mixed content
58
+ expect(doc.text).to be_a(String)
59
+ expect(doc.text).to eq("hello")
60
+ end
61
+
62
+ it "returns empty string for text when element has only PI" do
63
+ doc = adapter.parse(xml_with_only_pi)
64
+
65
+ expect(doc.text).to be_a(String)
66
+ expect(doc.text).to eq("")
67
+ end
68
+ end
69
+
70
+ describe "processing instructions excluded from parse_element" do
71
+ let(:xml_with_pi) do
72
+ "<root><?foo ignored?><bar>content</bar></root>"
73
+ end
74
+
75
+ it "does not include PI in elements hash" do
76
+ doc = adapter.parse(xml_with_pi)
77
+ hash = doc.to_h
78
+
79
+ elements = hash["elements"]
80
+ expect(elements).to have_key("bar")
81
+ # PI should not appear as an element key
82
+ expect(elements.keys).not_to include("foo")
83
+ end
84
+ end
85
+
86
+ describe "ASCII-8BIT encoding round-trip" do
87
+ let(:binary_xml) { "<root>\xC2\xB5</root>".b }
88
+
89
+ it "round-trips binary-tagged UTF-8 content without encoding error" do
90
+ doc = adapter.parse(binary_xml)
91
+
92
+ expect { doc.to_xml }.not_to raise_error
93
+ expect(doc.to_xml).to include("\u00B5") # micro sign
94
+ end
95
+
96
+ it "does not use ASCII-8BIT as output encoding" do
97
+ doc = adapter.parse(binary_xml)
98
+ output = doc.to_xml
99
+
100
+ expect(output.encoding).not_to eq(Encoding::ASCII_8BIT)
101
+ end
102
+ end
103
+
104
+ describe "namespaced_attr_name helper arity" do
105
+ it "resolves single-arg namespaced_attr_name from AdapterHelpers" do
106
+ # This verifies the AdapterHelpers version (single Moxml attribute arg)
107
+ # is accessible and not shadowed by a 2-arg def self. method
108
+ expect(adapter).to respond_to(:namespaced_attr_name)
109
+
110
+ # The method should accept 1 argument (a Moxml attribute object)
111
+ method = adapter.method(:namespaced_attr_name)
112
+ expect(method.arity).to eq(1)
113
+ end
114
+ end
115
+
116
+ describe "XmlParser helper methods are private" do
117
+ it "does not expose normalize_xml_for_parse as a public class method" do
118
+ expect(adapter.public_methods).not_to include(:normalize_xml_for_parse)
119
+ end
120
+
121
+ it "does not expose parse_with_moxml as a public class method" do
122
+ expect(adapter.public_methods).not_to include(:parse_with_moxml)
123
+ end
124
+
125
+ it "does not expose raise_empty_document_error as a public class method" do
126
+ expect(adapter.public_methods).not_to include(:raise_empty_document_error)
127
+ end
128
+
129
+ it "exposes parse as a public class method" do
130
+ expect(adapter.public_methods).to include(:parse)
131
+ end
132
+ end
133
+ end
134
+
135
+ RSpec.describe Lutaml::Xml::Adapter::BaseAdapter do
136
+ context "with NokogiriAdapter" do
137
+ it_behaves_like "base adapter regressions", Lutaml::Xml::Adapter::NokogiriAdapter
138
+ end
139
+
140
+ context "with OxAdapter" do
141
+ it_behaves_like "base adapter regressions", Lutaml::Xml::Adapter::OxAdapter
142
+ end
143
+
144
+ context "with OgaAdapter" do
145
+ it_behaves_like "base adapter regressions", Lutaml::Xml::Adapter::OgaAdapter
146
+ end
147
+
148
+ context "with RexmlAdapter" do
149
+ it_behaves_like "base adapter regressions", Lutaml::Xml::Adapter::RexmlAdapter
150
+ end
151
+ end
@@ -0,0 +1,150 @@
1
+ require "spec_helper"
2
+ require "lutaml/model"
3
+ require "lutaml/xml"
4
+ require "lutaml/xml/adapter/nokogiri_adapter"
5
+ require "lutaml/xml/adapter/ox_adapter"
6
+ require "lutaml/xml/adapter/oga_adapter"
7
+ require "lutaml/xml/adapter/rexml_adapter"
8
+
9
+ module XmlAdapterSharedFeaturesSpec
10
+ class ProcessingInstructionLookupElement < Lutaml::Model::Serializable
11
+ attribute :foo, :string
12
+
13
+ xml do
14
+ root "root"
15
+ map_element "foo", to: :foo
16
+ end
17
+ end
18
+ end
19
+
20
+ RSpec.describe "XML adapter order metadata" do
21
+ shared_examples "consistent order metadata" do |adapter_class|
22
+ let(:xml) do
23
+ "<root>before<![CDATA[cdata text]]><?pi data?><child/>after</root>"
24
+ end
25
+
26
+ let(:document) { adapter_class.parse(xml) }
27
+
28
+ let(:expected_order) do
29
+ [
30
+ ["Text", "text", :text, "before"],
31
+ ["Text", "#cdata-section", :cdata, "cdata text"],
32
+ ["ProcessingInstruction", "pi", :processing_instruction, "data"],
33
+ ["Element", "child", :element, nil],
34
+ ["Text", "text", :text, "after"],
35
+ ]
36
+ end
37
+
38
+ def order_data(order)
39
+ order.map do |item|
40
+ [item.type, item.name, item.node_type, item.text_content]
41
+ end
42
+ end
43
+
44
+ it "uses the same order representation for root, document, and class order" do
45
+ expect(order_data(document.root.order)).to eq(expected_order)
46
+ expect(order_data(document.order)).to eq(expected_order)
47
+ expect(order_data(adapter_class.order_of(document.root))).to eq(expected_order)
48
+ end
49
+
50
+ it "parses processing instructions as first-class XML nodes" do
51
+ instruction = document.root.children.find(&:processing_instruction?)
52
+
53
+ expect(instruction).not_to be_nil
54
+ expect(instruction.name).to eq("pi")
55
+ expect(instruction.text).to eq("data")
56
+ expect(instruction.node_type).to eq(:processing_instruction)
57
+ end
58
+
59
+ it "preserves processing instructions and CDATA when serializing parsed XML" do
60
+ output = document.to_xml
61
+
62
+ expect(output).to include("<?pi data?>")
63
+ expect(output).to include("<![CDATA[cdata text]]>")
64
+ end
65
+
66
+ it "uses the same order representation through to_h item_order" do
67
+ parsed_hash = nil
68
+
69
+ expect { parsed_hash = document.to_h }.not_to raise_error
70
+ expect(order_data(parsed_hash.item_order)).to eq(expected_order)
71
+ end
72
+ end
73
+
74
+ shared_examples "consistent shared adapter features" do |adapter_class|
75
+ around do |example|
76
+ old_adapter = Lutaml::Model::Config.xml_adapter
77
+ Lutaml::Model::Config.xml_adapter = adapter_class
78
+ example.run
79
+ ensure
80
+ Lutaml::Model::Config.xml_adapter = old_adapter
81
+ end
82
+
83
+ it "uses shared parse metadata preservation" do
84
+ xml = <<~XML
85
+ <?xml version="1.0"?>
86
+ <!DOCTYPE root SYSTEM "root.dtd">
87
+ <root><child/></root>
88
+ XML
89
+
90
+ document = adapter_class.parse(xml)
91
+
92
+ expect(document.xml_declaration[:had_declaration]).to be true
93
+ expect(document.doctype).to include(name: "root",
94
+ public_id: nil,
95
+ system_id: "root.dtd")
96
+ expect(document.to_xml(declaration: true)).to include(
97
+ '<!DOCTYPE root SYSTEM "root.dtd">',
98
+ )
99
+ end
100
+
101
+ it "parses binary UTF-8 XML input through shared normalization" do
102
+ document = adapter_class.parse("<root><name>μ</name></root>".b)
103
+
104
+ expect(document.root.name).to eq("root")
105
+ expect(document.root.element_children.first.text).to eq("μ")
106
+ end
107
+
108
+ it "transcodes non-UTF-8 strings from their declared encoding" do
109
+ xml = "<root><name>\xC2\xA3</name></root>".b
110
+ xml.force_encoding("ISO-8859-1")
111
+ document = adapter_class.parse(xml)
112
+
113
+ expect(document.root.element_children.first.text).to eq("£")
114
+ end
115
+
116
+ it "preserves processing instructions without matching them as elements" do
117
+ xml = "<root><?foo ignored?><foo>bar</foo></root>"
118
+ document = adapter_class.parse(xml)
119
+
120
+ expect(document.root.children.any?(&:processing_instruction?)).to be true
121
+ expect(document.root.element_children.map(&:text)).to eq(["bar"])
122
+ expect(document.root.find_children_by_name("foo").map(&:text)).to eq(["bar"])
123
+ expect(
124
+ XmlAdapterSharedFeaturesSpec::ProcessingInstructionLookupElement
125
+ .from_xml(xml)
126
+ .foo,
127
+ ).to eq("bar")
128
+ end
129
+ end
130
+
131
+ describe Lutaml::Xml::Adapter::NokogiriAdapter do
132
+ it_behaves_like "consistent order metadata", described_class
133
+ it_behaves_like "consistent shared adapter features", described_class
134
+ end
135
+
136
+ describe Lutaml::Xml::Adapter::OxAdapter do
137
+ it_behaves_like "consistent order metadata", described_class
138
+ it_behaves_like "consistent shared adapter features", described_class
139
+ end
140
+
141
+ describe Lutaml::Xml::Adapter::OgaAdapter do
142
+ it_behaves_like "consistent order metadata", described_class
143
+ it_behaves_like "consistent shared adapter features", described_class
144
+ end
145
+
146
+ describe Lutaml::Xml::Adapter::RexmlAdapter do
147
+ it_behaves_like "consistent order metadata", described_class
148
+ it_behaves_like "consistent shared adapter features", described_class
149
+ end
150
+ end