lutaml-model 0.8.15 → 0.8.16

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.
@@ -665,6 +665,10 @@ instance_object = nil)
665
665
  def process_options!
666
666
  validate_options!(@options)
667
667
  @raw = !!@options[:raw]
668
+ if @raw
669
+ warn "[DEPRECATED] attribute :#{name}, :string, raw: true is deprecated. " \
670
+ "Use map_element \"name\", to: :#{name}, raw: :content instead."
671
+ end
668
672
  @validations = @options[:validations]
669
673
  set_default_for_collection if collection?
670
674
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.15"
5
+ VERSION = "0.8.16"
6
6
  end
7
7
  end
data/lib/lutaml/model.rb CHANGED
@@ -149,7 +149,8 @@ module Lutaml
149
149
  "#{__dir__}/model/error/incorrect_sequence_error"
150
150
  autoload :ChoiceUpperBoundError,
151
151
  "#{__dir__}/model/error/choice_upper_bound_error"
152
- autoload :TypeOnlyMappingError, "#{__dir__}/model/error/type_only_mapping_error"
152
+ autoload :TypeOnlyMappingError,
153
+ "#{__dir__}/model/error/type_only_mapping_error"
153
154
  autoload :NoRootMappingError, "#{__dir__}/model/error/no_root_mapping_error"
154
155
  autoload :ImportModelWithRootError,
155
156
  "#{__dir__}/model/error/import_model_with_root_error"
@@ -66,7 +66,8 @@ module Lutaml
66
66
  emit_type_statements(graph, subject_uri, mapping)
67
67
  emit_predicate_statements(graph, subject_uri, instance, mapping)
68
68
  emit_member_link_statements(graph, subject_uri, instance, mapping)
69
- additional_resource_triples(instance, subject_uri, mapping).each do |stmt|
69
+ additional_resource_triples(instance, subject_uri,
70
+ mapping).each do |stmt|
70
71
  graph << stmt
71
72
  end
72
73
  end
@@ -332,6 +332,8 @@ module Lutaml
332
332
  xml_mapping: xml_mapping)
333
333
  when Lutaml::Xml::DataModel::XmlComment
334
334
  inner_xml.add_comment(child.content)
335
+ when Lutaml::Xml::DataModel::XmlRawFragment
336
+ inner_xml.add_xml_fragment(inner_xml, child.content)
335
337
  when String
336
338
  if element.cdata
337
339
  inner_xml.cdata(child.to_s)
@@ -764,7 +766,7 @@ module Lutaml
764
766
  xml.create_and_add_element(rule.name,
765
767
  attributes: attributes.empty? ? nil : attributes,
766
768
  prefix: resolved_prefix)
767
- elsif rule.raw_mapping?
769
+ elsif rule.raw_mapping? || rule.raw == :element
768
770
  xml.add_xml_fragment(xml, value)
769
771
  elsif value.is_a?(::Hash) && attribute&.type(register) == Lutaml::Model::Type::Hash
770
772
  xml.create_and_add_element(rule.name,
@@ -49,7 +49,10 @@ module Lutaml
49
49
  encoding = determine_encoding(options)
50
50
  builder_options = {}
51
51
  builder_options[:encoding] = encoding if encoding
52
- builder_options[:line_ending] = options[:line_ending] if options.key?(:line_ending)
52
+ if options.key?(:line_ending)
53
+ builder_options[:line_ending] =
54
+ options[:line_ending]
55
+ end
53
56
  builder_options[:indent] = options[:indent] if options.key?(:indent)
54
57
 
55
58
  # Pass doctype to builder for document-level insertion
@@ -66,15 +69,20 @@ module Lutaml
66
69
  if options[:standalone] == :preserve
67
70
  # Keep original standalone from parsed declaration (may be nil)
68
71
  else
69
- builder_options[:xml_declaration][:standalone] = standalone_value(options[:standalone])
72
+ builder_options[:xml_declaration][:standalone] =
73
+ standalone_value(options[:standalone])
70
74
  end
71
75
  end
72
76
  if options[:declaration].is_a?(String)
73
- builder_options[:xml_declaration][:version] = options[:declaration]
77
+ builder_options[:xml_declaration][:version] =
78
+ options[:declaration]
74
79
  elsif options[:declaration] == true
75
80
  builder_options[:xml_declaration][:version] = "1.0"
76
81
  end
77
- builder_options[:xml_declaration][:encoding] = encoding if options.key?(:encoding) && encoding
82
+ if options.key?(:encoding) && encoding
83
+ builder_options[:xml_declaration][:encoding] =
84
+ encoding
85
+ end
78
86
  elsif options[:encoding] && !options[:encoding].nil?
79
87
  builder_options[:force_declaration] = true
80
88
  end
@@ -287,6 +295,8 @@ module Lutaml
287
295
  previous_child_had_xmlns_blank ||= child_node.needs_xmlns_blank
288
296
  when Lutaml::Xml::DataModel::XmlComment
289
297
  xml.add_comment(xml_child.content)
298
+ when Lutaml::Xml::DataModel::XmlRawFragment
299
+ xml.add_xml_fragment(xml, xml_child.content)
290
300
  when String
291
301
  if xml_element.cdata
292
302
  xml.cdata(xml_child.to_s)
@@ -10,7 +10,8 @@ module Lutaml
10
10
  autoload :XmlParser, "#{__dir__}/adapter/xml_parser"
11
11
  autoload :XmlSerializer, "#{__dir__}/adapter/xml_serializer"
12
12
  autoload :PlanBasedBuilder, "#{__dir__}/adapter/plan_based_builder"
13
- autoload :NamespaceUriCollector, "#{__dir__}/adapter/namespace_uri_collector"
13
+ autoload :NamespaceUriCollector,
14
+ "#{__dir__}/adapter/namespace_uri_collector"
14
15
  autoload :OgaAdapter, "#{__dir__}/adapter/oga_adapter"
15
16
  Lutaml::Model::RuntimeCompatibility.autoload_native(
16
17
  self,
@@ -172,7 +172,8 @@ module Lutaml
172
172
  result = if @declaration_mode == :none && !has_document_level_nodes?
173
173
  @doc.root.to_xml(declaration: false, expand_empty: false)
174
174
  else
175
- @doc.to_xml(declaration: @declaration_mode == :default, expand_empty: false)
175
+ @doc.to_xml(declaration: @declaration_mode == :default,
176
+ expand_empty: false)
176
177
  end
177
178
 
178
179
  result = result.encode(encoding) if encoding && result.encoding.to_s != encoding
@@ -87,10 +87,10 @@ module Lutaml
87
87
  # @raise [TypeError] if child is not a supported type
88
88
  def add_child(child)
89
89
  unless child.is_a?(XmlElement) || child.is_a?(String) ||
90
- child.is_a?(XmlComment)
90
+ child.is_a?(XmlComment) || child.is_a?(XmlRawFragment)
91
91
  raise TypeError,
92
- "XmlElement#add_child expects XmlElement, String, or " \
93
- "XmlComment, got #{child.class}"
92
+ "XmlElement#add_child expects XmlElement, String, " \
93
+ "XmlComment, or XmlRawFragment, got #{child.class}"
94
94
  end
95
95
 
96
96
  @children << child
@@ -261,6 +261,22 @@ module Lutaml
261
261
  end
262
262
  end
263
263
 
264
+ # Represents a raw XML fragment that should be serialized as-is.
265
+ #
266
+ # Used by raw_element mappings to embed complete XML elements (e.g., SVG,
267
+ # MathML) without parsing, wrapping, or escaping.
268
+ class XmlRawFragment
269
+ attr_reader :content
270
+
271
+ def initialize(content)
272
+ @content = content.to_s
273
+ end
274
+
275
+ def to_s
276
+ @content
277
+ end
278
+ end
279
+
264
280
  # Represents an XML comment in the data model tree.
265
281
  # Stored as a child of XmlElement alongside String (text) and XmlElement children.
266
282
  class XmlComment
@@ -510,7 +510,8 @@ module Lutaml
510
510
  form: nil,
511
511
  documentation: nil,
512
512
  xsd_type: (xsd_type_provided = false
513
- nil)
513
+ nil),
514
+ raw: nil
514
515
  )
515
516
  validate!(
516
517
  name, to, with, render_nil, render_empty, type: TYPES[:element]
@@ -562,6 +563,7 @@ module Lutaml
562
563
  value_map: value_map,
563
564
  form: form,
564
565
  documentation: documentation,
566
+ raw: raw,
565
567
  )
566
568
  # Store rules with the same element name in an array to support
567
569
  # multiple mapping rules for the same element name with different target types
@@ -10,7 +10,8 @@ module Lutaml
10
10
  :as_list,
11
11
  :delimiter,
12
12
  :form,
13
- :documentation
13
+ :documentation,
14
+ :raw
14
15
 
15
16
  # Writers for deep_dup (preserves exact object references)
16
17
  attr_accessor :namespace, :prefix, :namespace_class
@@ -39,7 +40,8 @@ module Lutaml
39
40
  as_list: nil,
40
41
  delimiter: nil,
41
42
  form: nil,
42
- documentation: nil
43
+ documentation: nil,
44
+ raw: nil
43
45
  )
44
46
  super(
45
47
  name,
@@ -76,6 +78,7 @@ module Lutaml
76
78
  @delimiter = delimiter
77
79
  @form = validate_form(form)
78
80
  @documentation = documentation
81
+ @raw = validate_raw(raw)
79
82
 
80
83
  # Memoize prefixed_name at initialization for performance
81
84
  # This is safe because prefix and name are immutable after initialization
@@ -103,6 +106,10 @@ module Lutaml
103
106
  @static_namespace_option ||= { default_namespace: namespace }.freeze
104
107
  end
105
108
 
109
+ def raw_element?
110
+ @raw == :element
111
+ end
112
+
106
113
  def content_mapping?
107
114
  name.nil?
108
115
  end
@@ -225,6 +232,7 @@ module Lutaml
225
232
  delimiter: @delimiter,
226
233
  form: @form,
227
234
  documentation: @documentation,
235
+ raw: @raw,
228
236
  ).tap do |dup_rule|
229
237
  # Manually preserve the exact @namespace_class object to avoid
230
238
  # recreating anonymous classes (which would have different object_ids)
@@ -573,6 +581,24 @@ form_default = :unqualified)
573
581
 
574
582
  form
575
583
  end
584
+
585
+ def validate_raw(raw)
586
+ return nil if raw.nil? || raw == false
587
+
588
+ valid_raw = %i[element content]
589
+ if raw == true
590
+ warn "[DEPRECATED] raw: true on map_element is deprecated, " \
591
+ "use raw: :element instead."
592
+ return :element
593
+ end
594
+
595
+ unless valid_raw.include?(raw)
596
+ raise ArgumentError,
597
+ "raw must be :element or :content, got #{raw.inspect}"
598
+ end
599
+
600
+ raw
601
+ end
576
602
  end
577
603
  end
578
604
  end
@@ -668,6 +668,12 @@ _effective_register)
668
668
  !child_ns_prefix && rule_names.any? do |rn|
669
669
  ((colon = rn.rindex(":")) ? rn[(colon + 1)..] : rn) == child.unprefixed_name
670
670
  end
671
+ elsif !rule_namespace_set && (!child_ns_prefix || rule.raw == :element)
672
+ # For simple types (String, etc.) with no namespace constraint,
673
+ # match by unprefixed name. Handles elements in foreign namespaces
674
+ # (e.g., SVG inside <image>). raw_element rules match regardless
675
+ # of prefix — the intent is to capture any element with that name.
676
+ child.unprefixed_name == rule_name_str
671
677
  else
672
678
  false
673
679
  end
@@ -765,7 +771,9 @@ _effective_register)
765
771
  end
766
772
 
767
773
  values << cast_result
768
- elsif attr.raw?
774
+ elsif rule.raw == :element
775
+ values << child.to_xml
776
+ elsif rule.raw == :content || attr.raw?
769
777
  values << inner_xml_of(child)
770
778
  else
771
779
  return nil if rule.render_nil_as_nil? && child.nil_element?
@@ -155,23 +155,30 @@ module Lutaml
155
155
  # Using ::Hash to avoid conflict with Lutaml::Model::Hash
156
156
  collection_indices = ::Hash.new(0)
157
157
 
158
+ content_is_mixed = mixed?
159
+
158
160
  element_order.each do |el|
159
161
  if el.text?
160
- # Text node - yield the text content (skip whitespace-only)
161
162
  text = el.text_content
162
- yield(text) if text && !text.strip.empty?
163
+ if text && !text.empty? && (content_is_mixed || !text.strip.empty?)
164
+ # Mixed content: all text is significant (e.g. "Hello " before <b>)
165
+ # Ordered-only: skip whitespace-only text (indentation between elements)
166
+ yield(text)
167
+ end
163
168
  elsif el.element?
164
169
  # Element node - look up mapped collection and get next item
165
170
  attr_name = element_to_attr[el.name]
166
171
  next unless attr_name
167
172
 
168
- collection = send(attr_name)
169
- next unless collection.is_a?(Array)
170
-
171
- index = collection_indices[attr_name]
172
- collection_indices[attr_name] += 1
173
-
174
- obj = collection[index]
173
+ val = send(attr_name)
174
+ obj = if val.is_a?(Array)
175
+ index = collection_indices[attr_name]
176
+ collection_indices[attr_name] += 1
177
+ val[index]
178
+ elsif val.is_a?(Lutaml::Model::Serializable)
179
+ collection_indices[attr_name] += 1
180
+ collection_indices[attr_name] == 1 ? val : nil
181
+ end
175
182
  yield(obj) if obj
176
183
  end
177
184
  end
@@ -112,8 +112,6 @@ parent_element_form_default)
112
112
  end
113
113
  end
114
114
 
115
- private
116
-
117
115
  # Create element for nested model
118
116
  #
119
117
  # @param rule [CompiledRule] The rule
@@ -212,7 +210,7 @@ child_transformation)
212
210
  # with different URIs) -> child has its own ns, use child's prefix_default
213
211
  # - Child's namespace is self-declared through its attribute TYPE (different from parent)
214
212
  # -> child's XmlElement gets its own ns, use child's prefix_default
215
- child_ns_class = if value.class.respond_to?(:mappings_for)
213
+ child_ns_class = if value.is_a?(::Lutaml::Model::Serialize)
216
214
  value.class.mappings_for(:xml)&.namespace_class
217
215
  end
218
216
  ns_prefix = nil
@@ -84,6 +84,12 @@ register_id, register)
84
84
  return
85
85
  end
86
86
 
87
+ # raw: :element — value is a complete XML element string, inject directly
88
+ if rule.raw == :element
89
+ add_raw_element_fragments(parent, value)
90
+ return
91
+ end
92
+
87
93
  # Extract parent's namespace info for element_form_default inheritance
88
94
  parent_ns_class = parent.namespace_class
89
95
  # Only pass element_form_default VALUE if it was explicitly set
@@ -201,6 +207,21 @@ register_id)
201
207
 
202
208
  private
203
209
 
210
+ # Add raw element fragments to parent, handling single values and collections.
211
+ #
212
+ # @param parent [XmlElement] Parent element
213
+ # @param value [Object, Array] Raw XML string(s)
214
+ def add_raw_element_fragments(parent, value)
215
+ return if value.nil?
216
+
217
+ items = value.is_a?(Array) ? value : [value]
218
+ items.each do |item|
219
+ next if item.nil? || item.to_s.empty?
220
+
221
+ parent.add_child(::Lutaml::Xml::DataModel::XmlRawFragment.new(item.to_s))
222
+ end
223
+ end
224
+
204
225
  # Check if rule is custom-method-only (no real attribute)
205
226
  #
206
227
  # @param rule [CompiledRule] The rule
@@ -253,6 +253,15 @@ _register)
253
253
 
254
254
  private
255
255
 
256
+ # Resolve the unified raw mode from mapping rule and attribute
257
+ #
258
+ # @param mapping_rule [Xml::MappingRule] The mapping rule
259
+ # @param attr [Attribute, nil] The attribute (nil for custom methods)
260
+ # @return [Symbol, nil] :element, :content, or nil
261
+ def resolve_raw_mode(mapping_rule, attr)
262
+ mapping_rule.raw || (attr&.raw? ? :content : nil)
263
+ end
264
+
256
265
  # Infer attribute name from mapping rule or custom methods
257
266
  #
258
267
  # @param mapping_rule [Xml::MappingRule] The mapping rule
@@ -321,7 +330,7 @@ register_id, register, attr_name, custom_methods_value)
321
330
  mapping_type: :element,
322
331
  cdata: mapping_rule.cdata,
323
332
  mixed_content: mapping_rule.mixed_content?,
324
- raw: attr.raw?,
333
+ raw: resolve_raw_mode(mapping_rule, attr),
325
334
  render_default: mapping_rule.render_default,
326
335
  value_map: value_map,
327
336
  custom_methods: custom_methods_value,
@@ -350,7 +359,7 @@ custom_methods_value)
350
359
  mapping_type: :element,
351
360
  cdata: mapping_rule.cdata,
352
361
  mixed_content: mapping_rule.mixed_content?,
353
- raw: false,
362
+ raw: mapping_rule.raw,
354
363
  render_default: mapping_rule.render_default,
355
364
  value_map: value_map,
356
365
  custom_methods: custom_methods_value,
@@ -401,7 +410,7 @@ register_id, register, custom_methods_value)
401
410
  mapping_type: :element,
402
411
  cdata: mapping_rule.cdata,
403
412
  mixed_content: mapping_rule.mixed_content?,
404
- raw: attr.raw?,
413
+ raw: resolve_raw_mode(mapping_rule, attr),
405
414
  render_default: mapping_rule.render_default,
406
415
  value_map: value_map,
407
416
  custom_methods: custom_methods_value,
@@ -492,7 +492,9 @@ RSpec.describe Lutaml::JsonLd::Transform do
492
492
  ],
493
493
  )
494
494
  parsed = JSON.parse(parent.to_jsonld)
495
- parent_resource = parsed["@graph"].find { |r| r["@type"] == "skos:Collection" }
495
+ parent_resource = parsed["@graph"].find do |r|
496
+ r["@type"] == "skos:Collection"
497
+ end
496
498
  expect(parent_resource["member"]).to eq([
497
499
  { "@id" => "http://example.org/item/a" },
498
500
  { "@id" => "http://example.org/item/b" },
@@ -1111,19 +1111,22 @@ RSpec.describe "MixedContent" do
1111
1111
  expect(enum).to be_a(Enumerator)
1112
1112
  end
1113
1113
 
1114
- it "skips whitespace-only text nodes" do
1114
+ it "yields whitespace text nodes in mixed content" do
1115
1115
  parsed = MixedContentSpec::RootMixedContent.from_xml(xml)
1116
1116
 
1117
1117
  results = []
1118
1118
  parsed.each_mixed_content do |node|
1119
- results << node if node.is_a?(String) && node.strip.empty?
1119
+ results << node if node.is_a?(String)
1120
1120
  end
1121
1121
 
1122
- expect(results).to eq([])
1122
+ expect(results).not_to be_empty
1123
+ text_joined = results.join
1124
+ expect(text_joined).to include("Hello")
1125
+ expect(text_joined).to include("and")
1126
+ expect(text_joined).to include("!")
1123
1127
  end
1124
1128
 
1125
1129
  context "with ordered-only content (no mixed)" do
1126
- # Test that ordered content (elements only, no text) works
1127
1130
  let(:xml) do
1128
1131
  <<~XML
1129
1132
  <RootMixedContentNested id="outer">
@@ -1134,15 +1137,17 @@ RSpec.describe "MixedContent" do
1134
1137
  XML
1135
1138
  end
1136
1139
 
1137
- it "yields element objects in document order" do
1140
+ it "yields element values in document order" do
1138
1141
  parsed = MixedContentSpec::RootMixedContentNested.from_xml(xml)
1139
1142
 
1140
1143
  results = []
1141
1144
  parsed.content.each_mixed_content { |node| results << node }
1142
1145
 
1143
- # Should yield element objects in order (bold, italic strings)
1146
+ # RootMixedContent has mixed_content, so whitespace text nodes
1147
+ # ARE yielded alongside element values
1144
1148
  string_results = results.grep(String)
1145
- expect(string_results.length).to eq(2)
1149
+ expect(string_results).to include("first")
1150
+ expect(string_results).to include("second")
1146
1151
  end
1147
1152
  end
1148
1153
 
@@ -1152,6 +1157,42 @@ RSpec.describe "MixedContent" do
1152
1157
  expect(parsed.each_mixed_content.to_a).to eq([])
1153
1158
  end
1154
1159
  end
1160
+
1161
+ context "with ordered-only model (no mixed_content)" do
1162
+ before do
1163
+ stub_const("OrderedOnlyContainer", Class.new(Lutaml::Model::Serializable) do
1164
+ attribute :items, :string, collection: true
1165
+
1166
+ xml do
1167
+ element "container"
1168
+ ordered
1169
+ map_element "item", to: :items
1170
+ end
1171
+ end)
1172
+ end
1173
+
1174
+ it "skips whitespace-only text nodes between elements" do
1175
+ xml = <<~XML
1176
+ <container>
1177
+ <item>first</item>
1178
+ <item>second</item>
1179
+ </container>
1180
+ XML
1181
+
1182
+ parsed = OrderedOnlyContainer.from_xml(xml)
1183
+ results = []
1184
+ parsed.each_mixed_content { |node| results << node }
1185
+
1186
+ # Ordered-only: whitespace between elements is formatting noise, not content
1187
+ whitespace_only = results.select do |n|
1188
+ n.is_a?(String) && n.strip.empty?
1189
+ end
1190
+ expect(whitespace_only).to eq([])
1191
+
1192
+ string_results = results.grep(String)
1193
+ expect(string_results).to eq(%w[first second])
1194
+ end
1195
+ end
1155
1196
  end
1156
1197
 
1157
1198
  # Issue #630: Mutation after deserialization should update serialization output