lutaml-model 0.8.3 → 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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +23 -23
- data/README.adoc +213 -1
- data/docs/_guides/document-validation.adoc +303 -0
- data/docs/_guides/index.adoc +1 -0
- data/docs/_guides/xml-mapping.adoc +9 -1
- data/docs/_guides/xml_mappings/07_best_practices.adoc +36 -0
- data/docs/_guides/xml_mappings/08_troubleshooting.adoc +89 -0
- data/docs/_tutorials/lutaml-xml-architecture.adoc +6 -1
- data/lib/lutaml/model/attribute.rb +19 -1
- data/lib/lutaml/model/error/liquid_drop_already_registered_error.rb +11 -0
- data/lib/lutaml/model/error/ordered_content_mapping_error.rb +17 -0
- data/lib/lutaml/model/global_context.rb +1 -0
- data/lib/lutaml/model/liquefiable.rb +12 -15
- data/lib/lutaml/model/mapping/mapping_rule.rb +10 -2
- data/lib/lutaml/model/mapping_hash.rb +1 -1
- data/lib/lutaml/model/services/transformer.rb +67 -32
- data/lib/lutaml/model/transform.rb +41 -4
- data/lib/lutaml/model/uninitialized_class.rb +11 -5
- data/lib/lutaml/model/validation/concerns/has_issues.rb +27 -0
- data/lib/lutaml/model/validation/context.rb +36 -0
- data/lib/lutaml/model/validation/issue.rb +62 -0
- data/lib/lutaml/model/validation/layer_result.rb +34 -0
- data/lib/lutaml/model/validation/profile.rb +66 -0
- data/lib/lutaml/model/validation/registry.rb +60 -0
- data/lib/lutaml/model/validation/remediation.rb +33 -0
- data/lib/lutaml/model/validation/remediation_result.rb +20 -0
- data/lib/lutaml/model/validation/report.rb +39 -0
- data/lib/lutaml/model/validation/rule.rb +59 -0
- data/lib/lutaml/model/validation.rb +2 -1
- data/lib/lutaml/model/validation_framework.rb +77 -0
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/model.rb +4 -0
- data/lib/lutaml/xml/adapter/nokogiri_adapter.rb +9 -2
- data/lib/lutaml/xml/adapter/oga_adapter.rb +11 -3
- data/lib/lutaml/xml/adapter/ox_adapter.rb +5 -2
- data/lib/lutaml/xml/adapter/rexml_adapter.rb +10 -3
- data/lib/lutaml/xml/adapter_element.rb +26 -2
- data/lib/lutaml/xml/data_model.rb +14 -0
- data/lib/lutaml/xml/document.rb +3 -0
- data/lib/lutaml/xml/element.rb +8 -2
- data/lib/lutaml/xml/mapping.rb +9 -0
- data/lib/lutaml/xml/model_transform.rb +42 -0
- data/lib/lutaml/xml/schema/xsd/base.rb +4 -1
- data/lib/lutaml/xml/serialization/instance_methods.rb +3 -1
- data/lib/lutaml/xml/transformation/ordered_applier.rb +46 -2
- data/lib/lutaml/xml/transformation.rb +40 -1
- data/lib/lutaml/xml/xml_element.rb +8 -7
- data/lutaml-model.gemspec +1 -1
- data/spec/lutaml/model/attribute_default_cache_spec.rb +58 -0
- data/spec/lutaml/model/liquefiable_spec.rb +22 -6
- data/spec/lutaml/model/liquid_compatibility_spec.rb +442 -0
- data/spec/lutaml/model/ordered_content_spec.rb +5 -5
- data/spec/lutaml/model/services/transformer_spec.rb +43 -0
- data/spec/lutaml/model/transform_cache_spec.rb +62 -0
- data/spec/lutaml/model/transform_dynamic_attributes_spec.rb +41 -0
- data/spec/lutaml/model/uninitialized_class_deep_dup_spec.rb +39 -0
- data/spec/lutaml/model/uninitialized_class_spec.rb +14 -2
- data/spec/lutaml/model/validation/concerns/has_issues_spec.rb +76 -0
- data/spec/lutaml/model/validation/context_spec.rb +60 -0
- data/spec/lutaml/model/validation/issue_spec.rb +77 -0
- data/spec/lutaml/model/validation/layer_result_spec.rb +66 -0
- data/spec/lutaml/model/validation/profile_spec.rb +134 -0
- data/spec/lutaml/model/validation/registry_spec.rb +94 -0
- data/spec/lutaml/model/validation/remediation_result_spec.rb +23 -0
- data/spec/lutaml/model/validation/remediation_spec.rb +72 -0
- data/spec/lutaml/model/validation/report_spec.rb +58 -0
- data/spec/lutaml/model/validation/rule_spec.rb +134 -0
- data/spec/lutaml/model/validation/uninitialized_class_validate_spec.rb +29 -0
- data/spec/lutaml/model/validation/validation_error_spec.rb +29 -0
- data/spec/lutaml/model/validation/validation_framework_spec.rb +110 -0
- data/spec/lutaml/xml/content_model_validation_spec.rb +157 -0
- data/spec/lutaml/xml/mapping_spec.rb +12 -7
- metadata +46 -7
- 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
|
data/lib/lutaml/model/version.rb
CHANGED
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?
|
|
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
|
-
|
|
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
|
-
|
|
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?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?
|
|
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 =
|
|
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
|
data/lib/lutaml/xml/document.rb
CHANGED
|
@@ -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)
|
data/lib/lutaml/xml/element.rb
CHANGED
|
@@ -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
|
data/lib/lutaml/xml/mapping.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
@@ -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, :
|
|
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,
|
|
302
|
+
def apply_remaining_rules(_root, model_instance, options,
|
|
297
303
|
compiled_rules, mapping, processed_text_nodes)
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
data/lutaml-model.gemspec
CHANGED
|
@@ -35,7 +35,7 @@ Gem::Specification.new do |spec|
|
|
|
35
35
|
spec.add_dependency "bigdecimal"
|
|
36
36
|
spec.add_dependency "canon"
|
|
37
37
|
spec.add_dependency "concurrent-ruby"
|
|
38
|
-
spec.add_dependency "liquid", "
|
|
38
|
+
spec.add_dependency "liquid", ">= 4.0", "< 6.0"
|
|
39
39
|
spec.add_dependency "moxml", ">= 0.1.16"
|
|
40
40
|
spec.add_dependency "ostruct"
|
|
41
41
|
spec.add_dependency "rubyzip", "~> 2.3"
|