lutaml-model 0.8.31 → 0.8.33

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 (34) 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/key_value/transform.rb +7 -1
  8. data/lib/lutaml/model/attribute.rb +67 -24
  9. data/lib/lutaml/model/collection.rb +5 -2
  10. data/lib/lutaml/model/collection_handler.rb +45 -2
  11. data/lib/lutaml/model/serialize/enum_handling.rb +5 -1
  12. data/lib/lutaml/model/validation.rb +49 -4
  13. data/lib/lutaml/model/version.rb +1 -1
  14. data/lib/lutaml/xml/format.rb +2 -0
  15. data/lib/lutaml/xml/model_transform.rb +76 -14
  16. data/lib/lutaml/xml/nested_collection_attribute.rb +95 -0
  17. data/lib/lutaml/xml/schema/xsd_schema.rb +396 -128
  18. data/lib/lutaml/xml/serialization/instance_methods.rb +2 -2
  19. data/lib/lutaml/xml/transformation/element_builder.rb +55 -8
  20. data/spec/lutaml/model/attribute_collection_spec.rb +156 -0
  21. data/spec/lutaml/model/attribute_spec.rb +1 -1
  22. data/spec/lutaml/model/cardinality_strict_spec.rb +614 -0
  23. data/spec/lutaml/model/cdata_spec.rb +1 -1
  24. data/spec/lutaml/model/collection_register_spec.rb +119 -0
  25. data/spec/lutaml/model/collection_single_value_coercion_spec.rb +222 -0
  26. data/spec/lutaml/model/root_mappings_spec.rb +10 -7
  27. data/spec/lutaml/model/schema/xsd_schema_spec.rb +785 -25
  28. data/spec/lutaml/model/serializable_validation_spec.rb +293 -0
  29. data/spec/lutaml/model/value_policy_spec.rb +6 -3
  30. data/spec/lutaml/model/xsd_form_default_patterns_spec.rb +12 -12
  31. data/spec/lutaml/model/xsd_patterns_spec.rb +29 -29
  32. data/spec/lutaml/model/xsd_type_validation_spec.rb +1 -1
  33. data/spec/lutaml/turtle/transform_spec.rb +2 -2
  34. metadata +5 -1
@@ -12,6 +12,8 @@ module Lutaml
12
12
  # - Used by model's serialization pipeline via Transform.for(:xml)
13
13
  #
14
14
  class ModelTransform < ::Lutaml::Model::Transform
15
+ include NestedCollectionAttribute
16
+
15
17
  # Performance: Frozen empty hash to reduce allocations
16
18
  EMPTY_HASH = {}.freeze
17
19
 
@@ -361,6 +363,13 @@ module Lutaml
361
363
  value = normalize_xml_value(value, rule, attr, new_opts,
362
364
  effective_register)
363
365
  value = rule.transform_value(attr, value, :from, :xml)
366
+ # An over-count is normally left for `.validate` to report. A mapped
367
+ # PORO has no `.validate`, so deferring there would discard the
368
+ # violation entirely — check it while there is still someone to tell.
369
+ if !instance_is_serialize && attr && !attr.collection? &&
370
+ !rule.content_mapping?
371
+ attr.valid_collection!(value, context)
372
+ end
364
373
  rule.deserialize(instance, value, attributes, context)
365
374
 
366
375
  instance.value_set_for(rule_to)
@@ -460,7 +469,31 @@ _effective_register)
460
469
  end
461
470
  end
462
471
 
463
- def value_for_xml_attribute(doc, rule, rule_names)
472
+ # Whether no OTHER attribute rule in the mapping claims the same
473
+ # local name. A sole claimant is namespace-flexible on the local-
474
+ # name fallback: real-world documents qualify such attributes in
475
+ # the element's namespace, a foreign extension namespace, or not
476
+ # at all (OOXML w:val on w15: elements — lutaml-model#790). When
477
+ # a sibling claims the name, matching stays strict so each rule
478
+ # keeps its own namespace (#744, #758).
479
+ def attribute_rule_sole_local_claimant?(instance, rule,
480
+ effective_register)
481
+ klass = instance.class
482
+ return true unless klass.include?(Lutaml::Model::Serialize)
483
+
484
+ mapping = klass.mappings_for(:xml, effective_register)
485
+ return true unless mapping
486
+
487
+ expected = Array(rule.name).map(&:to_s)
488
+ mapping.mappings.none? do |sib|
489
+ next false unless sib.attribute? && !sib.equal?(rule)
490
+
491
+ Array(sib.name).map(&:to_s) == expected
492
+ end
493
+ end
494
+
495
+ def value_for_xml_attribute(doc, rule, rule_names,
496
+ flexible_local: false)
464
497
  # For attributes, rule_names may contain namespaced names, but find_attribute_value
465
498
  # expects prefix:name or just name, so we need to convert namespaced names to prefix format.
466
499
  #
@@ -487,7 +520,8 @@ _effective_register)
487
520
  # This handles the case where the namespace prefix is not declared in the document
488
521
  # (e.g., v:ext without xmlns:v="...").
489
522
  if value.nil? && rule_names.any? { |rn| rn.include?(":") }
490
- value = find_attribute_by_local_name(doc, rule_names)
523
+ value = find_attribute_by_local_name(doc, rule_names,
524
+ flexible_local: flexible_local)
491
525
  end
492
526
 
493
527
  value = value&.split(rule.delimiter) if rule.delimiter
@@ -550,7 +584,8 @@ _effective_register)
550
584
  # @param doc [XmlElement] the parsed XML document root
551
585
  # @param rule_names [Array<String>] the rule names to match
552
586
  # @return [String, nil] the attribute value or nil
553
- def find_attribute_by_local_name(doc, rule_names)
587
+ def find_attribute_by_local_name(doc, rule_names,
588
+ flexible_local: false)
554
589
  rule_names.each do |rn|
555
590
  next unless rn.include?(":")
556
591
 
@@ -570,7 +605,12 @@ _effective_register)
570
605
  # namespaced sibling attribute (lutaml-model#758). An
571
606
  # XmlAttribute with a resolved namespace has namespaced_name
572
607
  # "URI:local", so this comparison is allocation-free.
573
- if attr.namespace.nil?
608
+ if flexible_local
609
+ # Sole claimant: any qualification of the local name binds
610
+ # (element namespace, foreign extension namespace, none).
611
+ attr.unprefixed_name == local_name ||
612
+ ((colon = attr.name.rindex(":")) ? attr.name[(colon + 1)..] : attr.name) == local_name
613
+ elsif attr.namespace.nil?
574
614
  next false if attr.namespace_prefix.nil? &&
575
615
  !attr.name.include?(":")
576
616
 
@@ -621,7 +661,14 @@ _effective_register)
621
661
  # return the same children again.
622
662
  rule_names = rule_names.concat(extra_rule_names).uniq if extra_rule_names
623
663
 
624
- return value_for_xml_attribute(doc, rule, rule_names) if rule.attribute?
664
+ if rule.attribute?
665
+ return value_for_xml_attribute(
666
+ doc, rule, rule_names,
667
+ flexible_local: attribute_rule_sole_local_claimant?(
668
+ instance, rule, effective_register
669
+ )
670
+ )
671
+ end
625
672
 
626
673
  # Performance: Pre-compute type-related values used in the hot loop
627
674
  attr_type_is_serializable = attr_type && attr_type <= ::Lutaml::Model::Serialize
@@ -773,6 +820,10 @@ _effective_register)
773
820
  rule_has_custom_method = rule.has_custom_method_for_deserialization?
774
821
  if rule_has_custom_method || attr_type == ::Lutaml::Model::Type::Hash
775
822
  return_child = attr_type == ::Lutaml::Model::Type::Hash || !attr.collection? if attr
823
+ # A custom `from:` receives the over-count in the same shape a custom
824
+ # `to:` already gets for a collection.
825
+ return children if attr && !attr.collection? && children.size > 1
826
+
776
827
  return return_child ? children.first : children
777
828
  end
778
829
 
@@ -797,12 +848,20 @@ _effective_register)
797
848
  base_cast_options[:lutaml_root] = instance.lutaml_root || instance
798
849
  base_cast_options[:resolved_type] = attr_type
799
850
 
851
+ nested_mapping = nested_collection_attribute_mapping(attr, attr_type, effective_register)
852
+
800
853
  children.each do |child|
801
854
  if !rule_has_custom_method &&
802
855
  (attr_type_is_serializable ||
803
856
  (attr&.union? && ::Lutaml::Model::Type::Union.xml_structured?(child)))
857
+ cast_child = if attr_type_is_serializable
858
+ nested_collection_attribute_node(child, nested_mapping)
859
+ else
860
+ child
861
+ end
862
+
804
863
  # Performance: Build cast_options efficiently (dup + []= cheaper than merge)
805
- cast_options = if (child_namespace_uri = child.namespace_uri)
864
+ cast_options = if (child_namespace_uri = cast_child.namespace_uri)
806
865
  ns_type = attr.type_with_namespace(effective_register,
807
866
  child_namespace_uri)
808
867
  cast_options = base_cast_options.dup
@@ -813,7 +872,7 @@ _effective_register)
813
872
  base_cast_options
814
873
  end
815
874
 
816
- cast_result = attr.cast(child, :xml, effective_register,
875
+ cast_result = attr.cast(cast_child, :xml, effective_register,
817
876
  cast_options)
818
877
 
819
878
  # Track original namespace prefix for doubly-defined namespace support.
@@ -821,10 +880,7 @@ _effective_register)
821
880
  # we need to preserve which prefix was used for round-trip fidelity.
822
881
  # Store on the PARENT model instance keyed by attribute name.
823
882
  # Set for ALL attributes (both Serializable and non-Serializable).
824
- ns_prefix = if child.namespace_prefix_explicit &&
825
- child.namespace_prefix
826
- child.namespace_prefix
827
- end
883
+ ns_prefix = explicit_namespace_prefix(child)
828
884
  if ns_prefix && instance_is_serialize
829
885
  prefixes = instance.xml_ns_prefixes || {}
830
886
  prefixes[attr.name] = ns_prefix
@@ -835,7 +891,7 @@ _effective_register)
835
891
  # When parsing XML with alias URIs (e.g., "http://.../") against a namespace
836
892
  # class with canonical URI (e.g., "http://.../reqif.xsd"), store the original
837
893
  # alias URI so it can be serialized back correctly.
838
- child_uri = child.namespace_uri
894
+ child_uri = cast_child.namespace_uri
839
895
  if child_uri && attr_type_is_serializable
840
896
  child_mapping = cast_result.class.mappings_for(:xml)
841
897
  child_ns_class = child_mapping&.namespace_class
@@ -858,8 +914,10 @@ _effective_register)
858
914
  # - Nil/empty: doubly-defined case -> set @__xml_namespace_prefix on child
859
915
  # - Set: mixed content case -> don't set (child has its own namespace)
860
916
  # Performance: Use cached parent_ns_prefix instead of re-fetching
861
- if attr_type_is_serializable && ns_prefix && (parent_ns_prefix.nil? || parent_ns_prefix.to_s.empty?)
862
- cast_result.xml_namespace_prefix = ns_prefix
917
+ cast_child_ns_prefix = nested_collection_attribute_prefix(child, cast_child, ns_prefix)
918
+ if attr_type_is_serializable && cast_child_ns_prefix &&
919
+ (parent_ns_prefix.nil? || parent_ns_prefix.to_s.empty?)
920
+ cast_result.xml_namespace_prefix = cast_child_ns_prefix
863
921
  end
864
922
 
865
923
  values << cast_result
@@ -922,6 +980,10 @@ _effective_register)
922
980
  return [] if attr&.collection? && [[nil], [""]].include?(values)
923
981
  return values if attr&.collection?
924
982
 
983
+ # Keep the over-count (don't collapse via .first) so it reaches the
984
+ # attribute slot and `.validate` can report the cardinality violation.
985
+ return values if values.is_a?(Array) && values.size > 1
986
+
925
987
  values.is_a?(Array) ? values.first : values
926
988
  end
927
989
 
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ module NestedCollectionAttribute
6
+ private
7
+
8
+ def nested_collection_attribute_mapping(attr, attr_type, register)
9
+ return unless nested_collection_attribute?(attr, attr_type)
10
+
11
+ collection_mapping = attr_type.mappings_for(:xml, register)
12
+ root_name = collection_mapping&.element_name ||
13
+ collection_mapping&.root_element
14
+ return if root_name.nil?
15
+
16
+ root_name = root_name.to_s
17
+ return if root_name.empty?
18
+
19
+ [root_name, collection_mapping]
20
+ end
21
+
22
+ def nested_collection_attribute_node(child, nested_collection_mapping)
23
+ return child unless nested_collection_mapping
24
+
25
+ root_name, collection_mapping = nested_collection_mapping
26
+ return child if xml_element_matches_name?(child, root_name,
27
+ collection_mapping)
28
+
29
+ child.element_children.find do |nested_child|
30
+ xml_element_matches_name?(nested_child, root_name,
31
+ collection_mapping)
32
+ end || child
33
+ end
34
+
35
+ def nested_collection_attribute_prefix(child, cast_child, ns_prefix)
36
+ return ns_prefix if cast_child.equal?(child)
37
+
38
+ explicit_namespace_prefix(cast_child)
39
+ end
40
+
41
+ def nested_collection_attribute_element?(rule, value, child_element)
42
+ nested_collection_attribute_value?(rule, value) &&
43
+ rule.serialized_name != child_element.name
44
+ end
45
+
46
+ def nested_collection_attribute_value?(rule, value)
47
+ value.is_a?(::Lutaml::Model::Collection) &&
48
+ nested_collection_attribute_type?(rule.attribute_type) &&
49
+ !rule.collection?
50
+ end
51
+
52
+ def nested_collection_wrapper_prefix(rule, options)
53
+ parent_model = options[:current_model]
54
+ return unless parent_model.is_a?(::Lutaml::Model::Serialize)
55
+
56
+ prefix = parent_model.xml_ns_prefixes&.[](rule.attribute_name)
57
+ prefix unless prefix.to_s.empty?
58
+ end
59
+
60
+ def explicit_namespace_prefix(element)
61
+ return unless element.namespace_prefix_explicit &&
62
+ element.namespace_prefix
63
+
64
+ element.namespace_prefix
65
+ end
66
+
67
+ def nested_collection_attribute?(attr, attr_type)
68
+ attr&.singular? && nested_collection_attribute_type?(attr_type)
69
+ end
70
+
71
+ def nested_collection_attribute_type?(attr_type)
72
+ attr_type.is_a?(Class) &&
73
+ attr_type <= ::Lutaml::Model::Collection
74
+ end
75
+
76
+ def xml_element_matches_name?(element, name, mapping)
77
+ element.unprefixed_name == name &&
78
+ xml_element_matches_namespace?(element.namespace_uri, mapping)
79
+ end
80
+
81
+ def xml_element_matches_namespace?(namespace_uri, mapping)
82
+ namespace_class = mapping&.namespace_class
83
+ mapping_uri = mapping&.namespace_uri
84
+ return namespace_uri.nil? unless namespace_class || mapping_uri
85
+ return true if mapping_uri && namespace_uri == mapping_uri
86
+
87
+ if namespace_class.respond_to?(:all_uris)
88
+ namespace_class.all_uris.include?(namespace_uri)
89
+ else
90
+ namespace_uri == namespace_class&.uri
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end