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
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ class MinLengthError < RestrictionError
6
+ def initialize(attr_name, value, min_length)
7
+ @attr_name = attr_name
8
+ @value = value
9
+ @min_length = min_length
10
+
11
+ super()
12
+ end
13
+
14
+ def to_s
15
+ "#{@attr_name} length (#{@value.to_s.length}) is less than " \
16
+ "the minimum required length #{@min_length}"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ class RestrictionError < Error
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ class TotalDigitsError < RestrictionError
6
+ def initialize(attr_name, value, total_digits)
7
+ @attr_name = attr_name
8
+ @value = value
9
+ @total_digits = total_digits
10
+
11
+ super()
12
+ end
13
+
14
+ def to_s
15
+ "#{@attr_name} (#{@value}) exceeds the maximum of " \
16
+ "#{@total_digits} total digits"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ module Type
6
+ class MaxExclusiveError < Error
7
+ def initialize(value, max_bound)
8
+ @value = value
9
+ @max_bound = max_bound
10
+
11
+ super()
12
+ end
13
+
14
+ def to_s
15
+ "Value #{@value} is not less than the exclusive maximum #{@max_bound}"
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ module Type
6
+ class MinExclusiveError < Error
7
+ def initialize(value, min_bound)
8
+ @value = value
9
+ @min_bound = min_bound
10
+
11
+ super()
12
+ end
13
+
14
+ def to_s
15
+ "Value #{@value} is not greater than the exclusive minimum #{@min_bound}"
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -7,6 +7,8 @@ module Lutaml
7
7
  autoload :InvalidValueError, "#{__dir__}/type/invalid_value_error"
8
8
  autoload :MinBoundError, "#{__dir__}/type/min_bound_error"
9
9
  autoload :MaxBoundError, "#{__dir__}/type/max_bound_error"
10
+ autoload :MinExclusiveError, "#{__dir__}/type/min_exclusive_error"
11
+ autoload :MaxExclusiveError, "#{__dir__}/type/max_exclusive_error"
10
12
  autoload :PatternNotMatchedError,
11
13
  "#{__dir__}/type/pattern_not_matched_error"
12
14
  autoload :MinLengthError, "#{__dir__}/type/min_length_error"
@@ -0,0 +1,437 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ # Module for enforcing value restrictions on attributes.
6
+ #
7
+ # Provides the lazy validation seam used by `Attribute#validate_value!`:
8
+ # a configuration check (applicability of the effective facets to the
9
+ # resolved type) and a value check (ordered bounds / string length) that
10
+ # reuses the shared Type validators and translates their errors into
11
+ # model-level errors carrying the attribute name.
12
+ #
13
+ # The effective facet set is the conjunctive merge of Layer-1 attribute
14
+ # options (lowered to canonical keys) and the Layer-2 facets declared on a
15
+ # `Type::Value` subclass; the tightest constraint per key wins.
16
+ module RestrictionValidation
17
+ # Verify the effective facets are applicable to the resolved type. The
18
+ # type is only known after register resolution, so this runs at
19
+ # validation time and raises ahead of the value checks.
20
+ def validate_restriction_configuration!(resolved_type)
21
+ facets = effective_facets(resolved_type)
22
+
23
+ if @options.key?(:signed) && !numeric_type?(resolved_type)
24
+ raise ArgumentError,
25
+ "Invalid options for `#{name}`: " \
26
+ "`signed` is only allowed for numeric types"
27
+ end
28
+
29
+ if ordered_facets?(facets) && !ordered_type?(resolved_type)
30
+ raise ArgumentError,
31
+ "Invalid options for `#{name}`: " \
32
+ "`min`, `max`, `inclusive` and `exclusive` are only allowed " \
33
+ "for numeric types or temporal (date/time) types"
34
+ end
35
+
36
+ if length_facets?(facets) && !string_type?(resolved_type)
37
+ raise ArgumentError,
38
+ "Invalid options for `#{name}`: " \
39
+ "`min_length` and `max_length` are only allowed for :string type"
40
+ end
41
+
42
+ if facets.key?(:pattern) && !string_type?(resolved_type)
43
+ raise ArgumentError,
44
+ "Invalid options for `#{name}`: " \
45
+ "`pattern` is only allowed for string-derived types"
46
+ end
47
+
48
+ if digit_facets?(facets) && !digit_type?(resolved_type)
49
+ raise ArgumentError,
50
+ "Invalid options for `#{name}`: " \
51
+ "`total_digits` and `fraction_digits` are only allowed " \
52
+ "for :integer and :decimal types"
53
+ end
54
+ end
55
+
56
+ # Enforce the effective facets against the value, translating the reused
57
+ # validator's Type::* errors into model-level errors that carry the
58
+ # attribute name so `validate` can collect them.
59
+ def validate_restriction_values!(value, resolved_type)
60
+ facets = effective_facets(resolved_type)
61
+ return if facets.empty?
62
+ return if value.nil? || Utils.uninitialized?(value)
63
+
64
+ unless collection? && collection_instance?(value)
65
+ return validate_element_facets!(value, resolved_type, facets)
66
+ end
67
+
68
+ value.each do |element|
69
+ next if element.nil? || Utils.uninitialized?(element)
70
+
71
+ validate_element_facets!(element, resolved_type, facets)
72
+ end
73
+ end
74
+
75
+ # Effective facet set for schema emission: the validator's merge plus the
76
+ # pre-#191 `values:`/`pattern:` options. Those are enforced at runtime by
77
+ # a separate path (so they are absent from the validation facet set); fold
78
+ # them in here so the exported XSD is not weaker than what is enforced.
79
+ def effective_restriction_facets(resolved_type)
80
+ facets = effective_facets(resolved_type)
81
+ facets = merge_values_option(facets, resolved_type) if @options.key?(:values)
82
+ facets = merge_pattern_option(facets) if @options.key?(:pattern)
83
+ # Folding in the values: option can empty an enumeration (a disjoint
84
+ # set enforced by both layers); that is an unsatisfiable restriction,
85
+ # not an unrestricted one, so reject it as the merged interval does.
86
+ reject_empty_enumeration!(facets)
87
+ facets
88
+ end
89
+
90
+ private
91
+
92
+ # Value facets apply to a single value: the whole value for a singular
93
+ # attribute, or each present element for a collection. Cardinality (item
94
+ # count) is governed separately by `collection:` in the `&&`-chain.
95
+ def validate_element_facets!(value, resolved_type, facets)
96
+ if ordered_type?(resolved_type)
97
+ validate_inclusive_bounds!(value, facets)
98
+ validate_exclusive_bounds!(value, facets)
99
+ end
100
+ if string_type?(resolved_type)
101
+ # Length and pattern facets operate on the lexical (serialized) form:
102
+ # a string-derived type whose cast value is not a ::String (e.g.
103
+ # Type::Uri holding a ::URI) has no #length/#match?, so measuring or
104
+ # matching the raw cast object would raise. Serialize once, share it.
105
+ lexical = resolved_type.serialize(value)
106
+ validate_length_bounds!(value, lexical, facets)
107
+ validate_patterns!(value, lexical, facets)
108
+ end
109
+ validate_digit_bounds!(value, facets) if digit_type?(resolved_type)
110
+ validate_enumeration!(value, facets)
111
+ end
112
+
113
+ # Conjunctive merge of Layer-1 options and Layer-2 type facets, both in
114
+ # canonical-key form. Ordered bounds and enumeration members are cast to
115
+ # the resolved type per layer (before the merge, so `tighter_facet`/`&`
116
+ # compare homogeneous values). An empty resulting interval is a config
117
+ # error.
118
+ def effective_facets(resolved_type)
119
+ facets = option_facets(resolved_type)
120
+ .merge(type_facets(resolved_type)) do |key, a, b|
121
+ Type::Value.tighter_facet(key, a, b)
122
+ end
123
+ ensure_consistent_interval!(facets)
124
+ facets
125
+ end
126
+
127
+ def option_facets(resolved_type)
128
+ facets = {}
129
+ facets[:min_inclusive] = cast_bound(@options[:min], resolved_type) if @options.key?(:min)
130
+ facets[:max_inclusive] = cast_bound(@options[:max], resolved_type) if @options.key?(:max)
131
+ if @options[:signed] == false
132
+ zero = cast_bound(0, resolved_type)
133
+ facets[:min_inclusive] = [facets[:min_inclusive], zero].compact.max
134
+ end
135
+ facets[:min_length] = @options[:min_length] if @options.key?(:min_length)
136
+ facets[:max_length] = @options[:max_length] if @options.key?(:max_length)
137
+ # An explicit `min: nil` / `max: nil` means "no bound": drop the nil
138
+ # key so it neither emits an empty <xs:restriction> nor masks a genuinely
139
+ # empty interval during consolidation.
140
+ facets.compact
141
+ end
142
+
143
+ # Layer-2 facets are already cast to the type at declaration; only the
144
+ # Layer-1 option bounds (below) still need casting.
145
+ def type_facets(resolved_type)
146
+ return {} unless castable_type?(resolved_type)
147
+
148
+ resolved_type.facets
149
+ end
150
+
151
+ # Fold the Layer-1 `values:` option into the schema enumeration. Runtime
152
+ # enforces both sets conjunctively, so a Layer-2 enumeration is
153
+ # intersected with the values option (both cast to the type so the
154
+ # intersection compares like values).
155
+ def merge_values_option(facets, resolved_type)
156
+ members = Array(@options[:values]).map { |v| cast_bound(v, resolved_type) }
157
+ existing = facets[:enumeration]
158
+ facets.merge(enumeration: existing ? existing & members : members)
159
+ end
160
+
161
+ # Fold the Layer-1 `pattern:` option into the schema pattern list (the
162
+ # emitter fails fast if that yields conjunctive patterns XSD cannot OR).
163
+ def merge_pattern_option(facets)
164
+ option = @options[:pattern]
165
+ regexp = option.is_a?(Regexp) ? option : Regexp.new(option)
166
+ facets.merge(pattern: Array(facets[:pattern]) + [regexp])
167
+ end
168
+
169
+ def cast_bound(value, resolved_type)
170
+ return value unless castable_type?(resolved_type)
171
+
172
+ cast_facet_value(value, resolved_type)
173
+ end
174
+
175
+ # Cast a bound/enumeration literal to the resolved type. A non-nil literal
176
+ # that casts to nil is an unparseable restriction (e.g. `min: "abc"` on an
177
+ # integer); raise rather than silently drop the constraint.
178
+ def cast_facet_value(value, resolved_type)
179
+ cast = resolved_type.cast(value)
180
+ return cast if !cast.nil? || value.nil?
181
+
182
+ raise ArgumentError,
183
+ "Invalid restrictions for `#{name}`: " \
184
+ "#{value.inspect} is not a valid #{resolved_type} value"
185
+ end
186
+
187
+ def castable_type?(resolved_type)
188
+ resolved_type.is_a?(Class) && resolved_type <= Type::Value
189
+ end
190
+
191
+ def ensure_consistent_interval!(facets)
192
+ consolidate_min_bounds!(facets)
193
+ consolidate_max_bounds!(facets)
194
+ reject_empty_ordered_interval!(facets)
195
+ reject_empty_interval!(facets, :min_length, :max_length)
196
+ reject_exact_length_conflict!(facets)
197
+ reject_empty_enumeration!(facets)
198
+ reject_fraction_exceeding_total!(facets)
199
+ end
200
+
201
+ # A same-side inclusive+exclusive pair is a conjunction (both must hold),
202
+ # so the tighter bound binds and the looser one is dropped. Lower side:
203
+ # `>= inc AND > exc`; inclusive binds only when it excludes strictly more
204
+ # (`inc > exc`), otherwise the exclusive bound wins (tie included, since
205
+ # `> n` is tighter than `>= n`).
206
+ def consolidate_min_bounds!(facets)
207
+ inc = facets[:min_inclusive]
208
+ exc = facets[:min_exclusive]
209
+ return if inc.nil? || exc.nil?
210
+
211
+ facets.delete(inc > exc ? :min_exclusive : :min_inclusive)
212
+ end
213
+
214
+ # Upper side: `<= inc AND < exc`; inclusive binds only when it excludes
215
+ # strictly more (`inc < exc`), otherwise the exclusive bound wins (tie
216
+ # included, since `< n` is tighter than `<= n`).
217
+ def consolidate_max_bounds!(facets)
218
+ inc = facets[:max_inclusive]
219
+ exc = facets[:max_exclusive]
220
+ return if inc.nil? || exc.nil?
221
+
222
+ facets.delete(inc < exc ? :max_exclusive : :max_inclusive)
223
+ end
224
+
225
+ def reject_fraction_exceeding_total!(facets)
226
+ total = facets[:total_digits]
227
+ fraction = facets[:fraction_digits]
228
+ return if total.nil? || fraction.nil? || fraction <= total
229
+
230
+ raise ArgumentError,
231
+ "Invalid restrictions for `#{name}`: " \
232
+ "fraction_digits (#{fraction}) exceeds total_digits (#{total})"
233
+ end
234
+
235
+ def reject_empty_enumeration!(facets)
236
+ enumeration = facets[:enumeration]
237
+ return if enumeration.nil? || !enumeration.empty?
238
+
239
+ raise ArgumentError,
240
+ "Invalid restrictions for `#{name}`: " \
241
+ "enumeration allows no values"
242
+ end
243
+
244
+ # Reject an empty interval formed by the merged ordered bounds. After
245
+ # consolidation at most one lower and one upper bound remain; a shared
246
+ # endpoint is valid only when both bounds are inclusive (single point).
247
+ def reject_empty_ordered_interval!(facets)
248
+ lo_key = facets.key?(:min_inclusive) ? :min_inclusive : :min_exclusive
249
+ hi_key = facets.key?(:max_inclusive) ? :max_inclusive : :max_exclusive
250
+ lo = facets[lo_key]
251
+ hi = facets[hi_key]
252
+ return if lo.nil? || hi.nil?
253
+
254
+ if lo > hi
255
+ raise ArgumentError,
256
+ "Invalid restrictions for `#{name}`: " \
257
+ "#{lo_key} (#{lo}) exceeds #{hi_key} (#{hi})"
258
+ end
259
+
260
+ return unless lo == hi
261
+ return if lo_key == :min_inclusive && hi_key == :max_inclusive
262
+
263
+ raise ArgumentError,
264
+ "Invalid restrictions for `#{name}`: " \
265
+ "#{lo_key} (#{lo}) and #{hi_key} (#{hi}) define an empty interval"
266
+ end
267
+
268
+ def reject_empty_interval!(facets, min_key, max_key)
269
+ min = facets[min_key]
270
+ max = facets[max_key]
271
+ return if min.nil? || max.nil? || min <= max
272
+
273
+ raise ArgumentError,
274
+ "Invalid restrictions for `#{name}`: " \
275
+ "#{min_key} (#{min}) exceeds #{max_key} (#{max})"
276
+ end
277
+
278
+ def reject_exact_length_conflict!(facets)
279
+ length = facets[:length]
280
+ min = facets[:min_length]
281
+ max = facets[:max_length]
282
+ return if length.nil?
283
+ return if (min.nil? || length >= min) && (max.nil? || length <= max)
284
+
285
+ raise ArgumentError,
286
+ "Invalid restrictions for `#{name}`: " \
287
+ "length (#{length}) is outside [#{min}, #{max}]"
288
+ end
289
+
290
+ def ordered_facets?(facets)
291
+ facets.key?(:min_inclusive) || facets.key?(:max_inclusive) ||
292
+ facets.key?(:min_exclusive) || facets.key?(:max_exclusive)
293
+ end
294
+
295
+ def length_facets?(facets)
296
+ facets.key?(:min_length) || facets.key?(:max_length) ||
297
+ facets.key?(:length)
298
+ end
299
+
300
+ def digit_facets?(facets)
301
+ facets.key?(:total_digits) || facets.key?(:fraction_digits)
302
+ end
303
+
304
+ # Digit facets are countable only on integer- and decimal-derived types.
305
+ # Float is intentionally excluded: its binary representation makes the
306
+ # significant-digit count unreliable.
307
+ def digit_type?(resolved_type)
308
+ resolved_type <= Type::Integer || resolved_type <= Type::Decimal
309
+ end
310
+
311
+ def ordered_type?(resolved_type)
312
+ numeric_type?(resolved_type) || temporal_type?(resolved_type)
313
+ end
314
+
315
+ def numeric_type?(resolved_type)
316
+ resolved_type <= Type::Integer ||
317
+ resolved_type <= Type::Decimal ||
318
+ resolved_type <= Type::Float
319
+ end
320
+
321
+ # Ordered facets apply to temporal types whose cast value has a reliable
322
+ # total order (Date/Time/DateTime respond to <=>). Duration and
323
+ # TimeWithoutDate are intentionally excluded: their cast value is a
324
+ # lexical String, so comparing them as ordered bounds would be incorrect.
325
+ def temporal_type?(resolved_type)
326
+ resolved_type <= Type::Date ||
327
+ resolved_type <= Type::Time ||
328
+ resolved_type <= Type::DateTime
329
+ end
330
+
331
+ def string_type?(resolved_type)
332
+ resolved_type <= Type::String
333
+ end
334
+
335
+ def validate_inclusive_bounds!(value, facets)
336
+ bounds = { min: facets[:min_inclusive],
337
+ max: facets[:max_inclusive] }.compact
338
+ return if bounds.empty?
339
+
340
+ Services::Type::Validator.validate_min_max_bounds!(value, bounds)
341
+ rescue Lutaml::Model::Type::MinBoundError
342
+ raise Lutaml::Model::MinInclusiveError.new(name, value, bounds[:min])
343
+ rescue Lutaml::Model::Type::MaxBoundError
344
+ raise Lutaml::Model::MaxInclusiveError.new(name, value, bounds[:max])
345
+ end
346
+
347
+ def validate_exclusive_bounds!(value, facets)
348
+ min = facets[:min_exclusive]
349
+ max = facets[:max_exclusive]
350
+ return if min.nil? && max.nil?
351
+
352
+ Services::Type::Validator.validate_exclusive_bounds!(value, facets)
353
+ rescue Lutaml::Model::Type::MinExclusiveError
354
+ raise Lutaml::Model::MinExclusiveError.new(name, value, min)
355
+ rescue Lutaml::Model::Type::MaxExclusiveError
356
+ raise Lutaml::Model::MaxExclusiveError.new(name, value, max)
357
+ end
358
+
359
+ # `lexical` is the serialized form measured for length; `value` is the
360
+ # original cast value carried in the model error (as the pattern path does).
361
+ def validate_length_bounds!(value, lexical, facets)
362
+ validate_exact_length!(value, lexical, facets[:length])
363
+ min = facets[:min_length]
364
+ max = facets[:max_length]
365
+ Services::Type::Validator::String.validate_min_length!(lexical, min) if min
366
+ Services::Type::Validator::String.validate_max_length!(lexical, max) if max
367
+ rescue Lutaml::Model::Type::MinLengthError
368
+ raise Lutaml::Model::MinLengthError.new(name, value, min)
369
+ rescue Lutaml::Model::Type::MaxLengthError
370
+ raise Lutaml::Model::MaxLengthError.new(name, value, max)
371
+ end
372
+
373
+ def validate_exact_length!(value, lexical, length)
374
+ return if length.nil? || lexical.length == length
375
+
376
+ raise Lutaml::Model::LengthError.new(name, value, length)
377
+ end
378
+
379
+ def validate_digit_bounds!(value, facets)
380
+ total_limit = facets[:total_digits]
381
+ fraction_limit = facets[:fraction_digits]
382
+ return if total_limit.nil? && fraction_limit.nil?
383
+
384
+ total, fraction = digit_counts(value)
385
+ if total_limit && total > total_limit
386
+ raise Lutaml::Model::TotalDigitsError.new(name, value, total_limit)
387
+ end
388
+ return unless fraction_limit && fraction > fraction_limit
389
+
390
+ raise Lutaml::Model::FractionDigitsError.new(name, value, fraction_limit)
391
+ end
392
+
393
+ # Count digits of an already-cast numeric value using XSD semantics
394
+ # (xs:totalDigits / xs:fractionDigits). From the fixed-notation string
395
+ # drop the sign and strip trailing zeros from the fraction part; the
396
+ # significand is the integer part concatenated with that fraction, with
397
+ # leading zeros removed. total = significant digits in the significand;
398
+ # fraction = remaining fraction digits. A leading zero before the point
399
+ # (values < 1) is insignificant, so `0.05` is [1, 2]; zero is [0, 0].
400
+ #
401
+ # @return [Array(Integer, Integer)] [total_digits, fraction_digits]
402
+ def digit_counts(value)
403
+ fixed = value.is_a?(::Integer) ? value.to_s : value.to_s("F")
404
+ int_part, frac_part = fixed.delete("-").split(".")
405
+ significant_frac = frac_part.to_s.sub(/0+\z/, "")
406
+ significand = (int_part + significant_frac).sub(/\A0+/, "")
407
+ [significand.length, significant_frac.length]
408
+ end
409
+
410
+ def validate_enumeration!(value, facets)
411
+ allowed = facets[:enumeration]
412
+ return if allowed.nil?
413
+
414
+ Services::Type::Validator.validate_values!(value, allowed)
415
+ rescue Lutaml::Model::Type::InvalidValueError
416
+ raise Lutaml::Model::InvalidValueError.new(name, value, allowed)
417
+ end
418
+
419
+ # Each accumulated pattern must match against the lexical form (already
420
+ # serialized by the caller) so non-::String string-derived types are
421
+ # matched on their serialized text rather than their cast object; the
422
+ # model error still carries the original `value`.
423
+ def validate_patterns!(value, lexical, facets)
424
+ patterns = facets[:pattern]
425
+ return if patterns.nil?
426
+
427
+ patterns.each do |pattern|
428
+ Services::Type::Validator::String.validate_pattern!(
429
+ lexical, pattern: pattern
430
+ )
431
+ rescue Lutaml::Model::Type::PatternNotMatchedError
432
+ raise Lutaml::Model::PatternNotMatchedError.new(name, pattern, value)
433
+ end
434
+ end
435
+ end
436
+ end
437
+ end
@@ -7,16 +7,18 @@ module Lutaml
7
7
  # Restricted simple type — `class X < Lutaml::Model::Type::Y`
8
8
  # with a cast body that applies facets and delegates to super.
9
9
  class RestrictedType
10
- attr_accessor :class_name, :parent_class, :facets,
10
+ attr_accessor :class_name, :parent_class, :base_class, :facets,
11
11
  :transform_facet, :required_files,
12
12
  :keep_register_when_namespaced, :namespace_class_name
13
13
 
14
14
  def initialize(class_name:, facets:, parent_class: nil,
15
- transform_facet: nil, required_files: [],
15
+ base_class: nil, transform_facet: nil,
16
+ required_files: [],
16
17
  keep_register_when_namespaced: false,
17
18
  namespace_class_name: nil)
18
19
  @class_name = class_name
19
20
  @parent_class = parent_class
21
+ @base_class = base_class
20
22
  @facets = facets
21
23
  @transform_facet = transform_facet
22
24
  @required_files = required_files