lutaml-model 0.8.15 → 0.8.17

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +0 -3
  3. data/.rubocop_todo.yml +49 -52
  4. data/README.adoc +188 -25
  5. data/docs/_guides/xml/namespace-semantics.adoc +2 -0
  6. data/docs/_guides/xml-mapping.adoc +178 -24
  7. data/docs/_guides/xml-namespace-qualification.adoc +142 -0
  8. data/docs/_pages/importable_models.adoc +7 -1
  9. data/docs/_tutorials/xml-element-attribute-namespace-guide.adoc +2 -0
  10. data/docs/_tutorials/xml-schema-primer-style-guide.adoc +2 -0
  11. data/docs/namespace-management.adoc +2 -0
  12. data/docs/xml-schema-qualification.md +6 -0
  13. data/lib/lutaml/jsonld.rb +1 -4
  14. data/lib/lutaml/model/attribute.rb +4 -0
  15. data/lib/lutaml/model/choice.rb +34 -0
  16. data/lib/lutaml/model/compiled_rule.rb +7 -0
  17. data/lib/lutaml/model/version.rb +1 -1
  18. data/lib/lutaml/model.rb +3 -1
  19. data/lib/lutaml/{jsonld → rdf}/context.rb +1 -1
  20. data/lib/lutaml/{jsonld/transform.rb → rdf/linked_data_transform.rb} +33 -35
  21. data/lib/lutaml/{jsonld → rdf}/term_definition.rb +1 -1
  22. data/lib/lutaml/rdf.rb +3 -0
  23. data/lib/lutaml/turtle/transform.rb +2 -1
  24. data/lib/lutaml/xml/adapter/plan_based_builder.rb +3 -1
  25. data/lib/lutaml/xml/adapter/xml_serializer.rb +14 -4
  26. data/lib/lutaml/xml/adapter.rb +2 -1
  27. data/lib/lutaml/xml/adapter_element.rb +2 -2
  28. data/lib/lutaml/xml/builder/base.rb +2 -1
  29. data/lib/lutaml/xml/data_model.rb +19 -3
  30. data/lib/lutaml/xml/mapping.rb +3 -1
  31. data/lib/lutaml/xml/mapping_rule.rb +28 -2
  32. data/lib/lutaml/xml/model_transform.rb +9 -1
  33. data/lib/lutaml/xml/serialization/instance_methods.rb +16 -9
  34. data/lib/lutaml/xml/transformation/element_builder.rb +39 -26
  35. data/lib/lutaml/xml/transformation/rule_applier.rb +21 -0
  36. data/lib/lutaml/xml/transformation/rule_compiler.rb +12 -3
  37. data/lib/lutaml/yamlld/adapter.rb +25 -0
  38. data/lib/lutaml/yamlld.rb +25 -0
  39. data/spec/lutaml/integration/multi_format_spec.rb +23 -0
  40. data/spec/lutaml/model/attribute_spec.rb +8 -1
  41. data/spec/lutaml/model/choice_restrict_spec.rb +225 -0
  42. data/spec/lutaml/model/custom_model_spec.rb +4 -4
  43. data/spec/lutaml/model/mixed_content_spec.rb +50 -9
  44. data/spec/lutaml/model/multiple_mapping_spec.rb +4 -4
  45. data/spec/lutaml/model/ordered_content_spec.rb +3 -3
  46. data/spec/lutaml/model/raw_element_spec.rb +533 -0
  47. data/spec/lutaml/model/uninitialized_class_spec.rb +1 -1
  48. data/spec/lutaml/model/xsd_form_default_patterns_spec.rb +2 -2
  49. data/spec/lutaml/model/xsd_patterns_spec.rb +4 -4
  50. data/spec/lutaml/{jsonld → rdf}/context_spec.rb +2 -2
  51. data/spec/lutaml/{jsonld/transform_spec.rb → rdf/linked_data_transform_spec.rb} +18 -2
  52. data/spec/lutaml/rdf/mapping_spec.rb +2 -1
  53. data/spec/lutaml/{jsonld → rdf}/term_definition_spec.rb +2 -2
  54. data/spec/lutaml/turtle/transform_spec.rb +2 -2
  55. data/spec/lutaml/xml/enhanced_mapping_spec.rb +230 -1
  56. data/spec/lutaml/xml/mapping_spec.rb +4 -4
  57. data/spec/lutaml/xml/namespace_placement_spec.rb +11 -8
  58. data/spec/lutaml/xml/namespace_three_phase_spec.rb +1 -1
  59. data/spec/lutaml/xml/prefix_control_spec.rb +4 -4
  60. data/spec/lutaml/xml/serializable_namespace_spec.rb +6 -6
  61. data/spec/lutaml/xml/type_namespace_examples_spec.rb +1 -1
  62. data/spec/lutaml/xml/type_namespace_integration_spec.rb +3 -3
  63. data/spec/lutaml/yamlld/adapter_spec.rb +56 -0
  64. data/spec/lutaml/yamlld/registration_spec.rb +33 -0
  65. metadata +14 -8
@@ -1,34 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
-
5
3
  module Lutaml
6
- module JsonLd
7
- class Transform < Lutaml::Rdf::Transform
8
- def model_to_data(instance, _format, options = {})
9
- mapping = extract_mapping(options)
4
+ module Rdf
5
+ class LinkedDataTransform < Lutaml::Rdf::Transform
6
+ def model_to_data(instance, format, options = {})
7
+ mapping = extract_mapping(format, options)
10
8
  return {} unless mapping
11
9
 
12
10
  if mapping.rdf_members.any?
13
- build_graph_document(mapping, instance)
11
+ build_graph_document(mapping, instance, format)
14
12
  else
15
- build_resource_object(mapping, instance)
13
+ build_resource_object(mapping, instance, format)
16
14
  end
17
15
  end
18
16
 
19
- def data_to_model(data, _format, options = {})
20
- mapping = extract_mapping(options)
17
+ def data_to_model(data, format, options = {})
18
+ mapping = extract_mapping(format, options)
21
19
  return model_class.new unless mapping
22
20
 
23
- hash = data.is_a?(String) ? JSON.parse(data) : data
21
+ hash = data.is_a?(Hash) ? data : {}
24
22
 
25
23
  if hash.key?("@graph") && hash["@graph"].is_a?(Array) && !hash["@graph"].empty?
26
- graph_data = hash["@graph"]
27
- first = graph_data.first
24
+ first = hash["@graph"].first
28
25
  hash = first.is_a?(Hash) ? first : {}
29
26
  end
30
27
 
31
- hash = strip_jsonld_keywords(hash)
28
+ hash = strip_linked_data_keywords(hash)
32
29
 
33
30
  attrs = {}
34
31
  mapping.rdf_predicates.each do |rule|
@@ -53,29 +50,29 @@ module Lutaml
53
50
 
54
51
  private
55
52
 
56
- def extract_mapping(options)
57
- options[:mappings] || mappings_for(:jsonld, lutaml_register)
53
+ def extract_mapping(format, options)
54
+ options[:mappings] || mappings_for(format, lutaml_register)
58
55
  end
59
56
 
60
- def build_graph_document(mapping, instance)
61
- context = build_merged_context_recursive(mapping, instance)
62
- graph = collect_resources(mapping, instance)
57
+ def build_graph_document(mapping, instance, format)
58
+ context = build_merged_context_recursive(mapping, instance, format)
59
+ graph = collect_resources(mapping, instance, format)
63
60
 
64
61
  { "@context" => context, "@graph" => graph }
65
62
  end
66
63
 
67
- def collect_resources(mapping, instance)
64
+ def collect_resources(mapping, instance, format)
68
65
  graph = []
69
66
 
70
- resource = build_resource_data(mapping, instance)
67
+ resource = build_resource_data(mapping, instance, format)
71
68
  graph << resource unless resource.empty?
72
69
 
73
70
  mapping.rdf_members.each do |member_rule|
74
71
  each_member(instance, member_rule) do |member|
75
- member_mapping = member_mapping_for(member, :jsonld)
72
+ member_mapping = member_mapping_for(member, format)
76
73
  next unless member_mapping
77
74
 
78
- child_resources = collect_resources(member_mapping, member)
75
+ child_resources = collect_resources(member_mapping, member, format)
79
76
  graph.concat(child_resources)
80
77
  end
81
78
  end
@@ -83,17 +80,18 @@ module Lutaml
83
80
  graph
84
81
  end
85
82
 
86
- def build_merged_context_recursive(mapping, instance)
83
+ def build_merged_context_recursive(mapping, instance, format)
87
84
  context_hash = build_context_from_mapping(mapping).to_hash
88
85
 
89
86
  mapping.rdf_members.each do |member_rule|
90
87
  each_member(instance, member_rule) do |member|
91
- member_mapping = member_mapping_for(member, :jsonld)
88
+ member_mapping = member_mapping_for(member, format)
92
89
  next unless member_mapping
93
90
 
94
91
  context_hash.merge!(build_context_from_mapping(member_mapping).to_hash)
95
92
 
96
- child_ctx = build_merged_context_recursive(member_mapping, member)
93
+ child_ctx = build_merged_context_recursive(member_mapping, member,
94
+ format)
97
95
  context_hash.merge!(child_ctx)
98
96
  end
99
97
  end
@@ -134,13 +132,13 @@ module Lutaml
134
132
  end
135
133
  end
136
134
 
137
- def build_resource_object(mapping, instance)
135
+ def build_resource_object(mapping, instance, format)
138
136
  context = build_context_from_mapping(mapping).to_hash
139
- data = build_resource_data(mapping, instance)
137
+ data = build_resource_data(mapping, instance, format)
140
138
  { "@context" => context }.merge(data)
141
139
  end
142
140
 
143
- def build_resource_data(mapping, instance)
141
+ def build_resource_data(mapping, instance, format)
144
142
  result = {}
145
143
 
146
144
  if mapping.rdf_type.any?
@@ -166,10 +164,10 @@ module Lutaml
166
164
  mapping.rdf_members.each do |member_rule|
167
165
  next unless member_rule.linked?
168
166
 
169
- member_refs = collect_member_references(instance, member_rule)
167
+ member_refs = collect_member_references(instance, member_rule, format)
170
168
  next if member_refs.empty?
171
169
 
172
- key = jsonld_member_key(member_rule)
170
+ key = member_key(member_rule)
173
171
  result[key] = member_refs
174
172
  end
175
173
 
@@ -178,10 +176,10 @@ module Lutaml
178
176
  result
179
177
  end
180
178
 
181
- def collect_member_references(instance, member_rule)
179
+ def collect_member_references(instance, member_rule, format)
182
180
  refs = []
183
181
  each_member(instance, member_rule) do |member|
184
- member_mapping = member_mapping_for(member, :jsonld)
182
+ member_mapping = member_mapping_for(member, format)
185
183
  next unless member_mapping
186
184
 
187
185
  refs << { "@id" => resolve_subject_uri(member_mapping, member) }
@@ -189,7 +187,7 @@ module Lutaml
189
187
  refs
190
188
  end
191
189
 
192
- def jsonld_member_key(member_rule)
190
+ def member_key(member_rule)
193
191
  if member_rule.predicate_name
194
192
  member_rule.predicate_name.to_s
195
193
  elsif member_rule.link.is_a?(String)
@@ -241,7 +239,7 @@ module Lutaml
241
239
  end
242
240
  end
243
241
 
244
- def strip_jsonld_keywords(data)
242
+ def strip_linked_data_keywords(data)
245
243
  return data unless data.is_a?(Hash)
246
244
 
247
245
  data.reject { |key, _| key.start_with?("@") }
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lutaml
4
- module JsonLd
4
+ module Rdf
5
5
  class TermDefinition
6
6
  attr_reader :name, :id, :type, :container, :language, :reverse
7
7
 
data/lib/lutaml/rdf.rb CHANGED
@@ -15,5 +15,8 @@ module Lutaml
15
15
  autoload :MemberRule, "#{__dir__}/rdf/member_rule"
16
16
  autoload :Namespaces, "#{__dir__}/rdf/namespaces"
17
17
  autoload :Transform, "#{__dir__}/rdf/transform"
18
+ autoload :Context, "#{__dir__}/rdf/context"
19
+ autoload :TermDefinition, "#{__dir__}/rdf/term_definition"
20
+ autoload :LinkedDataTransform, "#{__dir__}/rdf/linked_data_transform"
18
21
  end
19
22
  end
@@ -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,
@@ -129,7 +129,7 @@ module Lutaml
129
129
  next if attr_is_namespace?(attr)
130
130
 
131
131
  ns_prefix = attr.namespace&.prefix
132
- ns_prefix = nil if ns_prefix&.empty?
132
+ ns_prefix = nil if ns_prefix && ns_prefix.empty?
133
133
 
134
134
  attr_name = ns_prefix ? "#{ns_prefix}:#{attr.name}" : attr.name
135
135
 
@@ -150,7 +150,7 @@ module Lutaml
150
150
  next if attr_is_namespace?(attr)
151
151
 
152
152
  ns_prefix = attr.namespace&.prefix
153
- ns_prefix = nil if ns_prefix&.empty?
153
+ ns_prefix = nil if ns_prefix && ns_prefix.empty?
154
154
 
155
155
  attr_name = ns_prefix ? "#{ns_prefix}:#{attr.name}" : attr.name
156
156
  order << attr_name
@@ -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
@@ -253,18 +251,6 @@ child_transformation)
253
251
  value.xml_namespace_prefix = ns_prefix
254
252
  end
255
253
 
256
- # Also set @__xml_namespace_prefix on the XmlElement for doubly-defined case.
257
- # This is read by NamespaceCollector to set NamespaceUsage.used_prefix.
258
- # For doubly-defined: parent has no ns, child's ns_prefix is from @__xml_ns_prefixes
259
- # (not from parent's @__xml_namespace_prefix).
260
- # For mixed content: parent's XmlElement already has @__xml_namespace_prefix set.
261
- if parent_ns_class.nil? && ns_prefix && !ns_prefix.empty? && !child_ns_class
262
- # Doubly-defined case: parent has no ns, child has ns class.
263
- # Set on XmlElement so NamespaceCollector reads it.
264
- # The value will be set on the model instance above (via @__xml_namespace_prefix)
265
- # and we'll set it on XmlElement too so collection phase picks it up.
266
- end
267
-
268
254
  child_element = child_transformation.transform(value, child_options)
269
255
 
270
256
  # Dual-namespace support: when the child model was deserialized from an element
@@ -306,15 +292,10 @@ child_transformation)
306
292
  child_element.name = rule.serialized_name
307
293
  end
308
294
 
309
- # W3C elementFormDefault: unqualified override
310
- # When parent's namespace has element_form_default :unqualified and the child's
311
- # namespace is the same as the parent's, override to blank namespace.
312
- # This ensures local elements are not namespace-qualified per W3C spec.
313
- if parent_element_form_default == :unqualified &&
314
- parent_ns_class &&
315
- child_element.namespace_class == parent_ns_class
316
- child_element.namespace_class = nil
317
- end
295
+ propagate_form(child_element, rule)
296
+ apply_unqualified_form_default(
297
+ child_element, rule, parent_ns_class, parent_element_form_default
298
+ )
318
299
 
319
300
  child_element
320
301
  end
@@ -358,7 +339,7 @@ child_transformation)
358
339
  element.xml_namespace_prefix = model_ns_prefix
359
340
  end
360
341
 
361
- element.form = rule.form if rule.form
342
+ propagate_form(element, rule)
362
343
  text = serialize_value(value, rule, rule.attribute_type, nil)
363
344
  element.text_content = text if text
364
345
  element
@@ -492,7 +473,7 @@ register_id)
492
473
  element.xml_namespace_prefix = ns_prefix
493
474
  end
494
475
 
495
- element.form = rule.form if rule.form
476
+ propagate_form(element, rule)
496
477
 
497
478
  # Handle Hash type - expand hash into child elements
498
479
  if value.is_a?(::Hash) && rule.attribute_type == Lutaml::Model::Type::Hash
@@ -525,6 +506,38 @@ register_id)
525
506
  end
526
507
  end
527
508
 
509
+ # Copy the rule's form option onto the produced element so downstream
510
+ # rules (ElementFormOptionRule) can read it. Without this propagation
511
+ # the form option would be lost on transformed/fallback/simple paths.
512
+ #
513
+ # @param element [::Lutaml::Xml::DataModel::XmlElement] The element to update
514
+ # @param rule [CompiledRule] The rule carrying the form option
515
+ def propagate_form(element, rule)
516
+ element.form = rule.form if rule.form_set?
517
+ end
518
+
519
+ # W3C elementFormDefault: unqualified override.
520
+ #
521
+ # When the parent schema declares elementFormDefault="unqualified" and
522
+ # a locally-declared child lives in the same namespace as the parent,
523
+ # the child MUST NOT be namespace-qualified (XSD spec). An explicit
524
+ # form: :qualified on the rule is the user's per-element opt-out and
525
+ # takes precedence over the schema default.
526
+ #
527
+ # @param element [::Lutaml::Xml::DataModel::XmlElement] The element to update
528
+ # @param rule [CompiledRule] The rule (checked for explicit form override)
529
+ # @param parent_ns_class [Class, nil] Parent's namespace class
530
+ # @param parent_form_default [Symbol, nil] Parent's element_form_default
531
+ def apply_unqualified_form_default(element, rule, parent_ns_class,
532
+ parent_form_default)
533
+ return if rule.form == :qualified
534
+ return if parent_form_default != :unqualified
535
+ return unless parent_ns_class
536
+ return unless element.namespace_class == parent_ns_class
537
+
538
+ element.namespace_class = nil
539
+ end
540
+
528
541
  # Apply nil marker to element if needed
529
542
  #
530
543
  # @param element [XmlElement] The element
@@ -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