lutaml-model 0.8.32 → 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 +47 -6
  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
@@ -7,9 +7,40 @@ module Lutaml
7
7
  module Schema
8
8
  module Renderers
9
9
  # Renders a Definitions::RestrictedType into a Ruby class extending
10
- # a Lutaml::Model::Type::* with a cast body that mutates options
11
- # with facet values and delegates to super.
10
+ # a Lutaml::Model::Type::* with Layer-2 facet macros (lazy validation
11
+ # + `.facets` round-trip) and a cast body that mutates options with
12
+ # the eager numeric facet values and delegates to super.
12
13
  class RestrictedType < Base
14
+ # XSD base types whose bounds/enumeration values are integer literals.
15
+ INTEGER_BASES = %i[
16
+ integer int long short byte
17
+ positiveInteger nonNegativeInteger negativeInteger nonPositiveInteger
18
+ unsignedLong unsignedInt unsignedShort unsignedByte
19
+ ].freeze
20
+
21
+ # Base types whose facet values must be emitted as a `cast` call on
22
+ # the Lutaml type, so the generated literal equals the cast attribute
23
+ # value it is compared against (temporal/boolean/float parse
24
+ # specially). Keyed on both the XSD spelling (`dateTime`, from the XSD
25
+ # compiler) and the snake_case spelling (`date_time`, from the RNG
26
+ # compiler) since both feed this shared renderer.
27
+ CAST_BASES = {
28
+ boolean: "Lutaml::Model::Type::Boolean",
29
+ float: "Lutaml::Model::Type::Float",
30
+ double: "Lutaml::Model::Type::Float",
31
+ date: "Lutaml::Model::Type::Date",
32
+ dateTime: "Lutaml::Model::Type::DateTime",
33
+ date_time: "Lutaml::Model::Type::DateTime",
34
+ time: "Lutaml::Model::Type::Time",
35
+ }.freeze
36
+
37
+ # Bases whose bounds render as bare numeric literals, so the eager
38
+ # `options[:min]/[:max]` path is valid Ruby. Every other base
39
+ # (temporal, boolean, string, or a user-defined type) carries a bound
40
+ # that is not a bare literal, so it stays macro-only and the eager
41
+ # path is skipped — the Layer-2 facet macros enforce it lazily.
42
+ EAGER_NUMERIC_BASES = (INTEGER_BASES + %i[decimal float double]).freeze
43
+
13
44
  def render
14
45
  Templates::RESTRICTED_SIMPLE_TYPE.result(binding)
15
46
  end
@@ -26,6 +57,28 @@ module Lutaml
26
57
 
27
58
  def restricted_simple_type_required_files = required_files_block
28
59
 
60
+ # Layer-2 facet macros declared in the class body. They store the
61
+ # facets on the type so generated models validate lazily through
62
+ # RestrictionValidation and round-trip via `.facets` back to XSD.
63
+ def restricted_simple_type_facet_declarations
64
+ f = @spec.facets
65
+ lines = [
66
+ ordered("inclusive", f.min_inclusive, f.max_inclusive),
67
+ ordered("exclusive", f.min_exclusive, f.max_exclusive),
68
+ length_macro,
69
+ # Mirror the eager `%r{#{pattern}}` form (not `pattern.inspect`):
70
+ # a built-in pattern (e.g. anyURI) is stored with live `#{...}`
71
+ # interpolation that `inspect` would escape, matching the literal
72
+ # source instead of the value.
73
+ (Utils.present?(f.pattern) ? "pattern(%r{#{f.pattern}})" : nil),
74
+ enumeration_macro(f.enumerations),
75
+ white_space_macro(f.white_space),
76
+ (Utils.present?(f.total_digits) ? "total_digits #{f.total_digits}" : nil),
77
+ (Utils.present?(f.fraction_digits) ? "fraction_digits #{f.fraction_digits}" : nil),
78
+ ]
79
+ lines.compact.map { |line| "#{@indent}#{line}\n" }.join
80
+ end
81
+
29
82
  def restricted_simple_type_cast_body
30
83
  [
31
84
  render_min_max,
@@ -35,15 +88,21 @@ module Lutaml
35
88
  ].compact.join
36
89
  end
37
90
 
91
+ # Eager numeric option path, valid only for bases whose bounds are
92
+ # bare numeric literals; other bases are macro-only (see above). The
93
+ # bound is rendered through `literal_for` so a decimal keeps its exact
94
+ # value (`BigDecimal("...")`, not a lossy/`.5`-invalid Float literal).
38
95
  def render_min_max
96
+ return nil unless EAGER_NUMERIC_BASES.include?(base_class_name)
97
+
39
98
  f = @spec.facets
40
99
  max = f.max_inclusive || f.max_exclusive
41
100
  min = f.min_inclusive || f.min_exclusive
42
101
  return nil unless max || min
43
102
 
44
103
  out = +""
45
- out << "#{@extended_indent}options[:max] = #{max}\n" if max
46
- out << "#{@extended_indent}options[:min] = #{min}\n" if min
104
+ out << "#{@extended_indent}options[:max] = #{literal_for(max)}\n" if max
105
+ out << "#{@extended_indent}options[:min] = #{literal_for(min)}\n" if min
47
106
  out
48
107
  end
49
108
 
@@ -65,6 +124,77 @@ module Lutaml
65
124
  t && "#{@extended_indent}value = #{t.expression}\n"
66
125
  end
67
126
 
127
+ # Format an ordered-facet macro (`inclusive`/`exclusive`) from its
128
+ # base-typed bounds, emitting only the bounds that are present.
129
+ def ordered(macro, min, max)
130
+ args = []
131
+ args << "min: #{literal_for(min)}" if Utils.present?(min)
132
+ args << "max: #{literal_for(max)}" if Utils.present?(max)
133
+ return if args.empty?
134
+
135
+ "#{macro} #{args.join(', ')}"
136
+ end
137
+
138
+ # Length facets are always plain integers (character/octet counts),
139
+ # independent of the base type: exact `length N`, or a min/max range.
140
+ def length_macro
141
+ f = @spec.facets
142
+ return "length #{f.length}" if Utils.present?(f.length)
143
+
144
+ args = []
145
+ args << "min: #{f.min_length}" if Utils.present?(f.min_length)
146
+ args << "max: #{f.max_length}" if Utils.present?(f.max_length)
147
+ return if args.empty?
148
+
149
+ "length #{args.join(', ')}"
150
+ end
151
+
152
+ def enumeration_macro(enumerations)
153
+ return nil unless Utils.present?(enumerations)
154
+
155
+ "enumeration(#{enumerations.map { |v| literal_for(v) }.join(', ')})"
156
+ end
157
+
158
+ # xs:whiteSpace is a string-only facet; the runtime macro rejects it
159
+ # on non-string bases. Emit it only for string-derived bases so the
160
+ # generated code loads.
161
+ def white_space_macro(white_space)
162
+ return nil unless string_derived_base? && Utils.present?(white_space)
163
+
164
+ "white_space #{white_space.inspect}"
165
+ end
166
+
167
+ # The one renderer for base-typed facet values (bounds, enumeration):
168
+ # decimal an exact BigDecimal, temporal/boolean/float a cast value
169
+ # comparable via `<=>`, integer a bare literal, a user-defined base a
170
+ # `cast` on its generated parent class (in scope as this class's
171
+ # superclass), and a built-in string base a quoted string.
172
+ def literal_for(raw)
173
+ name = base_class_name
174
+ return %{BigDecimal("#{raw}")} if name == :decimal
175
+ if (klass = CAST_BASES[name])
176
+ return "#{klass}.cast(#{raw.to_s.inspect})"
177
+ end
178
+ return raw.to_i.to_s if INTEGER_BASES.include?(name)
179
+ unless XmlCompiler::SupportedDataTypes[name]
180
+ return "#{Utils.camel_case(name.to_s)}.cast(#{raw.to_s.inspect})"
181
+ end
182
+
183
+ raw.to_s.inspect
184
+ end
185
+
186
+ def string_derived_base?
187
+ name = base_class_name
188
+ return false if name == :decimal
189
+ return false if INTEGER_BASES.include?(name)
190
+
191
+ !CAST_BASES.key?(name)
192
+ end
193
+
194
+ def base_class_name
195
+ @spec.base_class&.to_sym
196
+ end
197
+
68
198
  def registration_methods
69
199
  Registration.methods_block(
70
200
  class_name: @spec.class_name,
@@ -66,7 +66,9 @@ module Lutaml
66
66
  Definitions::RestrictedType.new(
67
67
  class_name: @class_name,
68
68
  parent_class: RngHelpers.parent_class_for(base),
69
+ base_class: base.to_s,
69
70
  facets: RngHelpers.facet_from_data(data),
71
+ required_files: RngHelpers.required_files_for(base),
70
72
  )
71
73
  end
72
74
 
@@ -77,6 +79,7 @@ module Lutaml
77
79
  Definitions::RestrictedType.new(
78
80
  class_name: @class_name,
79
81
  parent_class: RngHelpers.parent_class_for(:string),
82
+ base_class: "string",
80
83
  facets: RngHelpers.facet_from_values(choice.value),
81
84
  )
82
85
  end
@@ -121,6 +121,12 @@ module Lutaml
121
121
  ).to_s
122
122
  end
123
123
 
124
+ # A decimal-based restricted type renders `BigDecimal(...)` facet
125
+ # literals, so the generated file must require bigdecimal to load.
126
+ def required_files_for(base_symbol)
127
+ base_symbol == :decimal ? [%(require "bigdecimal")] : []
128
+ end
129
+
124
130
  def apply_param(facet, name, value)
125
131
  case name
126
132
  when "minInclusive" then facet.min_inclusive = numeric_or_string(value)
@@ -136,8 +142,10 @@ module Lutaml
136
142
 
137
143
  def numeric_or_string(value)
138
144
  return value.to_i if /\A-?\d+\z/.match?(value)
139
- return value.to_f if /\A-?\d+\.\d+\z/.match?(value)
140
145
 
146
+ # Keep a fractional bound as its exact lexical string; casting through
147
+ # Float would truncate a high-precision decimal before it reaches the
148
+ # `BigDecimal("...")` facet literal (mirrors the XSD compiler).
141
149
  value
142
150
  end
143
151
 
@@ -75,7 +75,9 @@ module Lutaml
75
75
  @classes, "#{Utils.camel_case(container.attr_name.to_s)}Type"
76
76
  ),
77
77
  parent_class: RngHelpers.parent_class_for(base),
78
+ base_class: base.to_s,
78
79
  facets: facet,
80
+ required_files: RngHelpers.required_files_for(base),
79
81
  )
80
82
  end
81
83
 
@@ -111,6 +111,7 @@ module Lutaml
111
111
  # Binding: module_opening, module_closing, registration_methods,
112
112
  # registration_execution, rendered_class_name, parent_class,
113
113
  # xml_namespace_line, restricted_simple_type_required_files,
114
+ # restricted_simple_type_facet_declarations,
114
115
  # restricted_simple_type_cast_body, boilerplate_indent_str.
115
116
  RESTRICTED_SIMPLE_TYPE = ERB.new(<<~TMPL, trim_mode: "-")
116
117
  # frozen_string_literal: true
@@ -123,6 +124,7 @@ module Lutaml
123
124
  <%= boilerplate_indent_str * 2 %><%= xml_namespace_line %>
124
125
  <%= boilerplate_indent_str %>end
125
126
  <%- end -%>
127
+ <%= restricted_simple_type_facet_declarations -%>
126
128
  <%= boilerplate_indent_str %>def self.cast(value, options = {})
127
129
  <%= boilerplate_indent_str * 2 %>return if value.nil?
128
130
 
@@ -39,6 +39,7 @@ module Lutaml
39
39
  Definitions::RestrictedType.new(
40
40
  class_name: Utils.camel_case(name),
41
41
  parent_class: restricted_parent_class(base),
42
+ base_class: base,
42
43
  facets: facet,
43
44
  transform_facet: transform,
44
45
  required_files: supported_required_files(base),
@@ -80,6 +81,7 @@ module Lutaml
80
81
  Definitions::RestrictedType.new(
81
82
  class_name: Utils.camel_case(name),
82
83
  parent_class: restricted_parent_class(base_class),
84
+ base_class: base_class,
83
85
  facets: facet,
84
86
  transform_facet: nil,
85
87
  required_files: restricted_required_files(base_class),
@@ -122,28 +124,58 @@ module Lutaml
122
124
  min_length: pick_minmax(restriction.min_length, :max),
123
125
  min_inclusive: pick_minmax(restriction.min_inclusive, :max),
124
126
  max_inclusive: pick_minmax(restriction.max_inclusive, :min),
125
- max_exclusive: pick_minmax(restriction.max_exclusive, :max),
126
- min_exclusive: pick_minmax(restriction.min_exclusive, :min),
127
- length: restriction.length&.any? ? restriction_length(restriction.length) : nil,
127
+ max_exclusive: pick_minmax(restriction.max_exclusive, :min),
128
+ min_exclusive: pick_minmax(restriction.min_exclusive, :max),
129
+ length: pick_minmax(restriction.length, :min),
128
130
  pattern: build_pattern(restriction.pattern),
129
131
  enumerations: restriction.enumeration&.any? ? restriction.enumeration.map(&:value) : nil,
132
+ white_space: single_facet(restriction.white_space, &:to_sym),
133
+ total_digits: single_facet(restriction.total_digits, &:to_i),
134
+ fraction_digits: single_facet(restriction.fraction_digits, &:to_i),
130
135
  )
131
136
  end
132
137
 
138
+ # Pick the tightest bound as its exact lexical string. A single
139
+ # value (the only schema-valid case) is returned verbatim so a
140
+ # decimal keeps its precision; a repeated bound is ordered by
141
+ # numeric magnitude (not lexical order, under which "5" > "10"),
142
+ # falling back to lexical order for non-numeric temporal bounds.
133
143
  def pick_minmax(field_value, method)
134
144
  return nil unless field_value&.any?
135
145
 
136
- field_value.map(&:value).public_send(method).to_s
146
+ values = field_value.map(&:value)
147
+ return values.first if values.one?
148
+
149
+ values.public_send(:"#{method}_by") { |v| comparable_bound(v) }
150
+ end
151
+
152
+ # Lazy, guarded require (mirrors Type::Decimal) so this Opal-booted
153
+ # file has no load-time bigdecimal dependency; the XSD compiler is a
154
+ # native-only path, so this only runs under MRI.
155
+ def comparable_bound(value)
156
+ require "bigdecimal" unless defined?(BigDecimal)
157
+ BigDecimal(value.to_s)
158
+ rescue ArgumentError
159
+ value.to_s
137
160
  end
138
161
 
139
- def restriction_length(lengths)
140
- lengths.map { |l| { value: l.value, fixed: l.fixed } }
162
+ # Carry a single-valued facet (whiteSpace/totalDigits/
163
+ # fractionDigits), normalizing its lexical value through the block.
164
+ def single_facet(field_value)
165
+ return nil unless field_value&.any?
166
+
167
+ yield(field_value.first.value)
141
168
  end
142
169
 
170
+ # Multiple <xsd:pattern> in one restriction are alternatives (OR),
171
+ # each grouped so a `|` inside one does not leak across; a single
172
+ # pattern needs no grouping and is carried verbatim so it round-trips
173
+ # exactly (and keeps any live `#{...}` interpolation, e.g. anyURI).
143
174
  def build_pattern(patterns)
144
175
  return nil if Utils.blank?(patterns)
145
176
 
146
- patterns.map { |p| "(#{p.value})" }.join("|")
177
+ values = patterns.map(&:value)
178
+ values.one? ? values.first : values.map { |v| "(#{v})" }.join("|")
147
179
  end
148
180
  end
149
181
  end
@@ -33,10 +33,13 @@ module Lutaml
33
33
  language: { skippable: false, class_name: TC[:string],
34
34
  validations: { pattern: /\A[a-zA-Z]{1,8}(-[a-zA-Z0-9]{1,8})*\z/ } },
35
35
  dateTime: { skippable: true, class_name: TC[:date_time] },
36
+ date: { skippable: true, class_name: TC[:date] },
37
+ time: { skippable: true, class_name: TC[:time] },
36
38
  boolean: { skippable: true, class_name: TC[:boolean] },
37
39
  integer: { skippable: true, class_name: TC[:integer] },
38
40
  decimal: { skippable: true, class_name: TC[:decimal] },
39
41
  string: { skippable: true, class_name: TC[:string] },
42
+ float: { skippable: true, class_name: TC[:float] },
40
43
  double: { skippable: true, class_name: TC[:float] },
41
44
  NCName: { skippable: false, class_name: TC[:string],
42
45
  validations: { pattern: /\A[a-zA-Z_][\w.-]*\z/ } },
@@ -27,8 +27,11 @@ module Lutaml
27
27
  unless method_defined?(name, false)
28
28
  define_method(name) do
29
29
  value = public_send(attr.method_name)
30
- # Cast the derived value to the specified type
31
- attr.cast_element(value, register_id)
30
+ # Cast the derived value to the specified type. cast_derived,
31
+ # not cast_element: this reader is its own casting entry point,
32
+ # so it needs the same "nothing arrived, nothing to cast" rule
33
+ # the writers get, and a collection has to come back as one.
34
+ attr.cast_derived(value, register_id)
32
35
  end
33
36
  end
34
37
  elsif attr.unresolved_type == Lutaml::Model::Type::Reference
@@ -64,14 +67,22 @@ module Lutaml
64
67
  unless method_defined?(:"#{name}_#{key_method_name}", false)
65
68
  define_method("#{name}_#{key_method_name}") do
66
69
  ref = instance_variable_get(:"@#{name}_ref")
67
- resolve_reference_key(ref)
70
+ # attr.reference_key first: once a collection reader has stored
71
+ # its resolved objects, the key has to come back off the object.
72
+ resolve_reference_key(attr.reference_key(ref))
68
73
  end
69
74
  end
70
75
 
71
76
  unless method_defined?(name, false)
72
- define_method(name) do
73
- ref = instance_variable_get(:"@#{name}_ref")
74
- resolve_reference_value(ref)
77
+ if attr.options[:collection]
78
+ define_method(name) do
79
+ materialize_reference_collection(name)
80
+ end
81
+ else
82
+ define_method(name) do
83
+ ref = instance_variable_get(:"@#{name}_ref")
84
+ resolve_reference_value(ref)
85
+ end
75
86
  end
76
87
  end
77
88
 
@@ -101,7 +112,7 @@ module Lutaml
101
112
  if attr.collection?
102
113
  define_method(name) do |*args|
103
114
  if args.empty?
104
- instance_variable_get(:"@#{name}")
115
+ materialize_lazy_collection(name)
105
116
  else
106
117
  # Builder-style: g.member(item) appends to collection
107
118
  value = args.first
@@ -75,11 +75,10 @@ module Lutaml
75
75
  mapping&.ordered? || false
76
76
  end
77
77
 
78
- # Whether this instance was constructed via a builder block and
79
- # therefore records mutations into element_order. Parsed models
80
- # and instances constructed without a block do not track; their
81
- # element_order (if any) comes from the parser and is treated as
82
- # the complete source of truth by the serializer.
78
+ # Whether this instance was constructed via a builder block and so
79
+ # records one element_order entry per mutation. A parsed instance
80
+ # does not: its order comes from the document, where one entry can
81
+ # stand for several rules that share an element name.
83
82
  # @return [Boolean]
84
83
  def order_tracking_enabled?
85
84
  @__order_tracking__ ? true : false
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ Lutaml::Model::RuntimeCompatibility.require_native("digest")
4
+
5
+ module Lutaml
6
+ module Model
7
+ module Serialize
8
+ # Opt-in caching of format conversions in both directions:
9
+ # deserialized whole objects and serialized output strings
10
+ # (issue #267).
11
+ #
12
+ # A class declares `cache_conversions`; plain `from_<format>` and
13
+ # `to_<format>` calls are then served from the store configured via
14
+ # `Config.conversion_cache`. The store is duck-typed — anything
15
+ # responding to `get(key)` and `set(key, value)` works.
16
+ # `Lutaml::Store::BasicStore` (lutaml-store gem) is the recommended
17
+ # backend; TTL, eviction, persistence and clearing are the store's
18
+ # concern, and store exceptions propagate unchanged. Cache hits do
19
+ # not emit Instrumentation events — the work did not happen.
20
+ #
21
+ # Semantics (deliberate):
22
+ # - `from_*` hits return the same cached instance for identical
23
+ # input — across callers and threads. Treat results as read-only;
24
+ # classes whose callers mutate parse results must not opt in.
25
+ # - Keys digest everything that determines the result: the input
26
+ # string (:from) or instance (:to), plus all options except
27
+ # :register (folded into the key as a resolved id). What cannot
28
+ # be digested bypasses instead of risking a wrong hit: non-String
29
+ # inputs (Pathname/IO — content lives elsewhere), non-Hash
30
+ # options (Psych/JSON generator protocol objects), and graphs
31
+ # Marshal refuses — e.g. instances parsed from XML hold native
32
+ # parser nodes for round-trip fidelity, so :to caching engages
33
+ # for programmatically built instances.
34
+ # - Structural invalidation is not propagated: mutating a register's
35
+ # mappings or calling GlobalContext.clear_caches does not touch
36
+ # the store — clear or replace the store after such mutations.
37
+ # - A hit returns before the wrapped body runs, so option-hash
38
+ # mutations the body performs on a miss (e.g. consuming :adapter)
39
+ # do not happen on a hit.
40
+ # - Caching is disabled under Opal: keys need native Marshal/Digest.
41
+ module ConversionCaching
42
+ NATIVE_RUNTIME = !Lutaml::Model::RuntimeCompatibility.opal?
43
+
44
+ UNMARSHALABLE_IVAR = :@__lutaml_conversion_cache_unmarshalable
45
+
46
+ def cache_conversions
47
+ define_singleton_method(:conversion_caching_enabled?) { true }
48
+ end
49
+
50
+ def conversion_caching_enabled?
51
+ false
52
+ end
53
+
54
+ private
55
+
56
+ def with_conversion_cache(kind, format, source, options)
57
+ store = conversion_cache_store(options)
58
+ return yield unless store
59
+
60
+ payload = conversion_cache_payload(kind, source, options)
61
+ return yield unless payload
62
+
63
+ key = conversion_cache_key(kind, format, payload, options)
64
+ cached = store.get(key)
65
+ return cached if cacheable_value?(kind, cached)
66
+
67
+ result = yield
68
+ store.set(key, result) if cacheable_value?(kind, result)
69
+ result
70
+ end
71
+
72
+ def conversion_cache_store(options)
73
+ unless NATIVE_RUNTIME && conversion_caching_enabled? &&
74
+ options.is_a?(::Hash)
75
+ return
76
+ end
77
+
78
+ Config.conversion_cache
79
+ end
80
+
81
+ # A :to source whose graph Marshal refuses (native parser nodes,
82
+ # IO, procs, ...) costs an O(graph) traversal to fail — ~749 ms at
83
+ # 50k objects, repeated on every call for zero benefit. The first
84
+ # failure is remembered on the instance so later calls bypass
85
+ # straight to yield. The frozen guard preserves today's graceful
86
+ # bypass (instance_variable_set would raise on a frozen source).
87
+ # The flag is sticky: an instance later mutated back to marshalable
88
+ # keeps bypassing the cache — a missed hit, never a wrong result.
89
+ def conversion_cache_payload(kind, source, options)
90
+ return if kind == :from && !source.is_a?(::String)
91
+ return if kind == :to && source.instance_variable_defined?(UNMARSHALABLE_IVAR)
92
+
93
+ ::Marshal.dump([source, options.except(:register)])
94
+ rescue ::TypeError
95
+ if kind == :to && !source.frozen?
96
+ source.instance_variable_set(UNMARSHALABLE_IVAR, true)
97
+ end
98
+ nil
99
+ end
100
+
101
+ # The key pins everything the payload digest does not: direction,
102
+ # class identity (name plus object_id — same-named classes can
103
+ # coexist, see TransformationRegistry's keys), format, register,
104
+ # and resolved adapter.
105
+ def conversion_cache_key(kind, format, payload, options)
106
+ register = extract_register_id(options[:register])
107
+ adapter = AdapterResolver.adapter_for(format)
108
+ digest = ::Digest::SHA256.hexdigest(payload)
109
+
110
+ "#{kind}:#{name}/#{object_id}:#{format}:#{register}:#{adapter}:#{digest}"
111
+ end
112
+
113
+ # Gate for both serving and admitting cache values: a :from value
114
+ # must be an instance of this model, a :to value a serialized
115
+ # String. Anything else — a store that JSON-marshals values back
116
+ # into hashes, Array results from multi-document formats, nil —
117
+ # is neither served nor stored.
118
+ def cacheable_value?(kind, value)
119
+ kind == :from ? value.is_a?(self) : value.is_a?(::String)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -49,15 +49,18 @@ module Lutaml
49
49
  value_set_for(enum_name)
50
50
  enum_vals = public_send(:"#{enum_name}")
51
51
 
52
+ # `+` and `-` rather than `<<` and `delete`. The reader hands
53
+ # back the stored Array now, so mutating it here would reach an
54
+ # Array a caller already holds — and would land even when the
55
+ # store below raises on a frozen model.
52
56
  enum_vals = if !!val
53
57
  if collection
54
- enum_vals << value
58
+ enum_vals.include?(value) ? enum_vals : enum_vals + [value]
55
59
  else
56
60
  [value]
57
61
  end
58
62
  elsif collection
59
- enum_vals.delete(value)
60
- enum_vals
63
+ enum_vals - [value]
61
64
  else
62
65
  instance_variable_get(:"@#{enum_name}") - [value]
63
66
  end
@@ -80,15 +83,46 @@ module Lutaml
80
83
  Utils.add_method_if_not_defined(klass, enum_name) do
81
84
  i = instance_variable_get(:"@#{enum_name}") || []
82
85
 
83
- if !collection && i.is_a?(Array)
84
- # A singular enum stores its one value as a one-element array.
85
- # Several values is a cardinality violation, so hand the array
86
- # on instead of collapsing it — #validate reads through here and
87
- # would otherwise never see the over-count.
88
- i.size > 1 ? i : i.first
89
- else
90
- i.uniq
86
+ # A singular enum stores its one value as a one-element array.
87
+ # Several values is a cardinality violation, so hand the array
88
+ # on instead of collapsing it — #validate reads through here and
89
+ # would otherwise never see the over-count. Collections fall
90
+ # through to the liveness path so pushes reach the model.
91
+ next(i.size > 1 ? i : i.first) if !collection && i.is_a?(Array)
92
+
93
+ current = materialize_lazy_collection(enum_name)
94
+
95
+ # An enum collection reads as an Array even when nothing was
96
+ # stored — the old `(ivar || []).uniq` never returned nil — so
97
+ # unlike a regular collection this one materializes nil too, and
98
+ # stores what it materialized so a push reaches the model.
99
+ if current.nil?
100
+ next [] if frozen?
101
+
102
+ next instance_variable_set(:"@#{enum_name}", [])
91
103
  end
104
+
105
+ # Not an Array, which means a model-defined writer stored
106
+ # something else. Read it the way the old reader did and leave
107
+ # what it stored alone — replacing it here would discard data.
108
+ next current.uniq unless current.is_a?(::Array)
109
+
110
+ # A frozen Array cannot be deduped in place, and handing back a
111
+ # copy would lose the shared sentinel's identity, so only copy
112
+ # when there is actually something to remove.
113
+ if current.frozen?
114
+ deduped = current.uniq
115
+ next deduped.size == current.size ? current : deduped
116
+ end
117
+
118
+ # Hand back the stored Array, not a copy. This used to be
119
+ # `i.uniq`, a fresh Array on every read, so `model.roles << "b"`
120
+ # pushed onto a throwaway and the value never reached the model —
121
+ # no error, no warning, the item simply never showed up in the
122
+ # document. It still has to read unique, which is what that `uniq`
123
+ # guaranteed, so dedupe in place rather than into a copy.
124
+ current.uniq!
125
+ current
92
126
  end
93
127
  end
94
128
 
@@ -109,6 +143,11 @@ module Lutaml
109
143
  if collection
110
144
  curr_value = public_send(:"#{enum_name}")
111
145
 
146
+ # Build a new Array rather than appending into the stored one.
147
+ # The reader hands back the real Array now, so `curr_value` may
148
+ # be this model's own collection — or, when the model defines
149
+ # its own reader, something shared between instances or not an
150
+ # Array at all. Duplicates are the reader's job either way.
112
151
  instance_variable_set(:"@#{enum_name}", curr_value + value)
113
152
  else
114
153
  instance_variable_set(:"@#{enum_name}", value)