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.
- 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 +47 -6
- 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
|
@@ -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
|
|
11
|
-
#
|
|
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
|
|
|
@@ -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, :
|
|
126
|
-
min_exclusive: pick_minmax(restriction.min_exclusive, :
|
|
127
|
-
length:
|
|
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)
|
|
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
|
-
|
|
140
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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)
|