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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 852a93ce391ef7eb0f8e7673bda66f7bb417637b252735cebe833d1e305a6c7b
4
- data.tar.gz: 65f067c3e0bf4e1d520d5e2d133a6de4606900844b5e91d367ce4edee51da0e1
3
+ metadata.gz: 5d29ecc4e1a5605ebf8f284cfc336e5eb2a35713eaca0ab91314605b6f4bcb60
4
+ data.tar.gz: c444893b940af836dc209144ba2f8b29e2c5d1c0d820624fdeb117ac4465388f
5
5
  SHA512:
6
- metadata.gz: 318f15b16d919319fd4c2cd9c2722a1ee37b8f1a4272b5a966ce45bf2811447c7c1004642fd76c3204ddd70c8fb4ac6de9397e67f7305529e4b2310769fca52b
7
- data.tar.gz: 65479ec0a4e9ef58938953f94802da282bfe2d443c4d7cd41a9bf83973e653c3d6ce700fb9eb07e7e5cf1ffa120cfb54fded8d71d8215f4e35c76360dd6393fe
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
- # Track order for mixed_content serialization
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
- # Builder-style: g.description(value) sets the value
124
- value = args.first
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
- track_order(name, value, nil) if @__order_tracking__
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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.18"
5
+ VERSION = "0.8.19"
6
6
  end
7
7
  end
@@ -297,7 +297,16 @@ _options)
297
297
  end
298
298
  end
299
299
 
300
- # Apply remaining rules (attributes and content/raw)
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
- next if rule.option(:mapping_type) == :element
332
+ mapping_type = rule.option(:mapping_type)
322
333
 
323
- # Skip content rules if we processed text nodes from element_order
324
- if %i[content raw].include?(rule.option(:mapping_type)) &&
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
- item = klass.new do |i|
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
- xml = item.to_xml
172
- b_pos = xml.index("<b>first</b>")
173
- a_pos = xml.index("<a>second</a>")
174
- expect(b_pos).to be < a_pos
175
- expect(item.element_order.map(&:name)).to eq(%w[b a])
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.18
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-24 00:00:00.000000000 Z
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