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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 559deafaf5121c17a2703c362f355feb440380ba2f80fbb0ff9afa5440995841
4
- data.tar.gz: 48ea3bfb26265e940cc54d88e49ce60b54887b7a52372e7e174284625185f88a
3
+ metadata.gz: 65361e31ef80bfd3600c0bc3ea4241c2b9a9e9d227b1772b9bd23f3324e92ad7
4
+ data.tar.gz: da258d5127e14511cd77bd1ca44e6ad1647aaaf445cc14a92102096f180a82a3
5
5
  SHA512:
6
- metadata.gz: a28e6ef895329e27e3796266c197b700a391df1aa0b8c8cdf4ed89c3c0e4016d876c92053481a2b6f118817327213445e88e7f9ea9c4f3e7c2b5d3b8465052b1
7
- data.tar.gz: e87f3702b6e783da4be97faeae5a7a0c572cd05995d4a7f15d73d9784d8ead92bddcb696c12c584b372ff603fb6a94dbfd1ef21eb63c33bfd4da69569ecd9349
6
+ metadata.gz: 2d5ece44cac496100e30c2d4f3c5ca3934f656bd81352ee9215e57c2a4b8563f0a7a8ae3c155909eaf132efcce992e1954731b89cd79808fcf8c948555b06abd
7
+ data.tar.gz: 9db5fc0ac630ad33336250d06a953357d8a2d60bb0f0cae037d5a2d9d6cc12da2fca2937aa5b73c5b490852381995937fad226049e51fd167906e900084fa837
data/.rubocop.yml CHANGED
@@ -32,6 +32,11 @@ RSpec/EmptyExampleGroup:
32
32
  Exclude:
33
33
  - 'spec/lutaml/jsonld/transform_spec.rb'
34
34
 
35
+ # emits_nothing_extra carries the expectations for these examples.
36
+ RSpec/NoExpectationExample:
37
+ AllowedPatterns:
38
+ - '^emits_nothing_extra'
39
+
35
40
  RSpec/NamedSubject:
36
41
  inherit_mode:
37
42
  merge:
data/Gemfile CHANGED
@@ -20,6 +20,10 @@ gem "bigdecimal"
20
20
  gem "canon" # , path: "../canon"
21
21
  gem "json-ld"
22
22
  gem "liquid", "~> 5"
23
+ # lutaml-store depends on lutaml-model, so it can never be a gemspec dependency.
24
+ # 0.2.4 relaxes rubyzip to >= 2.3, < 4 (lutaml/lutaml-store#5), co-resolving
25
+ # with lutaml-model's rubyzip ~> 3.4 CVE floor.
26
+ gem "lutaml-store", "~> 0.2.4"
23
27
  gem "multi_json"
24
28
  gem "nokogiri"
25
29
  gem "oj"
data/README.adoc CHANGED
@@ -67,6 +67,7 @@ link:docs/migration-guides/0-1-0-migrate-from-shale.adoc[Migrating from Shale to
67
67
  * Reusable XML mapping classes for sharing mappings across models
68
68
  * RDF namespace classes and JSON-LD/Turtle format adapters for Linked Data serialization
69
69
  * Consolidation mapping: group sibling XML elements into structured model instances (see <<consolidation-mapping>>)
70
+ * Opt-in caching of repeated serialization/deserialization, backed by the `lutaml-store` gem (see <<conversion-caching>>)
70
71
  * Document-level validation framework with composable rules, profiles, and remediation (see <<document-validation-framework>>)
71
72
 
72
73
 
@@ -16547,6 +16548,44 @@ raised to indicate that the input format is malformed and cannot be parsed.
16547
16548
 
16548
16549
 
16549
16550
 
16551
+ [[conversion-caching]]
16552
+ == Conversion caching
16553
+
16554
+ Repeated serialization/deserialization of the same content can be cached.
16555
+ A model class opts in with `cache_conversions`; the application provides the
16556
+ storage backend by adding the
16557
+ link:https://github.com/lutaml/lutaml-store[lutaml-store] gem to its
16558
+ `Gemfile` (lutaml-model itself does not depend on it). Without both, nothing
16559
+ changes.
16560
+
16561
+ [source,ruby]
16562
+ ----
16563
+ require "lutaml/store"
16564
+
16565
+ class Invoice < Lutaml::Model::Serializable
16566
+ attribute :number, :string
16567
+
16568
+ cache_conversions
16569
+
16570
+ xml do
16571
+ root "invoice"
16572
+ map_element "number", to: :number
16573
+ end
16574
+ end
16575
+
16576
+ Invoice.from_xml(xml) # parsed once
16577
+ Invoice.from_xml(xml) # the same instance, served from the cache
16578
+ ----
16579
+
16580
+ When `Lutaml::Store` is loaded, a memory-backed `Lutaml::Store::BasicStore`
16581
+ is created automatically on first use. Assign
16582
+ `Lutaml::Model::Config.conversion_cache` to use a custom backend (e.g.
16583
+ `Lutaml::Store::CacheStore` with TTL/LRU, or any object responding to
16584
+ `get`/`set`), or set it to `false` to disable caching globally.
16585
+
16586
+ See link:docs/conversion_caching.adoc[Conversion caching] for activation
16587
+ rules, backend configuration, cache-key semantics, and limitations.
16588
+
16550
16589
  [[custom-adapters]]
16551
16590
  == Custom serialization adapters
16552
16591
 
@@ -0,0 +1,143 @@
1
+ = LutaML Model — Conversion caching
2
+ :doctype: book
3
+
4
+ == Overview
5
+
6
+ LutaML Model can cache the results of format conversions to avoid redundant
7
+ processing when the same content is converted repeatedly:
8
+
9
+ - *Deserialization* (`from_xml`, `from_json`, ...): repeated parsing of the
10
+ same input string returns the cached model instance instead of re-parsing.
11
+ - *Serialization* (`to_xml`, `to_json`, ...): repeated serialization of an
12
+ unchanged instance returns the cached output string.
13
+
14
+ Caching is opt-in twice over: a model class declares it, and the application
15
+ provides a storage backend (the
16
+ link:https://github.com/lutaml/lutaml-store[lutaml-store] gem, or any
17
+ compatible object). Without both, nothing changes — classes that do not opt
18
+ in are never cached, and `cache_conversions` without a backend is a no-op.
19
+
20
+ == Quick start
21
+
22
+ Add the backend gem to your application's `Gemfile` (lutaml-model itself does
23
+ not depend on it):
24
+
25
+ [source,ruby]
26
+ ----
27
+ gem "lutaml-store"
28
+ ----
29
+
30
+ Require it, and declare caching on the models that need it:
31
+
32
+ [source,ruby]
33
+ ----
34
+ require "lutaml/model"
35
+ require "lutaml/store"
36
+
37
+ class Invoice < Lutaml::Model::Serializable
38
+ attribute :number, :string
39
+ attribute :amount, :decimal
40
+
41
+ cache_conversions
42
+
43
+ xml do
44
+ root "invoice"
45
+ map_element "number", to: :number
46
+ map_element "amount", to: :amount
47
+ end
48
+ end
49
+
50
+ Invoice.from_xml(xml) # parsed once
51
+ Invoice.from_xml(xml) # the same instance, served from the cache
52
+
53
+ invoice = Invoice.new(number: "42", amount: 10)
54
+ invoice.to_xml # serialized once
55
+ invoice.to_xml # served from the cache
56
+ ----
57
+
58
+ That is all: when `Lutaml::Store` is loaded, the first conversion call on an
59
+ opted-in class lazily creates a default memory-backed
60
+ `Lutaml::Store::BasicStore` and caching is active from then on.
61
+
62
+ == Activation rules
63
+
64
+ [cols="1,1,1", options="header"]
65
+ |===
66
+ | Class declares `cache_conversions` | Backend available | Result
67
+
68
+ | no | — | never cached
69
+ | yes | no store loaded or assigned | no-op (works as without caching)
70
+ | yes | lutaml-store loaded | cached via auto-created memory `BasicStore`
71
+ | yes | store assigned via config | cached via that store
72
+ | any | `Config.conversion_cache = false` | caching disabled globally
73
+ |===
74
+
75
+ Subclasses inherit the declaration:
76
+
77
+ [source,ruby]
78
+ ----
79
+ class SignedInvoice < Invoice
80
+ end
81
+
82
+ SignedInvoice.conversion_caching_enabled? # => true
83
+ ----
84
+
85
+ == Configuring the backend
86
+
87
+ The default (memory `BasicStore`, unbounded) suits scripts and CLI runs. For
88
+ long-running processes, assign a bounded or custom store:
89
+
90
+ [source,ruby]
91
+ ----
92
+ Lutaml::Model::Config.configure do |config|
93
+ # TTL + LRU eviction; suitable for to_* output strings.
94
+ config.conversion_cache = Lutaml::Store::CacheStore.new(
95
+ adapter: { type: :memory },
96
+ default_ttl: 600,
97
+ max_size: 1000,
98
+ )
99
+ end
100
+
101
+ # Disable caching entirely (even with lutaml-store loaded):
102
+ Lutaml::Model::Config.conversion_cache = false
103
+
104
+ # Return to auto-detection:
105
+ Lutaml::Model::Config.conversion_cache = nil
106
+ ----
107
+
108
+ Any object responding to `get(key)` and `set(key, value)` works as a backend.
109
+
110
+ NOTE: `Lutaml::Store::CacheStore` JSON-marshals stored values, so it
111
+ accelerates serialization output (strings survive the round-trip) while
112
+ deserialized model instances are recomputed on every call — correct, just
113
+ uncached. Use `BasicStore` to cache both directions.
114
+
115
+ == Semantics and guarantees
116
+
117
+ Correctness first:: Cache keys are content-derived — they digest the input
118
+ string (deserialization) or the instance state (serialization), plus the
119
+ class, format, register, resolved adapter, and any conversion options.
120
+ Mutating an instance changes its key, so stale output is never served.
121
+ Anything that cannot be keyed safely bypasses the cache and is computed
122
+ normally rather than risking a wrong result.
123
+
124
+ Shared instances:: Repeated deserialization of identical input returns the
125
+ *same* object — across callers and threads. Treat cached results as
126
+ read-only. If your code mutates parsed results, do not declare
127
+ `cache_conversions` on that class.
128
+
129
+ What bypasses the cache:: Non-`String` inputs (`Pathname`, `IO` — the content
130
+ lives elsewhere); non-`Hash` options (e.g. the generator state objects Ruby's
131
+ JSON/YAML protocols pass); instance graphs `Marshal` cannot digest — notably,
132
+ instances parsed from XML retain native parser nodes for round-trip fidelity
133
+ and therefore skip serialization caching (programmatically built instances
134
+ cache fully); and any environment without native `Marshal`/`Digest` (Opal).
135
+
136
+ Invalidation:: Entirely the store's concern — TTL, eviction, persistence, and
137
+ clearing belong to the backend you configured. If you mutate a register's
138
+ mappings at runtime, clear or replace the store afterwards; structural
139
+ changes are not propagated automatically.
140
+
141
+ Errors:: Backend exceptions propagate unchanged so operational problems stay
142
+ visible; malformed input still raises
143
+ `Lutaml::Model::InvalidFormatError` exactly as without caching.
@@ -78,6 +78,7 @@ require "lutaml/model/organization"
78
78
  require "lutaml/model/register"
79
79
  require "lutaml/model/registrable"
80
80
  require "lutaml/model/render_policy"
81
+ require "lutaml/model/restriction_validation"
81
82
  require "lutaml/model/runtime_compatibility"
82
83
  require "lutaml/model/schema"
83
84
  require "lutaml/model/schema_location"
@@ -235,6 +236,7 @@ require "lutaml/model/error/collection_count_out_of_range_error"
235
236
  require "lutaml/model/error/collection_true_missing_error"
236
237
  require "lutaml/model/error/element_count_out_of_range_error"
237
238
  require "lutaml/model/error/format_adapter_not_specified_error"
239
+ require "lutaml/model/error/fraction_digits_error"
238
240
  require "lutaml/model/error/import_model_with_root_error"
239
241
  require "lutaml/model/error/incorrect_mapping_argument_error"
240
242
  require "lutaml/model/error/incorrect_sequence_error"
@@ -244,12 +246,19 @@ require "lutaml/model/error/invalid_attribute_type_error"
244
246
  require "lutaml/model/error/invalid_choice_range_error"
245
247
  require "lutaml/model/error/invalid_format_error"
246
248
  require "lutaml/model/error/invalid_value_error"
249
+ require "lutaml/model/error/length_error"
247
250
  require "lutaml/model/error/liquid_class_not_found_error"
248
251
  require "lutaml/model/error/liquid_drop_already_registered_error"
249
252
  require "lutaml/model/error/liquid_not_enabled_error"
250
253
  require "lutaml/model/error/mapping_already_exists_error"
251
254
  require "lutaml/model/error/mapping_attribute_missing_error"
252
255
  require "lutaml/model/error/mapping_attribute_type_error"
256
+ require "lutaml/model/error/max_exclusive_error"
257
+ require "lutaml/model/error/max_inclusive_error"
258
+ require "lutaml/model/error/max_length_error"
259
+ require "lutaml/model/error/min_exclusive_error"
260
+ require "lutaml/model/error/min_inclusive_error"
261
+ require "lutaml/model/error/min_length_error"
253
262
  require "lutaml/model/error/mixed_content_collection_error"
254
263
  require "lutaml/model/error/multiple_mappings_error"
255
264
  require "lutaml/model/error/no_attributes_defined_liquid_error"
@@ -260,9 +269,11 @@ require "lutaml/model/error/ordered_content_mapping_error"
260
269
  require "lutaml/model/error/pattern_not_matched_error"
261
270
  require "lutaml/model/error/polymorphic_error"
262
271
  require "lutaml/model/error/required_attribute_missing_error"
272
+ require "lutaml/model/error/restriction_error"
263
273
  require "lutaml/model/error/reverse_transform_block_not_defined_error"
264
274
  require "lutaml/model/error/reverse_transformation_declaration_error"
265
275
  require "lutaml/model/error/sorting_configuration_conflict_error"
276
+ require "lutaml/model/error/total_digits_error"
266
277
  require "lutaml/model/error/transform_block_not_defined_error"
267
278
  require "lutaml/model/error/type_error"
268
279
  require "lutaml/model/error/type_not_enabled_error"
@@ -304,6 +315,7 @@ require "lutaml/model/schema/templates"
304
315
  require "lutaml/model/schema/yaml_schema"
305
316
  require "lutaml/model/serialize/attribute_definition"
306
317
  require "lutaml/model/serialize/builder"
318
+ require "lutaml/model/serialize/conversion_caching"
307
319
  require "lutaml/model/serialize/deserialization_context"
308
320
  require "lutaml/model/serialize/enum_handling"
309
321
  require "lutaml/model/serialize/format_conversion"
@@ -380,6 +392,7 @@ require "lutaml/xml/serialization/instance_methods"
380
392
  require "lutaml/xml/serialization/model_import_ext"
381
393
  require "lutaml/xml/transformation/custom_method_wrapper"
382
394
  require "lutaml/xml/transformation/element_builder"
395
+ require "lutaml/xml/transformation/order_reconciler"
383
396
  require "lutaml/xml/transformation/ordered_applier"
384
397
  require "lutaml/xml/transformation/rule_applier"
385
398
  require "lutaml/xml/transformation/rule_compiler"
@@ -437,8 +450,10 @@ require "lutaml/key_value/adapter/yamls/transform"
437
450
  require "lutaml/model/error/register/not_registrable_class_error"
438
451
  require "lutaml/model/error/type/invalid_value_error"
439
452
  require "lutaml/model/error/type/max_bound_error"
453
+ require "lutaml/model/error/type/max_exclusive_error"
440
454
  require "lutaml/model/error/type/max_length_error"
441
455
  require "lutaml/model/error/type/min_bound_error"
456
+ require "lutaml/model/error/type/min_exclusive_error"
442
457
  require "lutaml/model/error/type/min_length_error"
443
458
  require "lutaml/model/error/type/pattern_not_matched_error"
444
459
  require "lutaml/model/schema/decorators/attribute"
@@ -7,6 +7,7 @@ module Lutaml
7
7
 
8
8
  include CollectionHandler
9
9
  include DeepDupable
10
+ include RestrictionValidation
10
11
 
11
12
  ALLOWED_OPTIONS = %i[
12
13
  raw
@@ -29,6 +30,11 @@ module Lutaml
29
30
  ref_key_attribute
30
31
  xsd_type
31
32
  union_member_types
33
+ min
34
+ max
35
+ signed
36
+ min_length
37
+ max_length
32
38
  ].freeze
33
39
 
34
40
  # Per-type-class memo of the custom from_xml/from_json probe used by #cast.
@@ -344,6 +350,32 @@ module Lutaml
344
350
  value.map { |v| cast_element(v, register) }
345
351
  end
346
352
 
353
+ # Cast the value a derived reader just recomputed.
354
+ #
355
+ # A derived attribute has no ivar and no writer. Its reader runs the
356
+ # source method and casts the result on every call, which makes the
357
+ # reader a casting entry point of its own — nothing a writer-side guard
358
+ # can reach. A collection still has to come back as a collection here,
359
+ # even when the source method returned nothing.
360
+ #
361
+ # @param value [Object] whatever the source method returned
362
+ # @param register [Symbol] register for type resolution
363
+ # @return [Object] the cast value, or an empty collection
364
+ def cast_derived(value, register)
365
+ return cast_element(value, register) unless collection?
366
+ return build_collection if no_data?(value)
367
+ return cast_value(value, register) if collection_instance?(value)
368
+ # A plain Array is a list of source elements even when the attribute
369
+ # declares its own collection class, which collection_instance? only
370
+ # recognises by that class. Without this it becomes one element whose
371
+ # value is the inspected Array.
372
+ if value.is_a?(::Array)
373
+ return build_collection(value.map { |v| cast_element(v, register) })
374
+ end
375
+
376
+ build_collection(cast_element(value, register))
377
+ end
378
+
347
379
  # Apply a value map to transform a value.
348
380
  #
349
381
  # value_map keys (:nil, :empty, :omitted) each map either to a symbolic
@@ -389,8 +421,32 @@ module Lutaml
389
421
  true
390
422
  end
391
423
 
424
+ # True when the value is "nothing arrived" rather than data.
425
+ #
426
+ # nil and the uninitialized sentinel both mean the source carried no
427
+ # value. Neither is something a type can be asked to turn into an
428
+ # instance.
429
+ #
430
+ # @param value [Object]
431
+ # @return [Boolean]
432
+ def no_data?(value)
433
+ value.nil? || Utils.uninitialized?(value)
434
+ end
435
+
392
436
  def cast_element(value, register)
437
+ # Resolve first: an undeclared type has to raise UnknownTypeError even
438
+ # when the value carries nothing, the way it did before the guard
439
+ # below existed.
393
440
  resolved_type = type(register)
441
+
442
+ # Casting is for data. Every built-in type already hands nil and the
443
+ # uninitialized sentinel straight back, so this changes nothing for
444
+ # them. A type with its own `self.cast` returns a real instance
445
+ # instead, and that instance becomes a value nobody wrote — a phantom
446
+ # element in the document, or a one-item collection the source never
447
+ # contained. `cast` below guards the format entry point the same way.
448
+ return value if no_data?(value)
449
+
394
450
  return cast_union(value, nil, register) if union?
395
451
  return resolved_type.new(value) if value.is_a?(::Hash) && !hash_type?
396
452
 
@@ -570,6 +626,7 @@ instance_object = nil)
570
626
  register)
571
627
  end
572
628
  resolved_type = type(register)
629
+ validate_restriction_configuration!(resolved_type)
573
630
 
574
631
  # Shape before contents. An over-count is a cardinality violation, not
575
632
  # an enum or pattern one, and checking it first keeps those predicates
@@ -579,6 +636,8 @@ instance_object = nil)
579
636
  valid_pattern!(value, resolved_type) &&
580
637
  validate_polymorphic!(value, resolved_type) &&
581
638
  execute_validations!(value)
639
+
640
+ validate_restriction_values!(value, resolved_type)
582
641
  end
583
642
 
584
643
  # execute custom validations on the attribute value
@@ -724,6 +783,14 @@ instance_object = nil)
724
783
  end
725
784
 
726
785
  def cast(value, format, register, options = {})
786
+ # Same rule as cast_element, for the format entry point. from_json /
787
+ # from_yaml / from_toml / from_hash reach a missing key as nil, and a
788
+ # type with its own `self.cast` turns that nil into an instance the
789
+ # document never carried. Unlike cast_element this returns before
790
+ # resolving the type: resolution here depends on the format options
791
+ # below, and an undeclared type still raises through cast_element.
792
+ return value if no_data?(value)
793
+
727
794
  # Namespace-aware type resolution: use type_with_namespace if namespace_uri provided
728
795
  namespace_uri = options[:namespace_uri]
729
796
  resolved_type = if options[:resolved_type]
@@ -1122,6 +1189,28 @@ instance_object = nil)
1122
1189
  raise StandardError,
1123
1190
  "Invalid option `initialize_empty` given without `collection: true` option"
1124
1191
  end
1192
+
1193
+ # Length facets are XSD nonNegativeInteger; reject a misconfigured
1194
+ # type here so a String/Float/nil fails fast instead of raising an
1195
+ # opaque TypeError later at `value.length >= min`.
1196
+ %i[min_length max_length].each do |key|
1197
+ next unless options.key?(key)
1198
+
1199
+ value = options[key]
1200
+ next if value.is_a?(::Integer) && !value.negative?
1201
+
1202
+ raise ArgumentError,
1203
+ "Invalid options for `#{name}`: " \
1204
+ "`#{key}` must be a non-negative Integer, got #{value.inspect}"
1205
+ end
1206
+
1207
+ # `signed` is true|false per issue #191; a non-boolean (e.g. "false")
1208
+ # would silently read as signed, so reject it up front.
1209
+ if options.key?(:signed) && ![true, false].include?(options[:signed])
1210
+ raise ArgumentError,
1211
+ "Invalid options for `#{name}`: " \
1212
+ "`signed` must be true or false, got #{options[:signed].inspect}"
1213
+ end
1125
1214
  true
1126
1215
  end
1127
1216
 
@@ -92,6 +92,19 @@ module Lutaml
92
92
  end
93
93
  end
94
94
 
95
+ # Store used by classes that declare `cache_conversions`.
96
+ # Duck-typed: anything responding to #get(key) and #set(key, value).
97
+ # Defaults to a memory-backed Lutaml::Store::BasicStore when the
98
+ # lutaml-store gem is loaded; assign false to disable caching, or
99
+ # nil to return to auto-detection.
100
+ def conversion_cache
101
+ instance.conversion_cache
102
+ end
103
+
104
+ def conversion_cache=(store)
105
+ instance.conversion_cache = store
106
+ end
107
+
95
108
  def mappings_class_for(format)
96
109
  instance.mappings_class_for(format)
97
110
  end
@@ -19,9 +19,11 @@ module Lutaml
19
19
  # compilable XML models (Leptris::XML::Descriptor walk). Opt-in
20
20
  # while the full semantics audit completes.
21
21
  attr_accessor :xml_plan_fast_path
22
+ attr_writer :conversion_cache
22
23
 
23
24
  def initialize
24
25
  @default_register = :default
26
+ @conversion_cache = nil
25
27
  @configured = false
26
28
  @xml_plan_fast_path = false
27
29
  end
@@ -32,6 +34,18 @@ module Lutaml
32
34
  self
33
35
  end
34
36
 
37
+ # Store consulted by classes that declare `cache_conversions`.
38
+ # Defaults to a memory-backed Lutaml::Store::BasicStore when the
39
+ # lutaml-store gem is loaded (the application opts in by adding it
40
+ # to its Gemfile and requiring "lutaml/store"). Assign any object
41
+ # responding to #get/#set to override, false to disable caching
42
+ # entirely, or nil to return to auto-detection.
43
+ def conversion_cache
44
+ return if @conversion_cache == false
45
+
46
+ @conversion_cache ||= default_conversion_cache
47
+ end
48
+
35
49
  def configured?
36
50
  @configured
37
51
  end
@@ -68,6 +82,7 @@ module Lutaml
68
82
  # Reset configuration to defaults
69
83
  def reset!
70
84
  @default_register = :default
85
+ @conversion_cache = nil
71
86
  @configured = false
72
87
  AdapterResolver.reset!
73
88
  AdapterScope.reset!
@@ -91,6 +106,14 @@ module Lutaml
91
106
  # Adapter accessors are defined dynamically by FormatRegistry.register
92
107
  # via define_method. These forward to AdapterResolver.
93
108
  # The generic set_adapter/get_adapter handle any format.
109
+
110
+ private
111
+
112
+ def default_conversion_cache
113
+ return unless defined?(::Lutaml::Store::BasicStore)
114
+
115
+ ::Lutaml::Store::BasicStore.new(adapter: { type: :memory })
116
+ end
94
117
  end
95
118
  end
96
119
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ class FractionDigitsError < RestrictionError
6
+ def initialize(attr_name, value, fraction_digits)
7
+ @attr_name = attr_name
8
+ @value = value
9
+ @fraction_digits = fraction_digits
10
+
11
+ super()
12
+ end
13
+
14
+ def to_s
15
+ "#{@attr_name} (#{@value}) exceeds the maximum of " \
16
+ "#{@fraction_digits} fraction digits"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ class LengthError < RestrictionError
6
+ def initialize(attr_name, value, length)
7
+ @attr_name = attr_name
8
+ @value = value
9
+ @length = length
10
+
11
+ super()
12
+ end
13
+
14
+ def to_s
15
+ "#{@attr_name} length (#{@value.to_s.length}) must be exactly #{@length}"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ class MaxExclusiveError < RestrictionError
6
+ def initialize(attr_name, value, max)
7
+ @attr_name = attr_name
8
+ @value = value
9
+ @max = max
10
+
11
+ super()
12
+ end
13
+
14
+ def to_s
15
+ "#{@attr_name} is `#{@value}`, must be less than #{@max}"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ class MaxInclusiveError < RestrictionError
6
+ def initialize(attr_name, value, max)
7
+ @attr_name = attr_name
8
+ @value = value
9
+ @max = max
10
+
11
+ super()
12
+ end
13
+
14
+ def to_s
15
+ "#{@attr_name} is `#{@value}`, " \
16
+ "must be less than or equal to #{@max}"
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
+ class MaxLengthError < RestrictionError
6
+ def initialize(attr_name, value, max_length)
7
+ @attr_name = attr_name
8
+ @value = value
9
+ @max_length = max_length
10
+
11
+ super()
12
+ end
13
+
14
+ def to_s
15
+ "#{@attr_name} length (#{@value.to_s.length}) is greater than " \
16
+ "the maximum allowed length #{@max_length}"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ class MinExclusiveError < RestrictionError
6
+ def initialize(attr_name, value, min)
7
+ @attr_name = attr_name
8
+ @value = value
9
+ @min = min
10
+
11
+ super()
12
+ end
13
+
14
+ def to_s
15
+ "#{@attr_name} is `#{@value}`, must be greater than #{@min}"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ class MinInclusiveError < RestrictionError
6
+ def initialize(attr_name, value, min)
7
+ @attr_name = attr_name
8
+ @value = value
9
+ @min = min
10
+
11
+ super()
12
+ end
13
+
14
+ def to_s
15
+ "#{@attr_name} is `#{@value}`, " \
16
+ "must be greater than or equal to #{@min}"
17
+ end
18
+ end
19
+ end
20
+ end