lutaml-model 0.8.33 → 0.8.34

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +5 -0
  3. data/Gemfile +4 -0
  4. data/README.adoc +39 -0
  5. data/docs/conversion_caching.adoc +143 -0
  6. data/lib/compat/opal/lutaml_model_boot.rb +15 -0
  7. data/lib/lutaml/model/attribute.rb +89 -0
  8. data/lib/lutaml/model/config.rb +13 -0
  9. data/lib/lutaml/model/configuration.rb +23 -0
  10. data/lib/lutaml/model/error/fraction_digits_error.rb +20 -0
  11. data/lib/lutaml/model/error/length_error.rb +19 -0
  12. data/lib/lutaml/model/error/max_exclusive_error.rb +19 -0
  13. data/lib/lutaml/model/error/max_inclusive_error.rb +20 -0
  14. data/lib/lutaml/model/error/max_length_error.rb +20 -0
  15. data/lib/lutaml/model/error/min_exclusive_error.rb +19 -0
  16. data/lib/lutaml/model/error/min_inclusive_error.rb +20 -0
  17. data/lib/lutaml/model/error/min_length_error.rb +20 -0
  18. data/lib/lutaml/model/error/restriction_error.rb +8 -0
  19. data/lib/lutaml/model/error/total_digits_error.rb +20 -0
  20. data/lib/lutaml/model/error/type/max_exclusive_error.rb +20 -0
  21. data/lib/lutaml/model/error/type/min_exclusive_error.rb +20 -0
  22. data/lib/lutaml/model/error/type.rb +2 -0
  23. data/lib/lutaml/model/restriction_validation.rb +437 -0
  24. data/lib/lutaml/model/schema/definitions/restricted_type.rb +4 -2
  25. data/lib/lutaml/model/schema/renderers/restricted_type.rb +134 -4
  26. data/lib/lutaml/model/schema/rng_compiler/define_classifier.rb +3 -0
  27. data/lib/lutaml/model/schema/rng_compiler/rng_helpers.rb +9 -1
  28. data/lib/lutaml/model/schema/rng_compiler/value_type_resolver.rb +2 -0
  29. data/lib/lutaml/model/schema/templates.rb +2 -0
  30. data/lib/lutaml/model/schema/xml_compiler/spec_builder/simple_types.rb +39 -7
  31. data/lib/lutaml/model/schema/xml_compiler/supported_data_types.rb +3 -0
  32. data/lib/lutaml/model/serialize/attribute_definition.rb +18 -7
  33. data/lib/lutaml/model/serialize/builder.rb +4 -5
  34. data/lib/lutaml/model/serialize/conversion_caching.rb +124 -0
  35. data/lib/lutaml/model/serialize/enum_handling.rb +50 -11
  36. data/lib/lutaml/model/serialize/format_conversion.rb +46 -38
  37. data/lib/lutaml/model/serialize/initialization.rb +4 -2
  38. data/lib/lutaml/model/serialize.rb +61 -0
  39. data/lib/lutaml/model/services/type/validator.rb +23 -0
  40. data/lib/lutaml/model/type/string.rb +28 -3
  41. data/lib/lutaml/model/type/value.rb +231 -0
  42. data/lib/lutaml/model/type.rb +2 -0
  43. data/lib/lutaml/model/validation.rb +2 -1
  44. data/lib/lutaml/model/version.rb +1 -1
  45. data/lib/lutaml/model.rb +16 -0
  46. data/lib/lutaml/xml/model_transform.rb +4 -1
  47. data/lib/lutaml/xml/schema/xsd/max_exclusive.rb +1 -1
  48. data/lib/lutaml/xml/schema/xsd/min_exclusive.rb +1 -1
  49. data/lib/lutaml/xml/schema/xsd_schema.rb +183 -11
  50. data/lib/lutaml/xml/transformation/order_reconciler.rb +267 -0
  51. data/lib/lutaml/xml/transformation/ordered_applier.rb +80 -122
  52. data/lib/lutaml/xml/transformation/rule_compiler.rb +2 -0
  53. data/lib/lutaml/xml/transformation.rb +2 -0
  54. data/lib/lutaml/xml/transformation_support.rb +1 -0
  55. data/spec/fixtures/xml/restriction_facets.xsd +46 -0
  56. data/spec/lutaml/model/attribute_restriction_spec.rb +472 -0
  57. data/spec/lutaml/model/attribute_spec.rb +7 -2
  58. data/spec/lutaml/model/collection_reader_liveness_spec.rb +516 -0
  59. data/spec/lutaml/model/conversion_caching_spec.rb +280 -0
  60. data/spec/lutaml/model/lazy_collection_spec.rb +28 -3
  61. data/spec/lutaml/model/parsed_model_mutation_spec.rb +486 -0
  62. data/spec/lutaml/model/phantom_value_spec.rb +799 -0
  63. data/spec/lutaml/model/schema/renderers/restricted_type_spec.rb +1 -0
  64. data/spec/lutaml/model/schema/xsd_schema_spec.rb +340 -695
  65. data/spec/lutaml/model/serialize_perf_guard_spec.rb +53 -1
  66. data/spec/lutaml/model/type_digit_facet_spec.rb +229 -0
  67. data/spec/lutaml/model/type_facet_restriction_spec.rb +667 -0
  68. data/spec/lutaml/model/type_white_space_facet_spec.rb +132 -0
  69. data/spec/lutaml/xml/schema/compiler_spec.rb +302 -0
  70. data/spec/lutaml/xml/schema/facet_round_trip_spec.rb +153 -0
  71. metadata +27 -1
@@ -50,26 +50,30 @@ module Lutaml
50
50
  return fast if fast
51
51
  end
52
52
 
53
- Instrumentation.instrument(:from, model: name, format: format) do
54
- adapter = resolve_adapter(format, options.delete(:adapter))
53
+ # The rescue sits on the parse block, not the method, so cache
54
+ # store failures propagate instead of becoming InvalidFormatError.
55
+ with_conversion_cache(:from, format, data, options) do
56
+ Instrumentation.instrument(:from, model: name, format: format) do
57
+ adapter = resolve_adapter(format, options.delete(:adapter))
55
58
 
56
- raise Lutaml::Model::FormatAdapterNotSpecifiedError.new(format) if adapter.nil?
59
+ raise Lutaml::Model::FormatAdapterNotSpecifiedError.new(format) if adapter.nil?
57
60
 
58
- # Resolve imports at the entry point of deserialization
59
- register = options[:register] || Lutaml::Model::Config.default_register
61
+ # Resolve imports at the entry point of deserialization
62
+ register = options[:register] || Lutaml::Model::Config.default_register
60
63
 
61
- # Hook for format-specific pre-deserialization (e.g., XML mapping import resolution)
62
- pre_deserialize_hook(format, register)
64
+ # Hook for format-specific pre-deserialization (e.g., XML mapping import resolution)
65
+ pre_deserialize_hook(format, register)
63
66
 
64
- # Recursively resolve child model imports
65
- # This ensures the entire model tree is finalized before parsing
66
- ensure_child_imports_resolved!(register)
67
+ # Recursively resolve child model imports
68
+ # This ensures the entire model tree is finalized before parsing
69
+ ensure_child_imports_resolved!(register)
67
70
 
68
- doc = adapter.parse(data, options)
69
- send("of_#{format}", doc, options)
71
+ doc = adapter.parse(data, options)
72
+ send("of_#{format}", doc, options)
73
+ end
74
+ rescue *format_error_types => e
75
+ raise Lutaml::Model::InvalidFormatError.new(format, e.message)
70
76
  end
71
- rescue *format_error_types => e
72
- raise Lutaml::Model::InvalidFormatError.new(format, e.message)
73
77
  end
74
78
 
75
79
  # Hook for format-specific pre-deserialization logic.
@@ -253,33 +257,37 @@ module Lutaml
253
257
  # is always preserved when available, regardless of this option.
254
258
  # @return [String] The serialized output
255
259
  def to(format, instance, options = {})
256
- # Ruby's JSON generator hands #to_json its own JSON::State rather
257
- # than an options hash. It carries no LutaML options, but it does
258
- # carry the surrounding indent context, so it is forwarded to the
259
- # adapter unchanged instead of being read like a Hash -- json 3.0
260
- # removed JSON::State#[].
261
- options = Serialize.wrap_generator_state(options)
262
- generator_state = options.delete(Serialize::GENERATOR_STATE_KEY)
263
-
264
- Instrumentation.instrument(:to, model: name, format: format) do
265
- adapter_override = options.delete(:adapter)
266
- options[:_adapter_override] = true if adapter_override
267
- if format == :xml && Lutaml::Model::Config.instance.xml_plan_fast_path
268
- fast = xml_plan_fast_serialize(instance, options)
269
- return fast if fast
270
- end
271
- value = public_send(:"as_#{format}", instance, options)
272
- adapter = resolve_adapter(format, adapter_override)
260
+ # The rescue sits on the parse block, not the method, so cache
261
+ # store failures propagate instead of becoming InvalidFormatError.
262
+ with_conversion_cache(:to, format, instance, options) do
263
+ # Ruby's JSON generator hands #to_json its own JSON::State rather
264
+ # than an options hash. It carries no LutaML options, but it does
265
+ # carry the surrounding indent context, so it is forwarded to the
266
+ # adapter unchanged instead of being read like a Hash -- json 3.0
267
+ # removed JSON::State#[].
268
+ options = Serialize.wrap_generator_state(options)
269
+ generator_state = options.delete(Serialize::GENERATOR_STATE_KEY)
270
+
271
+ Instrumentation.instrument(:to, model: name, format: format) do
272
+ adapter_override = options.delete(:adapter)
273
+ options[:_adapter_override] = true if adapter_override
274
+ if format == :xml && Lutaml::Model::Config.instance.xml_plan_fast_path
275
+ fast = xml_plan_fast_serialize(instance, options)
276
+ return fast if fast
277
+ end
278
+ value = public_send(:"as_#{format}", instance, options)
279
+ adapter = resolve_adapter(format, adapter_override)
273
280
 
274
- # Hook for format-specific options preparation (e.g., XML prefix/namespace/declaration)
275
- options = prepare_to_options(format, instance, options)
281
+ # Hook for format-specific options preparation (e.g., XML prefix/namespace/declaration)
282
+ options = prepare_to_options(format, instance, options)
276
283
 
277
- document = adapter.new(value, register: options[:register])
284
+ document = adapter.new(value, register: options[:register])
278
285
 
279
- document.public_send(
280
- :"to_#{format}",
281
- forward_options(document, generator_state, options),
282
- )
286
+ document.public_send(
287
+ :"to_#{format}",
288
+ forward_options(document, generator_state, options),
289
+ )
290
+ end
283
291
  end
284
292
  end
285
293
 
@@ -395,8 +395,10 @@ module Lutaml
395
395
  def define_collection_register_methods(name)
396
396
  define_method(name) do |*args|
397
397
  if args.empty?
398
- current = instance_variable_get(:"@#{name}")
399
- current.equal?(LAZY_EMPTY_COLLECTION) ? [] : current
398
+ # Store the materialized Array instead of discarding it, so
399
+ # `model.items << x` on a register-scoped model reaches the model
400
+ # rather than a throwaway copy.
401
+ materialize_lazy_collection(name)
400
402
  else
401
403
  value = args.first
402
404
  current = instance_variable_get(:"@#{name}")
@@ -11,6 +11,7 @@ module Lutaml
11
11
  autoload :EnumHandling, "#{__dir__}/serialize/enum_handling"
12
12
  autoload :ModelImport, "#{__dir__}/serialize/model_import"
13
13
  autoload :FormatConversion, "#{__dir__}/serialize/format_conversion"
14
+ autoload :ConversionCaching, "#{__dir__}/serialize/conversion_caching"
14
15
  autoload :ValueMapping, "#{__dir__}/serialize/value_mapping"
15
16
  autoload :TransformationBuilder,
16
17
  "#{__dir__}/serialize/transformation_builder"
@@ -47,6 +48,7 @@ module Lutaml
47
48
  include Serialize::EnumHandling
48
49
  include Serialize::ModelImport
49
50
  include Serialize::FormatConversion
51
+ include Serialize::ConversionCaching
50
52
  include Serialize::ValueMapping
51
53
  include Serialize::TransformationBuilder
52
54
 
@@ -340,6 +342,65 @@ module Lutaml
340
342
 
341
343
  ref.is_a?(Type::Reference) ? ref.object : ref
342
344
  end
345
+
346
+ # Hand back a collection the caller can push onto.
347
+ #
348
+ # An unused collection points at LAZY_EMPTY_COLLECTION, one frozen Array
349
+ # shared by every instance of every class, so an untouched collection
350
+ # costs nothing. A reader cannot hand that array out: `model.items << x`
351
+ # raises FrozenError, and unfreezing it would leak one instance's items
352
+ # into all the others. So the first read swaps in this instance's own
353
+ # empty Array and stores it — which is what the sentinel's own comment
354
+ # has promised all along. A frozen model keeps the sentinel; there is
355
+ # nothing to store into.
356
+ #
357
+ # nil is left alone on purpose. A nil collection is a state of its own
358
+ # here, distinct from an empty one — `initialize_empty: false` is what
359
+ # produces it and `render_nil` renders it — so a reader that turned nil
360
+ # into [] would erase a documented distinction.
361
+ #
362
+ # @param attribute_name [Symbol] the collection attribute
363
+ # @return [Object] the stored collection
364
+ def materialize_lazy_collection(attribute_name)
365
+ current = instance_variable_get(:"@#{attribute_name}")
366
+ return current unless current.equal?(LAZY_EMPTY_COLLECTION)
367
+ return current if frozen?
368
+
369
+ instance_variable_set(:"@#{attribute_name}", [])
370
+ end
371
+
372
+ # Hand back a reference collection the caller can push onto.
373
+ #
374
+ # A reference attribute stores Type::Reference objects, and resolving
375
+ # them built a fresh Array per read — so `book.co_authors << author`
376
+ # landed on a throwaway and never reached the document. A Reference
377
+ # stays lazy until its target registers itself, so the reader can only
378
+ # store the resolved Array once every reference has actually resolved;
379
+ # until then it has to keep re-resolving and keeps handing back a copy.
380
+ # Once stored there is nothing left to resolve: resolve_reference_value
381
+ # passes a model instance straight through, and the generated key reader
382
+ # runs Attribute#reference_key over it to get the key back.
383
+ #
384
+ # Storing means `#{name}_ref` reports resolved objects from then on. It
385
+ # is the backing store, and after this the objects are what it holds.
386
+ #
387
+ # @param attribute_name [Symbol] the reference collection attribute
388
+ # @return [Object] the stored collection
389
+ def materialize_reference_collection(attribute_name)
390
+ refs = instance_variable_get(:"@#{attribute_name}_ref")
391
+ return resolve_reference_value(refs) unless refs.is_a?(::Array)
392
+ return refs if refs.none?(Type::Reference)
393
+
394
+ resolved = resolve_reference_value(refs)
395
+ # Only a Reference that resolved to nothing keeps this lazy. A raw nil
396
+ # the caller put there is data, and waiting on it would never end.
397
+ dangling = refs.zip(resolved).any? do |ref, value|
398
+ ref.is_a?(Type::Reference) && value.nil?
399
+ end
400
+ return resolved if frozen? || dangling
401
+
402
+ instance_variable_set(:"@#{attribute_name}_ref", resolved)
403
+ end
343
404
  end
344
405
  end
345
406
  end
@@ -35,6 +35,29 @@ module Lutaml
35
35
 
36
36
  raise Lutaml::Model::Type::MaxBoundError.new(value, max)
37
37
  end
38
+
39
+ # Reads the canonical facet keys (:min_exclusive/:max_exclusive);
40
+ # the inclusive `validate_min_max_bounds!` above reads the legacy
41
+ # :min/:max keys it shares with the eager cast path.
42
+ def validate_exclusive_bounds!(value, options)
43
+ min, max = options&.values_at(:min_exclusive, :max_exclusive)
44
+ return if min.nil? && max.nil?
45
+
46
+ validate_min_exclusive_bound!(value, min) if min
47
+ validate_max_exclusive_bound!(value, max) if max
48
+ end
49
+
50
+ def validate_min_exclusive_bound!(value, min)
51
+ return if value > min
52
+
53
+ raise Lutaml::Model::Type::MinExclusiveError.new(value, min)
54
+ end
55
+
56
+ def validate_max_exclusive_bound!(value, max)
57
+ return if value < max
58
+
59
+ raise Lutaml::Model::Type::MaxExclusiveError.new(value, max)
60
+ end
38
61
  end
39
62
 
40
63
  extend ClassMethods
@@ -8,13 +8,20 @@ module Lutaml
8
8
  def self.cast(value, options = {})
9
9
  return nil if value.nil?
10
10
  return value if Utils.uninitialized?(value)
11
- # Short-circuit: return immediately if already a String with no options
12
- # Use identity check for EMPTY_OPTIONS (faster than .empty?)
13
- if value.is_a?(::String) && options.equal?(EMPTY_OPTIONS)
11
+
12
+ # Short-circuit an already-::String value with no options only when
13
+ # no xs:whiteSpace normalization is requested (the common :string
14
+ # case); a non-preserve mode must fall through to transform the text.
15
+ mode = white_space_mode
16
+ if value.is_a?(::String) && options.equal?(EMPTY_OPTIONS) &&
17
+ mode == :preserve
14
18
  return value
15
19
  end
16
20
 
17
21
  value = value.to_s
22
+ value = value.gsub(/[\t\n\r]/, " ") unless mode == :preserve
23
+ value = value.squeeze(" ").strip if mode == :collapse
24
+
18
25
  unless options.equal?(EMPTY_OPTIONS)
19
26
  Model::Services::Type::Validator::String.validate!(value,
20
27
  options)
@@ -22,6 +29,24 @@ module Lutaml
22
29
  value
23
30
  end
24
31
 
32
+ # Effective xs:whiteSpace mode for this type, defaulting to :preserve.
33
+ # Memoized per class so the hot cast path avoids re-walking the facet
34
+ # chain; class-instance variables are not inherited, so each subclass
35
+ # computes (and freezes) its own effective mode.
36
+ def self.white_space_mode
37
+ @white_space_mode ||= facets[:white_space] || :preserve
38
+ end
39
+
40
+ # Drop the memoized mode when a facet is declared after it was first
41
+ # computed (e.g. a cast before the `white_space` declaration), so the
42
+ # next cast recomputes it from the updated facets.
43
+ def self.reset_facet_cache
44
+ return unless instance_variable_defined?(:@white_space_mode)
45
+
46
+ remove_instance_variable(:@white_space_mode)
47
+ end
48
+ private_class_method :reset_facet_cache
49
+
25
50
  # Default XSD type for String
26
51
  #
27
52
  # @return [String] xs:string
@@ -21,6 +21,28 @@ module Lutaml
21
21
  # Use options.equal?(EMPTY_OPTIONS) for fast-path checks
22
22
  EMPTY_OPTIONS = {}.freeze
23
23
 
24
+ # Canonical facet keys grouped by how a tighter constraint compares: a
25
+ # min-like facet tightens upward (greater wins), a max-like facet
26
+ # tightens downward (lesser wins). `:length` (exact) only matches equal.
27
+ MIN_FACETS = %i[min_inclusive min_exclusive min_length].freeze
28
+ MAX_FACETS = %i[
29
+ max_inclusive
30
+ max_exclusive
31
+ max_length
32
+ total_digits
33
+ fraction_digits
34
+ ].freeze
35
+
36
+ # Facets whose combined value is the concatenation of every declaration
37
+ # in the chain: accumulating them is always a tightening (all patterns
38
+ # must match), so they bypass the widen check on inheritance.
39
+ LIST_FACETS = %i[pattern].freeze
40
+
41
+ # xs:whiteSpace normalization modes in ascending strictness: a stricter
42
+ # mode transforms at least as much as a looser one, so `tighter_facet`
43
+ # picks the stricter and a subclass may tighten but not loosen it.
44
+ WHITE_SPACE_MODES = %i[preserve replace collapse].freeze
45
+
24
46
  # Format type serializer registry
25
47
  # Keys: [format, TypeClass] => { to: Proc, from: Proc }
26
48
  @format_type_serializers = {}
@@ -66,6 +88,215 @@ from: nil)
66
88
  cache[key] = found
67
89
  found
68
90
  end
91
+
92
+ # Freeze a type's facets to further declarations once it is
93
+ # subclassed, so a parent cannot widen facets after children exist.
94
+ def inherited(subclass)
95
+ super
96
+ @facets_closed = true
97
+ end
98
+
99
+ # Effective canonical facets for this type, merged parent-first. A
100
+ # subclass may only tighten an inherited facet; widening raises.
101
+ def facets
102
+ facet_layers.each_with_object({}) do |layer, merged|
103
+ layer.each do |key, value|
104
+ merged[key] = inherit_facet(key, merged[key], value)
105
+ end
106
+ end
107
+ end
108
+
109
+ # Conjunctive-merge primitive for two facet values of the same key:
110
+ # the tighter one wins (greater for min-like, lesser for max-like),
111
+ # an exact `:length` must agree. The canonical home for facet
112
+ # combination, reused by the Layer-1/Layer-2 merge.
113
+ def tighter_facet(key, existing, incoming)
114
+ return incoming if existing.nil?
115
+ return existing if incoming.nil?
116
+ return incoming & existing if key == :enumeration
117
+ return existing + incoming if LIST_FACETS.include?(key)
118
+ return [existing, incoming].max if MIN_FACETS.include?(key)
119
+ return [existing, incoming].min if MAX_FACETS.include?(key)
120
+ return stricter_white_space(existing, incoming) if key == :white_space
121
+ return existing if existing == incoming
122
+
123
+ raise ArgumentError,
124
+ "conflicting `#{key}` facets: #{existing} and #{incoming}"
125
+ end
126
+
127
+ # Constrain ordered values via min/maxInclusive.
128
+ def inclusive(min: nil, max: nil)
129
+ min = cast_facet_value(min)
130
+ max = cast_facet_value(max)
131
+ raise_unordered!("inclusive", min, max)
132
+ declare_facets(min_inclusive: min, max_inclusive: max)
133
+ end
134
+
135
+ # Constrain ordered values via min/maxExclusive.
136
+ def exclusive(min: nil, max: nil)
137
+ min = cast_facet_value(min)
138
+ max = cast_facet_value(max)
139
+ raise_unordered!("exclusive", min, max)
140
+ declare_facets(min_exclusive: min, max_exclusive: max)
141
+ end
142
+
143
+ # Constrain length: exact `length N`, or `length min:, max:`.
144
+ def length(exact = nil, min: nil, max: nil)
145
+ return declare_exact_length(exact, min, max) unless exact.nil?
146
+
147
+ raise_unordered!("length", min, max)
148
+ raise_negative_length!(min)
149
+ raise_negative_length!(max)
150
+ declare_facets(min_length: min, max_length: max)
151
+ end
152
+
153
+ # Restrict to an enumerated set of allowed values (any type). A
154
+ # subclass narrows the parent's set; the effective set is the
155
+ # intersection across the chain (see `tighter_facet`).
156
+ def enumeration(*values)
157
+ declare_facets(enumeration: values.map { |v| cast_facet_value(v) })
158
+ end
159
+
160
+ # Restrict a string-derived value to match a regular expression. A
161
+ # String argument is compiled to a Regexp. Patterns accumulate across
162
+ # the chain; a value must match all of them.
163
+ def pattern(regex_or_string)
164
+ regexp = regex_or_string.is_a?(Regexp) ? regex_or_string : Regexp.new(regex_or_string)
165
+ declare_facets(pattern: [regexp])
166
+ end
167
+
168
+ # Normalize a string-derived value's whitespace at cast time
169
+ # (xs:whiteSpace). Unlike every other facet this transforms the
170
+ # stored value rather than validating it, so it is applied in
171
+ # `String.cast`, not the lazy validator. Only string-derived types
172
+ # carry lexical text, so declaring it elsewhere fails fast.
173
+ def white_space(mode)
174
+ unless self <= Type::String
175
+ raise ArgumentError,
176
+ "`white_space` is only allowed for string-derived types"
177
+ end
178
+ unless WHITE_SPACE_MODES.include?(mode)
179
+ raise ArgumentError,
180
+ "`white_space` must be one of " \
181
+ "#{WHITE_SPACE_MODES.map(&:inspect).join(', ')}"
182
+ end
183
+
184
+ declare_facets(white_space: mode)
185
+ end
186
+
187
+ # Cap the total number of significant digits (xs:totalDigits) an
188
+ # integer- or decimal-derived value may carry. A maximum; the value
189
+ # must have at most `count` significant digits. Applicability to the
190
+ # resolved type is enforced lazily at validation time.
191
+ def total_digits(count)
192
+ declare_digit_facet(:total_digits, count, minimum: 1)
193
+ end
194
+
195
+ # Cap the number of significant fraction digits (xs:fractionDigits) an
196
+ # integer- or decimal-derived value may carry (a maximum).
197
+ def fraction_digits(count)
198
+ declare_digit_facet(:fraction_digits, count, minimum: 0)
199
+ end
200
+
201
+ private
202
+
203
+ # Cast a bound/enumeration literal to this type at declaration, so
204
+ # facets accumulate and compare as typed values (not raw strings whose
205
+ # lexical order or `eql?` would differ from the cast value). A non-nil
206
+ # literal that will not cast is a misdeclared facet.
207
+ def cast_facet_value(value)
208
+ return nil if value.nil?
209
+
210
+ cast = cast(value)
211
+ return cast unless cast.nil?
212
+
213
+ raise ArgumentError,
214
+ "`#{self}` facet value #{value.inspect} is not castable"
215
+ end
216
+
217
+ def declare_digit_facet(key, count, minimum:)
218
+ unless count.is_a?(::Integer) && count >= minimum
219
+ raise ArgumentError,
220
+ "`#{key}` must be an Integer >= #{minimum}, " \
221
+ "got #{count.inspect}"
222
+ end
223
+
224
+ declare_facets(key => count)
225
+ end
226
+
227
+ def stricter_white_space(existing, incoming)
228
+ [existing, incoming].max_by { |mode| WHITE_SPACE_MODES.index(mode) }
229
+ end
230
+
231
+ def facet_layers
232
+ layers = []
233
+ klass = self
234
+ while klass && klass <= Value
235
+ own = klass.instance_variable_get(:@facets)
236
+ layers.unshift(own) if own
237
+ klass = klass.superclass
238
+ end
239
+ layers
240
+ end
241
+
242
+ def inherit_facet(key, inherited, declared)
243
+ return declared if inherited.nil?
244
+ if LIST_FACETS.include?(key)
245
+ return tighter_facet(key, inherited, declared)
246
+ end
247
+ return declared if tighter_facet(key, inherited, declared) == declared
248
+
249
+ raise ArgumentError,
250
+ "#{self} cannot widen inherited facet `#{key}` " \
251
+ "from #{inherited} to #{declared}"
252
+ end
253
+
254
+ def declare_exact_length(exact, min, max)
255
+ unless min.nil? && max.nil?
256
+ raise ArgumentError,
257
+ "`length #{exact}` cannot be combined with min:/max:"
258
+ end
259
+
260
+ raise_negative_length!(exact)
261
+ declare_facets(length: exact)
262
+ end
263
+
264
+ def declare_facets(values)
265
+ ensure_facets_open!
266
+ facets = values.compact
267
+ raise ArgumentError, "at least one facet value is required" if facets.empty?
268
+
269
+ store = (@facets ||= {})
270
+ facets.each do |key, value|
271
+ store[key] = tighter_facet(key, store[key], value)
272
+ end
273
+ reset_facet_cache
274
+ end
275
+
276
+ # Hook: drop any per-class value derived from the facet set when a
277
+ # facet is (re)declared, so a stale memo cannot outlive the facets it
278
+ # was computed from. A no-op here; subclasses that memoize override it.
279
+ def reset_facet_cache; end
280
+
281
+ def ensure_facets_open!
282
+ return unless @facets_closed
283
+
284
+ raise ArgumentError,
285
+ "Cannot declare facets on #{self}: it already has subclasses"
286
+ end
287
+
288
+ def raise_unordered!(macro, min, max)
289
+ return if min.nil? || max.nil? || min <= max
290
+
291
+ raise ArgumentError,
292
+ "`#{macro}` min (#{min}) must not exceed max (#{max})"
293
+ end
294
+
295
+ def raise_negative_length!(value)
296
+ return if value.nil? || value >= 0
297
+
298
+ raise ArgumentError, "length facet must not be negative: #{value}"
299
+ end
69
300
  end
70
301
 
71
302
  attr_reader :value
@@ -29,6 +29,8 @@ module Lutaml
29
29
  autoload :InvalidValueError, "#{__dir__}/error/type/invalid_value_error"
30
30
  autoload :MinBoundError, "#{__dir__}/error/type/min_bound_error"
31
31
  autoload :MaxBoundError, "#{__dir__}/error/type/max_bound_error"
32
+ autoload :MinExclusiveError, "#{__dir__}/error/type/min_exclusive_error"
33
+ autoload :MaxExclusiveError, "#{__dir__}/error/type/max_exclusive_error"
32
34
  autoload :PatternNotMatchedError,
33
35
  "#{__dir__}/error/type/pattern_not_matched_error"
34
36
  autoload :MinLengthError, "#{__dir__}/error/type/min_length_error"
@@ -68,7 +68,8 @@ module Lutaml
68
68
  Lutaml::Model::PolymorphicError,
69
69
  Lutaml::Model::ValidationFailedError,
70
70
  Lutaml::Model::RequiredAttributeMissingError,
71
- Lutaml::Model::PatternNotMatchedError => e
71
+ Lutaml::Model::PatternNotMatchedError,
72
+ Lutaml::Model::RestrictionError => e
72
73
  errors << e
73
74
  end
74
75
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.33"
5
+ VERSION = "0.8.34"
6
6
  end
7
7
  end
data/lib/lutaml/model.rb CHANGED
@@ -70,6 +70,7 @@ module Lutaml
70
70
  autoload :ComparableNil, "#{__dir__}/model/comparable_nil"
71
71
  autoload :ComparableModel, "#{__dir__}/model/comparable_model"
72
72
  autoload :CollectionHandler, "#{__dir__}/model/collection_handler"
73
+ autoload :RestrictionValidation, "#{__dir__}/model/restriction_validation"
73
74
  autoload :AttributeValidator, "#{__dir__}/model/attribute_validator"
74
75
  autoload :Attribute, "#{__dir__}/model/attribute"
75
76
  autoload :ValuePolicy, "#{__dir__}/model/value_policy"
@@ -199,6 +200,21 @@ module Lutaml
199
200
  "#{__dir__}/model/error/mixed_content_collection_error"
200
201
  autoload :OrderedContentMappingError,
201
202
  "#{__dir__}/model/error/ordered_content_mapping_error"
203
+ autoload :RestrictionError, "#{__dir__}/model/error/restriction_error"
204
+ autoload :MinInclusiveError,
205
+ "#{__dir__}/model/error/min_inclusive_error"
206
+ autoload :MaxInclusiveError,
207
+ "#{__dir__}/model/error/max_inclusive_error"
208
+ autoload :MinExclusiveError,
209
+ "#{__dir__}/model/error/min_exclusive_error"
210
+ autoload :MaxExclusiveError,
211
+ "#{__dir__}/model/error/max_exclusive_error"
212
+ autoload :MinLengthError, "#{__dir__}/model/error/min_length_error"
213
+ autoload :MaxLengthError, "#{__dir__}/model/error/max_length_error"
214
+ autoload :LengthError, "#{__dir__}/model/error/length_error"
215
+ autoload :TotalDigitsError, "#{__dir__}/model/error/total_digits_error"
216
+ autoload :FractionDigitsError,
217
+ "#{__dir__}/model/error/fraction_digits_error"
202
218
 
203
219
  # Error for passing incorrect model type
204
220
  #
@@ -399,7 +399,10 @@ module Lutaml
399
399
  def set_instance_ordering(instance, doc, ordered_option,
400
400
  mixed_content_option, xml_mapping = nil,
401
401
  instance_is_serialize = nil)
402
- instance.element_order = doc.root.order
402
+ # dup: XmlElement#order hands back a frozen cache shared with the
403
+ # DOM. The model's copy has to stay mutable so callers can maintain
404
+ # element_order themselves.
405
+ instance.element_order = doc.root.order.dup
403
406
  if instance_is_serialize && doc.root.is_a?(::Lutaml::Xml::XmlElement)
404
407
  instance.attribute_order = doc.root.attribute_order
405
408
  end
@@ -6,7 +6,7 @@ module Lutaml
6
6
  module Xsd
7
7
  class MaxExclusive < Base
8
8
  attribute :fixed, :string
9
- attribute :value, :integer
9
+ attribute :value, :string
10
10
 
11
11
  xml do
12
12
  element "maxExclusive"
@@ -6,7 +6,7 @@ module Lutaml
6
6
  module Xsd
7
7
  class MinExclusive < Base
8
8
  attribute :fixed, :string
9
- attribute :value, :integer
9
+ attribute :value, :string
10
10
 
11
11
  xml do
12
12
  element "minExclusive"