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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Xml
|
|
5
|
+
# Phase 5 slice: compile a model's XML mapping into a
|
|
6
|
+
# Leptris::XML::Descriptor plan and materialize whole documents in
|
|
7
|
+
# one native pass (leptris_plan_walk) — no moxml wrapper tree, no
|
|
8
|
+
# per-element Ruby dispatch. Measured on the 200-item probe:
|
|
9
|
+
# walk+hydrate 8.7x faster, 82% fewer allocations than the
|
|
10
|
+
# interpretive path, hydration-equal output.
|
|
11
|
+
#
|
|
12
|
+
# A model compiles only when EVERY rule is plan-shaped; anything
|
|
13
|
+
# richer keeps the interpretive path (all-or-nothing per model):
|
|
14
|
+
# - map_attribute / map_element rows only
|
|
15
|
+
# - element rows: Value-scalar types or nested Serializables
|
|
16
|
+
# - no content/raw mappings, custom methods, delegates,
|
|
17
|
+
# polymorphism, transforms, multiple spellings, namespaces,
|
|
18
|
+
# ordered mappings, hash_mappings, root_mappings
|
|
19
|
+
# - attributes not derived/union/polymorphic
|
|
20
|
+
#
|
|
21
|
+
# The fast path is opt-in (Config.xml_plan_fast_path) while the
|
|
22
|
+
# full semantics audit (parent links, consolidation, ordering
|
|
23
|
+
# metadata) completes.
|
|
24
|
+
module PlanCompiler
|
|
25
|
+
# Isolated holder: suites freeze model classes; a cache on a
|
|
26
|
+
# frozen constant would be immutable (TypeProbeCache precedent).
|
|
27
|
+
PLAN_CACHE = ::Class.new do
|
|
28
|
+
class << self
|
|
29
|
+
def cache
|
|
30
|
+
@cache ||= {}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end.cache
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
def compile(model_class, register)
|
|
37
|
+
key = [model_class, register]
|
|
38
|
+
return PLAN_CACHE[key] if PLAN_CACHE.key?(key)
|
|
39
|
+
|
|
40
|
+
PLAN_CACHE[key] = build(model_class, register)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def build(model_class, register)
|
|
46
|
+
return nil unless model_class.is_a?(Class) &&
|
|
47
|
+
model_class.include?(::Lutaml::Model::Serialize)
|
|
48
|
+
|
|
49
|
+
mapping = model_class.mappings_for(:xml, register)
|
|
50
|
+
return nil unless compilable_mapping?(mapping)
|
|
51
|
+
|
|
52
|
+
rows = []
|
|
53
|
+
attr_rows = [] # [[rule, attr]] hydration metadata
|
|
54
|
+
plan_attrs = [] # [{name:, kind:}] rows for the engine plan
|
|
55
|
+
compiled = [] # [rule, attr, kind] in children order
|
|
56
|
+
mapping.mappings(register).each do |rule|
|
|
57
|
+
attr = model_class.attributes(register)[rule.to]
|
|
58
|
+
return nil if attr.nil?
|
|
59
|
+
return nil if attr.derived? || attr.union? || attr.polymorphic?
|
|
60
|
+
|
|
61
|
+
if rule.attribute?
|
|
62
|
+
return nil unless scalar_type?(attr, register)
|
|
63
|
+
|
|
64
|
+
attr_rows << [rule, attr]
|
|
65
|
+
plan_attrs << { name: rule.name.to_s }
|
|
66
|
+
else
|
|
67
|
+
return nil unless plan_element_row?(rule)
|
|
68
|
+
|
|
69
|
+
type = attr.type(register)
|
|
70
|
+
if serializable_type?(type)
|
|
71
|
+
child = compile(type, register)
|
|
72
|
+
return nil unless child
|
|
73
|
+
|
|
74
|
+
compiled << [rule, attr, :nested]
|
|
75
|
+
rows << { name: rule.name.to_s, kind: :nested,
|
|
76
|
+
plan: child[:tree] }
|
|
77
|
+
else
|
|
78
|
+
return nil unless scalar_type?(attr, register)
|
|
79
|
+
|
|
80
|
+
kind = attr.collection? ? :collection : :scalar
|
|
81
|
+
# Collection row values carry neither name nor type_tag
|
|
82
|
+
# echo (leptris 1.9.174): hydration can only attribute
|
|
83
|
+
# them unambiguously with a single collection row per
|
|
84
|
+
# element.
|
|
85
|
+
if kind == :collection && rows.count { |r| r[:kind] == :collection } >= 1
|
|
86
|
+
return nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
compiled << [rule, attr, kind]
|
|
90
|
+
rows << { name: rule.name.to_s, kind: kind }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
tree = { name: mapping.root_element.to_s, attributes: plan_attrs,
|
|
96
|
+
children: rows }
|
|
97
|
+
begin
|
|
98
|
+
# Lazy: the Opal boot loads this file, and leptris is a
|
|
99
|
+
# native gem there — the require only belongs on the
|
|
100
|
+
# engines-enabled path.
|
|
101
|
+
require "leptris/xml/descriptor"
|
|
102
|
+
descriptor = ::Leptris::XML::Descriptor.build(**tree)
|
|
103
|
+
rescue StandardError
|
|
104
|
+
return nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
{ descriptor: descriptor, tree: tree, rows: compiled,
|
|
108
|
+
attr_rows: attr_rows, mapping: mapping }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def compilable_mapping?(mapping)
|
|
112
|
+
!mapping.ordered? && mapping.root_element &&
|
|
113
|
+
!(mapping.respond_to?(:root_mappings) && mapping.root_mappings)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def plan_element_row?(rule)
|
|
117
|
+
!rule.raw_mapping? && !rule.content_mapping? &&
|
|
118
|
+
!rule.delegate && !rule.multiple_mappings? &&
|
|
119
|
+
!rule.namespace_set? && !rule.cdata &&
|
|
120
|
+
!rule.mixed_content && !rule.as_list && !rule.delimiter &&
|
|
121
|
+
!rule.has_custom_method_for_deserialization? &&
|
|
122
|
+
!rule.polymorphic_mapping? &&
|
|
123
|
+
!(rule.transform.is_a?(Hash) && !rule.transform.empty?) &&
|
|
124
|
+
!rule.transform.is_a?(Class)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def scalar_type?(attr, register)
|
|
128
|
+
type = attr.type(register)
|
|
129
|
+
type.is_a?(Class) && type < ::Lutaml::Model::Type::Value &&
|
|
130
|
+
!attr.custom_collection?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def serializable_type?(type)
|
|
134
|
+
type.is_a?(Class) && type.include?(::Lutaml::Model::Serialize)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Xml
|
|
5
|
+
# Hydrates model instances from a Descriptor#walk PlanValue tree,
|
|
6
|
+
# keyed by each value's producing row name (never position — rows
|
|
7
|
+
# for missing elements are simply absent). Builds constructor
|
|
8
|
+
# kwargs recursively and instantiates through .new, so typing,
|
|
9
|
+
# defaults, and collection semantics all ride the constructor.
|
|
10
|
+
module PlanHydrator
|
|
11
|
+
class << self
|
|
12
|
+
# plan: the compiler's entry for model_class
|
|
13
|
+
# value: the walk root PlanValue (element)
|
|
14
|
+
def call(model_class, plan, value)
|
|
15
|
+
kwargs = attributes_kwargs(plan, value)
|
|
16
|
+
kwargs.merge!(children_kwargs(model_class, plan, value))
|
|
17
|
+
model_class.new(**kwargs)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def attributes_kwargs(plan, value)
|
|
23
|
+
kwargs = {}
|
|
24
|
+
plan[:attr_rows].each do |rule, attr|
|
|
25
|
+
v = value.attribute(rule.name.to_s)
|
|
26
|
+
kwargs[attr.name.to_sym] = v unless v.nil?
|
|
27
|
+
end
|
|
28
|
+
kwargs
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def children_kwargs(_model_class, plan, value)
|
|
32
|
+
register = Lutaml::Model::Config.default_register
|
|
33
|
+
grouped = group_children_by_name(value)
|
|
34
|
+
|
|
35
|
+
kwargs = {}
|
|
36
|
+
plan[:rows].each do |rule, attr, kind|
|
|
37
|
+
values = grouped[kind == :collection ? :__collection : rule.name.to_s]
|
|
38
|
+
next if values.nil? || values.empty?
|
|
39
|
+
|
|
40
|
+
kwargs[attr.name.to_sym] =
|
|
41
|
+
case kind
|
|
42
|
+
when :scalar
|
|
43
|
+
values.first.string_value
|
|
44
|
+
when :collection
|
|
45
|
+
# One collection-row value per element; its items are
|
|
46
|
+
# the individual scalar matches.
|
|
47
|
+
values.flat_map do |cv|
|
|
48
|
+
Array.new(cv.count) { |i| cv.at(i).string_value }
|
|
49
|
+
end
|
|
50
|
+
when :nested
|
|
51
|
+
child_plan = PlanCompiler.compile(attr.type(register),
|
|
52
|
+
register)
|
|
53
|
+
items = values.map do |v|
|
|
54
|
+
call(attr.type(register), child_plan, v)
|
|
55
|
+
end
|
|
56
|
+
attr.collection? ? items : items.first
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
kwargs
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Scalar and nested values echo their producing row's name;
|
|
63
|
+
# collection values echo neither name nor type_tag, so with the
|
|
64
|
+
# compiler's single-collection-row guard they are attributed by
|
|
65
|
+
# kind.
|
|
66
|
+
def group_children_by_name(value)
|
|
67
|
+
grouped = {}
|
|
68
|
+
value.count.times do |i|
|
|
69
|
+
child = value.at(i)
|
|
70
|
+
key = child.name || (child.kind == :collection ? :__collection : nil)
|
|
71
|
+
next if key.nil?
|
|
72
|
+
|
|
73
|
+
(grouped[key] ||= []) << child
|
|
74
|
+
end
|
|
75
|
+
grouped
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -80,6 +80,14 @@ module Lutaml
|
|
|
80
80
|
require "moxml" unless defined?(Moxml)
|
|
81
81
|
fragment_doc = Moxml.new.parse(fragment_string, fragment: true)
|
|
82
82
|
add_fragment_children_to_parent(fragment_doc, parent)
|
|
83
|
+
rescue Moxml::ParseError
|
|
84
|
+
# Engines without a fragment mode (leptris today, lutaml/moxml#188)
|
|
85
|
+
# reject multiple top-level nodes; re-parse under a synthetic root
|
|
86
|
+
# and lift its children.
|
|
87
|
+
wrapped = Moxml.new.parse(
|
|
88
|
+
"<lutaml-fragment>#{fragment_string}</lutaml-fragment>",
|
|
89
|
+
)
|
|
90
|
+
add_fragment_children_to_parent(wrapped.root, parent)
|
|
83
91
|
rescue LoadError
|
|
84
92
|
append_raw_content(parent, fragment_string)
|
|
85
93
|
end
|
|
@@ -264,6 +264,8 @@ module Lutaml
|
|
|
264
264
|
root, model_instance, options,
|
|
265
265
|
compiled_rules, model_class, register_id
|
|
266
266
|
) do |action, rule, value, set_xsi_nil|
|
|
267
|
+
next if action == :apply_rule && duplicate_element_rules[rule]
|
|
268
|
+
|
|
267
269
|
rule_options = options.merge(current_model: model_instance)
|
|
268
270
|
case action
|
|
269
271
|
when :apply_rule
|
|
@@ -291,6 +293,32 @@ module Lutaml
|
|
|
291
293
|
# @param root [XmlElement] Root element
|
|
292
294
|
# @param model_instance [Object] The model instance
|
|
293
295
|
# @param options [Hash] Options
|
|
296
|
+
# Reader for the precomputed skip set (#765).
|
|
297
|
+
def duplicate_element_rules
|
|
298
|
+
@duplicate_element_rules
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# lutaml-model#765: several element rules may target one attribute
|
|
302
|
+
# (spelling tolerance, e.g. editorial-group vs editorialgroup). The
|
|
303
|
+
# extra spellings exist for parse tolerance only — serialization
|
|
304
|
+
# emits the value once, under the first declared spelling. Derived
|
|
305
|
+
# once at compile time (transformations are frozen and shared).
|
|
306
|
+
def after_compile
|
|
307
|
+
seen = {}
|
|
308
|
+
@duplicate_element_rules = {}
|
|
309
|
+
compiled_rules.each do |rule|
|
|
310
|
+
next unless rule.option(:mapping_type) == :element
|
|
311
|
+
|
|
312
|
+
key = rule.attribute_name.to_s
|
|
313
|
+
if seen.key?(key)
|
|
314
|
+
@duplicate_element_rules[rule] = true
|
|
315
|
+
else
|
|
316
|
+
seen[key] = true
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
@duplicate_element_rules.freeze
|
|
320
|
+
end
|
|
321
|
+
|
|
294
322
|
def apply_standard_rules(root, model_instance, options)
|
|
295
323
|
attr_order = model_instance.is_a?(Lutaml::Model::Serialize) &&
|
|
296
324
|
model_instance.attribute_order
|
|
@@ -303,6 +331,7 @@ module Lutaml
|
|
|
303
331
|
|
|
304
332
|
rules.each do |rule|
|
|
305
333
|
next unless valid_mapping?(rule, options)
|
|
334
|
+
next if duplicate_element_rules[rule]
|
|
306
335
|
|
|
307
336
|
rule_options = options.merge(current_model: model_instance)
|
|
308
337
|
apply_rule(root, rule, model_instance, rule_options, model_class,
|
|
@@ -299,7 +299,7 @@ module Lutaml
|
|
|
299
299
|
end
|
|
300
300
|
|
|
301
301
|
def text
|
|
302
|
-
return @text if children.empty?
|
|
302
|
+
return @text if @children.empty?
|
|
303
303
|
return @computed_text if defined?(@computed_text)
|
|
304
304
|
|
|
305
305
|
@computed_text = if content_bearing_children_count > 1
|
|
@@ -393,7 +393,11 @@ module Lutaml
|
|
|
393
393
|
|
|
394
394
|
@children_index = {}
|
|
395
395
|
@children.each do |child|
|
|
396
|
-
|
|
396
|
+
# Index element children only: the index serves by-name element
|
|
397
|
+
# lookup, and AdapterElement keeps non-element children raw
|
|
398
|
+
# (lazily wrapped) until #children is read.
|
|
399
|
+
next unless child.is_a?(XmlElement)
|
|
400
|
+
next if child.processing_instruction?
|
|
397
401
|
|
|
398
402
|
key = child.namespaced_name
|
|
399
403
|
@children_index[key] ||= []
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yeptris"
|
|
4
|
+
require "yeptris/yaml"
|
|
5
|
+
require_relative "standard_adapter"
|
|
6
|
+
|
|
7
|
+
module Lutaml
|
|
8
|
+
module Yaml
|
|
9
|
+
module Adapter
|
|
10
|
+
# YAML over the yeptris engine (libyeptris C11). Uses the native
|
|
11
|
+
# Yeptris::YAML surface, which does not rebind or interact with the
|
|
12
|
+
# Psych constant, so it coexists with the standard adapter in one
|
|
13
|
+
# process. Inherits the document/generation contract; only the
|
|
14
|
+
# engine calls differ.
|
|
15
|
+
#
|
|
16
|
+
# Semantic notes vs the standard adapter's Psych.safe_load:
|
|
17
|
+
# implicit typing is parity-verified (compat_11 schema); anchors
|
|
18
|
+
# and aliases always resolve; a tagged value without a core type
|
|
19
|
+
# materializes as plain data instead of raising DisallowedClass.
|
|
20
|
+
class YeptrisAdapter < StandardAdapter
|
|
21
|
+
def self.parse(yaml, _options = {})
|
|
22
|
+
# rubocop:disable-next Naming/VariableNumber -- upstream schema literal
|
|
23
|
+
Yeptris::YAML.load(yaml, schema: :compat_11)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_yaml(_options = {})
|
|
27
|
+
attributes_to_serialize = if @attributes.is_a?(Lutaml::KeyValue::DataModel::Element)
|
|
28
|
+
# Unwrap __root__ wrapper to get actual content
|
|
29
|
+
@attributes.to_hash["__root__"]
|
|
30
|
+
else
|
|
31
|
+
# Legacy Hash input (backward compatibility)
|
|
32
|
+
@attributes
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Yeptris::YAML.dump(attributes_to_serialize)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/lutaml/yaml/adapter.rb
CHANGED
|
@@ -8,6 +8,11 @@ module Lutaml
|
|
|
8
8
|
autoload :MappingRule, "#{__dir__}/adapter/mapping_rule"
|
|
9
9
|
autoload :Transform, "#{__dir__}/adapter/transform"
|
|
10
10
|
autoload :StandardAdapter, "#{__dir__}/adapter/standard_adapter"
|
|
11
|
+
# MRI-only native engine adapter (libyeptris FFI)
|
|
12
|
+
Lutaml::Model::RuntimeCompatibility.autoload_native(
|
|
13
|
+
self,
|
|
14
|
+
YeptrisAdapter: "#{__dir__}/adapter/yeptris_adapter",
|
|
15
|
+
)
|
|
11
16
|
end
|
|
12
17
|
end
|
|
13
18
|
end
|
data/lib/lutaml/yaml/format.rb
CHANGED
data/lib/tasks/performance.rake
CHANGED
|
@@ -63,6 +63,11 @@ namespace :performance do
|
|
|
63
63
|
run_bench("#{BENCH_DIR}/bench_unitsml.rb")
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
desc "XML model-pipeline performance probe (from_xml/to_xml i/s + allocs)"
|
|
67
|
+
task :probe_xml do
|
|
68
|
+
require_relative "performance_probe_xml"
|
|
69
|
+
end
|
|
70
|
+
|
|
66
71
|
# --- Gate comparison ---
|
|
67
72
|
|
|
68
73
|
desc "Compare downstream benchmark JSON results (gate enforcement)"
|
|
@@ -12,9 +12,9 @@ class PerformanceComparator
|
|
|
12
12
|
|
|
13
13
|
BENCHMARK_MATRIX = {
|
|
14
14
|
xml: %i[nokogiri ox oga rexml],
|
|
15
|
-
json: %i[standard_json multi_json oj],
|
|
16
|
-
yaml: %i[standard_yaml],
|
|
17
|
-
toml: %i[toml_rb tomlib],
|
|
15
|
+
json: %i[standard_json multi_json oj yeptris],
|
|
16
|
+
yaml: %i[standard_yaml yeptris],
|
|
17
|
+
toml: %i[toml_rb tomlib teptris],
|
|
18
18
|
}.freeze
|
|
19
19
|
|
|
20
20
|
DIRECTIONS = %i[from to].freeze
|
|
@@ -107,8 +107,24 @@ class PerformanceComparator
|
|
|
107
107
|
in [:toml, :tomlib]
|
|
108
108
|
# Skip tomlib on Windows due to segmentation fault issues
|
|
109
109
|
Gem.win_platform?
|
|
110
|
+
in [:yaml | :json, :yeptris]
|
|
111
|
+
# yeptris ships prebuilt platform gems for linux/darwin only
|
|
112
|
+
Gem.win_platform?
|
|
113
|
+
in [:toml, :teptris]
|
|
114
|
+
teptris_loadable?
|
|
110
115
|
else
|
|
111
116
|
false
|
|
112
117
|
end
|
|
113
118
|
end
|
|
119
|
+
|
|
120
|
+
# A teptris platform gem whose native library cannot load (the 0.2.17/
|
|
121
|
+
# 0.2.18 x86_64-linux/x64-mingw-ucrt soname gap) must skip its cell,
|
|
122
|
+
# not crash the comparator run.
|
|
123
|
+
def teptris_loadable?
|
|
124
|
+
require "teptris"
|
|
125
|
+
false
|
|
126
|
+
rescue LoadError, StandardError
|
|
127
|
+
warn "performance comparator: teptris not loadable here — skipping its cell"
|
|
128
|
+
true
|
|
129
|
+
end
|
|
114
130
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# One-command performance probe for the model pipeline (TODO.perf/01).
|
|
4
|
+
#
|
|
5
|
+
# rake performance:probe # steady-state i/s + allocations/op
|
|
6
|
+
# rake performance:profile # ruby-prof flat profiles (parse + dump)
|
|
7
|
+
# rake performance:oneshot # fresh-process boot+parse-once medians
|
|
8
|
+
desc "Performance probes for the model pipeline"
|
|
9
|
+
namespace :performance do
|
|
10
|
+
desc "steady-state i/s and allocations per op (nested 200-item model)"
|
|
11
|
+
task :probe do
|
|
12
|
+
system({ "BENCH_DIR" => nil }, Gem.ruby, File.expand_path("performance_probe.rb", __dir__), "probe") || raise("probe failed")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
desc "ruby-prof flat profiles for from_yaml and to_yaml"
|
|
16
|
+
task :profile do
|
|
17
|
+
system(Gem.ruby, File.expand_path("performance_probe.rb", __dir__), "profile") || raise("profile failed")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
desc "one-shot (fresh process) boot+define and parse-once medians"
|
|
21
|
+
task :oneshot do
|
|
22
|
+
boots = []
|
|
23
|
+
parses = []
|
|
24
|
+
5.times do
|
|
25
|
+
out = `#{Gem.ruby} #{File.expand_path("performance_probe.rb", __dir__)} oneshot`
|
|
26
|
+
boots << Regexp.last_match(1).to_f if out =~ /boot\+define: ([\d.]+)ms/
|
|
27
|
+
parses << Regexp.last_match(1).to_f if out =~ /parse-once: ([\d.]+)ms/
|
|
28
|
+
end
|
|
29
|
+
med = ->(a) { a.sort[a.length / 2] }
|
|
30
|
+
puts format("boot+define median: %<boot>.1fms parse-once median: %<parse>.1fms",
|
|
31
|
+
boot: med.call(boots), parse: med.call(parses)) + " (#{boots.length} runs)"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Model-pipeline performance probe (TODO.perf/01). Run via
|
|
4
|
+
# rake performance:probe / :profile / :oneshot, or directly:
|
|
5
|
+
# ruby lib/tasks/performance_probe.rb probe|profile|oneshot
|
|
6
|
+
|
|
7
|
+
MODE = ARGV[0] || "probe"
|
|
8
|
+
|
|
9
|
+
# One-shot mode times the require itself; every other mode needs it up front.
|
|
10
|
+
require "lutaml/model" unless MODE == "oneshot"
|
|
11
|
+
|
|
12
|
+
def define_models
|
|
13
|
+
item = Class.new(Lutaml::Model::Serializable) do
|
|
14
|
+
attribute :id, :integer
|
|
15
|
+
attribute :name, :string
|
|
16
|
+
attribute :tags, :string, collection: true
|
|
17
|
+
|
|
18
|
+
key_value do
|
|
19
|
+
map "id", to: :id
|
|
20
|
+
map "name", to: :name
|
|
21
|
+
map "tags", to: :tags
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
root = Class.new(Lutaml::Model::Serializable) do
|
|
25
|
+
attribute :item, item, collection: true
|
|
26
|
+
|
|
27
|
+
key_value { map "item", to: :item }
|
|
28
|
+
end
|
|
29
|
+
[item, root]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build_root(item, root, count)
|
|
33
|
+
root.new(item: (0...count).map { |i| item.new(id: i, name: "Test #{i}", tags: %w[a b c]) })
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def ips_of(seconds = 3)
|
|
37
|
+
require "benchmark/ips"
|
|
38
|
+
lambda do |label, &blk|
|
|
39
|
+
r = Benchmark.ips(time: seconds, quiet: true) { |x| x.report(label, &blk) }
|
|
40
|
+
r.entries.max_by(&:ips).ips.round(1)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def allocs_of(&blk)
|
|
45
|
+
GC.start
|
|
46
|
+
before = GC.stat(:total_allocated_objects)
|
|
47
|
+
50.times(&blk)
|
|
48
|
+
(GC.stat(:total_allocated_objects) - before) / 50.0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
case MODE
|
|
52
|
+
when "probe"
|
|
53
|
+
item, root = define_models
|
|
54
|
+
model = build_root(item, root, 200)
|
|
55
|
+
yaml_doc = model.to_yaml
|
|
56
|
+
json_doc = model.to_json
|
|
57
|
+
root.from_yaml(yaml_doc)
|
|
58
|
+
|
|
59
|
+
ips = ips_of
|
|
60
|
+
puts format("%<label>s: %<ips>8.1f i/s allocs/op: %<allocs>8.1f",
|
|
61
|
+
label: "from_yaml",
|
|
62
|
+
ips: ips.call("fy") { root.from_yaml(yaml_doc) },
|
|
63
|
+
allocs: allocs_of { root.from_yaml(yaml_doc) })
|
|
64
|
+
puts format("%<label>s: %<ips>8.1f i/s allocs/op: %<allocs>8.1f",
|
|
65
|
+
label: "to_yaml ",
|
|
66
|
+
ips: ips.call("ty") { model.to_yaml },
|
|
67
|
+
allocs: allocs_of { model.to_yaml })
|
|
68
|
+
puts format("%<label>s: %<ips>8.1f i/s allocs/op: %<allocs>8.1f",
|
|
69
|
+
label: "from_json",
|
|
70
|
+
ips: ips.call("fj") { root.from_json(json_doc) },
|
|
71
|
+
allocs: allocs_of { root.from_json(json_doc) })
|
|
72
|
+
puts format("%<label>s: %<ips>8.1f i/s allocs/op: %<allocs>8.1f",
|
|
73
|
+
label: "to_json ",
|
|
74
|
+
ips: ips.call("tj") { model.to_json },
|
|
75
|
+
allocs: allocs_of { model.to_json })
|
|
76
|
+
when "profile"
|
|
77
|
+
item, root = define_models
|
|
78
|
+
model = build_root(item, root, 200)
|
|
79
|
+
yaml_doc = model.to_yaml
|
|
80
|
+
root.from_yaml(yaml_doc)
|
|
81
|
+
|
|
82
|
+
require "ruby-prof"
|
|
83
|
+
def print_profile(profile, label)
|
|
84
|
+
puts "===== #{label} (20 iterations, self-time) ====="
|
|
85
|
+
printer = RubyProf::FlatPrinter.new(profile)
|
|
86
|
+
begin
|
|
87
|
+
printer.print($stdout, {})
|
|
88
|
+
rescue ArgumentError
|
|
89
|
+
printer.print($stdout)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
print_profile(RubyProf::Profile.profile { 20.times { root.from_yaml(yaml_doc) } }, "from_yaml")
|
|
94
|
+
print_profile(RubyProf::Profile.profile { 20.times { model.to_yaml } }, "to_yaml")
|
|
95
|
+
when "oneshot"
|
|
96
|
+
# Fresh-process mode: this script IS the one-shot body; the rake task
|
|
97
|
+
# re-invokes it N times and reports medians.
|
|
98
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
99
|
+
require "lutaml/model"
|
|
100
|
+
item, root = define_models
|
|
101
|
+
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
102
|
+
yaml_doc = build_root(item, root, 200).to_yaml
|
|
103
|
+
root.from_yaml(yaml_doc)
|
|
104
|
+
t2 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
105
|
+
puts format("boot+define: %<boot>.1fms parse-once: %<parse>.1fms", boot: (t1 - t0) * 1000, parse: (t2 - t1) * 1000)
|
|
106
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# XML pipeline probe (mirrors performance_probe.rb's KV shape): quantifies
|
|
4
|
+
# the lutaml-model XML layer over the moxml runtime-default engine.
|
|
5
|
+
# ruby lib/tasks/performance_probe.rb xml
|
|
6
|
+
|
|
7
|
+
require "lutaml/model"
|
|
8
|
+
|
|
9
|
+
item = Class.new(Lutaml::Model::Serializable) do
|
|
10
|
+
attribute :id, :integer
|
|
11
|
+
attribute :name, :string
|
|
12
|
+
attribute :tags, :string, collection: true
|
|
13
|
+
|
|
14
|
+
xml do
|
|
15
|
+
element "item"
|
|
16
|
+
map_attribute "id", to: :id
|
|
17
|
+
map_element "name", to: :name
|
|
18
|
+
map_element "tag", to: :tags
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
root = Class.new(Lutaml::Model::Serializable) do
|
|
22
|
+
attribute :item, item, collection: true
|
|
23
|
+
|
|
24
|
+
xml do
|
|
25
|
+
element "root"
|
|
26
|
+
map_element "item", to: :item
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
model = root.new(item: (0...200).map { |i| item.new(id: i, name: "Test #{i}", tags: %w[a b c]) })
|
|
31
|
+
xml_doc = model.to_xml
|
|
32
|
+
root.from_xml(xml_doc)
|
|
33
|
+
|
|
34
|
+
ips = lambda do |label, &blk|
|
|
35
|
+
require "benchmark/ips"
|
|
36
|
+
r = Benchmark.ips(time: 3, quiet: true) { |x| x.report(label, &blk) }
|
|
37
|
+
r.entries.max_by(&:ips).ips.round(1)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
allocs = lambda do |&blk|
|
|
41
|
+
GC.start
|
|
42
|
+
before = GC.stat(:total_allocated_objects)
|
|
43
|
+
50.times(&blk)
|
|
44
|
+
(GC.stat(:total_allocated_objects) - before) / 50.0
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
fy = ips.call("fx") { root.from_xml(xml_doc) }
|
|
48
|
+
fa = allocs.call { root.from_xml(xml_doc) }
|
|
49
|
+
ty = ips.call("tx") { model.to_xml }
|
|
50
|
+
ta = allocs.call { model.to_xml }
|
|
51
|
+
|
|
52
|
+
puts format("from_xml: %<ips>8.1f i/s allocs/op: %<allocs>8.1f", ips: fy, allocs: fa)
|
|
53
|
+
puts format("to_xml : %<ips>8.1f i/s allocs/op: %<allocs>8.1f", ips: ty, allocs: ta)
|
|
54
|
+
puts "engine: #{Moxml::VERSION} / #{Leptris::VERSION}"
|
data/lutaml-model.gemspec
CHANGED
|
@@ -38,7 +38,7 @@ Gem::Specification.new do |spec|
|
|
|
38
38
|
spec.add_dependency "canon"
|
|
39
39
|
spec.add_dependency "concurrent-ruby"
|
|
40
40
|
spec.add_dependency "liquid", ">= 4.0", "< 6.0"
|
|
41
|
-
spec.add_dependency "moxml", "~> 0.5.
|
|
41
|
+
spec.add_dependency "moxml", "~> 0.5.34"
|
|
42
42
|
spec.add_dependency "ostruct"
|
|
43
43
|
spec.add_dependency "rubyzip", "~> 2.3"
|
|
44
44
|
spec.add_dependency "thor"
|