lutaml-model 0.8.31 → 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 (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 +33 -9
  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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ffdb7bcf72a5a1f4b3339ed331fe96def70246dd36010d23a469e4d27c7f103
4
- data.tar.gz: edb32bba950336b77f2257383aec513126d0bdfb53d6fefa7f5eecbf5fe48f74
3
+ metadata.gz: 0a0c1ee735ac6b12da331e1f43dc6617d05cfe18534dc5255aaba0c789ee601e
4
+ data.tar.gz: bde4936c85167bcec7a4618cd8e91c9c4cf65d2feb97b0d94c78bf368f085248
5
5
  SHA512:
6
- metadata.gz: 489d6825726abe7fb758d544938a679c652e1a0eeb5699757eea57884197b4bd4890cda632231772226ac794ec91f5eac3fc21988594c1dba3c1151a1fe33dac
7
- data.tar.gz: 782cba0c296ffa16381f1f36c5614ca7536e3c7a4e628cef77f71d27a1acf37eada825de229d186ca76115149970eb7a110d04a6a5177d1331132f5f6ba8db10
6
+ metadata.gz: 773ec772915ec1fd0645d68fa2e62c50bea3af147b19c67c22e5bd16cf1f6dec2a00bc1ff1945e612a7a6f5d22ce404d3e9687fead480d54390a847908e6d804
7
+ data.tar.gz: 318c31871e7bbb83bd1bea6547f3dbeda4e1768da9d44f0e85c7434bd8db15ade859bdf9a909771126b33a85e345bc1e803a93eacdec2e557a51d39bf0e6f8cb
data/README.adoc CHANGED
@@ -4519,8 +4519,6 @@ end
4519
4519
 
4520
4520
  === Nested collections
4521
4521
 
4522
- TODO: This case needs to be fixed.
4523
-
4524
4522
  Collections can be nested within other models and define their own serialization
4525
4523
  rules.
4526
4524
 
@@ -4533,38 +4531,42 @@ Data:
4533
4531
 
4534
4532
  [source,xml]
4535
4533
  ----
4536
- <titles>
4537
- <title-group>
4538
- <artifact>
4539
- <content>Title One</content>
4540
- </artifact>
4541
- <artifact>
4542
- <content>Title Two</content>
4543
- </artifact>
4544
- <artifact>
4545
- <content>Title Three</content>
4546
- </artifact>
4547
- </title-group>
4548
- </titles>
4534
+ <bibitem>
4535
+ <titles>
4536
+ <title-group>
4537
+ <artifact>
4538
+ <content>Title One</content>
4539
+ </artifact>
4540
+ <artifact>
4541
+ <content>Title Two</content>
4542
+ </artifact>
4543
+ <artifact>
4544
+ <content>Title Three</content>
4545
+ </artifact>
4546
+ </title-group>
4547
+ </titles>
4548
+ </bibitem>
4549
4549
  ----
4550
4550
 
4551
4551
  [source,yaml]
4552
4552
  ----
4553
4553
  ---
4554
4554
  titles:
4555
- title-group:
4556
- - artifact:
4557
- content: Title One
4558
- - artifact:
4559
- content: Title Two
4560
- - artifact:
4561
- content: Title Three
4555
+ items:
4556
+ - content: Title One
4557
+ - content: Title Two
4558
+ - content: Title Three
4562
4559
  ----
4563
4560
 
4564
4561
  [source,ruby]
4565
4562
  ----
4566
4563
  class Title < Lutaml::Model::Serializable
4567
4564
  attribute :content, :string
4565
+
4566
+ xml do
4567
+ element "artifact"
4568
+ map_element "content", to: :content
4569
+ end
4568
4570
  end
4569
4571
 
4570
4572
  class TitleCollection < Lutaml::Model::Collection
@@ -4581,7 +4583,7 @@ class BibItem < Lutaml::Model::Serializable
4581
4583
 
4582
4584
  xml do
4583
4585
  element "bibitem"
4584
- # This overrides the collection's root "title-group"
4586
+ # This wraps the collection's "title-group" root under "titles"
4585
4587
  map_element "titles", to: :titles
4586
4588
  end
4587
4589
  end
@@ -143,6 +143,64 @@ end
143
143
  Simply remove the `no_root` call - models without `element()` are type-only by default.
144
144
 
145
145
 
146
+ === v0.9.0: Strict cardinality validation
147
+
148
+ Attributes now enforce their declared cardinality. An attribute without
149
+ `collection:` holds a single value, and giving it several is a violation that
150
+ `#validate` reports. See link:validation[Validation] for the full rules.
151
+
152
+ Four behavior changes come with it.
153
+
154
+ ==== Nested models now run their own validations
155
+
156
+ `#validate` recurses into nested models and validates each one. Polymorphic,
157
+ required and custom checks on a nested model ran nowhere before; they run now.
158
+ A model that validated clean may start reporting errors its children always had.
159
+
160
+ ==== Singular readers can return arrays
161
+
162
+ An attribute given more values than it was declared for keeps them all, so
163
+ `#validate` has something to report. Until the violation is fixed, the reader
164
+ returns an array rather than a single value.
165
+
166
+ [source,ruby]
167
+ ----
168
+ obj = Person.from_xml("<person><name>A</name><name>B</name></person>")
169
+ obj.name # => ["A", "B"]
170
+ obj.validate # => [#<Lutaml::Model::CollectionTrueMissingError ...>]
171
+ ----
172
+
173
+ Code that assumed a singular reader always returns a scalar should call
174
+ `#validate` before trusting the shape.
175
+
176
+ While the violation stands the document does not round-trip: serializing writes
177
+ the array out as a single stringified value. Fix the cardinality and output
178
+ returns to normal.
179
+
180
+ ==== Enum shorthand predicates answer false while over-counted
181
+
182
+ The generated `value?` methods read through the same reader, so an attribute
183
+ holding two values answers `false` for all of them.
184
+
185
+ [source,ruby]
186
+ ----
187
+ obj = Doc.from_json('{"kind": ["draft", "final"]}')
188
+ obj.draft? # => false, the attribute holds two values
189
+ obj.kind = "draft"
190
+ obj.draft? # => true
191
+ ----
192
+
193
+ ==== Assigning a scalar to a collection attribute wraps it
194
+
195
+ Assignment now produces the same shape as parsing.
196
+
197
+ [source,ruby]
198
+ ----
199
+ doc.tags = "one"
200
+ doc.tags # => ["one"]
201
+ ----
202
+
203
+
146
204
  === v0.8.0: XML default namespace behavior
147
205
 
148
206
  Version 0.8.0 introduces a **breaking change** to XML namespace serialization
@@ -784,8 +784,6 @@ See link:../_guides/consolidation-mapping[Consolidation Mapping] for the full gu
784
784
 
785
785
  === Nested collections
786
786
 
787
- TODO: This case needs to be fixed.
788
-
789
787
  Collections can be nested within other models and define their own serialization
790
788
  rules.
791
789
 
@@ -798,38 +796,42 @@ Data:
798
796
 
799
797
  [source,xml]
800
798
  ----
801
- <titles>
802
- <title-group>
803
- <artifact>
804
- <content>Title One</content>
805
- </artifact>
806
- <artifact>
807
- <content>Title Two</content>
808
- </artifact>
809
- <artifact>
810
- <content>Title Three</content>
811
- </artifact>
812
- </title-group>
813
- </titles>
799
+ <bibitem>
800
+ <titles>
801
+ <title-group>
802
+ <artifact>
803
+ <content>Title One</content>
804
+ </artifact>
805
+ <artifact>
806
+ <content>Title Two</content>
807
+ </artifact>
808
+ <artifact>
809
+ <content>Title Three</content>
810
+ </artifact>
811
+ </title-group>
812
+ </titles>
813
+ </bibitem>
814
814
  ----
815
815
 
816
816
  [source,yaml]
817
817
  ----
818
818
  ---
819
819
  titles:
820
- title-group:
821
- - artifact:
822
- content: Title One
823
- - artifact:
824
- content: Title Two
825
- - artifact:
826
- content: Title Three
820
+ items:
821
+ - content: Title One
822
+ - content: Title Two
823
+ - content: Title Three
827
824
  ----
828
825
 
829
826
  [source,ruby]
830
827
  ----
831
828
  class Title < Lutaml::Model::Serializable
832
829
  attribute :content, :string
830
+
831
+ xml do
832
+ element "artifact"
833
+ map_element "content", to: :content
834
+ end
833
835
  end
834
836
 
835
837
  class TitleCollection < Lutaml::Model::Collection
@@ -846,7 +848,7 @@ class BibItem < Lutaml::Model::Serializable
846
848
 
847
849
  xml do
848
850
  element "bibitem"
849
- # This overrides the collection's root "title-group"
851
+ # This wraps the collection's "title-group" root under "titles"
850
852
  map_element "titles", to: :titles
851
853
  end
852
854
  end
@@ -1667,4 +1669,3 @@ class ReferenceSet < Lutaml::Model::Collection
1667
1669
  end
1668
1670
  end
1669
1671
  ----
1670
-
@@ -25,14 +25,61 @@ Lutaml::Model supports the following validation types:
25
25
 
26
26
  === Collection validation
27
27
 
28
- The `collection` option validates collection size ranges.
28
+ The `collection` option sets an attribute's cardinality: `collection: true`
29
+ means `0..*`, a range such as `(1..)` or `(0..5)` bounds the count, and omitting
30
+ it (the default) means `0..1`, a single value.
29
31
 
30
32
  [source,ruby]
31
33
  ----
32
34
  attribute :items, :string, collection: (1..) # At least one item
33
35
  attribute :tags, :string, collection: (0..5) # Up to 5 tags
36
+ attribute :name, :string # Default 0..1 (single value)
34
37
  ----
35
38
 
39
+ A non-collection attribute given multiple values keeps them. Parsing does not
40
+ raise; the values land in the attribute as an array and `#validate` reports
41
+ `Lutaml::Model::CollectionTrueMissingError`, which `#validate!` raises wrapped
42
+ in a `Lutaml::Model::ValidationError`.
43
+
44
+ [source,ruby]
45
+ ----
46
+ obj = Person.from_xml("<person><name>A</name><name>B</name></person>")
47
+ obj.name # => ["A", "B"]
48
+ obj.validate # => [#<Lutaml::Model::CollectionTrueMissingError ...>]
49
+ obj.validate! # raises Lutaml::Model::ValidationError
50
+ ----
51
+
52
+ Declare `collection: true` for attributes that hold many values.
53
+
54
+ The rule is about shape, not count. A singular attribute is declared to hold a
55
+ value, not a list, so a one-element array is still a violation:
56
+
57
+ [source,ruby]
58
+ ----
59
+ Person.from_json('{"name": ["A"]}').validate
60
+ # => [#<Lutaml::Model::CollectionTrueMissingError ...>]
61
+ ----
62
+
63
+ XML has no such distinction — a single `<name>` element is a value, so only a
64
+ repeated element trips the check.
65
+
66
+ An invalid document does not round-trip. While an attribute holds more values
67
+ than it was declared for, serializing it back out will not reproduce the input.
68
+ Fix the violation and it round-trips normally.
69
+
70
+ Two cases still report at parse time rather than on `#validate`:
71
+
72
+ * A declared range that is exceeded raises
73
+ `Lutaml::Model::CollectionCountOutOfRangeError` for key/value formats.
74
+ * A model mapped onto a plain Ruby class with `model` has no `#validate`, so
75
+ there is no later point to report to.
76
+
77
+ A custom deserializer (`with: { from: }`) receives whatever arrived — a single
78
+ node for one occurrence, an array for several, the same shape a custom
79
+ `to:` method already receives for a collection. If that method collapses the
80
+ array to a single value, the over-count is not reported: the method has decided
81
+ what the attribute holds.
82
+
36
83
  === Value enumeration validation
37
84
 
38
85
  The `values` option validates that an attribute value is one of a fixed set of values.
@@ -163,6 +163,7 @@ require "lutaml/xml/namespace_resolver"
163
163
  require "lutaml/xml/namespace_scope_config"
164
164
  require "lutaml/xml/namespace_type_resolver"
165
165
  require "lutaml/xml/namespace_usage"
166
+ require "lutaml/xml/nested_collection_attribute"
166
167
  require "lutaml/xml/oga"
167
168
  require "lutaml/xml/parse_session"
168
169
  require "lutaml/xml/parsed_namespace_declaration"
@@ -272,7 +272,13 @@ format)
272
272
  instance)
273
273
  end
274
274
 
275
- attr.valid_collection!(value, context)
275
+ # Range errors stay eager here. The over-count error only fires when the
276
+ # attribute is not a collection, so this guard defers exactly that case
277
+ # to `.validate` while leaving declared ranges checked at parse. A mapped
278
+ # PORO has no `.validate` to defer into, so it keeps the eager check.
279
+ if attr.collection? || !instance.is_a?(Lutaml::Model::Serialize)
280
+ attr.valid_collection!(value, context)
281
+ end
276
282
  rule.deserialize(instance, value, attributes, self)
277
283
  end
278
284
 
@@ -317,9 +317,31 @@ module Lutaml
317
317
  end
318
318
 
319
319
  def cast_value(value, register)
320
- return cast_element(value, register) unless collection_instance?(value)
320
+ if list_like?(value)
321
+ build_collection(cast_items(value, register), register: register)
322
+ elsif coerce_to_collection?(value)
323
+ build_collection(cast_items([value], register), register: register)
324
+ else
325
+ cast_element(value, register)
326
+ end
327
+ end
328
+
329
+ # Plain Arrays count as list input. On singular attributes
330
+ # collection_instance? matches Arrays too (their collection class is
331
+ # Array), preserving the Array so strict cardinality can flag it.
332
+ def list_like?(value)
333
+ collection_instance?(value) || (collection? && value.is_a?(::Array))
334
+ end
335
+
336
+ # Prepare the seed items for a collection. Custom Collection classes
337
+ # cast their own items (idempotently) in #initialize, so hand them a
338
+ # plain array of the existing elements — rebuilding a fresh collection
339
+ # avoids sharing a mutable default across instances. Plain collections
340
+ # cast each element here.
341
+ def cast_items(value, register)
342
+ return value.to_a if custom_collection?
321
343
 
322
- build_collection(value.map { |v| cast_element(v, register) })
344
+ value.map { |v| cast_element(v, register) }
323
345
  end
324
346
 
325
347
  # Apply a value map to transform a value.
@@ -483,10 +505,20 @@ instance_object = nil)
483
505
  end
484
506
 
485
507
  def valid_value!(value)
486
- return true if value.nil? && singular?
508
+ # nil is never tested against the value set, whether it arrives as the
509
+ # whole value or as one element of a collection (this method recurses
510
+ # per element). valid_pattern! skips nil the same way, and an unset
511
+ # attribute is validate_required!'s business, not the enum's.
512
+ return true if value.nil?
487
513
  return true unless enum?
488
514
  return true if Utils.uninitialized?(value)
489
515
 
516
+ # Collection attributes hold multiple values; validate each element
517
+ # against the allowed set rather than the coerced collection itself.
518
+ if value.is_a?(::Array) || collection_instance?(value)
519
+ return value.all? { |v| valid_value!(v) }
520
+ end
521
+
490
522
  unless valid_value?(value)
491
523
  raise Lutaml::Model::InvalidValueError.new(name, value, enum_values)
492
524
  end
@@ -500,16 +532,17 @@ instance_object = nil)
500
532
  options[:values].include?(value)
501
533
  end
502
534
 
503
- # Pattern binds string values only. For a plain :string attribute that is
504
- # the resolved type; for a union, the pattern applies to a value that took
505
- # the :string branch (a non-string member is exempt). Collections check
506
- # each element, so a mix of members validates only its string entries.
507
- def valid_pattern!(value, resolved_type)
535
+ # Pattern binds actual string values only. For a plain :string attribute
536
+ # every element is a string; for a union the pattern applies to whichever
537
+ # elements took the :string branch (non-string members are exempt). A nil
538
+ # element is skipped along with every other non-string, and Array(value)
539
+ # flattens both plain Arrays and custom Collections so the pattern applies
540
+ # per element instead of to the collection as a whole.
541
+ def valid_pattern!(value, _resolved_type)
508
542
  return true unless pattern
509
543
 
510
544
  Array(value).each do |item|
511
- next unless resolved_type == Lutaml::Model::Type::String ||
512
- item.is_a?(::String)
545
+ next unless item.is_a?(::String)
513
546
  next if pattern.match?(item)
514
547
 
515
548
  raise Lutaml::Model::PatternNotMatchedError.new(name, pattern, item)
@@ -538,8 +571,11 @@ instance_object = nil)
538
571
  end
539
572
  resolved_type = type(register)
540
573
 
541
- valid_value!(value) &&
542
- valid_collection!(value, self) &&
574
+ # Shape before contents. An over-count is a cardinality violation, not
575
+ # an enum or pattern one, and checking it first keeps those predicates
576
+ # from reporting on a value whose shape is already wrong.
577
+ valid_collection!(value, instance_object&.class) &&
578
+ valid_value!(value) &&
543
579
  valid_pattern!(value, resolved_type) &&
544
580
  validate_polymorphic!(value, resolved_type) &&
545
581
  execute_validations!(value)
@@ -619,7 +655,11 @@ instance_object = nil)
619
655
  # Allow any value for unbounded collections
620
656
  return true if collection == true
621
657
 
622
- unless (Utils.uninitialized?(value) && resolved_collection.min.zero?) || collection_instance?(value)
658
+ unless collection_instance?(value)
659
+ # An absent value satisfies a zero minimum and has nothing to size.
660
+ return true if (value.nil? || Utils.uninitialized?(value)) &&
661
+ resolved_collection.min.zero?
662
+
623
663
  raise Lutaml::Model::CollectionCountOutOfRangeError.new(
624
664
  name,
625
665
  value,
@@ -627,23 +667,26 @@ instance_object = nil)
627
667
  )
628
668
  end
629
669
 
630
- return true unless resolved_collection.is_a?(Range)
670
+ # cover? rather than between?, so an exclusive range such as (0...5)
671
+ # rejects a count of 5.
672
+ in_range = if resolved_collection.end.infinite?
673
+ value.size >= resolved_collection.begin
674
+ else
675
+ resolved_collection.cover?(value.size)
676
+ end
631
677
 
632
- if resolved_collection.is_a?(Range) && resolved_collection.end.infinite?
633
- if value.size < resolved_collection.begin
634
- raise Lutaml::Model::CollectionCountOutOfRangeError.new(
635
- name,
636
- value,
637
- collection,
638
- )
639
- end
640
- elsif resolved_collection.is_a?(Range) && !resolved_collection.cover?(value.size)
678
+ unless in_range
641
679
  raise Lutaml::Model::CollectionCountOutOfRangeError.new(
642
680
  name,
643
681
  value,
644
682
  collection,
645
683
  )
646
684
  end
685
+
686
+ # Truthy on success: validate_value! chains this with && before the
687
+ # value/pattern/polymorphic/custom checks, so falling through as nil would
688
+ # silently skip them for a valid bounded-range collection.
689
+ true
647
690
  end
648
691
 
649
692
  def serialize(value, format, register, options = {})
@@ -696,7 +739,7 @@ instance_object = nil)
696
739
  converted: true)
697
740
  return build_collection(value.map do |v|
698
741
  cast(v, format, register, merged_opts)
699
- end)
742
+ end, register: register)
700
743
  end
701
744
 
702
745
  return value if already_serialized?(resolved_type, value)
@@ -600,7 +600,7 @@ lutaml_register: Lutaml::Model::Config.default_register)
600
600
  end
601
601
  end
602
602
 
603
- # Override validate to support both instance and collection-level validations
603
+ # Adds collection-level validations on top of the instance-level ones
604
604
  #
605
605
  # Collection-level validations run in order and can share state through a
606
606
  # context object. Validations can stop the chain early by calling ctx.stop!
@@ -627,7 +627,10 @@ lutaml_register: Lutaml::Model::Config.default_register)
627
627
  # end
628
628
  # end
629
629
  #
630
- def validate(register: Lutaml::Model::Config.default_register)
630
+ # Hooks into the guarded pipeline rather than overriding #validate, so
631
+ # the collection rules run inside the cycle guard and a self-referencing
632
+ # collection runs them once rather than twice.
633
+ def collect_validation_errors(register)
631
634
  errors = []
632
635
 
633
636
  # Run standard instance-level validations first (inherited from Serializable)
@@ -7,6 +7,9 @@ module Lutaml
7
7
  # Provides methods for checking if an attribute is a collection,
8
8
  # getting the collection class, building collections, and related operations.
9
9
  module CollectionHandler
10
+ # Parameter kinds that carry a named keyword in Method#parameters.
11
+ KEYWORD_PARAM_KINDS = %i[key keyreq].freeze
12
+
10
13
  # Get the collection options
11
14
  #
12
15
  # @return [Object, nil] The collection option value
@@ -48,10 +51,50 @@ module Lutaml
48
51
 
49
52
  # Build a new collection with the given values
50
53
  #
54
+ # Custom Collection classes resolve their item type through the register,
55
+ # so thread it through when one is supplied. Plain Array collections and
56
+ # register-less callers keep the prior construction path untouched.
57
+ #
51
58
  # @param args [Array] Values to include in the collection
59
+ # @param register [Symbol, nil] Register for custom-collection type resolution
52
60
  # @return [Object] A new collection instance
53
- def build_collection(*args)
54
- collection_class.new(args.flatten)
61
+ def build_collection(*args, register: nil)
62
+ items = args.flatten
63
+ unless register && initialize_accepts_register?
64
+ return collection_class.new(items)
65
+ end
66
+
67
+ collection_class.new(items, lutaml_register: register)
68
+ end
69
+
70
+ # Whether the collection class can be handed a register at construction.
71
+ #
72
+ # A custom Collection subclass may override #initialize with the
73
+ # positional signature that predates the register, and passing the
74
+ # keyword to one of those raises ArgumentError. Such a subclass resolves
75
+ # its item types against the default register instead — there is no way
76
+ # to reach a constructor that does not accept one.
77
+ #
78
+ # @return [Boolean] true if #initialize declares the register keyword
79
+ def initialize_accepts_register?
80
+ return @initialize_accepts_register if defined?(@initialize_accepts_register)
81
+
82
+ @initialize_accepts_register = custom_collection? &&
83
+ collection_class.instance_method(:initialize).parameters.any? do |kind, param|
84
+ kind == :keyrest ||
85
+ (KEYWORD_PARAM_KINDS.include?(kind) && param == :lutaml_register)
86
+ end
87
+ end
88
+
89
+ # Whether a bare single value assigned to this attribute must be
90
+ # coerced into a one-element collection, so assignment produces the
91
+ # same shape as parsing. nil and the uninitialized sentinel pass
92
+ # through untouched to preserve absent-value semantics.
93
+ #
94
+ # @param value [Object] The value being assigned
95
+ # @return [Boolean] true if the value should be wrapped
96
+ def coerce_to_collection?(value)
97
+ collection? && !value.nil? && !Utils.uninitialized?(value)
55
98
  end
56
99
 
57
100
  # Check if this attribute uses a custom collection class
@@ -81,7 +81,11 @@ module Lutaml
81
81
  i = instance_variable_get(:"@#{enum_name}") || []
82
82
 
83
83
  if !collection && i.is_a?(Array)
84
- i.first
84
+ # A singular enum stores its one value as a one-element array.
85
+ # Several values is a cardinality violation, so hand the array
86
+ # on instead of collapsing it — #validate reads through here and
87
+ # would otherwise never see the over-count.
88
+ i.size > 1 ? i : i.first
85
89
  else
86
90
  i.uniq
87
91
  end
@@ -3,19 +3,64 @@
3
3
  module Lutaml
4
4
  module Model
5
5
  module Validation
6
+ # Models being validated on this thread's current stack.
7
+ VALIDATING_KEY = :lutaml_model_validating
8
+
9
+ # Whether a model is already being validated further up this stack.
10
+ def self.visiting?(model)
11
+ stack = Thread.current[VALIDATING_KEY]
12
+ !stack.nil? && stack.key?(model)
13
+ end
14
+
15
+ # The single guarded entry point. A model can appear inside its own
16
+ # subtree, and re-entering it would recurse forever, so a re-entry
17
+ # returns early — the outer call is already collecting its errors.
18
+ #
19
+ # Override #collect_validation_errors, not this method, to add
20
+ # validation that must run inside the cycle guard. Overriding #validate
21
+ # directly puts the override outside the guard.
6
22
  def validate(register: Lutaml::Model::Config.default_register)
23
+ visiting = (Thread.current[VALIDATING_KEY] ||= {}.compare_by_identity)
24
+ return [] if visiting.key?(self)
25
+
26
+ begin
27
+ # Marked inside the begin, so an exception delivered between the
28
+ # check and the mark cannot strand an entry on the stack.
29
+ visiting[self] = true
30
+ collect_validation_errors(register)
31
+ ensure
32
+ visiting.delete(self)
33
+ Thread.current[VALIDATING_KEY] = nil if visiting.empty?
34
+ end
35
+ end
36
+
37
+ def collect_validation_errors(register)
7
38
  errors = []
8
39
 
9
40
  self.class.attributes(register).each do |name, attr|
10
41
  value = public_send(:"#{name}")
11
42
 
12
43
  begin
13
- if value.is_a?(Lutaml::Model::Serialize)
14
- sub_errors = value.validate
44
+ # Recurse into nested models — a single child or every element of
45
+ # an array — so their own validation errors surface here. A
46
+ # Collection is itself a model, so it is validated as one: that
47
+ # runs its collection-level rules as well as its elements.
48
+ # #validate takes no arguments here: it is a public override point
49
+ # that downstream models legitimately define with zero arity.
50
+ (value.is_a?(::Array) ? value : [value]).each do |item|
51
+ next unless item.is_a?(Lutaml::Model::Serialize)
52
+ # Skip a model already on the stack rather than calling into it.
53
+ # A downstream #validate override sits above the guard, so
54
+ # re-entering it would run the override's own body a second time.
55
+ next if Validation.visiting?(item)
56
+
57
+ sub_errors = item.validate
15
58
  errors.concat(sub_errors) if sub_errors.is_a?(Array)
16
- else
17
- attr.validate_value!(value, register, instance_object: self)
18
59
  end
60
+
61
+ # Always run attribute-level validation (cardinality, required,
62
+ # enum, pattern, polymorphic, custom) regardless of value type.
63
+ attr.validate_value!(value, register, instance_object: self)
19
64
  rescue Lutaml::Model::CollectionCountOutOfRangeError => e
20
65
  errors << e unless attr.choice
21
66
  rescue Lutaml::Model::InvalidValueError,
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.31"
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"