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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.30"
5
+ VERSION = "0.8.32"
6
6
  end
7
7
  end
@@ -128,6 +128,8 @@ module Lutaml
128
128
  autoload :TransformationBuilder, "#{__dir__}/transformation_builder"
129
129
  autoload :AdapterLoader, "#{__dir__}/adapter_loader"
130
130
  autoload :Element, "#{__dir__}/element"
131
+ autoload :NestedCollectionAttribute,
132
+ "#{__dir__}/nested_collection_attribute"
131
133
  autoload :ModelTransform, "#{__dir__}/model_transform"
132
134
  autoload :ParseSession, "#{__dir__}/parse_session"
133
135
  autoload :TypeNamespaceResolver, "#{__dir__}/type_namespace_resolver"
@@ -239,7 +241,21 @@ Lutaml::Model::Serialize.prepend(
239
241
  # ModelImportExt override (root?) must land on ClassMethods too. The other
240
242
  # two (FormatConversion, InstanceMethods) are already prepended unconditionally
241
243
  # above, so Opal's no-double-prepend rule means we don't repeat them here.
242
- if Lutaml::Model.opal?
244
+ #
245
+ # TruffleRuby (34.0.1) has the same propagation gap, on both the include
246
+ # side and the extend/singleton side — a module prepended into an
247
+ # already-extended module does not reach prior extenders' singleton chains
248
+ # either (https://github.com/truffleruby/truffleruby/issues/4452). Without
249
+ # the include-side re-prepends below, Mml.parse et al. die with
250
+ # "undefined method 'pending_plan_root_element='". The extend side
251
+ # additionally needs FormatConversion re-prepended onto Serializable's
252
+ # singleton: otherwise `model PlainClass` runs the no-op base
253
+ # add_format_specific_model_methods and plain (non-Serialize) model classes
254
+ # never receive their XML accessors (encoding=, element_order=, ordered=,
255
+ # mixed, doctype, xml_declaration, raw_schema_location), so
256
+ # ModelTransform#apply_xml_mapping dies at `instance.encoding = ...` —
257
+ # e.g. every relaton ItemData parse via Relaton::Cli.parse_xml.
258
+ if Lutaml::Model.opal? || RUBY_ENGINE == "truffleruby"
243
259
  Lutaml::Model::Serializable.singleton_class.prepend(
244
260
  Lutaml::Xml::Serialization::ModelImportExt,
245
261
  )
@@ -251,6 +267,14 @@ if Lutaml::Model.opal?
251
267
  Lutaml::Model::Serialize::ClassMethods.prepend(
252
268
  Lutaml::Xml::Serialization::ModelImportExt,
253
269
  )
270
+
271
+ # TruffleRuby only: Opal's no-double-prepend rule makes the unconditional
272
+ # ClassMethods.prepend(FormatConversion) above sufficient there.
273
+ if RUBY_ENGINE == "truffleruby"
274
+ Lutaml::Model::Serializable.singleton_class.prepend(
275
+ Lutaml::Xml::Serialization::FormatConversion,
276
+ )
277
+ end
254
278
  end
255
279
 
256
280
  # Register XML-specific attribute override warning names
@@ -323,7 +323,13 @@ module Lutaml
323
323
  strict_match = uri && ns_info[:prefix] &&
324
324
  !ns_info[:unqualified_same_ns] && !ns_info[:via_schema_default]
325
325
  if strict_match
326
- ["#{uri}:#{name}"].freeze
326
+ # Type-level qualification stays lenient on parse: a W3C
327
+ # unprefixed attribute carries no namespace, and 0.8.19 bound
328
+ # it to the type-qualified rule (lutaml-model#786). Only rule
329
+ # and schema-level qualification is strictly prefixed-only.
330
+ names = ["#{uri}:#{name}"]
331
+ names << name.to_s if ns_info[:via_type_namespace]
332
+ names.freeze
327
333
  else
328
334
  namespaced_names(default_namespace)
329
335
  end
@@ -471,6 +477,7 @@ form_default = :unqualified)
471
477
  if attr && (type_ns_class = attr.type_namespace_class(register)) &&
472
478
  type_ns_class != :blank
473
479
  result = build_namespace_result_from_class(type_ns_class)
480
+ result[:via_type_namespace] = true
474
481
  # CRITICAL W3C FLAG: Mark when attribute is in same namespace as parent with :unqualified
475
482
  # Serialization code will check this to omit prefix per attributeFormDefault
476
483
  if type_ns_class.uri == parent_ns_class&.uri && form_default == :unqualified
@@ -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)
@@ -773,6 +782,10 @@ _effective_register)
773
782
  rule_has_custom_method = rule.has_custom_method_for_deserialization?
774
783
  if rule_has_custom_method || attr_type == ::Lutaml::Model::Type::Hash
775
784
  return_child = attr_type == ::Lutaml::Model::Type::Hash || !attr.collection? if attr
785
+ # A custom `from:` receives the over-count in the same shape a custom
786
+ # `to:` already gets for a collection.
787
+ return children if attr && !attr.collection? && children.size > 1
788
+
776
789
  return return_child ? children.first : children
777
790
  end
778
791
 
@@ -797,12 +810,20 @@ _effective_register)
797
810
  base_cast_options[:lutaml_root] = instance.lutaml_root || instance
798
811
  base_cast_options[:resolved_type] = attr_type
799
812
 
813
+ nested_mapping = nested_collection_attribute_mapping(attr, attr_type, effective_register)
814
+
800
815
  children.each do |child|
801
816
  if !rule_has_custom_method &&
802
817
  (attr_type_is_serializable ||
803
818
  (attr&.union? && ::Lutaml::Model::Type::Union.xml_structured?(child)))
819
+ cast_child = if attr_type_is_serializable
820
+ nested_collection_attribute_node(child, nested_mapping)
821
+ else
822
+ child
823
+ end
824
+
804
825
  # Performance: Build cast_options efficiently (dup + []= cheaper than merge)
805
- cast_options = if (child_namespace_uri = child.namespace_uri)
826
+ cast_options = if (child_namespace_uri = cast_child.namespace_uri)
806
827
  ns_type = attr.type_with_namespace(effective_register,
807
828
  child_namespace_uri)
808
829
  cast_options = base_cast_options.dup
@@ -813,7 +834,7 @@ _effective_register)
813
834
  base_cast_options
814
835
  end
815
836
 
816
- cast_result = attr.cast(child, :xml, effective_register,
837
+ cast_result = attr.cast(cast_child, :xml, effective_register,
817
838
  cast_options)
818
839
 
819
840
  # Track original namespace prefix for doubly-defined namespace support.
@@ -821,10 +842,7 @@ _effective_register)
821
842
  # we need to preserve which prefix was used for round-trip fidelity.
822
843
  # Store on the PARENT model instance keyed by attribute name.
823
844
  # 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
845
+ ns_prefix = explicit_namespace_prefix(child)
828
846
  if ns_prefix && instance_is_serialize
829
847
  prefixes = instance.xml_ns_prefixes || {}
830
848
  prefixes[attr.name] = ns_prefix
@@ -835,7 +853,7 @@ _effective_register)
835
853
  # When parsing XML with alias URIs (e.g., "http://.../") against a namespace
836
854
  # class with canonical URI (e.g., "http://.../reqif.xsd"), store the original
837
855
  # alias URI so it can be serialized back correctly.
838
- child_uri = child.namespace_uri
856
+ child_uri = cast_child.namespace_uri
839
857
  if child_uri && attr_type_is_serializable
840
858
  child_mapping = cast_result.class.mappings_for(:xml)
841
859
  child_ns_class = child_mapping&.namespace_class
@@ -858,8 +876,10 @@ _effective_register)
858
876
  # - Nil/empty: doubly-defined case -> set @__xml_namespace_prefix on child
859
877
  # - Set: mixed content case -> don't set (child has its own namespace)
860
878
  # 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
879
+ cast_child_ns_prefix = nested_collection_attribute_prefix(child, cast_child, ns_prefix)
880
+ if attr_type_is_serializable && cast_child_ns_prefix &&
881
+ (parent_ns_prefix.nil? || parent_ns_prefix.to_s.empty?)
882
+ cast_result.xml_namespace_prefix = cast_child_ns_prefix
863
883
  end
864
884
 
865
885
  values << cast_result
@@ -922,6 +942,10 @@ _effective_register)
922
942
  return [] if attr&.collection? && [[nil], [""]].include?(values)
923
943
  return values if attr&.collection?
924
944
 
945
+ # Keep the over-count (don't collapse via .first) so it reaches the
946
+ # attribute slot and `.validate` can report the cardinality violation.
947
+ return values if values.is_a?(Array) && values.size > 1
948
+
925
949
  values.is_a?(Array) ? values.first : values
926
950
  end
927
951
 
@@ -985,9 +1009,41 @@ effective_register = lutaml_register)
985
1009
  parent_ns_class = nil
986
1010
  form_default = :unqualified
987
1011
  end
988
- rule.attribute_match_names(attr, effective_register, parent_ns_class,
989
- form_default,
990
- options[:default_namespace])
1012
+ names = rule.attribute_match_names(attr, effective_register,
1013
+ parent_ns_class, form_default,
1014
+ options[:default_namespace])
1015
+
1016
+ # #786 lenient alias (["uri:name", "name"] from a type-qualified
1017
+ # rule): the unprefixed form must not steal the value when a
1018
+ # sibling namespace-less rule owns the bare local name (#744
1019
+ # disjointness). No sibling — the alias keeps 0.8.19 leniency.
1020
+ if names.size == 2 && !names[1].include?(":") &&
1021
+ instance_is_serialize &&
1022
+ unqualified_sibling_attribute_rule?(instance, rule,
1023
+ effective_register)
1024
+ names = names.first(1)
1025
+ end
1026
+ names
1027
+ end
1028
+
1029
+ # Whether another attribute rule with the same local name resolves
1030
+ # namespace-less (no rule namespace, no value-type namespace) —
1031
+ # the #744 owner of the unprefixed source form.
1032
+ def unqualified_sibling_attribute_rule?(instance, rule,
1033
+ effective_register)
1034
+ mapping = instance.class.mappings_for(:xml, effective_register)
1035
+ return false unless mapping
1036
+
1037
+ attrs = instance.class.attributes(effective_register)
1038
+ mapping.mappings.any? do |sib|
1039
+ next false unless sib.attribute? && !sib.equal?(rule)
1040
+ next false unless sib.name.to_s == rule.name.to_s
1041
+ next false if sib.namespace_set?
1042
+
1043
+ sib_attr = attrs[sib.to]
1044
+ type_ns = sib_attr&.type_namespace_class(effective_register)
1045
+ type_ns.nil? || type_ns == :blank
1046
+ end
991
1047
  end
992
1048
 
993
1049
  # [namespace_class, attribute_form_default] for the instance's model,
@@ -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