Skip to content

fix: builder setters record mutations; serializer never silently drops attributes - #740

Merged
ronaldtse merged 6 commits into
mainfrom
fix/ordered-builder-silent-drop
Jul 27, 2026
Merged

fix: builder setters record mutations; serializer never silently drops attributes#740
ronaldtse merged 6 commits into
mainfrom
fix/ordered-builder-silent-drop

Conversation

@ronaldtse

Copy link
Copy Markdown
Contributor

Summary

Fixes the silent-attribute-drop bug class in Serialize::Builder. Direct setters (x.foo = v) inside Klass.new do |x| ... end blocks were silently dropped from to_xml for ordered and mixed_content models whenever any sibling appender call (x.foo(v)) was also present.

The recent gate change from mixed_content? to ordered? in commit 4d65ba0 (PR #737) widened this bug surface from mixed_content-only models to every ordered model. The regression spec that shipped with that PR only exercised the appender path, which was never broken, so it could not detect the actual bug.

Root cause

Order tracking was hooked into the getter-with-arg path (x.foo(v)), which delegated to the generated setter. The setter itself never called track_order, so direct-setter mutations were missing from element_order. The serializer's apply_remaining_rules then skipped any element-typed rule not represented in element_order, producing silent data loss.

Fix (two layers)

1. Root cause — setters now record mutations. Serialize::Builder gains two encapsulated helpers:

  • record_mutation(name, value) for singular attributes
  • record_mutation_collection(name, value) for wholesale collection assignment (one entry per item)

These replace five scattered track_order(...) if @__order_tracking__ incantations in attribute_definition.rb and initialization.rb. Both setter and getter-with-arg paths now route through the same helpers, making them behaviourally identical.

2. Safety net — OrderedApplier#apply_remaining_rules no longer skips element-typed rules wholesale. After the ordered pass, it emits any element-typed rule whose value would otherwise vanish. This guarantees the invariant: every attribute with a non-default value appears in serialized output, regardless of which mutation path set it. Future code paths that forget to call record_mutation will degrade to declaration-order emission rather than silent data loss.

Reproduction (before fix)

class Container < Lutaml::Model::Serializable
  attribute :singular, :string
  attribute :items, :string, collection: true

  xml do
    element "c"
    ordered
    map_element "singular", to: :singular
    map_element "items", to: :items
  end
end

# Case B (the bug):
Container.new { |x| x.singular = "s"; x.items "i" }.to_xml
# => <c><items>i</items></c>     ← singular silently dropped

# Case D (also the bug):
Container.new { |x| x.singular "s"; x.items = %w[a b c] }.to_xml
# => <c><singular>s</singular></c>     ← all items silently dropped

After the fix, both produce the expected output with all attributes present.

Test plan

  • New spec file spec/lutaml/model/serialize/builder_spec.rb — 18 specs covering:
    • no-silent-drop across 4 mutation styles (appender-only, setter+appender, wholesale collection, all setters)
    • call-order preservation for both syntaxes
    • round-trip parity for ordered and mixed_content
    • .tap vs builder block equivalence
    • no-op guarantee when tracking is disabled
    • wholesale collection reassignment
    • cross-cutting no-silent-drop invariant parameterised over mutation patterns
  • Updated spec/lutaml/model/ordered_content_spec.rb regression to exercise both appender and direct-setter syntaxes
  • Full suite: 5,309 examples, 0 failures, 1 pre-existing pending
  • Rubocop: 0 offenses across 959 files
  • All six repro cases (A through F) verified manually

Commits

  1. fix: builder setters record mutations + safety net in apply_remaining_rules
  2. test: comprehensive builder specs covering silent-drop bug class
  3. docs: new docs/_guides/builder-dsl.adoc documenting syntax, semantics, and invariant

…silently drops attributes

Direct setter calls (`x.foo = v`) inside `Klass.new do |x| ... end` blocks
were silently dropped from `to_xml` for `ordered` and `mixed_content`
models whenever any sibling appender call (`x.foo(v)`) was also present.

Root cause: order tracking was hooked into the getter-with-arg path
(`x.foo(v)`), which delegated to the generated setter. The setter itself
never called `track_order`, so direct-setter mutations were missing from
`element_order`. The serializer's `apply_remaining_rules` then skipped
any element-typed rule not represented in `element_order`, producing
silent data loss.

The recent gate change from `mixed_content?` to `ordered?`
(commit 4d65ba0) widened this bug surface from `mixed_content`-only
models to every `ordered` model.

Fix has two layers:

1. Root cause — setters now record mutations.
   `Serialize::Builder` gains two encapsulated helpers:
     - `record_mutation(name, value)` for singular attributes
     - `record_mutation_collection(name, value)` for wholesale collection
       assignment (one entry per item)
   These replace five scattered `track_order(...) if @__order_tracking__`
   incantations in `attribute_definition.rb` and `initialization.rb`.
   Both setter and getter-with-arg paths now route through the same
   helpers, making them behaviourally identical.

2. Safety net — `OrderedApplier#apply_remaining_rules` no longer skips
   element-typed rules wholesale. After the ordered pass, it emits any
   element-typed rule whose value would otherwise vanish, using
   `element_order_coverage` + `element_rule_already_emitted?` to detect
   coverage. This guarantees the invariant: every attribute with a
   non-default value appears in serialized output, regardless of which
   mutation path set it. Future code paths that forget to call
   `record_mutation` will degrade to declaration-order emission rather
   than silent data loss.
The regression spec that shipped with commit 4d65ba0 only exercised
the appender path (`i.b "first"`), which was never broken. It could
not detect the actual bug because the bug lived in the direct-setter
path. This commit adds a dedicated spec file for Serialize::Builder
and broadens the existing ordered_content regression to cover both
syntaxes.

New: spec/lutaml/model/serialize/builder_spec.rb
- no-silent-drop invariant across four mutation styles:
  * appender-only
  * direct setter for singular + appender for collection
  * wholesale collection assignment
  * all direct setters
- call-order preservation for both syntaxes
- round-trip parity for ordered and mixed_content models
- .tap vs builder block equivalence
- no-op guarantee when tracking is disabled
- wholesale collection reassignment (multiple assignments)
- cross-cutting no-silent-drop guard parameterised over mutation
  patterns — regression guard for the bug class, not just this instance

Updated: spec/lutaml/model/ordered_content_spec.rb
- Regression now exercises both appender and direct-setter syntaxes
  on the same model, so any future divergence between the two paths
  fails the test.
…ntics

The builder DSL had no user-facing documentation. Users had no way to
discover that `x.foo(v)` and `x.foo = v` are equivalent inside builder
blocks, that order tracking is gated on `ordered`/`mixed_content` and
on block-form construction, or that the serializer guarantees no
silent data loss.

This new guide covers:
- the two equivalent syntaxes (appender and direct setter)
- collection appender vs wholesale assignment semantics
- when order tracking is active vs inactive
- equivalence (and non-equivalence) with the `.tap` pattern
- the no-silent-drop invariant enforced by the serializer
- performance notes (no allocation when tracking is disabled)
@github-actions

Copy link
Copy Markdown

JS build check

Triggered [js-pr-check]
(https://github.com/lutaml/lutaml-model-js/actions/workflows/pr-check.yml)
against this PR's head (8549537).

The result will appear as a lutaml-model-js / pr-check status check
on this PR once the workflow run completes.

…nsumers

The record_mutation and record_mutation_collection helpers returned nil
when order tracking was disabled (the common case). Because the
generated setters ended in a call to one of these helpers, the setters
also returned nil. This broke Ruby's setter contract: `obj.foo = v`
must evaluate to `v`.

Downstream impact: metanorma/docbook (and metanorma/uniword via the
same pattern) constructs Para instances and uses
`collection = send(attr_name) || send(:"#{attr_name}=", [])` in
`Docbook::Elements::Para#try_add_inline`. With the setters returning
nil, `collection` was nil, and `collection << element` failed with
NoMethodError.

This was a regression introduced by the earlier commit on this branch
that wired setters through record_mutation / record_mutation_collection.
The original setters' last expression was `instance_variable_set` or
the cast value, both of which returned the assigned value.

Fix: both helpers now return `value`. This makes them side-effect-only
helpers that also preserve the setter contract, regardless of whether
tracking is enabled.

Specs:
- singular setter returns the assigned value
- collection setter returns the assigned value
- the `obj.foo || (obj.foo = [])` pattern works (regression guard for
  the docbook/uniword failure)

The dependent-gems-test workflow was the only place that caught this —
the bug only manifests when downstream consumer code uses the setter's
return value, which our own specs never did.
@github-actions

Copy link
Copy Markdown

JS build check

Triggered [js-pr-check]
(https://github.com/lutaml/lutaml-model-js/actions/workflows/pr-check.yml)
against this PR's head (aa58332).

The result will appear as a lutaml-model-js / pr-check status check
on this PR once the workflow run completes.

The safety net added in the earlier commit on this branch used ad-hoc
nil/empty checks to decide whether to emit an uncovered element-typed
rule. This diverged from the standard serialization path's skip logic
(RenderPolicy#check_skip_logic?), which also considers:

- using_default? (don't emit defaults the user never set)
- render_nil / render_empty DSL options
- value_map :to mappings
- UninitializedClass sentinels
- boolean value_map handling

Result: metanorma/uniword round-trip specs failed because parsed
models carried default-valued child elements (e.g. an empty Text model
for a <t/> element that was not in the source XML). The safety net
emitted those defaults as extra <t/> elements, producing XML that no
longer matched the original.

Fix: delegate to should_skip_value? — the same helper used by the
standard rule path. The safety net now emits only values the standard
path would have emitted, so it cannot introduce spurious elements.
The no-silent-drop invariant is preserved for the bug class it was
added for (explicit setter calls that bypass element_order), because
those values are non-default and pass should_skip_value?.

This subsumes the prior nil/empty/blank checks; should_skip_value?
already handles them via RenderPolicy.
@github-actions

Copy link
Copy Markdown

JS build check

Triggered [js-pr-check]
(https://github.com/lutaml/lutaml-model-js/actions/workflows/pr-check.yml)
against this PR's head (4d574a6).

The result will appear as a lutaml-model-js / pr-check status check
on this PR once the workflow run completes.

The safety net added earlier still over-emitted for parsed models:
metanorma/uniword round-trip specs failed because parsed models carry
default/uninitialized values for elements not present in the source
XML (e.g. an empty <t/> child of <r/>). The standard skip logic
declines to skip UninitializedClass under the default value_map
(omitted: :nil), so the safety net emitted those values as extra
elements.

The safety net exists to catch builder-block mutations that bypass
element_order tracking (the original silent-drop bug). After the
Option A fix (setters record mutations), this scenario only arises
inside `Klass.new do |x| ... end` construction. Parsed models trust
element_order as the complete source of truth — the standard path
iterates element_order and does not emit elements missing from it,
so the safety net must mirror that.

Fix:
- Add Builder#order_tracking_enabled? as the public reader for
  @__order_tracking__ (avoids instance_variable_get from the
  serializer, which would violate encapsulation rules).
- element_rule_already_emitted? short-circuits to "already emitted"
  (i.e. skip the safety net) when tracking is disabled. The safety
  net now fires only for builder-block-constructed instances where
  element_order could legitimately be incomplete.

This restores pre-PR behavior for parsed models (element_order is
authoritative) while preserving the no-silent-drop guarantee for
builder-block models (the original bug case).
@github-actions

Copy link
Copy Markdown

JS build check

Triggered [js-pr-check]
(https://github.com/lutaml/lutaml-model-js/actions/workflows/pr-check.yml)
against this PR's head (3fad6fd).

The result will appear as a lutaml-model-js / pr-check status check
on this PR once the workflow run completes.

@ronaldtse
ronaldtse merged commit 8602cca into main Jul 27, 2026
108 of 118 checks passed
@ronaldtse
ronaldtse deleted the fix/ordered-builder-silent-drop branch July 27, 2026 05:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant