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.
- checksums.yaml +4 -4
- data/.rubocop.yml +5 -0
- data/Gemfile +4 -0
- data/README.adoc +39 -0
- data/docs/conversion_caching.adoc +143 -0
- data/lib/compat/opal/lutaml_model_boot.rb +15 -0
- data/lib/lutaml/model/attribute.rb +89 -0
- data/lib/lutaml/model/config.rb +13 -0
- data/lib/lutaml/model/configuration.rb +23 -0
- data/lib/lutaml/model/error/fraction_digits_error.rb +20 -0
- data/lib/lutaml/model/error/length_error.rb +19 -0
- data/lib/lutaml/model/error/max_exclusive_error.rb +19 -0
- data/lib/lutaml/model/error/max_inclusive_error.rb +20 -0
- data/lib/lutaml/model/error/max_length_error.rb +20 -0
- data/lib/lutaml/model/error/min_exclusive_error.rb +19 -0
- data/lib/lutaml/model/error/min_inclusive_error.rb +20 -0
- data/lib/lutaml/model/error/min_length_error.rb +20 -0
- data/lib/lutaml/model/error/restriction_error.rb +8 -0
- data/lib/lutaml/model/error/total_digits_error.rb +20 -0
- data/lib/lutaml/model/error/type/max_exclusive_error.rb +20 -0
- data/lib/lutaml/model/error/type/min_exclusive_error.rb +20 -0
- data/lib/lutaml/model/error/type.rb +2 -0
- data/lib/lutaml/model/restriction_validation.rb +437 -0
- data/lib/lutaml/model/schema/definitions/restricted_type.rb +4 -2
- data/lib/lutaml/model/schema/renderers/restricted_type.rb +134 -4
- data/lib/lutaml/model/schema/rng_compiler/define_classifier.rb +3 -0
- data/lib/lutaml/model/schema/rng_compiler/rng_helpers.rb +9 -1
- data/lib/lutaml/model/schema/rng_compiler/value_type_resolver.rb +2 -0
- data/lib/lutaml/model/schema/templates.rb +2 -0
- data/lib/lutaml/model/schema/xml_compiler/spec_builder/simple_types.rb +39 -7
- data/lib/lutaml/model/schema/xml_compiler/supported_data_types.rb +3 -0
- data/lib/lutaml/model/serialize/attribute_definition.rb +18 -7
- data/lib/lutaml/model/serialize/builder.rb +4 -5
- data/lib/lutaml/model/serialize/conversion_caching.rb +124 -0
- data/lib/lutaml/model/serialize/enum_handling.rb +50 -11
- data/lib/lutaml/model/serialize/format_conversion.rb +46 -38
- data/lib/lutaml/model/serialize/initialization.rb +4 -2
- data/lib/lutaml/model/serialize.rb +61 -0
- data/lib/lutaml/model/services/type/validator.rb +23 -0
- data/lib/lutaml/model/type/string.rb +28 -3
- data/lib/lutaml/model/type/value.rb +231 -0
- data/lib/lutaml/model/type.rb +2 -0
- data/lib/lutaml/model/validation.rb +2 -1
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/model.rb +16 -0
- data/lib/lutaml/xml/model_transform.rb +4 -1
- data/lib/lutaml/xml/schema/xsd/max_exclusive.rb +1 -1
- data/lib/lutaml/xml/schema/xsd/min_exclusive.rb +1 -1
- data/lib/lutaml/xml/schema/xsd_schema.rb +183 -11
- data/lib/lutaml/xml/transformation/order_reconciler.rb +267 -0
- data/lib/lutaml/xml/transformation/ordered_applier.rb +80 -122
- data/lib/lutaml/xml/transformation/rule_compiler.rb +2 -0
- data/lib/lutaml/xml/transformation.rb +2 -0
- data/lib/lutaml/xml/transformation_support.rb +1 -0
- data/spec/fixtures/xml/restriction_facets.xsd +46 -0
- data/spec/lutaml/model/attribute_restriction_spec.rb +472 -0
- data/spec/lutaml/model/attribute_spec.rb +7 -2
- data/spec/lutaml/model/collection_reader_liveness_spec.rb +516 -0
- data/spec/lutaml/model/conversion_caching_spec.rb +280 -0
- data/spec/lutaml/model/lazy_collection_spec.rb +28 -3
- data/spec/lutaml/model/parsed_model_mutation_spec.rb +486 -0
- data/spec/lutaml/model/phantom_value_spec.rb +799 -0
- data/spec/lutaml/model/schema/renderers/restricted_type_spec.rb +1 -0
- data/spec/lutaml/model/schema/xsd_schema_spec.rb +340 -695
- data/spec/lutaml/model/serialize_perf_guard_spec.rb +53 -1
- data/spec/lutaml/model/type_digit_facet_spec.rb +229 -0
- data/spec/lutaml/model/type_facet_restriction_spec.rb +667 -0
- data/spec/lutaml/model/type_white_space_facet_spec.rb +132 -0
- data/spec/lutaml/xml/schema/compiler_spec.rb +302 -0
- data/spec/lutaml/xml/schema/facet_round_trip_spec.rb +153 -0
- metadata +27 -1
|
@@ -50,26 +50,30 @@ module Lutaml
|
|
|
50
50
|
return fast if fast
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
59
|
+
raise Lutaml::Model::FormatAdapterNotSpecifiedError.new(format) if adapter.nil?
|
|
57
60
|
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
# Resolve imports at the entry point of deserialization
|
|
62
|
+
register = options[:register] || Lutaml::Model::Config.default_register
|
|
60
63
|
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
# Hook for format-specific pre-deserialization (e.g., XML mapping import resolution)
|
|
65
|
+
pre_deserialize_hook(format, register)
|
|
63
66
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
#
|
|
257
|
-
#
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
275
|
-
|
|
281
|
+
# Hook for format-specific options preparation (e.g., XML prefix/namespace/declaration)
|
|
282
|
+
options = prepare_to_options(format, instance, options)
|
|
276
283
|
|
|
277
|
-
|
|
284
|
+
document = adapter.new(value, register: options[:register])
|
|
278
285
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
|
|
12
|
-
#
|
|
13
|
-
|
|
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
|
data/lib/lutaml/model/type.rb
CHANGED
|
@@ -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
|
|
71
|
+
Lutaml::Model::PatternNotMatchedError,
|
|
72
|
+
Lutaml::Model::RestrictionError => e
|
|
72
73
|
errors << e
|
|
73
74
|
end
|
|
74
75
|
end
|
data/lib/lutaml/model/version.rb
CHANGED
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
|
-
|
|
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
|