lutaml-model 0.8.23 → 0.8.28
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/_pages/configuration.adoc +11 -7
- data/docs/_pages/serialization_adapters.adoc +14 -5
- data/lib/compat/opal/lutaml_model_boot.rb +5 -0
- data/lib/lutaml/json/adapter/multi_json_adapter.rb +12 -1
- 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/key_value/adapter/json/multi_json_adapter.rb +16 -1
- data/lib/lutaml/key_value/adapter/json/yeptris_adapter.rb +15 -0
- 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/compiled_rule.rb +16 -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/serialize/format_conversion.rb +65 -0
- data/lib/lutaml/model/toml.rb +10 -6
- 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/declaration_planner.rb +8 -2
- data/lib/lutaml/xml/format.rb +7 -1
- data/lib/lutaml/xml/leptris/element.rb +34 -0
- data/lib/lutaml/xml/namespace_collector.rb +4 -3
- data/lib/lutaml/xml/plan_compiler.rb +253 -0
- data/lib/lutaml/xml/plan_hydrator.rb +341 -0
- data/lib/lutaml/xml/plan_order.rb +50 -0
- data/lib/lutaml/xml/plan_serializer.rb +241 -0
- data/lib/lutaml/xml/plan_walk.rb +30 -0
- data/lib/lutaml/xml/transformation/custom_method_wrapper.rb +8 -0
- data/lib/lutaml/xml/xml_element.rb +6 -2
- data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +46 -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_plan_walk.rb +58 -0
- data/lib/tasks/performance_probe_xml.rb +54 -0
- data/lutaml-model.gemspec +1 -1
- data/spec/lutaml/json/yeptris_adapter_spec.rb +63 -0
- data/spec/lutaml/key_value/adapter/toml/teptris_adapter_spec.rb +71 -0
- data/spec/lutaml/model/json_adapter_spec.rb +6 -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 +547 -0
- data/spec/lutaml/xml/plan_walk_spec.rb +102 -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 +28 -4
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Xml
|
|
5
|
+
# Serialize-side mirror of the plan fast path: builds the output
|
|
6
|
+
# tree directly through leptris's fused create_child chain — no
|
|
7
|
+
# XmlElement tree, no DeclarationPlanner pass, no builder context
|
|
8
|
+
# stack, no moxml wrapper minting. Serialized shapes must be the
|
|
9
|
+
# plain ones (attributes, scalars, collections, nested models,
|
|
10
|
+
# raw fragments, content runs); custom-method, polymorphic,
|
|
11
|
+
# union, and multi-spelling rows keep the interpretive serializer
|
|
12
|
+
# (their output flows through hash-shaped custom APIs).
|
|
13
|
+
#
|
|
14
|
+
# Ordered/mixed models serialize from element_order when the
|
|
15
|
+
# instance carries one (parsed models): text runs, comments, and
|
|
16
|
+
# element entries in document order, mirroring the interpretive
|
|
17
|
+
# order applier — including its content-attr-first text reads
|
|
18
|
+
# (mutations to the model show up) and CDATA flattening. Models
|
|
19
|
+
# without element_order fall back interpretively (return nil).
|
|
20
|
+
module PlanSerializer
|
|
21
|
+
SERIALIZABLE_KINDS = %i[scalar collection_native collection_cb
|
|
22
|
+
nested ordered_deferred raw content
|
|
23
|
+
content_deferred].freeze
|
|
24
|
+
CONTENT_KINDS = %i[content content_deferred].freeze
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# plan: the compiler's entry for instance's class
|
|
28
|
+
def call(instance, plan)
|
|
29
|
+
return nil if plan[:ordered] && instance.element_order.nil?
|
|
30
|
+
|
|
31
|
+
doc = ::Leptris::XML::Document.create
|
|
32
|
+
root = doc.create_element(plan[:tree][:name])
|
|
33
|
+
doc.root = root
|
|
34
|
+
if plan[:ordered]
|
|
35
|
+
build_ordered(root, instance, plan, doc)
|
|
36
|
+
else
|
|
37
|
+
build(root, instance, plan, doc)
|
|
38
|
+
end
|
|
39
|
+
doc.to_xml(indent: 2, no_decl: true)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether the compiled plan is serialize-shaped. Ordered/mixed
|
|
43
|
+
# models serialize through build_ordered from element_order;
|
|
44
|
+
# plain models through plan rows.
|
|
45
|
+
def serializable?(plan)
|
|
46
|
+
plan[:rows].all? do |_rule, _attr, kind, _sp, _del|
|
|
47
|
+
SERIALIZABLE_KINDS.include?(kind)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def register
|
|
54
|
+
Lutaml::Model::Config.default_register
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def build(element, instance, plan, doc)
|
|
58
|
+
write_attributes(element, instance, plan)
|
|
59
|
+
plan[:rows].each do |rule, attr, kind, spelling, delegate|
|
|
60
|
+
value = value_of(instance, attr, delegate)
|
|
61
|
+
next unless rule.render?(value, instance)
|
|
62
|
+
|
|
63
|
+
case kind
|
|
64
|
+
when :scalar
|
|
65
|
+
add_leaf(element, row_name(rule, spelling),
|
|
66
|
+
attr.serialize(value, :xml, register), doc)
|
|
67
|
+
when :collection_native, :collection_cb
|
|
68
|
+
Array(value).each do |item|
|
|
69
|
+
add_leaf(element, rule.name.to_s,
|
|
70
|
+
attr.serialize(item, :xml, register), doc)
|
|
71
|
+
end
|
|
72
|
+
when :nested, :ordered_deferred
|
|
73
|
+
Array(value).each do |item|
|
|
74
|
+
child_plan = PlanCompiler.compile(item.class, register)
|
|
75
|
+
child = element.create_child(child_plan[:tree][:name])
|
|
76
|
+
if child_plan[:ordered]
|
|
77
|
+
return nil unless build_ordered(child, item, child_plan, doc)
|
|
78
|
+
|
|
79
|
+
else
|
|
80
|
+
build(child, item, child_plan, doc)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
when :raw
|
|
84
|
+
Array(value).each { |raw| element.add_child(raw.to_s) }
|
|
85
|
+
when :content, :content_deferred
|
|
86
|
+
Array(value).each { |run| element.add_child(doc.create_text_node(run.to_s)) }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Element_order-driven emission (the interpretive order
|
|
92
|
+
# applier's contract): text runs and comments verbatim, each
|
|
93
|
+
# element entry consuming the next item of its collection
|
|
94
|
+
# (document order). Text reads prefer the content attribute
|
|
95
|
+
# when it lines up with the text-node count, so mutations to
|
|
96
|
+
# the model are reflected; element_order text is the fallback.
|
|
97
|
+
# PIs drop (interpretive parity). Returns nil when a nested
|
|
98
|
+
# ordered child lacks element_order (caller falls back).
|
|
99
|
+
def build_ordered(element, instance, plan, doc)
|
|
100
|
+
write_attributes(element, instance, plan)
|
|
101
|
+
rows_by_name = {}
|
|
102
|
+
plan[:rows].each do |rule, attr, kind, spelling, delegate|
|
|
103
|
+
next unless rule.name
|
|
104
|
+
|
|
105
|
+
rows_by_name[rule.name.to_s] ||= [rule, attr, kind, spelling,
|
|
106
|
+
delegate]
|
|
107
|
+
end
|
|
108
|
+
content_entry = plan[:rows].find do |rule, _attr, kind, _sp, _del|
|
|
109
|
+
CONTENT_KINDS.include?(kind) && rule.name.nil?
|
|
110
|
+
end
|
|
111
|
+
order = instance.element_order
|
|
112
|
+
element_indices = ::Hash.new(0)
|
|
113
|
+
content_attr = content_entry && content_entry[1]
|
|
114
|
+
content_value = content_attr &&
|
|
115
|
+
value_of(instance, content_attr, content_entry[4])
|
|
116
|
+
text_node_count = order.count { |o| o.type == "Text" }
|
|
117
|
+
use_content_index = content_value.is_a?(Array) &&
|
|
118
|
+
content_value.length == text_node_count
|
|
119
|
+
content_cdata = content_entry && content_entry[0].cdata
|
|
120
|
+
text_node_index = 0
|
|
121
|
+
|
|
122
|
+
order.each do |object|
|
|
123
|
+
case object.type
|
|
124
|
+
when "Text"
|
|
125
|
+
text = if content_attr && !content_value.nil?
|
|
126
|
+
if use_content_index
|
|
127
|
+
content_value[text_node_index].to_s
|
|
128
|
+
elsif !content_value.is_a?(Array) && text_node_count <= 1
|
|
129
|
+
content_value.to_s
|
|
130
|
+
else
|
|
131
|
+
object.text_content || object.name
|
|
132
|
+
end
|
|
133
|
+
else
|
|
134
|
+
object.text_content || object.name
|
|
135
|
+
end
|
|
136
|
+
text_node_index += 1
|
|
137
|
+
next if text.nil?
|
|
138
|
+
# Mixed content keeps whitespace; ordered-only skips it
|
|
139
|
+
next if content_attr.nil? && text.strip.empty?
|
|
140
|
+
|
|
141
|
+
element.add_child(content_cdata ? doc.create_cdata(text) : doc.create_text_node(text))
|
|
142
|
+
when "Comment"
|
|
143
|
+
element.add_child(doc.create_comment(object.text_content))
|
|
144
|
+
when "Element"
|
|
145
|
+
entry = rows_by_name[object.name]
|
|
146
|
+
next unless entry
|
|
147
|
+
|
|
148
|
+
_, attr, kind, _spelling, delegate = entry
|
|
149
|
+
value = value_of(instance, attr, delegate)
|
|
150
|
+
case kind
|
|
151
|
+
when :collection_native, :collection_cb
|
|
152
|
+
index = element_indices[object.name]
|
|
153
|
+
items = Array(value)
|
|
154
|
+
next unless index < items.length
|
|
155
|
+
|
|
156
|
+
element_indices[object.name] += 1
|
|
157
|
+
add_leaf(element, object.name,
|
|
158
|
+
attr.serialize(items[index], :xml, register), doc)
|
|
159
|
+
when :nested, :ordered_deferred
|
|
160
|
+
item = if attr.collection?
|
|
161
|
+
index = element_indices[object.name]
|
|
162
|
+
element_indices[object.name] += 1
|
|
163
|
+
Array(value)[index]
|
|
164
|
+
else
|
|
165
|
+
value
|
|
166
|
+
end
|
|
167
|
+
next unless item
|
|
168
|
+
|
|
169
|
+
child_plan = PlanCompiler.compile(item.class, register)
|
|
170
|
+
child = element.create_child(child_plan[:tree][:name])
|
|
171
|
+
if child_plan[:ordered]
|
|
172
|
+
return nil unless build_ordered(child, item, child_plan, doc)
|
|
173
|
+
else
|
|
174
|
+
build(child, item, child_plan, doc)
|
|
175
|
+
end
|
|
176
|
+
when :raw
|
|
177
|
+
element.add_child(value.to_s) unless value.nil?
|
|
178
|
+
when :scalar
|
|
179
|
+
add_leaf(element, object.name,
|
|
180
|
+
attr.serialize(value, :xml, register), doc)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
true
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Attributes in document order when the instance recorded it
|
|
188
|
+
# (parsed models), else plan row order.
|
|
189
|
+
def write_attributes(element, instance, plan)
|
|
190
|
+
recorded = instance.attribute_order
|
|
191
|
+
if recorded && !recorded.empty?
|
|
192
|
+
by_name = {}
|
|
193
|
+
plan[:attr_rows].each do |rule, attr, _sp, delegate|
|
|
194
|
+
by_name[rule.name.to_s] = [rule, attr, delegate]
|
|
195
|
+
end
|
|
196
|
+
names = recorded |
|
|
197
|
+
plan[:attr_rows].map { |rule, _a, _s, _d| rule.name.to_s }
|
|
198
|
+
names.each do |name|
|
|
199
|
+
write_attribute(element, instance, name, by_name[name])
|
|
200
|
+
end
|
|
201
|
+
else
|
|
202
|
+
plan[:attr_rows].each do |rule, attr, _sp, delegate|
|
|
203
|
+
write_attribute(element, instance, rule.name.to_s,
|
|
204
|
+
[rule, attr, delegate])
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def write_attribute(element, instance, name, entry)
|
|
210
|
+
return unless entry
|
|
211
|
+
|
|
212
|
+
_rule, attr, delegate = entry
|
|
213
|
+
value = value_of(instance, attr, delegate)
|
|
214
|
+
return if value.nil?
|
|
215
|
+
|
|
216
|
+
element.set_attribute(name,
|
|
217
|
+
attr.serialize(value, :xml, register).to_s)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def add_leaf(element, name, value, doc)
|
|
221
|
+
child = element.create_child(name)
|
|
222
|
+
child.add_child(doc.create_text_node(value.to_s)) unless value.nil?
|
|
223
|
+
child
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def value_of(instance, attr, delegate)
|
|
227
|
+
if delegate
|
|
228
|
+
target = instance.public_send(delegate)
|
|
229
|
+
target&.public_send(attr.name)
|
|
230
|
+
else
|
|
231
|
+
instance.public_send(attr.name)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def row_name(rule, spelling)
|
|
236
|
+
(spelling || rule.name).to_s
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Xml
|
|
5
|
+
# Public consumer surface for the plan fast path: one engine
|
|
6
|
+
# parse + descriptor walk and direct model hydration, bypassing
|
|
7
|
+
# the wrapper-tree construction that dominates large-document
|
|
8
|
+
# consumer paths. The returned object is the mapped model root;
|
|
9
|
+
# collection attributes remain collections on that model.
|
|
10
|
+
module PlanWalk
|
|
11
|
+
class << self
|
|
12
|
+
# Build the plan, walk the document, and hydrate the mapped
|
|
13
|
+
# root. Returns nil when the model is not plan-compilable.
|
|
14
|
+
def call(model_class, xml, _options = {})
|
|
15
|
+
register = Lutaml::Model::Config.default_register
|
|
16
|
+
plan = PlanCompiler.compile(model_class, register)
|
|
17
|
+
return nil unless plan
|
|
18
|
+
|
|
19
|
+
document = ::Leptris::XML.parse(xml)
|
|
20
|
+
root = document.root
|
|
21
|
+
return nil unless root && root.name == plan[:tree][:name]
|
|
22
|
+
|
|
23
|
+
PlanHydrator.call(model_class, plan,
|
|
24
|
+
plan[:descriptor].walk(root),
|
|
25
|
+
node: root)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
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
|
|
@@ -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,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "standard_adapter"
|
|
4
|
+
|
|
5
|
+
module Lutaml
|
|
6
|
+
module Yaml
|
|
7
|
+
module Adapter
|
|
8
|
+
# YAML over the yeptris engine (libyeptris C11). Uses the native
|
|
9
|
+
# Yeptris::YAML surface, which does not rebind or interact with the
|
|
10
|
+
# Psych constant, so it coexists with the standard adapter in one
|
|
11
|
+
# process. Inherits the document/generation contract; only the
|
|
12
|
+
# engine calls differ.
|
|
13
|
+
#
|
|
14
|
+
# Semantic notes vs the standard adapter's Psych.safe_load:
|
|
15
|
+
# implicit typing is parity-verified (compat_11 schema); anchors
|
|
16
|
+
# and aliases always resolve; a tagged value without a core type
|
|
17
|
+
# materializes as plain data instead of raising DisallowedClass.
|
|
18
|
+
class YeptrisAdapter < StandardAdapter
|
|
19
|
+
# yeptris is required lazily: the ruby-platform variant installs
|
|
20
|
+
# everywhere but only loads where libyeptris exists (no Windows
|
|
21
|
+
# prebuilts) — a broken install must degrade to detection
|
|
22
|
+
# fallback, never crash loads.
|
|
23
|
+
def self.parse(yaml, _options = {})
|
|
24
|
+
require "yeptris"
|
|
25
|
+
require "yeptris/yaml"
|
|
26
|
+
# rubocop:disable-next Naming/VariableNumber -- upstream schema literal
|
|
27
|
+
Yeptris::YAML.load(yaml, schema: :compat_11)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_yaml(_options = {})
|
|
31
|
+
attributes_to_serialize = if @attributes.is_a?(Lutaml::KeyValue::DataModel::Element)
|
|
32
|
+
# Unwrap __root__ wrapper to get actual content
|
|
33
|
+
@attributes.to_hash["__root__"]
|
|
34
|
+
else
|
|
35
|
+
# Legacy Hash input (backward compatibility)
|
|
36
|
+
@attributes
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
require "yeptris"
|
|
40
|
+
require "yeptris/yaml"
|
|
41
|
+
Yeptris::YAML.dump(attributes_to_serialize)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
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,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
require "leptris"
|
|
5
|
+
require "leptris/xml/descriptor"
|
|
6
|
+
require "benchmark"
|
|
7
|
+
|
|
8
|
+
module PlanWalkProbe
|
|
9
|
+
class BulkItem < Lutaml::Model::Serializable
|
|
10
|
+
attribute :id, :integer
|
|
11
|
+
attribute :name, :string
|
|
12
|
+
attribute :tags, :string, collection: true
|
|
13
|
+
|
|
14
|
+
xml do
|
|
15
|
+
element "row"
|
|
16
|
+
map_attribute "id", to: :id
|
|
17
|
+
map_element "name", to: :name
|
|
18
|
+
map_element "tags", to: :tags
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class BulkRoot < Lutaml::Model::Serializable
|
|
23
|
+
attribute :item, BulkItem, collection: true
|
|
24
|
+
|
|
25
|
+
xml do
|
|
26
|
+
element "iso"
|
|
27
|
+
map_element "row", to: :item
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
xml = File.read("/tmp/iso.xml")
|
|
33
|
+
Lutaml::Model::Config.adapter_for(:xml) # resolve adapter before benchmarking
|
|
34
|
+
|
|
35
|
+
measure = lambda do |label, &block|
|
|
36
|
+
elapsed = Benchmark.measure { 3.times(&block) }.real * 1000
|
|
37
|
+
puts "#{label}: %.2f ms total / %.2f ms each" % [elapsed, elapsed / 3]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
measure.call("raw parse + descriptor walk") do
|
|
41
|
+
descriptor = Leptris::XML::Descriptor.build(
|
|
42
|
+
name: "iso",
|
|
43
|
+
children: [{ name: "row", kind: :collection }],
|
|
44
|
+
)
|
|
45
|
+
descriptor.walk(Leptris::XML.parse(xml).root)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Lutaml::Model::Config.instance.xml_plan_fast_path = true
|
|
49
|
+
measure.call("PlanWalk parse + 5000 model.new") do
|
|
50
|
+
result = Lutaml::Xml::PlanWalk.call(PlanWalkProbe::BulkRoot, xml)
|
|
51
|
+
raise "PlanWalk returned nil" unless result&.item&.size == 5000
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
Lutaml::Model::Config.instance.xml_plan_fast_path = false
|
|
55
|
+
measure.call("interpretive from_xml") do
|
|
56
|
+
result = PlanWalkProbe::BulkRoot.from_xml(xml)
|
|
57
|
+
raise "interpretive returned wrong size" unless result.item.size == 5000
|
|
58
|
+
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}"
|