lutaml-model 0.8.2 → 0.8.4

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +23 -23
  3. data/README.adoc +213 -1
  4. data/docs/_guides/document-validation.adoc +303 -0
  5. data/docs/_guides/index.adoc +1 -0
  6. data/docs/_guides/xml-mapping.adoc +9 -1
  7. data/docs/_guides/xml_mappings/07_best_practices.adoc +36 -0
  8. data/docs/_guides/xml_mappings/08_troubleshooting.adoc +89 -0
  9. data/docs/_tutorials/lutaml-xml-architecture.adoc +6 -1
  10. data/lib/lutaml/model/attribute.rb +19 -1
  11. data/lib/lutaml/model/error/liquid_drop_already_registered_error.rb +11 -0
  12. data/lib/lutaml/model/error/ordered_content_mapping_error.rb +17 -0
  13. data/lib/lutaml/model/global_context.rb +1 -0
  14. data/lib/lutaml/model/liquefiable.rb +12 -15
  15. data/lib/lutaml/model/mapping/mapping_rule.rb +10 -2
  16. data/lib/lutaml/model/mapping_hash.rb +1 -1
  17. data/lib/lutaml/model/services/transformer.rb +67 -32
  18. data/lib/lutaml/model/transform.rb +41 -4
  19. data/lib/lutaml/model/uninitialized_class.rb +11 -5
  20. data/lib/lutaml/model/validation/concerns/has_issues.rb +27 -0
  21. data/lib/lutaml/model/validation/context.rb +36 -0
  22. data/lib/lutaml/model/validation/issue.rb +62 -0
  23. data/lib/lutaml/model/validation/layer_result.rb +34 -0
  24. data/lib/lutaml/model/validation/profile.rb +66 -0
  25. data/lib/lutaml/model/validation/registry.rb +60 -0
  26. data/lib/lutaml/model/validation/remediation.rb +33 -0
  27. data/lib/lutaml/model/validation/remediation_result.rb +20 -0
  28. data/lib/lutaml/model/validation/report.rb +39 -0
  29. data/lib/lutaml/model/validation/rule.rb +59 -0
  30. data/lib/lutaml/model/validation.rb +2 -1
  31. data/lib/lutaml/model/validation_framework.rb +77 -0
  32. data/lib/lutaml/model/version.rb +1 -1
  33. data/lib/lutaml/model.rb +4 -0
  34. data/lib/lutaml/xml/adapter/nokogiri_adapter.rb +9 -2
  35. data/lib/lutaml/xml/adapter/oga_adapter.rb +11 -3
  36. data/lib/lutaml/xml/adapter/ox_adapter.rb +5 -2
  37. data/lib/lutaml/xml/adapter/rexml_adapter.rb +10 -3
  38. data/lib/lutaml/xml/adapter_element.rb +26 -2
  39. data/lib/lutaml/xml/data_model.rb +14 -0
  40. data/lib/lutaml/xml/document.rb +3 -0
  41. data/lib/lutaml/xml/element.rb +8 -2
  42. data/lib/lutaml/xml/mapping.rb +9 -0
  43. data/lib/lutaml/xml/model_transform.rb +42 -0
  44. data/lib/lutaml/xml/schema/xsd/base.rb +4 -1
  45. data/lib/lutaml/xml/schema/xsd/schema_path.rb +6 -0
  46. data/lib/lutaml/xml/serialization/instance_methods.rb +3 -1
  47. data/lib/lutaml/xml/transformation/ordered_applier.rb +46 -2
  48. data/lib/lutaml/xml/transformation.rb +40 -1
  49. data/lib/lutaml/xml/xml_element.rb +8 -7
  50. data/lutaml-model.gemspec +1 -2
  51. data/spec/lutaml/model/attribute_default_cache_spec.rb +58 -0
  52. data/spec/lutaml/model/liquefiable_spec.rb +22 -6
  53. data/spec/lutaml/model/liquid_compatibility_spec.rb +442 -0
  54. data/spec/lutaml/model/ordered_content_spec.rb +5 -5
  55. data/spec/lutaml/model/services/transformer_spec.rb +43 -0
  56. data/spec/lutaml/model/transform_cache_spec.rb +62 -0
  57. data/spec/lutaml/model/transform_dynamic_attributes_spec.rb +41 -0
  58. data/spec/lutaml/model/uninitialized_class_deep_dup_spec.rb +39 -0
  59. data/spec/lutaml/model/uninitialized_class_spec.rb +14 -2
  60. data/spec/lutaml/model/validation/concerns/has_issues_spec.rb +76 -0
  61. data/spec/lutaml/model/validation/context_spec.rb +60 -0
  62. data/spec/lutaml/model/validation/issue_spec.rb +77 -0
  63. data/spec/lutaml/model/validation/layer_result_spec.rb +66 -0
  64. data/spec/lutaml/model/validation/profile_spec.rb +134 -0
  65. data/spec/lutaml/model/validation/registry_spec.rb +94 -0
  66. data/spec/lutaml/model/validation/remediation_result_spec.rb +23 -0
  67. data/spec/lutaml/model/validation/remediation_spec.rb +72 -0
  68. data/spec/lutaml/model/validation/report_spec.rb +58 -0
  69. data/spec/lutaml/model/validation/rule_spec.rb +134 -0
  70. data/spec/lutaml/model/validation/uninitialized_class_validate_spec.rb +29 -0
  71. data/spec/lutaml/model/validation/validation_error_spec.rb +29 -0
  72. data/spec/lutaml/model/validation/validation_framework_spec.rb +110 -0
  73. data/spec/lutaml/xml/content_model_validation_spec.rb +157 -0
  74. data/spec/lutaml/xml/mapping_spec.rb +12 -7
  75. data/spec/lutaml/xml/schema/xsd/glob_spec.rb +12 -0
  76. metadata +46 -21
  77. data/spec/fixtures/liquid_templates/_ceramics.liquid +0 -3
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "validation/issue"
4
+ require_relative "validation/layer_result"
5
+ require_relative "validation/report"
6
+ require_relative "validation/context"
7
+ require_relative "validation/rule"
8
+ require_relative "validation/registry"
9
+ require_relative "validation/profile"
10
+ require_relative "validation/remediation"
11
+ require_relative "validation/remediation_result"
12
+
13
+ module Lutaml
14
+ module Model
15
+ # Document-level validation framework. Orthogonal to the existing
16
+ # attribute-level Validation module — this validates structural
17
+ # integrity, cross-references, and conformance against domain rules.
18
+ #
19
+ # @example Run all registered rules
20
+ # issues = Lutaml::Model::Validation.validate(context, registry)
21
+ # @example Run and raise on errors
22
+ # Lutaml::Model::Validation.validate!(context, registry)
23
+ module Validation
24
+ class << self
25
+ def new_registry
26
+ Registry.new
27
+ end
28
+
29
+ def validate(context, registry, profile: nil)
30
+ rules = if profile
31
+ profile.resolve(registry)
32
+ else
33
+ registry.all
34
+ end
35
+
36
+ all_issues = []
37
+ rules.each do |rule|
38
+ next unless rule.applicable?(context)
39
+
40
+ issues = rule.check(context)
41
+ if context.respond_to?(:add_error)
42
+ issues.each { |i| context.add_error(i) }
43
+ end
44
+ all_issues.concat(issues)
45
+ end
46
+
47
+ all_issues
48
+ end
49
+
50
+ def validate!(context, registry, profile: nil)
51
+ issues = validate(context, registry, profile: profile)
52
+ return if issues.empty?
53
+
54
+ errors = issues.select(&:error?)
55
+ unless errors.empty?
56
+ raise ValidationError.new(format_errors(errors), issues: errors)
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def format_errors(errors)
63
+ errors.map { |e| "[#{e.code}] #{e.message}" }.join("\n")
64
+ end
65
+ end
66
+
67
+ class ValidationError < StandardError
68
+ attr_reader :issues
69
+
70
+ def initialize(message, issues: [])
71
+ super(message)
72
+ @issues = issues
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.2"
5
+ VERSION = "0.8.4"
6
6
  end
7
7
  end
data/lib/lutaml/model.rb CHANGED
@@ -115,6 +115,8 @@ module Lutaml
115
115
  "#{__dir__}/model/error/liquid_not_enabled_error"
116
116
  autoload :LiquidClassNotFoundError,
117
117
  "#{__dir__}/model/error/liquid_class_not_found_error"
118
+ autoload :LiquidDropAlreadyRegisteredError,
119
+ "#{__dir__}/model/error/liquid_drop_already_registered_error"
118
120
  autoload :NoAttributesDefinedLiquidError,
119
121
  "#{__dir__}/model/error/no_attributes_defined_liquid_error"
120
122
  autoload :IncorrectMappingArgumentsError,
@@ -184,6 +186,8 @@ module Lutaml
184
186
  "#{__dir__}/model/error/unresolvable_type_error"
185
187
  autoload :MixedContentCollectionError,
186
188
  "#{__dir__}/model/error/mixed_content_collection_error"
189
+ autoload :OrderedContentMappingError,
190
+ "#{__dir__}/model/error/ordered_content_mapping_error"
187
191
 
188
192
  # Error for passing incorrect model type
189
193
  #
@@ -191,9 +191,13 @@ module Lutaml
191
191
  def order
192
192
  children.filter_map do |child|
193
193
  if child.text?
194
- next if child.text.nil? || child.text.strip.empty?
194
+ next if child.text.nil?
195
195
 
196
196
  Element.new("Text", "text", text_content: child.text)
197
+ elsif child.comment?
198
+ Element.new("Comment", "comment",
199
+ text_content: child.content,
200
+ node_type: :comment)
197
201
  else
198
202
  Element.new("Element", child.unprefixed_name)
199
203
  end
@@ -967,7 +971,7 @@ module Lutaml
967
971
  # Pass THIS element as parent so children can inherit namespaces
968
972
  child_element_index = 0
969
973
  previous_sibling_had_xmlns_blank = false
970
- xml_element.children.each do |xml_child|
974
+ xml_element.children.each do |xml_child| # rubocop:disable Metrics/BlockLength
971
975
  # Entity reference nodes are preserved via the marker
972
976
  # preprocessing approach in NokogiriAdapter.parse.
973
977
  if xml_child.is_a?(Lutaml::Xml::NokogiriElement) &&
@@ -993,6 +997,9 @@ module Lutaml
993
997
  elsif xml_child.is_a?(String)
994
998
  add_content_node(element, xml_child, doc,
995
999
  cdata: xml_element.cdata && !xml_child.strip.empty?)
1000
+ elsif xml_child.is_a?(::Lutaml::Xml::DataModel::XmlComment)
1001
+ comment_node = doc.create_comment(xml_child.content)
1002
+ element.add_child(comment_node)
996
1003
  end
997
1004
  end
998
1005
 
@@ -549,7 +549,8 @@ module Lutaml
549
549
  # 6. Recursively build children by INDEX (PARALLEL TRAVERSAL)
550
550
  child_element_index = 0
551
551
  xml_element.children.each do |xml_child|
552
- if xml_child.is_a?(Lutaml::Xml::DataModel::XmlElement)
552
+ case xml_child
553
+ when Lutaml::Xml::DataModel::XmlElement
553
554
  child_node = element_node.element_nodes[child_element_index]
554
555
  child_element_index += 1
555
556
 
@@ -557,9 +558,12 @@ module Lutaml
557
558
  global_registry, moxml_doc,
558
559
  element, plan: plan)
559
560
  element.add_child(child_element)
560
- elsif xml_child.is_a?(String)
561
+ when String
561
562
  add_content_node(element, xml_child, moxml_doc,
562
563
  cdata: xml_element.cdata && !xml_child.strip.empty?)
564
+ when ::Lutaml::Xml::DataModel::XmlComment
565
+ comment_node = moxml_doc.create_comment(xml_child.content)
566
+ element.add_child(comment_node)
563
567
  end
564
568
  end
565
569
 
@@ -833,9 +837,13 @@ module Lutaml
833
837
  def order
834
838
  children.filter_map do |child|
835
839
  if child.text?
836
- next if child.text.nil? || child.text.strip.empty?
840
+ next if child.text.nil?
837
841
 
838
842
  Element.new("Text", "text", text_content: child.text)
843
+ elsif child.comment?
844
+ Element.new("Comment", "comment",
845
+ text_content: child.content,
846
+ node_type: :comment)
839
847
  else
840
848
  Element.new("Element", child.unprefixed_name)
841
849
  end
@@ -220,14 +220,17 @@ plan: nil)
220
220
  # 8. Recursively build children by INDEX (PARALLEL TRAVERSAL)
221
221
  child_element_index = 0
222
222
  xml_element.children.each do |xml_child|
223
- if xml_child.is_a?(Lutaml::Xml::DataModel::XmlElement)
223
+ case xml_child
224
+ when Lutaml::Xml::DataModel::XmlElement
224
225
  child_node = element_node.element_nodes[child_element_index]
225
226
  child_element_index += 1
226
227
 
227
228
  build_ox_node(xml, xml_child, child_node, global_registry,
228
229
  plan: plan)
229
- elsif xml_child.is_a?(String)
230
+ when String
230
231
  xml.text(xml_child)
232
+ when ::Lutaml::Xml::DataModel::XmlComment
233
+ xml.comment(xml_child.content)
231
234
  end
232
235
  end
233
236
  end
@@ -203,14 +203,17 @@ global_registry, plan)
203
203
  # 9. Recursively build children by INDEX (PARALLEL TRAVERSAL)
204
204
  child_element_index = 0
205
205
  xml_element.children.each do |xml_child|
206
- if xml_child.is_a?(Lutaml::Xml::DataModel::XmlElement)
206
+ case xml_child
207
+ when Lutaml::Xml::DataModel::XmlElement
207
208
  child_node = element_node.element_nodes[child_element_index]
208
209
  child_element_index += 1
209
210
 
210
211
  build_rexml_element(inner_xml, xml_child, child_node,
211
212
  global_registry, plan)
212
- elsif xml_child.is_a?(String)
213
+ when String
213
214
  inner_xml.text(xml_child)
215
+ when ::Lutaml::Xml::DataModel::XmlComment
216
+ inner_xml.comment(xml_child.content)
214
217
  end
215
218
  end
216
219
  end
@@ -246,9 +249,13 @@ global_registry, plan)
246
249
  def order
247
250
  children.filter_map do |child|
248
251
  if child.text?
249
- next if child.text.nil? || child.text.strip.empty?
252
+ next if child.text.nil?
250
253
 
251
254
  Element.new("Text", child.unprefixed_name)
255
+ elsif child.comment?
256
+ Element.new("Comment", "comment",
257
+ text_content: child.content,
258
+ node_type: :comment)
252
259
  else
253
260
  Element.new("Element", child.unprefixed_name)
254
261
  end
@@ -41,7 +41,7 @@ module Lutaml
41
41
 
42
42
  children = parse_children(node,
43
43
  default_namespace: default_namespace)
44
- attributes = node_attributes(node)
44
+ attributes, attr_order = node_attributes_with_order(node)
45
45
  @root = node
46
46
  EncodingNormalizer.normalize_to_utf8(node.inner_text)
47
47
  when Moxml::Text
@@ -63,7 +63,8 @@ module Lutaml
63
63
  namespace_prefix: namespace_name,
64
64
  default_namespace: default_namespace,
65
65
  explicit_no_namespace: explicit_no_namespace || false,
66
- node_type: node_type
66
+ node_type: node_type,
67
+ attribute_order: attr_order
67
68
  )
68
69
  end
69
70
 
@@ -134,6 +135,29 @@ module Lutaml
134
135
  end
135
136
  end
136
137
 
138
+ def node_attributes_with_order(node)
139
+ return [{}, nil] unless node.is_a?(Moxml::Element)
140
+
141
+ order = []
142
+ hash = node.attributes.each_with_object({}) do |attr, h|
143
+ next if attr_is_namespace?(attr)
144
+
145
+ ns_prefix = attr.namespace&.prefix
146
+ ns_prefix = nil if ns_prefix&.empty?
147
+
148
+ attr_name = ns_prefix ? "#{ns_prefix}:#{attr.name}" : attr.name
149
+ order << attr_name
150
+
151
+ h[attr_name] = XmlAttribute.new(
152
+ attr_name,
153
+ attribute_value_for_build(attr),
154
+ namespace: ns_prefix ? attr.namespace&.uri : nil,
155
+ namespace_prefix: ns_prefix,
156
+ )
157
+ end
158
+ [hash, order.empty? ? nil : order]
159
+ end
160
+
137
161
  def attribute_value_for_build(attr)
138
162
  attr.value
139
163
  end
@@ -252,6 +252,20 @@ module Lutaml
252
252
  result
253
253
  end
254
254
  end
255
+
256
+ # Represents an XML comment in the data model tree.
257
+ # Stored as a child of XmlElement alongside String (text) and XmlElement children.
258
+ class XmlComment
259
+ attr_accessor :content
260
+
261
+ def initialize(content)
262
+ @content = content
263
+ end
264
+
265
+ def to_s
266
+ "<!--#{content}-->"
267
+ end
268
+ end
255
269
  end
256
270
  end
257
271
  end
@@ -94,8 +94,11 @@ module Lutaml
94
94
  result = Lutaml::Model::MappingHash.new
95
95
  result.node = element
96
96
  result.item_order = self.class.order_of(element)
97
+ result.attribute_order = element.attribute_order
97
98
 
98
99
  element.children.each do |child|
100
+ next if child.respond_to?(:comment?) && child.comment?
101
+
99
102
  if klass&.<= Serialize
100
103
  attr = klass.attribute_for_child(self.class.name_of(child),
101
104
  format)
@@ -38,13 +38,18 @@ module Lutaml
38
38
  @node_type == :cdata
39
39
  end
40
40
 
41
+ # Check if this is a comment node
42
+ def comment?
43
+ @node_type == :comment
44
+ end
45
+
41
46
  # Check if this is a regular element
42
47
  def element?
43
48
  @node_type == :element
44
49
  end
45
50
 
46
51
  def element_tag
47
- @name unless text? || cdata?
52
+ @name unless text? || cdata? || comment?
48
53
  end
49
54
 
50
55
  def eql?(other)
@@ -71,12 +76,13 @@ module Lutaml
71
76
  def infer_node_type(type, name)
72
77
  return :text if type == "Text" && name != "#cdata-section"
73
78
  return :cdata if name == "#cdata-section" || (type == "Text" && name == "#cdata-section")
79
+ return :comment if type == "Comment"
74
80
 
75
81
  :element
76
82
  end
77
83
 
78
84
  def register_liquid_methods
79
- %i[text? element_tag type name text_content node_type
85
+ %i[text? comment? element_tag type name text_content node_type
80
86
  cdata? namespace_uri namespace_prefix].each do |attr_name|
81
87
  self.class.register_drop_method(attr_name)
82
88
  end
@@ -101,6 +101,9 @@ module Lutaml
101
101
  # Validate mixed content requires collection attribute for content mapping
102
102
  validate_mixed_content_collection!(mapper_class)
103
103
 
104
+ # Validate element-only content models do not use map_content
105
+ validate_ordered_content_mapping!(mapper_class)
106
+
104
107
  # Performance: Clear caches and mark finalized
105
108
  @cached_elements.clear
106
109
  @cached_attributes.clear
@@ -138,6 +141,12 @@ module Lutaml
138
141
  mapper_class)
139
142
  end
140
143
 
144
+ def validate_ordered_content_mapping!(mapper_class)
145
+ return unless @ordered && !@mixed_content && @content_mapping
146
+
147
+ raise Lutaml::Model::OrderedContentMappingError.new(mapper_class)
148
+ end
149
+
141
150
  # Enable mixed content for this element
142
151
  #
143
152
  # Mixed content means the element can contain both text nodes
@@ -249,6 +249,20 @@ effective_register = nil, instance_is_serialize = nil)
249
249
  # Performance: Get namespace_uri once if needed for default_namespace
250
250
  namespace_uri = xml_mapping&.namespace_uri
251
251
 
252
+ # Performance: Pre-build Set of child namespaced names for fast rule matching.
253
+ # This allows skipping value_for_rule for ~91% of rules that have no matching child.
254
+ child_names_set = nil
255
+ if doc.respond_to?(:element_children)
256
+ ec = doc.element_children
257
+ unless ec.empty?
258
+ child_names_set = Set.new
259
+ ec.each do |child|
260
+ child_names_set.add(child.namespaced_name)
261
+ child_names_set.add(child.unprefixed_name)
262
+ end
263
+ end
264
+ end
265
+
252
266
  mappings.each do |rule|
253
267
  # Performance: Cache rule properties accessed multiple times
254
268
  rule.name
@@ -277,6 +291,17 @@ effective_register = nil, instance_is_serialize = nil)
277
291
  doc.root.inner_xml
278
292
  elsif rule.content_mapping?
279
293
  rule.cdata ? doc.cdata : doc.text
294
+ elsif child_names_set && !rule.attribute? &&
295
+ !child_matches_rule?(rule, child_names_set,
296
+ default_namespace)
297
+ # Pre-match: no child element matches this rule.
298
+ # Skip expensive value_for_rule, handle defaults inline.
299
+ if instance.using_default?(rule_to) || rule.render_default
300
+ defaults_used << rule_to
301
+ attr&.default(effective_register) || rule.to_value_for(instance)
302
+ else
303
+ ::Lutaml::Model::UninitializedClass.instance
304
+ end
280
305
  else
281
306
  # Performance: Pass cached attr to avoid recomputing attribute_for_rule
282
307
  val = value_for_rule(doc, rule, new_opts, instance, attr,
@@ -339,6 +364,9 @@ effective_register = nil, instance_is_serialize = nil)
339
364
  mixed_content_option, xml_mapping = nil,
340
365
  instance_is_serialize = nil)
341
366
  instance.element_order = doc.root.order
367
+ if doc.root.respond_to?(:attribute_order) && instance.respond_to?(:attribute_order=)
368
+ instance.attribute_order = doc.root.attribute_order
369
+ end
342
370
 
343
371
  # For Serialize instances, ordered?/mixed? delegate to class mapping.
344
372
  # For non-Serialize model classes (model Id), @ordered/@mixed are needed
@@ -939,6 +967,20 @@ effective_register = lutaml_register)
939
967
  end
940
968
  end
941
969
  end
970
+
971
+ # Check if any child element in the pre-built Set matches the rule.
972
+ # Conservative: may return true (false positive) when namespace aliasing
973
+ # is involved, but never returns false when value_for_rule would find a match.
974
+ #
975
+ # @param rule [MappingRule] the mapping rule to check
976
+ # @param child_names_set [Set] Set of child namespaced_name and unprefixed_name
977
+ # @param default_namespace [String, nil] the default namespace
978
+ # @return [Boolean] true if a matching child likely exists
979
+ def child_matches_rule?(rule, child_names_set, default_namespace)
980
+ rule_names = rule.namespaced_names(default_namespace)
981
+ rule_names.any? { |rn| child_names_set.include?(rn) } ||
982
+ child_names_set.include?(rule.name.to_s)
983
+ end
942
984
  end
943
985
  end
944
986
  end
@@ -17,7 +17,10 @@ module Lutaml
17
17
  end
18
18
 
19
19
  def resolved_element_order
20
- element_order&.each_with_object(element_order.dup) do |element, array|
20
+ return nil unless element_order
21
+
22
+ filtered = element_order.reject { |e| e.is_a?(Lutaml::Xml::Element) && e.comment? }
23
+ filtered.each_with_object(filtered.dup) do |element, array|
21
24
  next delete_deletables(array, element) if deletable?(element)
22
25
 
23
26
  update_element_array(array, element)
@@ -34,6 +34,9 @@ module Lutaml
34
34
  def include_schema(schema_location)
35
35
  return unless schema_location
36
36
 
37
+ schema_location = schema_location.strip
38
+ return if schema_location.empty?
39
+
37
40
  resolved_location = resolve_schema_location(schema_location)
38
41
  if absolute_path?(resolved_location)
39
42
  return read_absolute_path(
@@ -54,6 +57,9 @@ module Lutaml
54
57
  end
55
58
 
56
59
  def location_for(schema_location)
60
+ schema_location = schema_location&.strip
61
+ return nil if schema_location.nil? || schema_location.empty?
62
+
57
63
  resolved_location = resolve_schema_location(schema_location)
58
64
  return resolved_location if absolute_path?(resolved_location) || absolute_url?(resolved_location)
59
65
 
@@ -16,7 +16,8 @@ module Lutaml
16
16
  # Writer is used by :eager mode during parsing. Reader delegates to
17
17
  # import_declaration_plan which handles lazy building.
18
18
  attr_writer :import_declaration_plan
19
- attr_accessor :element_order, :schema_location, :encoding, :doctype
19
+ attr_accessor :element_order, :attribute_order, :schema_location,
20
+ :encoding, :doctype
20
21
 
21
22
  # Store pre-collected namespace data for lazy plan building.
22
23
  # This is a plain Hash (no adapter objects) collected during from_xml.
@@ -284,6 +285,7 @@ module Lutaml
284
285
  return unless attrs.respond_to?(:item_order)
285
286
 
286
287
  @element_order = attrs.item_order
288
+ @attribute_order = attrs.attribute_order if attrs.respond_to?(:attribute_order)
287
289
  end
288
290
 
289
291
  def set_schema_location(attrs)
@@ -153,6 +153,12 @@ text_node_count = 0, use_content_index = false)
153
153
  text_node_count, use_content_index)
154
154
  end
155
155
 
156
+ # For comment nodes, add them directly to preserve position
157
+ if object.type == "Comment"
158
+ root.add_child(::Lutaml::Xml::DataModel::XmlComment.new(object.text_content))
159
+ return :comment_node
160
+ end
161
+
156
162
  # Find the mapping rule for this element
157
163
  rule = find_rule_for_element(object, compiled_rules)
158
164
  return nil unless rule
@@ -293,9 +299,19 @@ _options)
293
299
  # @param compiled_rules [Array<CompiledRule>] The compiled rules
294
300
  # @param mapping [Xml::Mapping] The mapping
295
301
  # @param processed_text_nodes [Boolean] Whether text nodes were processed
296
- def apply_remaining_rules(_root, _model_instance, options,
302
+ def apply_remaining_rules(_root, model_instance, options,
297
303
  compiled_rules, mapping, processed_text_nodes)
298
- compiled_rules.each do |rule|
304
+ attr_order = model_instance.respond_to?(:attribute_order) &&
305
+ model_instance.attribute_order
306
+
307
+ rules_to_apply = if attr_order
308
+ sort_rules_by_attribute_order(compiled_rules,
309
+ attr_order)
310
+ else
311
+ compiled_rules
312
+ end
313
+
314
+ rules_to_apply.each do |rule|
299
315
  next if rule.option(:mapping_type) == :element
300
316
 
301
317
  # Skip content rules if we processed text nodes from element_order
@@ -310,6 +326,34 @@ compiled_rules, mapping, processed_text_nodes)
310
326
  end
311
327
  end
312
328
 
329
+ # Sort compiled rules so attribute rules follow the captured attribute_order.
330
+ # Non-attribute rules (content, raw) maintain their original position.
331
+ #
332
+ # @param rules [Array<CompiledRule>] The compiled rules
333
+ # @param attr_order [Array<String>] Attribute names in document order
334
+ # @return [Array<CompiledRule>] Rules sorted by attribute order
335
+ def sort_rules_by_attribute_order(rules, attr_order)
336
+ order_index = attr_order.each_with_index
337
+ .with_object({}) { |(name, i), h| h[name] = i }
338
+
339
+ local_index = attr_order.each_with_index
340
+ .with_object({}) do |(name, i), h|
341
+ local = name.include?(":") ? name.split(":", 2).last : name
342
+ h[local] = i unless h.key?(local)
343
+ end
344
+
345
+ non_attr_rules, attr_rules = rules.partition do |r|
346
+ r.option(:mapping_type) != :attribute
347
+ end
348
+
349
+ sorted_attr_rules = attr_rules.sort_by do |r|
350
+ order_index[r.serialized_name] || local_index[r.serialized_name] ||
351
+ Float::INFINITY
352
+ end
353
+
354
+ non_attr_rules + sorted_attr_rules
355
+ end
356
+
313
357
  # Check if a mapping rule should be applied based on only/except options
314
358
  #
315
359
  # @param rule [CompiledRule] The rule to check
@@ -292,7 +292,16 @@ module Lutaml
292
292
  # @param model_instance [Object] The model instance
293
293
  # @param options [Hash] Options
294
294
  def apply_standard_rules(root, model_instance, options)
295
- compiled_rules.each do |rule|
295
+ attr_order = model_instance.respond_to?(:attribute_order) &&
296
+ model_instance.attribute_order
297
+
298
+ rules = if attr_order
299
+ sort_rules_by_attribute_order(compiled_rules, attr_order)
300
+ else
301
+ compiled_rules
302
+ end
303
+
304
+ rules.each do |rule|
296
305
  next unless valid_mapping?(rule, options)
297
306
 
298
307
  rule_options = options.merge(current_model: model_instance)
@@ -301,6 +310,36 @@ module Lutaml
301
310
  end
302
311
  end
303
312
 
313
+ # Sort compiled rules so attribute rules follow the captured attribute_order.
314
+ # Non-attribute rules maintain their original position.
315
+ #
316
+ # @param rules [Array<CompiledRule>] The compiled rules
317
+ # @param attr_order [Array<String>] Attribute names in document order
318
+ # @return [Array<CompiledRule>] Rules sorted by attribute order
319
+ def sort_rules_by_attribute_order(rules, attr_order)
320
+ order_index = attr_order.each_with_index
321
+ .with_object({}) { |(name, i), h| h[name] = i }
322
+
323
+ # Also index by local name (after ':') for namespace-prefixed attributes
324
+ # e.g., "xlink:href" → "href" so rules with serialized_name "href" can match
325
+ local_index = attr_order.each_with_index
326
+ .with_object({}) do |(name, i), h|
327
+ local = name.include?(":") ? name.split(":", 2).last : name
328
+ h[local] = i unless h.key?(local)
329
+ end
330
+
331
+ non_attr_rules, attr_rules = rules.partition do |r|
332
+ r.option(:mapping_type) != :attribute
333
+ end
334
+
335
+ sorted_attr_rules = attr_rules.sort_by do |r|
336
+ order_index[r.serialized_name] || local_index[r.serialized_name] ||
337
+ Float::INFINITY
338
+ end
339
+
340
+ non_attr_rules + sorted_attr_rules
341
+ end
342
+
304
343
  # Serialize a value to string for XML output
305
344
  #
306
345
  # Delegates to ValueSerializer module
@@ -24,7 +24,7 @@ module Lutaml
24
24
  # - :processing_instruction - processing instruction
25
25
  NODE_TYPES = %i[element text cdata comment processing_instruction].freeze
26
26
 
27
- attr_reader :children, :attributes, :namespace_prefix,
27
+ attr_reader :children, :attributes, :attribute_order, :namespace_prefix,
28
28
  :namespace_prefix_explicit, :parent_document, :node_type
29
29
  attr_accessor :adapter_node, :processing_instructions
30
30
 
@@ -91,12 +91,14 @@ module Lutaml
91
91
  namespace_prefix: nil,
92
92
  default_namespace: nil,
93
93
  explicit_no_namespace: false,
94
- node_type: nil
94
+ node_type: nil,
95
+ attribute_order: nil
95
96
  )
96
97
  @name = name
97
98
  @namespace_prefix = namespace_prefix
98
99
  @namespace_prefix_explicit = !namespace_prefix.nil? && !namespace_prefix.empty?
99
100
  @attributes = attributes
101
+ @attribute_order = attribute_order
100
102
  @children = children
101
103
  @text = text
102
104
  @parent_document = parent_document
@@ -256,9 +258,7 @@ module Lutaml
256
258
 
257
259
  @order_cache = children.filter_map do |child|
258
260
  if child.text?
259
- # Skip whitespace-only text nodes (formatting between elements).
260
- # Significant text in mixed content will contain non-whitespace.
261
- next if child.text.nil? || child.text.strip.empty?
261
+ next if child.text.nil?
262
262
 
263
263
  # For text nodes:
264
264
  # - name is "text" for backward compatibility with tests
@@ -276,8 +276,9 @@ module Lutaml
276
276
  text_content: child.text,
277
277
  node_type: :cdata)
278
278
  elsif child.comment?
279
- # Skip comments - they're not part of schema element order
280
- nil
279
+ Lutaml::Xml::Element.new("Comment", "comment",
280
+ text_content: child.text,
281
+ node_type: :comment)
281
282
  else
282
283
  # For regular elements:
283
284
  # - name is the actual element name