Skip to content

fix: give each object property one key and refuse two that own the same (#169) - #181

Merged
kawasima merged 1 commit into
developfrom
feature/issue-169
Oct 1, 2026
Merged

kawasima merged 1 commit into
developfrom
feature/issue-169

Conversation

@kawasima

@kawasima kawasima commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator

Closes #169.

Cause

MapEncoders.object took EntryEncoder, a function that could write any number of keys into the shared output map. The key a property writes lived only inside its closure, so object saw nothing but a sequence of writes, and a repeated key showed up only as a second LinkedHashMap.put: the later value replaced the earlier one in the earlier one's position. The Raoh Specification's object encoder is a list of properties of one member each, with requires: distinct_members; the Java type was wider than that.

Adding key() to EntryEncoder would have left two sources of truth (the declared key and the keys actually written), so this removes the generalisation instead.

Change

  • PropertyEncoder owns exactly one key and writes it at most once: the encoded value, null, or nothing. It holds the key and a function that returns the value or a private omit marker; encodeTo is the only place that writes to the map, and it writes only its own key. The constructor stays private, so no property can write a different key.
  • optionalProperty and presenceProperty return PropertyEncoder<T>, built by PropertyEncoder.ofOptional / ofPresence like the other factories.
  • EntryEncoder is removed. PropertyEncoder.encode(T) is removed (it cannot say "leave the key out"); key() and encodeTo are package-private.
  • object(PropertyEncoder<T>...) copies the properties (as since fix: keep oneOf's candidate issues as issues, and say "elements" for map sizes (#167) #178) and checks that copy when it is built, throwing IllegalArgumentException("object key 'a' is owned by properties 0 and 2"). The check does not look at values, so optionalProperty("a") with property("a") is refused too.
  • Since each property writes at most one key, the output map is now pre-sized to the property count. Before, LinkedHashMap resized from the 13th key.
  • discriminate's rule that the injected tag wins over a body's entry is unchanged; it is a composition rule, not a duplicate definition.

The only other requires in the specification catalogue on the encoder side is this one; the decoder-side ones were handled earlier.

Tests

  • MapEncoderTest: same key twice (message with key and positions), across property kinds with a getter that must not run, declaration order around an omitted key, object() with no properties. nullablePropertyWritesNullWhenGetterReturnsNull now checks the key is present, not just that get is null.
  • GivenCollectionsAreCopiedTest: changing the array afterwards, even into a duplicate, changes nothing.
  • Mutations checked: removing the duplicate check, writing null instead of omitting (optional, Absent), omitting for PresentNull, and dropping the copy each fail tests. Replacing the pre-size with the default constructor passes, deliberately: sizing is not behaviour.

mvn verify (all modules, effect audit), -Pnullcheck clean compile on Zulu 25 and javadoc pass.

Not in this PR

The specification suite has no format for a definition rejected when it is built, so distinct_members is covered by raoh-java's tests only. That belongs in a separate raoh-specification issue.

🤖 Generated with Claude Code

…me (#169)

MapEncoders.object took EntryEncoder, which could write any number of keys
into the shared map, so object could not see which keys its parts own and
a repeated key silently replaced the earlier value. The Raoh Specification's
object encoder is a list of properties of one member each.

PropertyEncoder now owns exactly one key and writes it at most once: the
encoded value, null, or nothing. optionalProperty and presenceProperty
return PropertyEncoder, built inside PropertyEncoder with a private omit
marker, so the only write to the map is PropertyEncoder.encodeTo putting
its own key. EntryEncoder and PropertyEncoder.encode(T) are removed; key()
and encodeTo are package-private.

object copies the properties and checks that copy for a repeated key when
it is built, throwing IllegalArgumentException with the key and both
positions, whatever the property kinds. With at most one key per property,
the output map is now sized to the property count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kawasima
kawasima merged commit 4fbfb1c into develop Oct 1, 2026
3 checks passed
@kawasima
kawasima deleted the feature/issue-169 branch October 1, 2026 11:34
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