lutaml-model 0.8.22 → 0.8.24
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/.github/workflows/performance.yml +1 -1
- data/Gemfile +6 -0
- data/README.adoc +16 -7
- data/docs/_guides/index.adoc +4 -0
- data/docs/_guides/native-engines.adoc +61 -0
- data/docs/_guides/xml-mapping.adoc +31 -0
- data/docs/_pages/configuration.adoc +11 -7
- data/docs/_pages/serialization_adapters.adoc +14 -5
- data/lib/compat/opal/lutaml_model_boot.rb +2 -0
- data/lib/lutaml/json/adapter/multi_json_adapter.rb +12 -1
- data/lib/lutaml/json/adapter/oj_adapter.rb +7 -0
- data/lib/lutaml/json/adapter/standard_adapter.rb +17 -5
- data/lib/lutaml/json/adapter/yeptris_adapter.rb +21 -0
- data/lib/lutaml/json/adapter.rb +1 -0
- data/lib/lutaml/json/format.rb +1 -1
- data/lib/lutaml/json/generator_options.rb +62 -0
- data/lib/lutaml/jsonl/adapter/standard_adapter.rb +1 -1
- data/lib/lutaml/jsonld/adapter.rb +12 -4
- data/lib/lutaml/key_value/adapter/json/multi_json_adapter.rb +16 -1
- data/lib/lutaml/key_value/adapter/json/oj_adapter.rb +7 -0
- data/lib/lutaml/key_value/adapter/json/standard_adapter.rb +17 -5
- data/lib/lutaml/key_value/adapter/json/yeptris_adapter.rb +15 -0
- data/lib/lutaml/key_value/adapter/jsonl/standard_adapter.rb +1 -1
- data/lib/lutaml/key_value/adapter/toml/teptris_adapter.rb +42 -0
- data/lib/lutaml/key_value/adapter/toml.rb +1 -0
- data/lib/lutaml/key_value/adapter/yaml/yeptris_adapter.rb +15 -0
- data/lib/lutaml/key_value/transform.rb +82 -0
- data/lib/lutaml/key_value/transformation/value_serializer.rb +25 -0
- data/lib/lutaml/key_value/transformation.rb +25 -1
- data/lib/lutaml/model/adapter_resolver.rb +23 -6
- data/lib/lutaml/model/attribute.rb +31 -6
- data/lib/lutaml/model/collection.rb +5 -0
- data/lib/lutaml/model/configuration.rb +5 -0
- data/lib/lutaml/model/error/unknown_adapter_type_error.rb +4 -4
- data/lib/lutaml/model/serialization_adapter.rb +1 -1
- data/lib/lutaml/model/serialize/format_conversion.rb +75 -7
- data/lib/lutaml/model/serialize.rb +18 -0
- data/lib/lutaml/model/toml.rb +10 -6
- data/lib/lutaml/model/transformation.rb +5 -0
- data/lib/lutaml/model/type/date_time.rb +2 -2
- data/lib/lutaml/model/type/value.rb +24 -2
- data/lib/lutaml/model/utils.rb +6 -5
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/toml/adapter/teptris_adapter.rb +14 -0
- data/lib/lutaml/toml/adapter.rb +1 -0
- data/lib/lutaml/toml/format.rb +1 -1
- data/lib/lutaml/xml/adapter/leptris_adapter.rb +15 -0
- data/lib/lutaml/xml/adapter_element.rb +111 -3
- data/lib/lutaml/xml/builder/base.rb +3 -1
- data/lib/lutaml/xml/builder/leptris.rb +13 -0
- data/lib/lutaml/xml/builder/nokogiri.rb +3 -0
- data/lib/lutaml/xml/builder.rb +1 -0
- data/lib/lutaml/xml/format.rb +4 -1
- data/lib/lutaml/xml/leptris/element.rb +34 -0
- data/lib/lutaml/xml/mapping.rb +24 -0
- data/lib/lutaml/xml/model_transform.rb +52 -1
- data/lib/lutaml/xml/plan_compiler.rb +139 -0
- data/lib/lutaml/xml/plan_hydrator.rb +80 -0
- data/lib/lutaml/xml/transformation/custom_method_wrapper.rb +8 -0
- data/lib/lutaml/xml/transformation.rb +29 -0
- data/lib/lutaml/xml/xml_element.rb +6 -2
- data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +40 -0
- data/lib/lutaml/yaml/adapter.rb +5 -0
- data/lib/lutaml/yaml/format.rb +1 -1
- data/lib/tasks/performance.rake +5 -0
- data/lib/tasks/performance_comparator.rb +19 -3
- data/lib/tasks/performance_probe.rake +33 -0
- data/lib/tasks/performance_probe.rb +106 -0
- data/lib/tasks/performance_probe_xml.rb +54 -0
- data/lutaml-model.gemspec +1 -1
- data/spec/lutaml/json/adapter/standard_adapter_spec.rb +159 -0
- data/spec/lutaml/json/yeptris_adapter_spec.rb +63 -0
- data/spec/lutaml/jsonld/adapter_spec.rb +19 -0
- data/spec/lutaml/key_value/adapter/toml/teptris_adapter_spec.rb +71 -0
- data/spec/lutaml/model/collection_spec.rb +1 -1
- data/spec/lutaml/model/collection_state_nesting_spec.rb +44 -0
- data/spec/lutaml/model/generator_defaults_spec.rb +101 -0
- data/spec/lutaml/model/json_adapter_spec.rb +6 -0
- data/spec/lutaml/model/json_state_nesting_spec.rb +82 -0
- data/spec/lutaml/model/toml_adapter_spec.rb +8 -0
- data/spec/lutaml/model/toml_spec.rb +36 -1
- data/spec/lutaml/xml/plan_fast_path_spec.rb +96 -0
- data/spec/lutaml/xml/shared_element_rules_spec.rb +64 -0
- data/spec/lutaml/xml/xml_spec.rb +13 -0
- data/spec/lutaml/yaml/yeptris_adapter_spec.rb +63 -0
- data/spec/spec_helper.rb +7 -1
- metadata +29 -4
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require_relative "../../../json/generator_options"
|
|
4
5
|
|
|
5
6
|
# Backward compatibility - delegates to Lutaml::Json::Adapter
|
|
6
7
|
# @deprecated Use Lutaml::Json::Adapter::StandardAdapter instead
|
|
@@ -13,12 +14,16 @@ module Lutaml
|
|
|
13
14
|
FORMAT_SYMBOL = :json
|
|
14
15
|
|
|
15
16
|
def self.parse(json, _options = {})
|
|
16
|
-
JSON.parse(json
|
|
17
|
+
JSON.parse(json)
|
|
17
18
|
end
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
# This adapter hands its payload to the stdlib generator, so a
|
|
21
|
+
# JSON::State carrying the outer formatting is meaningful here.
|
|
22
|
+
def accepts_generator_state?
|
|
23
|
+
true
|
|
24
|
+
end
|
|
21
25
|
|
|
26
|
+
def to_json(*args)
|
|
22
27
|
# Handle KeyValueElement input (new symmetric architecture)
|
|
23
28
|
attributes_to_serialize = if @attributes.is_a?(Lutaml::KeyValue::DataModel::Element)
|
|
24
29
|
# Unwrap __root__ wrapper to get actual content
|
|
@@ -28,10 +33,17 @@ module Lutaml
|
|
|
28
33
|
@attributes
|
|
29
34
|
end
|
|
30
35
|
|
|
36
|
+
unless Lutaml::Json::GeneratorOptions.lutaml_options?(args.first)
|
|
37
|
+
return JSON.generate(attributes_to_serialize, args.first)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
options = args.first || {}
|
|
41
|
+
generator_options = Lutaml::Json::GeneratorOptions.filter(options)
|
|
42
|
+
|
|
31
43
|
if options[:pretty]
|
|
32
|
-
JSON.pretty_generate(attributes_to_serialize,
|
|
44
|
+
JSON.pretty_generate(attributes_to_serialize, generator_options)
|
|
33
45
|
else
|
|
34
|
-
JSON.generate(attributes_to_serialize,
|
|
46
|
+
JSON.generate(attributes_to_serialize, generator_options)
|
|
35
47
|
end
|
|
36
48
|
end
|
|
37
49
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../../json/adapter/yeptris_adapter"
|
|
4
|
+
|
|
5
|
+
module Lutaml
|
|
6
|
+
module KeyValue
|
|
7
|
+
module Adapter
|
|
8
|
+
module Json
|
|
9
|
+
# Backward-compatibility entry for the adapter resolver's default
|
|
10
|
+
# load path; the implementation lives in Lutaml::Json::Adapter.
|
|
11
|
+
YeptrisAdapter = Lutaml::Json::Adapter::YeptrisAdapter
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# TOML over the teptris engine (libteptris via FFI). teptris ships
|
|
4
|
+
# prebuilt platform gems for every platform we support — including
|
|
5
|
+
# mingw-ucrt — which retires the "no native TOML on Windows" gap that
|
|
6
|
+
# keeps tomlib off Windows. Value semantics mirror tomlib: offset and
|
|
7
|
+
# local datetimes become Time, dates become Date, local times stay
|
|
8
|
+
# String. Parse failures raise Teptris::ParseError (line/column).
|
|
9
|
+
|
|
10
|
+
module Lutaml
|
|
11
|
+
module KeyValue
|
|
12
|
+
module Adapter
|
|
13
|
+
module Toml
|
|
14
|
+
class TeptrisAdapter < Document
|
|
15
|
+
# teptris is required lazily (in parse/to_toml), NOT at file
|
|
16
|
+
# load: a platform gem whose native library is broken must
|
|
17
|
+
# degrade to detection-fallback + skipped specs, never crash
|
|
18
|
+
# unrelated loads. The gem's own require raises a clear
|
|
19
|
+
# LoadError ("no fallback by design") for that case.
|
|
20
|
+
def self.parse(toml, _options = {})
|
|
21
|
+
require "teptris"
|
|
22
|
+
Teptris::TOML.load(toml)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_toml(*)
|
|
26
|
+
require "teptris"
|
|
27
|
+
# Handle KeyValueElement input (new symmetric architecture)
|
|
28
|
+
attributes_to_serialize = if @attributes.is_a?(Lutaml::KeyValue::DataModel::Element)
|
|
29
|
+
# Unwrap __root__ wrapper to get actual content
|
|
30
|
+
@attributes.to_hash["__root__"]
|
|
31
|
+
else
|
|
32
|
+
# Legacy Hash input (backward compatibility)
|
|
33
|
+
@attributes
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
Teptris::TOML.dump(attributes_to_serialize)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -12,6 +12,7 @@ module Lutaml
|
|
|
12
12
|
autoload :Transform, "#{__dir__}/toml/transform"
|
|
13
13
|
Lutaml::Model::RuntimeCompatibility.autoload_native(
|
|
14
14
|
self,
|
|
15
|
+
TeptrisAdapter: "#{__dir__}/toml/teptris_adapter",
|
|
15
16
|
TomlibAdapter: "#{__dir__}/toml/tomlib_adapter",
|
|
16
17
|
TomlRbAdapter: "#{__dir__}/toml/toml_rb_adapter",
|
|
17
18
|
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../../yaml/adapter/yeptris_adapter"
|
|
4
|
+
|
|
5
|
+
module Lutaml
|
|
6
|
+
module KeyValue
|
|
7
|
+
module Adapter
|
|
8
|
+
module Yaml
|
|
9
|
+
# Backward-compatibility entry for the adapter resolver's default
|
|
10
|
+
# load path; the implementation lives in Lutaml::Yaml::Adapter.
|
|
11
|
+
YeptrisAdapter = Lutaml::Yaml::Adapter::YeptrisAdapter
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -244,6 +244,13 @@ format)
|
|
|
244
244
|
rule, attr
|
|
245
245
|
)
|
|
246
246
|
|
|
247
|
+
if (plan = kv_rule_plan(format, rule, attr)) &&
|
|
248
|
+
(value = kv_fast_extract(doc, plan))
|
|
249
|
+
rule.deserialize(instance, kv_fast_cast(value, plan, instance),
|
|
250
|
+
attributes, self)
|
|
251
|
+
return
|
|
252
|
+
end
|
|
253
|
+
|
|
247
254
|
value = rule_value_extractor_class.call(rule, doc, format, attr,
|
|
248
255
|
lutaml_register, options, instance)
|
|
249
256
|
value = apply_value_map(value, rule.value_map(:from, options), attr)
|
|
@@ -269,6 +276,81 @@ format)
|
|
|
269
276
|
rule.deserialize(instance, value, attributes, self)
|
|
270
277
|
end
|
|
271
278
|
|
|
279
|
+
# Compiled rule plans (TODO.perf/07): for plain scalar rules the
|
|
280
|
+
# per-value interpretation (extraction dispatch, type resolution,
|
|
281
|
+
# per-element probes, validation inside casts) collapses to a
|
|
282
|
+
# precomputed wire name, target class, and identity-guard cast.
|
|
283
|
+
# Guard + cached target — an inline cache per rule. Complex rules
|
|
284
|
+
# (custom methods, transforms, value maps, delegates, hash mappings,
|
|
285
|
+
# unions, polymorphism) return nil and keep the interpretive path.
|
|
286
|
+
KvRulePlan = ::Struct.new(:wire, :klass, :collection)
|
|
287
|
+
|
|
288
|
+
def kv_rule_plan(format, rule, attr)
|
|
289
|
+
@kv_rule_plans ||= {}
|
|
290
|
+
@kv_rule_plans[[format, rule]] ||= build_kv_rule_plan(format, rule,
|
|
291
|
+
attr)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# `rule.polymorphic` defaults to an empty Hash, which is truthy —
|
|
295
|
+
# presence is emptiness-based, mirroring MappingRule#polymorphic_mapping?.
|
|
296
|
+
def polymorphic_rule?(rule)
|
|
297
|
+
poly = rule.polymorphic
|
|
298
|
+
poly.respond_to?(:empty?) ? !poly.empty? : !!poly
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def build_kv_rule_plan(_format, rule, attr)
|
|
302
|
+
return nil if rule.raw_mapping? ||
|
|
303
|
+
rule.has_custom_method_for_deserialization? ||
|
|
304
|
+
rule.hash_mappings || rule.multiple_mappings? ||
|
|
305
|
+
polymorphic_rule?(rule) || rule.delegate ||
|
|
306
|
+
(rule.transform.is_a?(Hash) && !rule.transform.empty?) ||
|
|
307
|
+
rule.transform.is_a?(Class) ||
|
|
308
|
+
attr.derived? ||
|
|
309
|
+
attr.union? || attr.polymorphic? ||
|
|
310
|
+
attr.custom_collection?
|
|
311
|
+
|
|
312
|
+
type = attr.type(lutaml_register)
|
|
313
|
+
return nil unless type.is_a?(Class) &&
|
|
314
|
+
type < ::Lutaml::Model::Type::Value &&
|
|
315
|
+
!attr.value_policy.whole_value?(type) &&
|
|
316
|
+
!Lutaml::Model::Attribute.custom_from_probe?(type)
|
|
317
|
+
|
|
318
|
+
KvRulePlan.new(rule.name.to_s, type, attr.collection?)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Extraction fast path: plain hash fetch for a present, non-blank
|
|
322
|
+
# value. nil/blank values keep the interpretive path — their
|
|
323
|
+
# value-map/missing-value semantics must not be duplicated here.
|
|
324
|
+
def kv_fast_extract(doc, plan)
|
|
325
|
+
return nil unless doc.is_a?(::Hash)
|
|
326
|
+
|
|
327
|
+
v = doc[plan.wire]
|
|
328
|
+
return nil if v.nil?
|
|
329
|
+
return nil if v.is_a?(::String) && v.empty?
|
|
330
|
+
return nil if v.is_a?(::Array) && (v.empty? || !plan.collection)
|
|
331
|
+
# Structured values on scalar plans keep the interpretive path —
|
|
332
|
+
# its collection guidance error and cast semantics own them.
|
|
333
|
+
return nil if v.is_a?(::Hash)
|
|
334
|
+
|
|
335
|
+
v
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Cast fast path: identity when the value already has the target
|
|
339
|
+
# class (the overwhelming case for engine-typed scalars), the type's
|
|
340
|
+
# cast otherwise. Collections cast per element.
|
|
341
|
+
def kv_fast_cast(value, plan, _instance)
|
|
342
|
+
if plan.collection
|
|
343
|
+
unless value.is_a?(::Array)
|
|
344
|
+
return value.is_a?(plan.klass) ? value : plan.klass.cast(value)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
klass = plan.klass
|
|
348
|
+
value.map { |e| e.is_a?(klass) ? e : klass.cast(e) }
|
|
349
|
+
else
|
|
350
|
+
value.is_a?(plan.klass) ? value : plan.klass.cast(value)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
272
354
|
def cast_value(value, attr, format, rule, instance)
|
|
273
355
|
cast_options = rule.polymorphic ? { polymorphic: rule.polymorphic } : {}
|
|
274
356
|
cast_options[:lutaml_parent] = instance if instance
|
|
@@ -57,6 +57,14 @@ model_class: nil)
|
|
|
57
57
|
return nil if value.nil?
|
|
58
58
|
return nil if Lutaml::Model::Utils.uninitialized?(value)
|
|
59
59
|
|
|
60
|
+
# Compiled serialize plan (TODO.perf/07): builtin scalars whose
|
|
61
|
+
# type registers no custom to_<format> serializer serialize as the
|
|
62
|
+
# value itself — Type::Value#to_<format>'s default — without the
|
|
63
|
+
# wrap-allocate-cast ceremony per field. Computed once per rule.
|
|
64
|
+
if (fast = serialize_plan(rule)) && value.instance_of?(fast)
|
|
65
|
+
return value
|
|
66
|
+
end
|
|
67
|
+
|
|
60
68
|
# Check for Reference type first - even if value is a Serializable,
|
|
61
69
|
# it should be serialized as a key, not as a nested model
|
|
62
70
|
if reference_type?(rule)
|
|
@@ -151,6 +159,23 @@ model_class: nil)
|
|
|
151
159
|
end
|
|
152
160
|
end
|
|
153
161
|
|
|
162
|
+
# Per-rule serialize plan: the type class for builtin scalars with
|
|
163
|
+
# no custom to_<format> serializer (identity serialization), nil
|
|
164
|
+
# otherwise. Memoized per [rule].
|
|
165
|
+
def serialize_plan(rule)
|
|
166
|
+
@serialize_plans ||= {}
|
|
167
|
+
@serialize_plans[rule] ||= begin
|
|
168
|
+
type = rule.attribute_type
|
|
169
|
+
if type.is_a?(::Class) && type < ::Lutaml::Model::Type::Value
|
|
170
|
+
serializer = ::Lutaml::Model::Type::Value
|
|
171
|
+
.format_type_serializer_for(@format, type)
|
|
172
|
+
has_custom_to = serializer && serializer[:to]
|
|
173
|
+
has_custom_from = ::Lutaml::Model::Attribute.custom_from_probe?(type)
|
|
174
|
+
type unless has_custom_to || has_custom_from
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
154
179
|
private
|
|
155
180
|
|
|
156
181
|
# Validate that a nested model value matches the expected type.
|
|
@@ -16,6 +16,18 @@ module Lutaml
|
|
|
16
16
|
# This is a critical step toward symmetric OOP architecture
|
|
17
17
|
# across all serialization formats.
|
|
18
18
|
class Transformation < Lutaml::Model::Transformation
|
|
19
|
+
# Builtin Value types whose cast of an already-native value is the
|
|
20
|
+
# identity — used by the serialize_value fast path.
|
|
21
|
+
NATIVE_VALUE_CLASS = {
|
|
22
|
+
::Lutaml::Model::Type::String => ::String,
|
|
23
|
+
::Lutaml::Model::Type::Integer => ::Integer,
|
|
24
|
+
::Lutaml::Model::Type::Float => ::Float,
|
|
25
|
+
::Lutaml::Model::Type::Date => ::Date,
|
|
26
|
+
::Lutaml::Model::Type::Time => ::Time,
|
|
27
|
+
::Lutaml::Model::Type::DateTime => ::DateTime,
|
|
28
|
+
::Lutaml::Model::Type::Symbol => ::Symbol,
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
19
31
|
include Lutaml::Model::RenderPolicy
|
|
20
32
|
|
|
21
33
|
autoload :RuleCompiler, "#{__dir__}/transformation/rule_compiler"
|
|
@@ -1082,7 +1094,19 @@ child_mappings, options)
|
|
|
1082
1094
|
# Wrap value in type and call to_#{format} instance method (like legacy Attribute#serialize_value)
|
|
1083
1095
|
# This allows custom type subclasses to override to_json, to_yaml, etc.
|
|
1084
1096
|
if rule.attribute_type.is_a?(Class) && rule.attribute_type < Lutaml::Model::Type::Value
|
|
1085
|
-
|
|
1097
|
+
type = rule.attribute_type
|
|
1098
|
+
# Identity fast path (TODO.perf/07): a builtin type with no custom
|
|
1099
|
+
# to_<format>/from_<format> behavior emits an already-native value
|
|
1100
|
+
# unchanged — the wrap only re-casts and re-emits it.
|
|
1101
|
+
native = NATIVE_VALUE_CLASS[type]
|
|
1102
|
+
if native && value.instance_of?(native) &&
|
|
1103
|
+
!::Lutaml::Model::Type::Value
|
|
1104
|
+
.format_type_serializer_for(format, type)&.fetch(:to, nil) &&
|
|
1105
|
+
!::Lutaml::Model::Attribute.custom_from_probe?(type)
|
|
1106
|
+
return value
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
wrapped_value = type.new(value)
|
|
1086
1110
|
wrapped_value.public_send(:"to_#{format}")
|
|
1087
1111
|
else
|
|
1088
1112
|
value
|
|
@@ -286,7 +286,7 @@ module Lutaml
|
|
|
286
286
|
if format == :toml && type_name == :tomlib && RuntimeCompatibility.windows?
|
|
287
287
|
raise ArgumentError,
|
|
288
288
|
"The `:tomlib` adapter is not supported on Windows due to " \
|
|
289
|
-
"segmentation fault issues. Please use `:toml_rb` instead."
|
|
289
|
+
"segmentation fault issues. Please use `:teptris` or `:toml_rb` instead."
|
|
290
290
|
end
|
|
291
291
|
|
|
292
292
|
available = adapter_config[:available]
|
|
@@ -363,11 +363,23 @@ module Lutaml
|
|
|
363
363
|
detect_xml_adapter
|
|
364
364
|
when :toml
|
|
365
365
|
detect_toml_adapter
|
|
366
|
+
when :yaml, :json
|
|
367
|
+
detect_yeptris_kv_adapter(format)
|
|
366
368
|
else
|
|
367
369
|
metadata.dig(format, :default)
|
|
368
370
|
end
|
|
369
371
|
end
|
|
370
372
|
|
|
373
|
+
# Native key-value engine preference: yeptris (libyeptris C11)
|
|
374
|
+
# when its gem is in the bundle, else the format's standard
|
|
375
|
+
# adapter. Opt-in by bundle contents — nothing is required of
|
|
376
|
+
# consumers that do not add yeptris.
|
|
377
|
+
def detect_yeptris_kv_adapter(format)
|
|
378
|
+
return metadata.dig(format, :default) if Lutaml::Model.opal?
|
|
379
|
+
|
|
380
|
+
Utils.safe_load("yeptris", :Yeptris) ? :yeptris : metadata.dig(format, :default)
|
|
381
|
+
end
|
|
382
|
+
|
|
371
383
|
# Detect available XML adapter.
|
|
372
384
|
#
|
|
373
385
|
# Delegates to moxml which is the authority on XML adapter
|
|
@@ -380,17 +392,22 @@ module Lutaml
|
|
|
380
392
|
|
|
381
393
|
# Detect available TOML adapter.
|
|
382
394
|
#
|
|
383
|
-
# @return [Symbol, nil] :tomlib, :toml_rb, or nil
|
|
395
|
+
# @return [Symbol, nil] :teptris, :tomlib, :toml_rb, or nil
|
|
384
396
|
def detect_toml_adapter
|
|
385
397
|
return nil if Lutaml::Model.opal?
|
|
386
398
|
|
|
387
|
-
|
|
388
|
-
|
|
399
|
+
# teptris 0.2.12+ parses ~3x and dumps ~8x faster than
|
|
400
|
+
# tomlib and ships prebuilts for every platform (mingw-ucrt
|
|
401
|
+
# restored in 0.2.4+) — native TOML on Windows retires the
|
|
402
|
+
# tomlib segfault workaround (previously pure-Ruby toml-rb
|
|
403
|
+
# only).
|
|
404
|
+
return :teptris if Utils.safe_load("teptris", :Teptris)
|
|
389
405
|
|
|
390
|
-
|
|
406
|
+
if !RuntimeCompatibility.windows? &&
|
|
407
|
+
Utils.safe_load("tomlib", :Tomlib)
|
|
408
|
+
return :tomlib
|
|
391
409
|
end
|
|
392
410
|
|
|
393
|
-
return :tomlib if Utils.safe_load("tomlib", :Tomlib)
|
|
394
411
|
return :toml_rb if Utils.safe_load("toml-rb", :TomlRb)
|
|
395
412
|
|
|
396
413
|
nil
|
|
@@ -31,6 +31,29 @@ module Lutaml
|
|
|
31
31
|
union_member_types
|
|
32
32
|
].freeze
|
|
33
33
|
|
|
34
|
+
# Per-type-class memo of the custom from_xml/from_json probe used by #cast.
|
|
35
|
+
# Isolated cache holder: enclosing-class freezes (some suites freeze
|
|
36
|
+
# Lutaml::Model::Attribute) would freeze a constant Hash with it.
|
|
37
|
+
TypeProbeCache = ::Class.new do
|
|
38
|
+
class << self
|
|
39
|
+
def cache
|
|
40
|
+
@cache ||= {}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
# Public reader for compiled rule plans: does this type class carry a
|
|
47
|
+
# custom from_xml/from_json (answer static, probed once)?
|
|
48
|
+
def custom_from_probe?(type)
|
|
49
|
+
TypeProbeCache.cache[type] ||= begin
|
|
50
|
+
base = Lutaml::Model::Type::Value.singleton_class
|
|
51
|
+
type.method(:from_xml).owner != base ||
|
|
52
|
+
type.method(:from_json).owner != base
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
34
57
|
MODEL_STRINGS = [
|
|
35
58
|
Lutaml::Model::Type::String,
|
|
36
59
|
"String",
|
|
@@ -252,7 +275,9 @@ module Lutaml
|
|
|
252
275
|
end
|
|
253
276
|
|
|
254
277
|
def union?
|
|
255
|
-
|
|
278
|
+
return @union unless @union.nil?
|
|
279
|
+
|
|
280
|
+
@union = unresolved_type == Lutaml::Model::Type::Union
|
|
256
281
|
end
|
|
257
282
|
|
|
258
283
|
def union_member_types
|
|
@@ -687,11 +712,11 @@ instance_object = nil)
|
|
|
687
712
|
|
|
688
713
|
# Fast path for Type::Value subclasses (String, Integer, Boolean, etc.)
|
|
689
714
|
# These are never Serializable, so skip expensive can_serialize? and needs_conversion? checks
|
|
690
|
-
# Skip if type has custom from_xml/from_json methods (defined on the class itself, not inherited)
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
return resolved_type.cast(value)
|
|
715
|
+
# Skip if type has custom from_xml/from_json methods (defined on the class itself, not inherited).
|
|
716
|
+
# The probe allocated two Method objects per cast call; the answer is
|
|
717
|
+
# static per type class, so it is resolved once.
|
|
718
|
+
if resolved_type.is_a?(Class) && resolved_type < Lutaml::Model::Type::Value && !self.class.custom_from_probe?(resolved_type)
|
|
719
|
+
return resolved_type.cast(value)
|
|
695
720
|
end
|
|
696
721
|
|
|
697
722
|
klass = resolve_polymorphic_class(resolved_type, value, options)
|
|
@@ -308,6 +308,10 @@ module Lutaml
|
|
|
308
308
|
end
|
|
309
309
|
|
|
310
310
|
def to(format, instance, options = {})
|
|
311
|
+
# Wrap before the branch, not inside it: only XML reaches the
|
|
312
|
+
# unwrapped path today, but a raw JSON::State must never survive
|
|
313
|
+
# into either arm.
|
|
314
|
+
options = Lutaml::Model::Serialize.wrap_generator_state(options)
|
|
311
315
|
mappings = mappings_for(format)
|
|
312
316
|
|
|
313
317
|
if mappings.no_root? && collection_unwrapped_to?(format)
|
|
@@ -456,6 +460,7 @@ lutaml_register: Lutaml::Model::Config.default_register)
|
|
|
456
460
|
end
|
|
457
461
|
|
|
458
462
|
def to_format(format, options = {})
|
|
463
|
+
options = Lutaml::Model::Serialize.wrap_generator_state(options)
|
|
459
464
|
super(format, options.merge(collection: true))
|
|
460
465
|
end
|
|
461
466
|
|
|
@@ -15,10 +15,15 @@ module Lutaml
|
|
|
15
15
|
#
|
|
16
16
|
class Configuration
|
|
17
17
|
attr_reader :default_register
|
|
18
|
+
# Experimental: whole-document native materialization for plan-
|
|
19
|
+
# compilable XML models (Leptris::XML::Descriptor walk). Opt-in
|
|
20
|
+
# while the full semantics audit completes.
|
|
21
|
+
attr_accessor :xml_plan_fast_path
|
|
18
22
|
|
|
19
23
|
def initialize
|
|
20
24
|
@default_register = :default
|
|
21
25
|
@configured = false
|
|
26
|
+
@xml_plan_fast_path = false
|
|
22
27
|
end
|
|
23
28
|
|
|
24
29
|
def configure
|
|
@@ -3,10 +3,10 @@ module Lutaml
|
|
|
3
3
|
class UnknownAdapterTypeError < Error
|
|
4
4
|
# Available adapters by format
|
|
5
5
|
AVAILABLE_ADAPTERS = {
|
|
6
|
-
xml: %w[nokogiri ox oga rexml],
|
|
7
|
-
json: %w[standard multi_json oj],
|
|
8
|
-
yaml: %w[standard],
|
|
9
|
-
toml: %w[tomlib toml_rb],
|
|
6
|
+
xml: %w[nokogiri ox oga rexml leptris],
|
|
7
|
+
json: %w[standard multi_json oj yeptris],
|
|
8
|
+
yaml: %w[standard yeptris],
|
|
9
|
+
toml: %w[teptris tomlib toml_rb],
|
|
10
10
|
hash: %w[standard],
|
|
11
11
|
jsonl: %w[standard],
|
|
12
12
|
yamls: %w[standard],
|
|
@@ -45,6 +45,11 @@ module Lutaml
|
|
|
45
45
|
# @param options [Hash] Additional options
|
|
46
46
|
# @return [Object] The deserialized model instance
|
|
47
47
|
def from(format, data, options = {})
|
|
48
|
+
if format == :xml && Lutaml::Model::Config.instance.xml_plan_fast_path
|
|
49
|
+
fast = xml_plan_fast_path(data, options)
|
|
50
|
+
return fast if fast
|
|
51
|
+
end
|
|
52
|
+
|
|
48
53
|
Instrumentation.instrument(:from, model: name, format: format) do
|
|
49
54
|
adapter = resolve_adapter(format, options.delete(:adapter))
|
|
50
55
|
|
|
@@ -76,6 +81,36 @@ module Lutaml
|
|
|
76
81
|
# No-op by default; XML overrides via prepend
|
|
77
82
|
end
|
|
78
83
|
|
|
84
|
+
# Whole-document native materialization (Phase 5): compile the
|
|
85
|
+
# mapping into a Leptris descriptor plan and hydrate from one
|
|
86
|
+
# plan walk. Only when the resolved XML adapter is leptris-
|
|
87
|
+
# backed, the model fully compiles, and no path-affecting
|
|
88
|
+
# options are present; anything else falls back to the
|
|
89
|
+
# interpretive pipeline.
|
|
90
|
+
def xml_plan_fast_path(data, options)
|
|
91
|
+
return nil unless defined?(::Leptris::XML::Descriptor)
|
|
92
|
+
return nil if options.key?(:adapter) || options.key?(:only) ||
|
|
93
|
+
options.key?(:except) || options.key?(:mappings) ||
|
|
94
|
+
options.key?(:register)
|
|
95
|
+
|
|
96
|
+
adapter_name = Lutaml::Model::Config.adapter_for(:xml)
|
|
97
|
+
adapter_name = adapter_name&.name
|
|
98
|
+
return nil unless adapter_name.to_s.end_with?("LeptrisAdapter")
|
|
99
|
+
|
|
100
|
+
register = Lutaml::Model::Config.default_register
|
|
101
|
+
plan = Lutaml::Xml::PlanCompiler.compile(self, register)
|
|
102
|
+
return nil unless plan
|
|
103
|
+
|
|
104
|
+
root = ::Leptris::XML.parse(data.to_s).root
|
|
105
|
+
return nil if root.nil?
|
|
106
|
+
return nil unless root.name == plan[:tree][:name]
|
|
107
|
+
|
|
108
|
+
Lutaml::Xml::PlanHydrator.call(self, plan,
|
|
109
|
+
plan[:descriptor].walk(root))
|
|
110
|
+
rescue ::Leptris::XML::ParseError => e
|
|
111
|
+
raise Lutaml::Model::InvalidFormatError.new(:xml, e.message)
|
|
112
|
+
end
|
|
113
|
+
|
|
79
114
|
# Get list of error types that can be raised during format parsing.
|
|
80
115
|
# Core errors are always included; format-specific errors come from
|
|
81
116
|
# FormatRegistry registrations.
|
|
@@ -119,6 +154,8 @@ module Lutaml
|
|
|
119
154
|
toml_errors = Array(toml_errors)
|
|
120
155
|
tomllib_err = compatibility.safe_constantize("Tomlib::ParseError")
|
|
121
156
|
toml_errors << tomllib_err if tomllib_err
|
|
157
|
+
teptris_err = compatibility.safe_constantize("Teptris::ParseError")
|
|
158
|
+
toml_errors << teptris_err if teptris_err
|
|
122
159
|
|
|
123
160
|
@format_error_types_base + toml_errors
|
|
124
161
|
end
|
|
@@ -192,24 +229,55 @@ module Lutaml
|
|
|
192
229
|
# is always preserved when available, regardless of this option.
|
|
193
230
|
# @return [String] The serialized output
|
|
194
231
|
def to(format, instance, options = {})
|
|
232
|
+
# Ruby's JSON generator hands #to_json its own JSON::State rather
|
|
233
|
+
# than an options hash. It carries no LutaML options, but it does
|
|
234
|
+
# carry the surrounding indent context, so it is forwarded to the
|
|
235
|
+
# adapter unchanged instead of being read like a Hash -- json 3.0
|
|
236
|
+
# removed JSON::State#[].
|
|
237
|
+
options = Serialize.wrap_generator_state(options)
|
|
238
|
+
generator_state = options.delete(Serialize::GENERATOR_STATE_KEY)
|
|
239
|
+
|
|
195
240
|
Instrumentation.instrument(:to, model: name, format: format) do
|
|
196
|
-
adapter_override = options.
|
|
197
|
-
if adapter_override
|
|
198
|
-
options[:_adapter_override] =
|
|
199
|
-
true
|
|
200
|
-
end
|
|
241
|
+
adapter_override = options.delete(:adapter)
|
|
242
|
+
options[:_adapter_override] = true if adapter_override
|
|
201
243
|
value = public_send(:"as_#{format}", instance, options)
|
|
202
244
|
adapter = resolve_adapter(format, adapter_override)
|
|
203
245
|
|
|
204
246
|
# Hook for format-specific options preparation (e.g., XML prefix/namespace/declaration)
|
|
205
247
|
options = prepare_to_options(format, instance, options)
|
|
206
248
|
|
|
207
|
-
adapter.new(value, register: options[:register])
|
|
208
|
-
|
|
249
|
+
document = adapter.new(value, register: options[:register])
|
|
250
|
+
|
|
251
|
+
document.public_send(
|
|
252
|
+
:"to_#{format}",
|
|
253
|
+
forward_options(document, generator_state, options),
|
|
209
254
|
)
|
|
210
255
|
end
|
|
211
256
|
end
|
|
212
257
|
|
|
258
|
+
# Main's behaviour differs per ADAPTER, not per option, so this follows
|
|
259
|
+
# the adapter rather than trying to translate option names:
|
|
260
|
+
# stdlib honours script_safe / ascii_only / pretty -> give it the state
|
|
261
|
+
# Oj ignores them all and uses its own escape_mode -> give it none
|
|
262
|
+
# others reach the stdlib generator underneath -> give them the options
|
|
263
|
+
def forward_options(document, generator_state, options)
|
|
264
|
+
return options if generator_state.nil?
|
|
265
|
+
|
|
266
|
+
if declares?(document, :accepts_generator_state?)
|
|
267
|
+
generator_state
|
|
268
|
+
elsif declares?(document, :ignores_generator_options?)
|
|
269
|
+
options
|
|
270
|
+
elsif generator_state.respond_to?(:to_h)
|
|
271
|
+
options.merge(generator_state.to_h)
|
|
272
|
+
else
|
|
273
|
+
options
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def declares?(document, predicate)
|
|
278
|
+
document.respond_to?(predicate) && document.public_send(predicate)
|
|
279
|
+
end
|
|
280
|
+
|
|
213
281
|
# Hook for format-specific options preparation before serialization.
|
|
214
282
|
# XML overrides to handle prefix, namespace overrides, declaration plan.
|
|
215
283
|
#
|
|
@@ -225,7 +225,25 @@ module Lutaml
|
|
|
225
225
|
self.class.as_yaml(self)
|
|
226
226
|
end
|
|
227
227
|
|
|
228
|
+
# Ruby's JSON generator hands #to_json its own JSON::State instead of an
|
|
229
|
+
# options hash. It carries no LutaML options, but it does carry the
|
|
230
|
+
# surrounding indent context, so it travels IN BAND under a private key
|
|
231
|
+
# rather than replacing the options hash. Wrapping rather than returning
|
|
232
|
+
# early keeps every later step -- register propagation, root-mapping
|
|
233
|
+
# validation, Collection's `collection: true` merge -- working on a real
|
|
234
|
+
# Hash. json 3.0 removed JSON::State#[] and rejects unknown keys in
|
|
235
|
+
# State#merge, so nothing may treat it as a Hash.
|
|
236
|
+
GENERATOR_STATE_KEY = :_generator_state
|
|
237
|
+
|
|
238
|
+
def self.wrap_generator_state(options)
|
|
239
|
+
return options if options.is_a?(::Hash)
|
|
240
|
+
|
|
241
|
+
{ GENERATOR_STATE_KEY => options }
|
|
242
|
+
end
|
|
243
|
+
|
|
228
244
|
def to_format(format, options = {})
|
|
245
|
+
options = Lutaml::Model::Serialize.wrap_generator_state(options)
|
|
246
|
+
|
|
229
247
|
# Hook for format-specific validation (e.g., XML root mapping check)
|
|
230
248
|
validate_root_mapping!(format, options)
|
|
231
249
|
|