lutaml-model 0.8.30 → 0.8.32

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +25 -23
  3. data/docs/_pages/breaking-changes.adoc +58 -0
  4. data/docs/_pages/collections.adoc +25 -24
  5. data/docs/_pages/validation.adoc +48 -1
  6. data/lib/compat/opal/lutaml_model_boot.rb +1 -0
  7. data/lib/lutaml/json/adapter/document.rb +2 -0
  8. data/lib/lutaml/json/adapter/standard_adapter.rb +1 -0
  9. data/lib/lutaml/key_value/transform.rb +7 -1
  10. data/lib/lutaml/model/attribute.rb +67 -24
  11. data/lib/lutaml/model/collection.rb +5 -2
  12. data/lib/lutaml/model/collection_handler.rb +45 -2
  13. data/lib/lutaml/model/serialize/enum_handling.rb +5 -1
  14. data/lib/lutaml/model/validation.rb +49 -4
  15. data/lib/lutaml/model/version.rb +1 -1
  16. data/lib/lutaml/xml/format.rb +25 -1
  17. data/lib/lutaml/xml/mapping_rule.rb +8 -1
  18. data/lib/lutaml/xml/model_transform.rb +68 -12
  19. data/lib/lutaml/xml/nested_collection_attribute.rb +95 -0
  20. data/lib/lutaml/xml/schema/xsd_schema.rb +396 -128
  21. data/lib/lutaml/xml/serialization/instance_methods.rb +2 -2
  22. data/lib/lutaml/xml/transformation/element_builder.rb +72 -9
  23. data/lib/lutaml/yaml/adapter/document.rb +2 -0
  24. data/lib/lutaml/yaml/adapter/standard_adapter.rb +2 -0
  25. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +7 -3
  26. data/spec/lutaml/model/attribute_collection_spec.rb +156 -0
  27. data/spec/lutaml/model/attribute_spec.rb +1 -1
  28. data/spec/lutaml/model/cardinality_strict_spec.rb +614 -0
  29. data/spec/lutaml/model/cdata_spec.rb +1 -1
  30. data/spec/lutaml/model/collection_register_spec.rb +119 -0
  31. data/spec/lutaml/model/collection_single_value_coercion_spec.rb +222 -0
  32. data/spec/lutaml/model/raw_adapter_require_spec.rb +22 -0
  33. data/spec/lutaml/model/root_mappings_spec.rb +10 -7
  34. data/spec/lutaml/model/schema/xsd_schema_spec.rb +785 -25
  35. data/spec/lutaml/model/serializable_validation_spec.rb +293 -0
  36. data/spec/lutaml/model/value_policy_spec.rb +6 -3
  37. data/spec/lutaml/model/xsd_form_default_patterns_spec.rb +12 -12
  38. data/spec/lutaml/model/xsd_patterns_spec.rb +29 -29
  39. data/spec/lutaml/model/xsd_type_validation_spec.rb +1 -1
  40. data/spec/lutaml/turtle/transform_spec.rb +2 -2
  41. data/spec/lutaml/xml/cross_class_dispatch_spec.rb +79 -0
  42. data/spec/lutaml/xml/plain_model_accessors_spec.rb +50 -0
  43. data/spec/lutaml/xml/type_qualified_attribute_leniency_spec.rb +66 -0
  44. metadata +9 -1
@@ -266,8 +266,8 @@ module Lutaml
266
266
  # Append XSD schema errors (validate_xml_with macro) to the
267
267
  # standard validation errors. No-op unless schemas are configured.
268
268
  #
269
- # @see Lutaml::Model::Validation#validate
270
- def validate(register: Lutaml::Model::Config.default_register)
269
+ # @see Lutaml::Model::Validation#collect_validation_errors
270
+ def collect_validation_errors(register)
271
271
  errors = super
272
272
  return errors if self.class.xml_schema_paths.empty?
273
273
 
@@ -12,6 +12,7 @@ module Lutaml
12
12
  # - Simple value serialization
13
13
  module ElementBuilder
14
14
  include ValueSerializer
15
+ include ::Lutaml::Xml::NestedCollectionAttribute
15
16
 
16
17
  # Create an element for a value
17
18
  #
@@ -140,9 +141,25 @@ parent_element_form_default)
140
141
  actual_class = resolve_polymorphic_class(rule, value, is_polymorphic,
141
142
  is_polymorphic_subtype)
142
143
 
144
+ # Dispatch on value's actual class when it is a Serializable that
145
+ # differs from the declared attribute_type. This covers ad-hoc
146
+ # polymorphism (caller assigns an unrelated Serializable to a typed
147
+ # slot) and prevents the wrong transformation being applied
148
+ # silently. Subsumes is_polymorphic_subtype; preserved here for
149
+ # the resolve_polymorphic_class call above.
150
+ union || is_polymorphic ||
151
+ (value.is_a?(Lutaml::Model::Serialize) &&
152
+ value.class != rule.attribute_type)
153
+
143
154
  # Get transformation for the actual class. Unions resolve the member
144
155
  # from the value's own class, like polymorphism.
145
- child_transformation = if union
156
+ # Unions and unrelated Serializables dispatch on the value's
157
+ # own class (subsumes the subtype case); polymorphic config
158
+ # keeps its resolved-class dispatch (the class_map target).
159
+ child_transformation = if union ||
160
+ (!is_polymorphic &&
161
+ value.is_a?(Lutaml::Model::Serialize) &&
162
+ value.class != rule.attribute_type)
146
163
  value.class.transformation_for(:xml,
147
164
  register)
148
165
  elsif is_polymorphic || is_polymorphic_subtype
@@ -233,6 +250,9 @@ child_transformation)
233
250
  prefixes = parent_model.xml_ns_prefixes
234
251
  ns_prefix = prefixes[rule.attribute_name] if prefixes
235
252
  end
253
+ ns_prefix_present = ns_prefix && !ns_prefix.empty?
254
+ nested_collection_attribute =
255
+ nested_collection_attribute_value?(rule, value)
236
256
  child_self_declared_ns = child_ns_class &&
237
257
  parent_ns_class &&
238
258
  child_ns_class != parent_ns_class
@@ -252,18 +272,24 @@ child_transformation)
252
272
  # This preserves the dual-namespace behavior (w:rPr inside m:r) while preventing
253
273
  # stale prefixes (dcterms prefix leaking to children that should use default ns).
254
274
  dual_namespace_applies = child_self_declared_ns && ns_prefix_valid
275
+ parent_prefix_applies_to_child =
276
+ !nested_collection_attribute &&
277
+ ns_prefix_present &&
278
+ !child_self_declared_ns &&
279
+ (parent_ns_class.nil? ||
280
+ (child_ns_class && child_ns_class.uri == parent_ns_class.uri) ||
281
+ dual_namespace_applies)
255
282
  # Parent has no ns OR child shares parent's URI (and child doesn't self-declare ns)
256
283
  # OR dual-namespace case where child's ns_prefix matches its namespace expectation
257
- if (parent_ns_class.nil? || (child_ns_class && child_ns_class.uri == parent_ns_class.uri) ||
258
- dual_namespace_applies) &&
259
- !child_self_declared_ns && ns_prefix && !ns_prefix.empty?
260
- value.xml_namespace_prefix = ns_prefix
261
- end
284
+ value.xml_namespace_prefix = ns_prefix if parent_prefix_applies_to_child
285
+
286
+ dual_namespace_prefix_applies =
287
+ !nested_collection_attribute &&
288
+ dual_namespace_applies &&
289
+ ns_prefix_present
262
290
  # For dual-namespace case: set @__xml_namespace_prefix on model so it can be
263
291
  # transferred to XmlElement at lines 282-289 for prefix preservation.
264
- if dual_namespace_applies && ns_prefix && !ns_prefix.empty?
265
- value.xml_namespace_prefix = ns_prefix
266
- end
292
+ value.xml_namespace_prefix = ns_prefix if dual_namespace_prefix_applies
267
293
 
268
294
  child_element = child_transformation.transform(value, child_options)
269
295
 
@@ -301,6 +327,16 @@ child_transformation)
301
327
  child_element.xml_namespace_prefix = nil
302
328
  end
303
329
 
330
+ if nested_collection_attribute_element?(rule, value, child_element)
331
+ return create_nested_collection_wrapper_element(
332
+ rule,
333
+ child_element,
334
+ options,
335
+ parent_ns_class,
336
+ parent_element_form_default,
337
+ )
338
+ end
339
+
304
340
  # Use parent's mapping name, not child's root name
305
341
  if rule.serialized_name != child_element.name
306
342
  child_element.name = rule.serialized_name
@@ -314,6 +350,33 @@ child_transformation)
314
350
  child_element
315
351
  end
316
352
 
353
+ def create_nested_collection_wrapper_element(
354
+ rule,
355
+ child_element,
356
+ options,
357
+ parent_ns_class,
358
+ parent_element_form_default
359
+ )
360
+ element_namespace_class = determine_element_namespace(
361
+ rule,
362
+ parent_ns_class,
363
+ parent_element_form_default,
364
+ )
365
+ element = ::Lutaml::Xml::DataModel::XmlElement.new(
366
+ rule.serialized_name,
367
+ element_namespace_class,
368
+ )
369
+
370
+ ns_prefix = nested_collection_wrapper_prefix(rule, options)
371
+ element.xml_namespace_prefix = ns_prefix if ns_prefix
372
+
373
+ propagate_form(element, rule)
374
+ apply_unqualified_form_default(
375
+ element, rule, parent_ns_class, parent_element_form_default
376
+ )
377
+ element.add_child(child_element)
378
+ end
379
+
317
380
  # Check if a prefix is valid for a namespace class by verifying it maps to the same URI.
318
381
  # This ensures we only use ns_prefix from @__xml_ns_prefixes when it actually matches
319
382
  # the child's expected namespace prefix.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "lutaml/key_value/document"
4
+
3
5
  module Lutaml
4
6
  module Yaml
5
7
  module Adapter
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "document"
4
+
3
5
  require "date"
4
6
  require "yaml"
5
7
 
@@ -36,9 +36,13 @@ module Lutaml
36
36
  @attributes
37
37
  end
38
38
 
39
- require "yeptris"
40
- require "yeptris/yaml"
41
- Yeptris::YAML.dump(attributes_to_serialize)
39
+ # Generation must match the standard (Psych) adapter's output
40
+ # byte-for-byte: document separator, quoting of digit-leading
41
+ # strings, flow styles. The neutral Yeptris::YAML.dump is
42
+ # deliberately headerless, so parity goes through the
43
+ # Psych-compatible face of the same engine.
44
+ require "yeptris/psych"
45
+ Yeptris::Psych.dump(attributes_to_serialize)
42
46
  end
43
47
  end
44
48
  end
@@ -27,6 +27,68 @@ module AttributeCollection
27
27
  title_parts.to_s
28
28
  end
29
29
  end
30
+
31
+ class NestedTitle < Lutaml::Model::Serializable
32
+ attribute :content, :string
33
+
34
+ xml do
35
+ element "artifact"
36
+ map_element "content", to: :content
37
+ end
38
+ end
39
+
40
+ class NestedTitleCollection < Lutaml::Model::Collection
41
+ instances :items, NestedTitle
42
+
43
+ xml do
44
+ element "title-group"
45
+ map_element "artifact", to: :items
46
+ end
47
+ end
48
+
49
+ class NestedBibliographicItem < Lutaml::Model::Serializable
50
+ attribute :titles, NestedTitleCollection
51
+
52
+ xml do
53
+ element "bibitem"
54
+ map_element "titles", to: :titles
55
+ end
56
+ end
57
+
58
+ class NestedTitleNamespace < Lutaml::Xml::Namespace
59
+ uri "http://example.com/nested-title"
60
+ uri_aliases "http://example.com/nested-title-alias"
61
+ prefix_default "nt"
62
+ end
63
+
64
+ class NamespacedNestedTitle < Lutaml::Model::Serializable
65
+ attribute :content, :string
66
+
67
+ xml do
68
+ element "artifact"
69
+ namespace NestedTitleNamespace
70
+ map_element "content", to: :content
71
+ end
72
+ end
73
+
74
+ class NamespacedNestedTitleCollection < Lutaml::Model::Collection
75
+ instances :items, NamespacedNestedTitle
76
+
77
+ xml do
78
+ element "title-group"
79
+ namespace NestedTitleNamespace
80
+ map_element "artifact", to: :items
81
+ end
82
+ end
83
+
84
+ class NamespacedNestedBibliographicItem < Lutaml::Model::Serializable
85
+ attribute :titles, NamespacedNestedTitleCollection
86
+
87
+ xml do
88
+ element "bibitem"
89
+ map_element "titles", to: :titles
90
+ end
91
+ end
30
92
  end
31
93
 
32
94
  RSpec.describe AttributeCollection do
@@ -101,4 +163,98 @@ RSpec.describe AttributeCollection do
101
163
  expect(bib_item.title_parts.parts).to eq(["Custom One", "Custom Two"])
102
164
  end
103
165
  end
166
+
167
+ describe "BibliographicItem with nested collection element" do
168
+ let(:nested_xml_data) do
169
+ <<~XML
170
+ <bibitem>
171
+ <titles>
172
+ <title-group>
173
+ <artifact>
174
+ <content>Title One</content>
175
+ </artifact>
176
+ <artifact>
177
+ <content>Title Two</content>
178
+ </artifact>
179
+ <artifact>
180
+ <content>Title Three</content>
181
+ </artifact>
182
+ </title-group>
183
+ </titles>
184
+ </bibitem>
185
+ XML
186
+ end
187
+
188
+ it "deserializes the collection from its nested root element" do
189
+ bib_item = AttributeCollection::NestedBibliographicItem.from_xml(
190
+ nested_xml_data,
191
+ )
192
+
193
+ expect(bib_item.titles).to be_a(AttributeCollection::NestedTitleCollection)
194
+ expect(bib_item.titles.items.map(&:content)).to eq(
195
+ ["Title One", "Title Two", "Title Three"],
196
+ )
197
+ end
198
+
199
+ it "serializes the collection under the mapped parent element" do
200
+ titles = AttributeCollection::NestedTitleCollection.new(
201
+ [
202
+ AttributeCollection::NestedTitle.new(content: "Title One"),
203
+ AttributeCollection::NestedTitle.new(content: "Title Two"),
204
+ AttributeCollection::NestedTitle.new(content: "Title Three"),
205
+ ],
206
+ )
207
+ bib_item = AttributeCollection::NestedBibliographicItem.new(
208
+ titles: titles,
209
+ )
210
+
211
+ expect(bib_item.to_xml).to be_xml_equivalent_to(nested_xml_data)
212
+ end
213
+
214
+ it "does not unwrap a matching collection root from the wrong namespace" do
215
+ xml = <<~XML
216
+ <bibitem xmlns:nt="http://example.com/nested-title"
217
+ xmlns:other="http://example.com/other-title">
218
+ <titles>
219
+ <other:title-group>
220
+ <other:artifact>
221
+ <other:content>Wrong namespace</other:content>
222
+ </other:artifact>
223
+ </other:title-group>
224
+ <nt:title-group>
225
+ <nt:artifact>
226
+ <nt:content>Right namespace</nt:content>
227
+ </nt:artifact>
228
+ </nt:title-group>
229
+ </titles>
230
+ </bibitem>
231
+ XML
232
+
233
+ bib_item = AttributeCollection::NamespacedNestedBibliographicItem.from_xml(
234
+ xml,
235
+ )
236
+
237
+ expect(bib_item.titles.items.map(&:content)).to eq(["Right namespace"])
238
+ end
239
+
240
+ it "accepts namespace aliases for the nested collection root" do
241
+ xml = <<~XML
242
+ <bibitem xmlns:alias="http://example.com/nested-title-alias">
243
+ <titles>
244
+ <alias:title-group>
245
+ <alias:artifact>
246
+ <alias:content>Alias namespace</alias:content>
247
+ </alias:artifact>
248
+ </alias:title-group>
249
+ </titles>
250
+ </bibitem>
251
+ XML
252
+
253
+ bib_item = AttributeCollection::NamespacedNestedBibliographicItem.from_xml(
254
+ xml,
255
+ )
256
+
257
+ expect(bib_item.titles.items.map(&:content)).to eq(["Alias namespace"])
258
+ end
259
+ end
104
260
  end
@@ -43,7 +43,7 @@ RSpec.describe Lutaml::Model::Attribute do
43
43
  expect { obj.image = Pathname.new("avatar.png") }
44
44
  .to change { obj.image }
45
45
  .from(nil)
46
- .to("avatar.png")
46
+ .to(["avatar.png"])
47
47
  end
48
48
 
49
49
  it "raises error if both type and method_name are not given" do