lutaml-model 0.8.18 → 0.8.19
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/docs/_guides/builder-dsl.adoc +127 -0
- data/lib/lutaml/model/serialize/attribute_definition.rb +9 -9
- data/lib/lutaml/model/serialize/builder.rb +55 -0
- data/lib/lutaml/model/serialize/initialization.rb +3 -2
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/xml/transformation/ordered_applier.rb +98 -4
- data/spec/lutaml/model/ordered_content_spec.rb +18 -7
- data/spec/lutaml/model/serialize/builder_spec.rb +358 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5d29ecc4e1a5605ebf8f284cfc336e5eb2a35713eaca0ab91314605b6f4bcb60
|
|
4
|
+
data.tar.gz: c444893b940af836dc209144ba2f8b29e2c5d1c0d820624fdeb117ac4465388f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2772f8c3a58a177aca7924c8f7a8b971d121710c524cba1565e9ef2f4f02cfc176167fb2cf33d9ee3570914f03221e239ed5ba9ec831032ed7958c1e46ae6a14
|
|
7
|
+
data.tar.gz: d2b61c3d3b1bde0c648fc26a05fa573a689d5f8e37db3331b8d7369d60984a649dce6452e459545bdc8253b34d94b450ab6e85babb3157a486988c208998002c
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Builder DSL
|
|
3
|
+
nav_order: 99
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
= Builder DSL
|
|
7
|
+
:toc:
|
|
8
|
+
:toclevels: 3
|
|
9
|
+
|
|
10
|
+
The Builder DSL gives any `Lutaml::Model::Serializable` class a block-based
|
|
11
|
+
construction syntax:
|
|
12
|
+
|
|
13
|
+
[source,ruby]
|
|
14
|
+
----
|
|
15
|
+
person = Person.new do |p|
|
|
16
|
+
p.name "Ada"
|
|
17
|
+
p.email "ada@example.com"
|
|
18
|
+
end
|
|
19
|
+
----
|
|
20
|
+
|
|
21
|
+
It works for every model. For models declared with `ordered` or
|
|
22
|
+
`mixed_content`, the builder also records the order in which attributes were
|
|
23
|
+
mutated so that `to_xml` can emit child elements in the same order.
|
|
24
|
+
|
|
25
|
+
== Two equivalent syntaxes
|
|
26
|
+
|
|
27
|
+
Every attribute supports both the *appender* form and the *direct setter* form
|
|
28
|
+
inside the block. They are interchangeable:
|
|
29
|
+
|
|
30
|
+
[source,ruby]
|
|
31
|
+
----
|
|
32
|
+
# Appender form: looks like a method call with the value as argument
|
|
33
|
+
person = Person.new do |p|
|
|
34
|
+
p.name "Ada" # equivalent to p.name = "Ada"
|
|
35
|
+
p.email "ada@x.org" # equivalent to p.email = "ada@x.org"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Direct setter form: looks like a normal assignment
|
|
39
|
+
person = Person.new do |p|
|
|
40
|
+
p.name = "Ada"
|
|
41
|
+
p.email = "ada@x.org"
|
|
42
|
+
end
|
|
43
|
+
----
|
|
44
|
+
|
|
45
|
+
Both snippets produce identical `to_xml`, `to_json`, etc. Use whichever reads
|
|
46
|
+
better in context.
|
|
47
|
+
|
|
48
|
+
=== Collections
|
|
49
|
+
|
|
50
|
+
Collection attributes accept both forms too:
|
|
51
|
+
|
|
52
|
+
[source,ruby]
|
|
53
|
+
----
|
|
54
|
+
group = Group.new do |g|
|
|
55
|
+
# Append one item at a time:
|
|
56
|
+
g.member(person1)
|
|
57
|
+
g.member(person2)
|
|
58
|
+
|
|
59
|
+
# Or assign the whole collection at once:
|
|
60
|
+
g.members = [person1, person2, person3]
|
|
61
|
+
end
|
|
62
|
+
----
|
|
63
|
+
|
|
64
|
+
The wholesale assignment records one order-entry per item, so the resulting
|
|
65
|
+
`element_order` length matches the number of serialized child elements.
|
|
66
|
+
|
|
67
|
+
== Order tracking and serialization
|
|
68
|
+
|
|
69
|
+
The builder tracks call order only when *all* of the following are true:
|
|
70
|
+
|
|
71
|
+
1. The instance was constructed via `Klass.new do |x| ... end` (a block was
|
|
72
|
+
passed).
|
|
73
|
+
2. The XML mapping is declared with `ordered` or `mixed_content`.
|
|
74
|
+
|
|
75
|
+
When tracking is active, every mutation (via either syntax) is appended to the
|
|
76
|
+
instance's `element_order`. `to_xml` then iterates `element_order` to emit
|
|
77
|
+
child elements in the order they were set.
|
|
78
|
+
|
|
79
|
+
When tracking is *not* active (no block, or the model is neither `ordered` nor
|
|
80
|
+
`mixed_content`), `element_order` stays `nil` and `to_xml` emits elements in
|
|
81
|
+
declaration order.
|
|
82
|
+
|
|
83
|
+
=== Equivalence with `.tap`
|
|
84
|
+
|
|
85
|
+
The block form is the only way to enable tracking. Constructing the instance
|
|
86
|
+
separately and mutating it afterwards does not enable tracking:
|
|
87
|
+
|
|
88
|
+
[source,ruby]
|
|
89
|
+
----
|
|
90
|
+
# Tracking active:
|
|
91
|
+
item = Item.new { |i| i.b = "first"; i.a = "second" }
|
|
92
|
+
item.element_order.map(&:name) # => ["b", "a"]
|
|
93
|
+
|
|
94
|
+
# Tracking NOT active:
|
|
95
|
+
item = Item.new
|
|
96
|
+
item.b = "first"
|
|
97
|
+
item.a = "second"
|
|
98
|
+
item.element_order # => nil
|
|
99
|
+
# Falls back to declaration order for serialization.
|
|
100
|
+
----
|
|
101
|
+
|
|
102
|
+
If you want call-order preservation, use the block form.
|
|
103
|
+
|
|
104
|
+
== No silent data loss (invariant)
|
|
105
|
+
|
|
106
|
+
The serializer guarantees that every attribute with a non-default value appears
|
|
107
|
+
in the serialized output, regardless of which syntax was used to set it.
|
|
108
|
+
|
|
109
|
+
This is enforced by `OrderedApplier#apply_remaining_rules`: any element-typed
|
|
110
|
+
attribute not represented in `element_order` is emitted in declaration order
|
|
111
|
+
after the tracked entries. This is a safety net on top of the builder's own
|
|
112
|
+
tracking, so that future code paths (new mutation helpers, attribute merge
|
|
113
|
+
operations, etc.) cannot silently drop user data.
|
|
114
|
+
|
|
115
|
+
If you find an attribute missing from `to_xml`, the right answer is to fix the
|
|
116
|
+
mutation path so it records into `element_order`. The safety net guarantees
|
|
117
|
+
correctness; it does not guarantee order.
|
|
118
|
+
|
|
119
|
+
== Performance notes
|
|
120
|
+
|
|
121
|
+
For models that are neither `ordered` nor `mixed_content`, the builder does
|
|
122
|
+
not allocate any `Lutaml::Xml::Element` order-tracking objects. The check is
|
|
123
|
+
`@__order_tracking__` (a boolean), and the recording helpers are no-ops.
|
|
124
|
+
|
|
125
|
+
The check is also a no-op for `ordered`/`mixed_content` models constructed
|
|
126
|
+
without a block. This keeps the common case (`Klass.from_xml(xml)` followed
|
|
127
|
+
by `to_xml`) free of tracking overhead.
|
|
@@ -108,24 +108,20 @@ module Lutaml
|
|
|
108
108
|
current = instance_variable_get(:"@#{name}") || []
|
|
109
109
|
new_value = current.is_a?(Array) ? current + [value] : value
|
|
110
110
|
instance_variable_set(:"@#{name}", new_value)
|
|
111
|
-
|
|
112
|
-
track_order(name, value, nil) if @__order_tracking__
|
|
111
|
+
record_mutation(name, value)
|
|
113
112
|
value
|
|
114
113
|
end
|
|
115
114
|
end
|
|
116
115
|
else
|
|
117
116
|
# For non-collection attributes, getter accepts optional argument
|
|
118
|
-
# for builder-style syntax: g.description(value) sets the value
|
|
117
|
+
# for builder-style syntax: g.description(value) sets the value.
|
|
118
|
+
# Tracking happens inside the setter, so no duplicate call here.
|
|
119
119
|
define_method(name) do |*args|
|
|
120
120
|
if args.empty?
|
|
121
121
|
instance_variable_get(:"@#{name}")
|
|
122
122
|
else
|
|
123
|
-
#
|
|
124
|
-
|
|
125
|
-
public_send(:"#{name}=", value)
|
|
126
|
-
# Track order for mixed_content serialization
|
|
127
|
-
track_order(name, value, nil) if @__order_tracking__
|
|
128
|
-
value
|
|
123
|
+
public_send(:"#{name}=", args.first)
|
|
124
|
+
args.first
|
|
129
125
|
end
|
|
130
126
|
end
|
|
131
127
|
end
|
|
@@ -156,12 +152,16 @@ module Lutaml
|
|
|
156
152
|
else
|
|
157
153
|
instance_variable_set(:"@#{name}", value)
|
|
158
154
|
end
|
|
155
|
+
# Track one entry per item so element_order reflects the
|
|
156
|
+
# number of <name> elements that will be emitted.
|
|
157
|
+
record_mutation_collection(name, value)
|
|
159
158
|
end
|
|
160
159
|
else
|
|
161
160
|
define_method(:"#{name}=") do |value|
|
|
162
161
|
value_set_for(name)
|
|
163
162
|
value = attr.cast_value(value, lutaml_register)
|
|
164
163
|
instance_variable_set(:"@#{name}", value)
|
|
164
|
+
record_mutation(name, value)
|
|
165
165
|
end
|
|
166
166
|
end
|
|
167
167
|
end
|
|
@@ -75,6 +75,61 @@ module Lutaml
|
|
|
75
75
|
mapping&.ordered? || false
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
# Whether this instance was constructed via a builder block and
|
|
79
|
+
# therefore records mutations into element_order. Parsed models
|
|
80
|
+
# and instances constructed without a block do not track; their
|
|
81
|
+
# element_order (if any) comes from the parser and is treated as
|
|
82
|
+
# the complete source of truth by the serializer.
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def order_tracking_enabled?
|
|
85
|
+
@__order_tracking__ ? true : false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Record a singular attribute mutation in element_order.
|
|
89
|
+
#
|
|
90
|
+
# No-op unless order tracking is enabled (i.e. the instance was
|
|
91
|
+
# constructed via a builder block on an ordered/mixed_content
|
|
92
|
+
# model). This is the single entry point for singular mutations
|
|
93
|
+
# and is called from generated setters and getter-with-arg paths
|
|
94
|
+
# so that direct setters (`x.foo = v`) and appender calls
|
|
95
|
+
# (`x.foo(v)`) behave identically.
|
|
96
|
+
#
|
|
97
|
+
# Returns `value` so generated setters preserve Ruby's setter
|
|
98
|
+
# contract: `obj.foo = v` evaluates to `v`. Callers like
|
|
99
|
+
# `obj.foo || (obj.foo = [])` depend on this.
|
|
100
|
+
#
|
|
101
|
+
# @param attribute_name [Symbol] The attribute being mutated
|
|
102
|
+
# @param value [Object, nil] The value being set; stored as text
|
|
103
|
+
# content for content-mapped attributes, ignored otherwise
|
|
104
|
+
# @return [Object] the passed value
|
|
105
|
+
def record_mutation(attribute_name, value = nil)
|
|
106
|
+
return value unless @__order_tracking__
|
|
107
|
+
|
|
108
|
+
track_order(attribute_name, value, nil)
|
|
109
|
+
value
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Record a collection-attribute mutation in element_order.
|
|
113
|
+
#
|
|
114
|
+
# Emits one entry per item so that element_order length matches
|
|
115
|
+
# the number of serialized child elements. No-op when tracking
|
|
116
|
+
# is disabled, when the value is nil/empty, or when the
|
|
117
|
+
# collection's frozen sentinel is preserved (no real data).
|
|
118
|
+
#
|
|
119
|
+
# Returns `value` so generated setters preserve Ruby's setter
|
|
120
|
+
# contract (see {record_mutation}).
|
|
121
|
+
#
|
|
122
|
+
# @param attribute_name [Symbol] The collection attribute
|
|
123
|
+
# @param value [Object, nil] The value assigned to the collection
|
|
124
|
+
# @return [Object] the passed value
|
|
125
|
+
def record_mutation_collection(attribute_name, value)
|
|
126
|
+
return value unless @__order_tracking__
|
|
127
|
+
return value if value.nil? || Lutaml::Model::Utils.uninitialized?(value)
|
|
128
|
+
|
|
129
|
+
Array(value).each { |item| track_order(attribute_name, item, nil) }
|
|
130
|
+
value
|
|
131
|
+
end
|
|
132
|
+
|
|
78
133
|
private
|
|
79
134
|
|
|
80
135
|
# Intercept method calls to track order for mixed_content
|
|
@@ -370,7 +370,6 @@ module Lutaml
|
|
|
370
370
|
instance_variable_get(:"@#{name}")
|
|
371
371
|
else
|
|
372
372
|
public_send(:"#{name}=", args.first)
|
|
373
|
-
track_order(name, args.first, nil) if @__order_tracking__
|
|
374
373
|
args.first
|
|
375
374
|
end
|
|
376
375
|
end
|
|
@@ -380,6 +379,7 @@ module Lutaml
|
|
|
380
379
|
reg_attr = resolve_register_attr(name)
|
|
381
380
|
value = reg_attr.cast_value(value, lutaml_register)
|
|
382
381
|
instance_variable_set(:"@#{name}", value)
|
|
382
|
+
record_mutation(name, value)
|
|
383
383
|
end
|
|
384
384
|
end
|
|
385
385
|
|
|
@@ -395,7 +395,7 @@ module Lutaml
|
|
|
395
395
|
current = [] if current.equal?(LAZY_EMPTY_COLLECTION)
|
|
396
396
|
new_value = current.is_a?(Array) ? current + [value] : value
|
|
397
397
|
instance_variable_set(:"@#{name}", new_value)
|
|
398
|
-
|
|
398
|
+
record_mutation(name, value)
|
|
399
399
|
value
|
|
400
400
|
end
|
|
401
401
|
end
|
|
@@ -411,6 +411,7 @@ module Lutaml
|
|
|
411
411
|
else
|
|
412
412
|
instance_variable_set(:"@#{name}", value)
|
|
413
413
|
end
|
|
414
|
+
record_mutation_collection(name, value)
|
|
414
415
|
end
|
|
415
416
|
end
|
|
416
417
|
|
data/lib/lutaml/model/version.rb
CHANGED
|
@@ -297,7 +297,16 @@ _options)
|
|
|
297
297
|
end
|
|
298
298
|
end
|
|
299
299
|
|
|
300
|
-
# Apply remaining rules (attributes
|
|
300
|
+
# Apply remaining rules (attributes, content/raw, and any element
|
|
301
|
+
# rules not represented in element_order).
|
|
302
|
+
#
|
|
303
|
+
# The element-type skip that used to live here caused silent data
|
|
304
|
+
# loss whenever an element-typed attribute was missing from
|
|
305
|
+
# element_order (e.g. when a direct setter forgot to call
|
|
306
|
+
# track_order). After the builder-side fix all mutation paths
|
|
307
|
+
# record into element_order, so this branch is a defense-in-depth
|
|
308
|
+
# safety net: emit any element-typed rule whose value would
|
|
309
|
+
# otherwise vanish.
|
|
301
310
|
#
|
|
302
311
|
# @param root [XmlElement] Root element
|
|
303
312
|
# @param model_instance [Object] The model instance
|
|
@@ -317,21 +326,106 @@ compiled_rules, mapping, processed_text_nodes)
|
|
|
317
326
|
compiled_rules
|
|
318
327
|
end
|
|
319
328
|
|
|
329
|
+
emitted_counts = element_order_coverage(model_instance, compiled_rules)
|
|
330
|
+
|
|
320
331
|
rules_to_apply.each do |rule|
|
|
321
|
-
|
|
332
|
+
mapping_type = rule.option(:mapping_type)
|
|
322
333
|
|
|
323
|
-
# Skip content
|
|
324
|
-
if %i[content raw].include?(
|
|
334
|
+
# Skip content/raw if mixed or text nodes were processed
|
|
335
|
+
if %i[content raw].include?(mapping_type) &&
|
|
325
336
|
(mapping&.mixed_content? || processed_text_nodes)
|
|
326
337
|
next
|
|
327
338
|
end
|
|
328
339
|
|
|
340
|
+
if mapping_type == :element &&
|
|
341
|
+
element_rule_already_emitted?(rule, model_instance,
|
|
342
|
+
emitted_counts)
|
|
343
|
+
next
|
|
344
|
+
end
|
|
345
|
+
|
|
329
346
|
next unless valid_mapping?(rule, options)
|
|
330
347
|
|
|
331
348
|
yield(:apply_rule, rule, nil) if block_given?
|
|
332
349
|
end
|
|
333
350
|
end
|
|
334
351
|
|
|
352
|
+
# Count, per element-typed rule, how many entries in element_order
|
|
353
|
+
# already cover it. Returns a Hash keyed by CompiledRule identity.
|
|
354
|
+
#
|
|
355
|
+
# @param model_instance [Object] The model instance
|
|
356
|
+
# @param compiled_rules [Array<CompiledRule>] The compiled rules
|
|
357
|
+
# @return [Hash<CompiledRule, Integer>] Coverage counts
|
|
358
|
+
def element_order_coverage(model_instance, compiled_rules)
|
|
359
|
+
counts = ::Hash.new(0)
|
|
360
|
+
return counts unless model_instance.respond_to?(:element_order)
|
|
361
|
+
return counts unless (order = model_instance.element_order)
|
|
362
|
+
|
|
363
|
+
element_rules = compiled_rules.select do |r|
|
|
364
|
+
r.is_a?(::Lutaml::Model::CompiledRule) &&
|
|
365
|
+
r.option(:mapping_type) == :element
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
order.each do |object|
|
|
369
|
+
next unless object.type == "Element"
|
|
370
|
+
|
|
371
|
+
object_ns_uri = object.namespace_uri
|
|
372
|
+
matched = element_rules.find do |r|
|
|
373
|
+
matches_element_rule?(r, object.name, object_ns_uri)
|
|
374
|
+
end
|
|
375
|
+
counts[matched] += 1 if matched
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
counts
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Whether an element-typed rule has already been fully emitted
|
|
382
|
+
# via element_order (or should not be emitted at all by the safety
|
|
383
|
+
# net). Returns true when:
|
|
384
|
+
# - the model was not constructed via builder block
|
|
385
|
+
# (`@__order_tracking__` is nil/false): parsed models trust
|
|
386
|
+
# element_order as the complete source of truth, so the safety
|
|
387
|
+
# net must not second-guess it by emitting defaults/uninitialized
|
|
388
|
+
# values that the standard path would otherwise have skipped.
|
|
389
|
+
# - the standard skip logic says the value should be skipped
|
|
390
|
+
# (handles defaults, render_nil/render_empty, value_map, etc.)
|
|
391
|
+
# - or the rule was fully covered by element_order entries
|
|
392
|
+
#
|
|
393
|
+
# The safety net targets the bug class where a builder-block
|
|
394
|
+
# construction bypassed element_order tracking (e.g. via a
|
|
395
|
+
# mutation path that forgot to call record_mutation). After
|
|
396
|
+
# Option A, all setter/getter paths record into element_order,
|
|
397
|
+
# so this branch is defense-in-depth rather than the common
|
|
398
|
+
# path.
|
|
399
|
+
#
|
|
400
|
+
# @param rule [CompiledRule] The element rule
|
|
401
|
+
# @param model_instance [Object] The model instance
|
|
402
|
+
# @param emitted_counts [Hash<CompiledRule, Integer>] Coverage map
|
|
403
|
+
# @return [Boolean]
|
|
404
|
+
def element_rule_already_emitted?(rule, model_instance,
|
|
405
|
+
emitted_counts)
|
|
406
|
+
return true unless model_order_tracking_enabled?(model_instance)
|
|
407
|
+
|
|
408
|
+
value = extract_ordered_rule_value(rule, model_instance)
|
|
409
|
+
return true if should_skip_value?(value, rule, model_instance)
|
|
410
|
+
|
|
411
|
+
emitted = emitted_counts[rule]
|
|
412
|
+
if rule.collection?
|
|
413
|
+
value_length = value.respond_to?(:length) ? value.length : 0
|
|
414
|
+
emitted >= value_length
|
|
415
|
+
else
|
|
416
|
+
emitted.positive?
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Whether the model was constructed via a builder block and thus
|
|
421
|
+
# has order tracking enabled. Only such models are candidates for
|
|
422
|
+
# the safety net; parsed models trust element_order as-is.
|
|
423
|
+
def model_order_tracking_enabled?(model_instance)
|
|
424
|
+
return false unless model_instance.respond_to?(:order_tracking_enabled?)
|
|
425
|
+
|
|
426
|
+
model_instance.order_tracking_enabled?
|
|
427
|
+
end
|
|
428
|
+
|
|
335
429
|
# Sort compiled rules so attribute rules follow the captured attribute_order.
|
|
336
430
|
# Non-attribute rules (content, raw) maintain their original position.
|
|
337
431
|
#
|
|
@@ -148,7 +148,9 @@ RSpec.describe "OrderedContent" do
|
|
|
148
148
|
|
|
149
149
|
# Regression for issue #735: `ordered` builder must honour setter
|
|
150
150
|
# call order, not declaration order. The @__order_tracking__ flag
|
|
151
|
-
# was gated on mixed_content? instead of ordered?.
|
|
151
|
+
# was gated on mixed_content? instead of ordered?. Comprehensive
|
|
152
|
+
# coverage of the setter/appender equivalence lives in
|
|
153
|
+
# spec/lutaml/model/serialize/builder_spec.rb.
|
|
152
154
|
context "ordered builder honours setter call order" do
|
|
153
155
|
it "emits elements in builder-call order, not declaration order" do
|
|
154
156
|
klass = Class.new(Lutaml::Model::Serializable) do
|
|
@@ -163,16 +165,25 @@ RSpec.describe "OrderedContent" do
|
|
|
163
165
|
end
|
|
164
166
|
end
|
|
165
167
|
|
|
166
|
-
|
|
168
|
+
# Both the appender syntax and the direct setter syntax MUST
|
|
169
|
+
# honour call order and MUST NOT silently drop the other.
|
|
170
|
+
item_appender = klass.new do |i|
|
|
167
171
|
i.b "first"
|
|
168
172
|
i.a "second"
|
|
169
173
|
end
|
|
170
174
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
175
|
+
item_setter = klass.new do |i|
|
|
176
|
+
i.b = "first"
|
|
177
|
+
i.a = "second"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
[item_appender, item_setter].each do |item|
|
|
181
|
+
xml = item.to_xml
|
|
182
|
+
b_pos = xml.index("<b>first</b>")
|
|
183
|
+
a_pos = xml.index("<a>second</a>")
|
|
184
|
+
expect(b_pos).to be < a_pos
|
|
185
|
+
expect(item.element_order.map(&:name)).to eq(%w[b a])
|
|
186
|
+
end
|
|
176
187
|
end
|
|
177
188
|
end
|
|
178
189
|
end
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require_relative "../../../../lib/lutaml/model"
|
|
5
|
+
require "lutaml/xml/adapter/nokogiri_adapter"
|
|
6
|
+
|
|
7
|
+
# Specs for Lutaml::Model::Serialize::Builder — the module that gives
|
|
8
|
+
# Serializable the `Klass.new do |x| ... end` block syntax.
|
|
9
|
+
#
|
|
10
|
+
# These specs exist because of a regression where direct setters
|
|
11
|
+
# (`x.attr = v`) inside the builder block silently dropped attributes
|
|
12
|
+
# from serialized output while appender calls (`x.attr(v)`) worked.
|
|
13
|
+
# The root cause was that only getter-with-arg paths called track_order;
|
|
14
|
+
# generated setters did not. The fix centralises mutation recording via
|
|
15
|
+
# Builder#record_mutation / record_mutation_collection, and the
|
|
16
|
+
# serializer's apply_remaining_rules acts as a safety net so element
|
|
17
|
+
# values are never silently dropped.
|
|
18
|
+
#
|
|
19
|
+
# These specs cover the invariant ("no silent data loss"), call-order
|
|
20
|
+
# preservation, round-trip parity, equivalence between .tap and block,
|
|
21
|
+
# the no-op guarantee for non-ordered models, and the wholesale
|
|
22
|
+
# collection assignment case.
|
|
23
|
+
RSpec.describe "Lutaml::Model::Serialize::Builder" do
|
|
24
|
+
let(:ordered_klass) do
|
|
25
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
26
|
+
attribute :singular, :string
|
|
27
|
+
attribute :items, :string, collection: true
|
|
28
|
+
|
|
29
|
+
xml do
|
|
30
|
+
element "container"
|
|
31
|
+
ordered
|
|
32
|
+
map_element "singular", to: :singular
|
|
33
|
+
map_element "items", to: :items
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
let(:mixed_klass) do
|
|
39
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
40
|
+
attribute :singular, :string
|
|
41
|
+
attribute :items, :string, collection: true
|
|
42
|
+
|
|
43
|
+
xml do
|
|
44
|
+
element "container"
|
|
45
|
+
mixed_content
|
|
46
|
+
map_element "singular", to: :singular
|
|
47
|
+
map_element "items", to: :items
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
let(:plain_klass) do
|
|
53
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
54
|
+
attribute :singular, :string
|
|
55
|
+
attribute :items, :string, collection: true
|
|
56
|
+
|
|
57
|
+
xml do
|
|
58
|
+
element "container"
|
|
59
|
+
map_element "singular", to: :singular
|
|
60
|
+
map_element "items", to: :items
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
shared_examples "no silent data loss across mutation styles" do
|
|
66
|
+
it "appender-only: emits every call in order" do
|
|
67
|
+
obj = ordered_klass.new do |x|
|
|
68
|
+
x.singular "first"
|
|
69
|
+
x.items "a"
|
|
70
|
+
x.items "b"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
xml = obj.to_xml
|
|
74
|
+
expect(xml).to be_xml_equivalent_to(<<~XML)
|
|
75
|
+
<container>
|
|
76
|
+
<singular>first</singular>
|
|
77
|
+
<items>a</items>
|
|
78
|
+
<items>b</items>
|
|
79
|
+
</container>
|
|
80
|
+
XML
|
|
81
|
+
expect(obj.element_order.map(&:name)).to eq(%w[singular items items])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "direct setter for singular: still emits the element" do
|
|
85
|
+
obj = ordered_klass.new do |x|
|
|
86
|
+
x.singular = "first"
|
|
87
|
+
x.items "a"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
xml = obj.to_xml
|
|
91
|
+
expect(xml).to be_xml_equivalent_to(<<~XML)
|
|
92
|
+
<container>
|
|
93
|
+
<singular>first</singular>
|
|
94
|
+
<items>a</items>
|
|
95
|
+
</container>
|
|
96
|
+
XML
|
|
97
|
+
expect(obj.element_order.map(&:name)).to eq(%w[singular items])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "wholesale collection assignment: emits one element per item" do
|
|
101
|
+
obj = ordered_klass.new do |x|
|
|
102
|
+
x.singular "first"
|
|
103
|
+
x.items = %w[a b c]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
xml = obj.to_xml
|
|
107
|
+
expect(xml).to be_xml_equivalent_to(<<~XML)
|
|
108
|
+
<container>
|
|
109
|
+
<singular>first</singular>
|
|
110
|
+
<items>a</items>
|
|
111
|
+
<items>b</items>
|
|
112
|
+
<items>c</items>
|
|
113
|
+
</container>
|
|
114
|
+
XML
|
|
115
|
+
expect(obj.element_order.map(&:name)).to eq(%w[singular items items items])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it "all direct setters: emits in call order" do
|
|
119
|
+
obj = ordered_klass.new do |x|
|
|
120
|
+
x.singular = "first"
|
|
121
|
+
x.items = %w[a b]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
xml = obj.to_xml
|
|
125
|
+
expect(xml).to be_xml_equivalent_to(<<~XML)
|
|
126
|
+
<container>
|
|
127
|
+
<singular>first</singular>
|
|
128
|
+
<items>a</items>
|
|
129
|
+
<items>b</items>
|
|
130
|
+
</container>
|
|
131
|
+
XML
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it "mixed style preserves call order across setters and appenders" do
|
|
135
|
+
obj = ordered_klass.new do |x|
|
|
136
|
+
x.items "a"
|
|
137
|
+
x.singular = "middle"
|
|
138
|
+
x.items "b"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
xml = obj.to_xml
|
|
142
|
+
expect(xml).to be_xml_equivalent_to(<<~XML)
|
|
143
|
+
<container>
|
|
144
|
+
<items>a</items>
|
|
145
|
+
<singular>middle</singular>
|
|
146
|
+
<items>b</items>
|
|
147
|
+
</container>
|
|
148
|
+
XML
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
describe "no silent data loss" do
|
|
153
|
+
it_behaves_like "no silent data loss across mutation styles"
|
|
154
|
+
|
|
155
|
+
it "mixed_content model emits setter-only attributes too" do
|
|
156
|
+
obj = mixed_klass.new do |x|
|
|
157
|
+
x.singular = "first"
|
|
158
|
+
x.items "a"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
xml = obj.to_xml
|
|
162
|
+
expect(xml).to include("<singular>first</singular>")
|
|
163
|
+
expect(xml).to include("<items>a</items>")
|
|
164
|
+
expect(obj.element_order.map(&:name)).to eq(%w[singular items])
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
describe "call order preservation" do
|
|
169
|
+
it "reverse declaration order is honoured via direct setters" do
|
|
170
|
+
obj = ordered_klass.new do |x|
|
|
171
|
+
x.items = %w[a b]
|
|
172
|
+
x.singular = "last"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
xml = obj.to_xml
|
|
176
|
+
items_pos = xml.index("<items>")
|
|
177
|
+
singular_pos = xml.index("<singular>")
|
|
178
|
+
expect(items_pos).to be < singular_pos
|
|
179
|
+
expect(obj.element_order.map(&:name)).to eq(%w[items items singular])
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it "reverse declaration order is honoured via appenders" do
|
|
183
|
+
obj = ordered_klass.new do |x|
|
|
184
|
+
x.items "a"
|
|
185
|
+
x.singular "last"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
xml = obj.to_xml
|
|
189
|
+
items_pos = xml.index("<items>")
|
|
190
|
+
singular_pos = xml.index("<singular>")
|
|
191
|
+
expect(items_pos).to be < singular_pos
|
|
192
|
+
expect(obj.element_order.map(&:name)).to eq(%w[items singular])
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
describe "round-trip parity" do
|
|
197
|
+
it "ordered: parses and re-emits equivalent XML" do
|
|
198
|
+
original = <<~XML
|
|
199
|
+
<container>
|
|
200
|
+
<items>a</items>
|
|
201
|
+
<singular>middle</singular>
|
|
202
|
+
<items>b</items>
|
|
203
|
+
</container>
|
|
204
|
+
XML
|
|
205
|
+
|
|
206
|
+
parsed = ordered_klass.from_xml(original)
|
|
207
|
+
expect(parsed.to_xml).to be_xml_equivalent_to(original)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
it "mixed_content: parses and re-emits equivalent XML" do
|
|
211
|
+
original = <<~XML
|
|
212
|
+
<container>before <items>a</items> mid <singular>x</singular> end</container>
|
|
213
|
+
XML
|
|
214
|
+
|
|
215
|
+
parsed = mixed_klass.from_xml(original)
|
|
216
|
+
roundtrip = parsed.to_xml
|
|
217
|
+
expect(roundtrip).to include("<items>a</items>")
|
|
218
|
+
expect(roundtrip).to include("<singular>x</singular>")
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
describe ".tap vs builder block equivalence" do
|
|
223
|
+
it "produces identical element_order presence and XML shape" do
|
|
224
|
+
via_tap = ordered_klass.new.tap do |x|
|
|
225
|
+
x.singular = "first"
|
|
226
|
+
x.items = %w[a b]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
via_block = ordered_klass.new do |x|
|
|
230
|
+
x.singular = "first"
|
|
231
|
+
x.items = %w[a b]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# .tap path does not enable tracking; both must still emit the same XML.
|
|
235
|
+
expect(via_tap.element_order).to be_nil
|
|
236
|
+
expect(via_block.element_order).not_to be_nil
|
|
237
|
+
expect(via_tap.to_xml).to be_xml_equivalent_to(via_block.to_xml)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
describe "setter return value contract" do
|
|
242
|
+
# Ruby's setter contract: `obj.foo = v` evaluates to `v`. Callers
|
|
243
|
+
# like `obj.foo || (obj.foo = [])` depend on this. Generated
|
|
244
|
+
# setters must continue to honour this contract regardless of
|
|
245
|
+
# whether order tracking is enabled.
|
|
246
|
+
it "singular setter returns the assigned value" do
|
|
247
|
+
obj = plain_klass.new
|
|
248
|
+
expect((obj.singular = "v")).to eq("v")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
it "collection setter returns the assigned value" do
|
|
252
|
+
obj = plain_klass.new
|
|
253
|
+
expect((obj.items = %w[a b])).to eq(%w[a b])
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
it "supports the `obj.foo || (obj.foo = [])` pattern from downstream consumers" do
|
|
257
|
+
obj = plain_klass.new
|
|
258
|
+
# Mirror Docbook::Elements::Para#try_add_inline, which relies on
|
|
259
|
+
# the setter returning the array (not nil) when the getter is nil.
|
|
260
|
+
# The bug: when the setter returned nil (because record_mutation
|
|
261
|
+
# returned nil with tracking disabled), `collection` was nil and
|
|
262
|
+
# `collection << element` failed with NoMethodError.
|
|
263
|
+
collection = obj.items || (obj.items = [])
|
|
264
|
+
expect(collection).to eq([])
|
|
265
|
+
expect { collection << "x" }.not_to raise_error
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
describe "no-op guarantee when not tracking" do
|
|
270
|
+
it "plain (non-ordered) models do not allocate element_order entries" do
|
|
271
|
+
obj = plain_klass.new do |x|
|
|
272
|
+
x.singular = "first"
|
|
273
|
+
x.items "a"
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
expect(obj.element_order).to be_nil
|
|
277
|
+
expect { obj.to_xml }.not_to raise_error
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
it "ordered models constructed without a block do not track" do
|
|
281
|
+
obj = ordered_klass.new
|
|
282
|
+
obj.singular = "first"
|
|
283
|
+
obj.items = %w[a b]
|
|
284
|
+
|
|
285
|
+
expect(obj.element_order).to be_nil
|
|
286
|
+
expect(obj.to_xml).to include("<singular>first</singular>")
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
describe "wholesale collection reassignment" do
|
|
291
|
+
it "tracks one entry per item across multiple assignments" do
|
|
292
|
+
obj = ordered_klass.new do |x|
|
|
293
|
+
x.items = %w[a b]
|
|
294
|
+
x.items = %w[c d e]
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# element_order is a log of mutations, not a reflection of final state,
|
|
298
|
+
# so both assignments are recorded. The serializer emits the current
|
|
299
|
+
# value of the attribute via element_order in recorded order; the
|
|
300
|
+
# safety net guarantees no data loss.
|
|
301
|
+
expect(obj.element_order.map(&:name)).to eq(%w[items items items items items])
|
|
302
|
+
xml = obj.to_xml
|
|
303
|
+
# The serialized output must contain all current items
|
|
304
|
+
expect(xml).to include("<items>c</items>")
|
|
305
|
+
expect(xml).to include("<items>d</items>")
|
|
306
|
+
expect(xml).to include("<items>e</items>")
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
describe "no-silent-drop invariant (cross-cutting)" do
|
|
311
|
+
# The bug class: any attribute with a non-default value MUST appear in
|
|
312
|
+
# the serialized output, regardless of how it was set. This spec is the
|
|
313
|
+
# regression guard for the whole class of bug, not just one instance.
|
|
314
|
+
shared_examples "no silent drop" do |description, mutation_block|
|
|
315
|
+
it "does not silently drop any attribute set via: #{description}" do
|
|
316
|
+
obj = ordered_klass.new(&mutation_block)
|
|
317
|
+
xml = obj.to_xml
|
|
318
|
+
|
|
319
|
+
if !obj.singular.nil? && !obj.singular.to_s.empty?
|
|
320
|
+
expect(xml).to include("<singular>"), "singular was dropped from output"
|
|
321
|
+
end
|
|
322
|
+
return if obj.items.nil? || obj.items.empty?
|
|
323
|
+
|
|
324
|
+
obj.items.each do |item|
|
|
325
|
+
expect(xml).to include("<items>#{item}</items>"),
|
|
326
|
+
"items element for #{item.inspect} was dropped from output"
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
it_behaves_like "no silent drop",
|
|
332
|
+
"singular setter + wholesale items setter",
|
|
333
|
+
lambda { |x|
|
|
334
|
+
x.singular = "v"
|
|
335
|
+
x.items = %w[a b]
|
|
336
|
+
}
|
|
337
|
+
it_behaves_like "no silent drop",
|
|
338
|
+
"all appenders",
|
|
339
|
+
lambda { |x|
|
|
340
|
+
x.singular "v"
|
|
341
|
+
x.items "a"
|
|
342
|
+
x.items "b"
|
|
343
|
+
}
|
|
344
|
+
it_behaves_like "no silent drop",
|
|
345
|
+
"singular setter + items appender (the original bug)",
|
|
346
|
+
lambda { |x|
|
|
347
|
+
x.singular = "v"
|
|
348
|
+
x.items "a"
|
|
349
|
+
x.items "b"
|
|
350
|
+
}
|
|
351
|
+
it_behaves_like "no silent drop",
|
|
352
|
+
"singular appender + wholesale items setter",
|
|
353
|
+
lambda { |x|
|
|
354
|
+
x.singular "v"
|
|
355
|
+
x.items = %w[a b]
|
|
356
|
+
}
|
|
357
|
+
end
|
|
358
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lutaml-model
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.8.
|
|
4
|
+
version: 0.8.19
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|
|
@@ -198,6 +198,7 @@ files:
|
|
|
198
198
|
- docs/PERFORMANCE_METHODOLOGY.md
|
|
199
199
|
- docs/_config.yml
|
|
200
200
|
- docs/_guides/advanced-mapping.adoc
|
|
201
|
+
- docs/_guides/builder-dsl.adoc
|
|
201
202
|
- docs/_guides/character-encoding.adoc
|
|
202
203
|
- docs/_guides/collection-serialization.adoc
|
|
203
204
|
- docs/_guides/creating-xsd.adoc
|
|
@@ -1719,6 +1720,7 @@ files:
|
|
|
1719
1720
|
- spec/lutaml/model/sequence_spec.rb
|
|
1720
1721
|
- spec/lutaml/model/serializable_spec.rb
|
|
1721
1722
|
- spec/lutaml/model/serializable_validation_spec.rb
|
|
1723
|
+
- spec/lutaml/model/serialize/builder_spec.rb
|
|
1722
1724
|
- spec/lutaml/model/serialize_perf_guard_spec.rb
|
|
1723
1725
|
- spec/lutaml/model/services/transformer_spec.rb
|
|
1724
1726
|
- spec/lutaml/model/simple_model_spec.rb
|