Skip to content

fix: give withDefault's default for a null or absent input, from the boundary (#164) - #182

Merged
kawasima merged 3 commits into
developfrom
feature/issue-164
Oct 1, 2026
Merged

kawasima merged 3 commits into
developfrom
feature/issue-164

Conversation

@kawasima

@kawasima kawasima commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #164.

Cause

Decoders.withDefault(dec, x) ran dec and gave x when every issue it returned had the code required, wherever those issues were. It was a weak recover, reading the result, while the Raoh Specification defines withDefault by its input: the default for a JSON null or an absent value, the inner decoder for anything else. Once dec has run, "the value is absent" and "the value is there but a member inside it is missing" both become required issues, and nothing can tell them apart afterwards. That gave the four failing cases:

  • R000836/R000837: {} and {"a":1} against object(a, b) were given the default instead of the members' required.
  • R000838: a JSON null handed to an object decoder failed with type_mismatch, so it was not defaulted.
  • R000839: withDefault(nullable(int), 42) gave null, because the inner nullable saw the input first.

shouldUseDefault was the only place in the library that branches on an issue code.

Change

  • Decoders.withDefault (both overloads) and shouldUseDefault are removed. A generic <I, T> combinator cannot know what null or absent means for I, and keeping it would keep a legal but non-conforming call for JSON input.
  • ObjectDecoders.withDefault(dec, x) / (dec, Supplier): the default for a Java null. A Map field passes null both for an absent key and a null value; a jOOQ column holding SQL NULL gives null.
  • JsonDecoders.withDefault(dec, x) / (dec, Supplier): the default for a JSON null (NullNode), the MissingNode that field passes for a missing member, or a Java null.
  • Both look at the value before dec runs and return dec's result as it is otherwise: they never look at a Result or an Issue. The supplier runs only for the default.
  • jOOQ: a column the record does not have is structural absence, refused with missing_field by JooqRecordDecoders.field before the value decoder runs. That is unchanged and now documented on field. To default both a missing column and SQL NULL, the docs give optionalField(c, withDefault(dec, x)).map(o -> o.orElse(x)); optionalField alone passes SQL NULL to the value decoder, which refuses it with required. JooqDecoderTest pins all three compositions against a missing column, SQL NULL and a value.
  • No InputPresence<I> abstraction or 3-arg core: withDefault is a predicate and a delegation, so sharing it would only inject the predicate.
  • JsonDecoders.nullable's Javadoc said an absent value gives null; it goes to the inner decoder (R000238), so the Javadoc now says so and contrasts it with withDefault.

Docs and examples

  • README, tutorial.md, tutorial.ja.md: the default goes inside the field, field("x", withDefault(d, v)). A field(...) is a CombinePart, not a Decoder (since decode: replace FieldDecoder with explicit CombinePart values #114 in 0.7.0), so withDefault(field(...), v) does not compile; a whole-input default wraps a decoder of the whole input, such as withDefault(nested(combine(...).map(...)), v). The semantics are rewritten (input-based, what null/absent is per boundary, the jOOQ distinction), and withDefault is no longer listed as a Decoders combinator. The CHANGELOG migrates from field("x", Decoders.withDefault(d, v)), the form 0.8.0 accepted.
  • The tutorial's pagination, recursive lazy and recover snippets did not compile on develop either: a field(...) is a CombinePart, not a Decoder, so it cannot be passed to recover or the old withDefault. They now put recover / withDefault inside the field and use a typed array with nested for recursion. All rewritten snippets were run with jetshell; the config example's error output was also stale (must not be blank, not is required).
  • boundary-modules.md lists withDefault for each module (with the jOOQ note), and both package-infos, MapEncoders, and CONTRIBUTING.md no longer call withDefault a failure-handling Decoders combinator.
  • examples/spring uses JsonDecoders.withDefault, examples/schema-versioning ObjectDecoders.withDefault; both build and pass.

Other doc snippets found broken (same cause)

The broken withDefault / recover snippets came from nothing running the docs, so every Java block of the tutorials, README, boundary-modules, comparisons and composition-patterns was run through jetshell and its // ==> outputs compared. Fixed here:

  • iso8601().past() / future() (and pastOrPresent / futureOrPresent in the README list) never existed: a decoder does not read the clock. The tutorial compares with a time passed in instead.
  • README's localDateTime() is dateTime().
  • A single-field variant(...) needs .asDecoder(); list(...) of a Map decoder needs nested(...).
  • combine(...).map(...) is a Decoder, not a JsonDecoder / MapDecoder; README, comparisons and composition-patterns declared it as one. boundary-modules shows field(...) as a CombinePart.
  • Stale outputs: nonBlank() messages, list-size and duplicate messages, unknown field, the oneOf failure path, bytes().
  • Period::parse and Currency in fragments read as JDK types; they now name their own.
  • CLAUDE.md's jetshell notes (MapDecoders re-exporting string(), nonBlank() giving required) were wrong and are corrected.

What remains unchecked are fragments that depend on values the reader supplies (input, json, an Order type) and Map.of outputs whose order is not fixed.

Tests

  • ObjectDecodersWithDefaultTest (new) and JsonDecoderTest: R000824–R000830 with the specification's own fixtures (defaults 0/1 and 7/8) and R000836–R000839 (JSON via readTree, Map analogues), MissingNode, a Java null, nullable vs withDefault on a missing member, an inner failure returned with assertSame, and the inner/supplier call counts for null, present-Ok and present-Err.
  • The four DecodersCombinatorTest cases that pinned the old "all issues required" behaviour are removed.
  • Mutations: going back to "run, then default when all issues are required", dropping isMissingNode or isNull from the JSON check, calling the supplier eagerly, and rewrapping the inner result each fail tests.

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

Performance

ObjectDecoders.withDefault against the old Decoders.withDefault around int_(), 5M decodes per run, each version in its own JVM: a present value 0.5 ns both; null 1.5 → 0.3 ns (the inner decoder no longer runs); a wrong-type value 20–21 → 19–20 ns (no issue scan). No change worth noting either way.

🤖 Generated with Claude Code

kawasima and others added 3 commits October 1, 2026 20:53
…boundary (#164)

Decoders.withDefault ran the inner decoder and gave the default when every
issue it returned was "required", wherever those issues were. Once the
inner decoder has run, "the value is absent" and "the value is there but
something inside it is missing" are the same issues, so a nested missing
member silently became the default, a JSON null given to an object decoder
was not defaulted (type_mismatch), and withDefault(nullable(d), x) gave
null. The specification defines withDefault by its input.

Only the boundary knows what null and absent are, so Decoders.withDefault
and shouldUseDefault are removed. ObjectDecoders.withDefault defaults a Java
null (a Map key absent or null, a jOOQ SQL NULL); JsonDecoders.withDefault
defaults a JSON null or a MissingNode. Both look at the value before the
inner decoder runs and return its result unchanged otherwise; the Supplier
overloads call the supplier only for the default. A jOOQ column missing
from the record stays missing_field from field(), which runs first.

Docs and examples move the default inside the field. The tutorial's
pagination, recursion and recover snippets did not compile (a field is a
CombinePart, not a Decoder); they are rewritten and checked with jetshell.
JsonDecoders.nullable's Javadoc said an absent value gives null; it goes to
the inner decoder, as the specification says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…issing column

The docs said withDefault(field(...), x) looks at the enclosing object.
A field has been a CombinePart, not a Decoder, since 0.7.0 (#114), so that
form does not compile; the docs now say so and show a whole-input default
on a decoder of the whole input. The CHANGELOG's migration starts from
the form 0.8.0 accepted, field("x", Decoders.withDefault(d, v)).

The jOOQ advice to accept a missing column with
optionalField(c, string()).map(o -> o.orElse(x)) refused SQL NULL with
required, since optionalField passes NULL to the value decoder. It is now
optionalField(c, withDefault(string(), x)).map(o -> o.orElse(x)), and
JooqDecoderTest pins what each composition does with a missing column,
SQL NULL and a value.

The withDefault spec-case tests use the specification's own fixtures:
defaults 0/1 for R000824-R000826 and 7/8 for R000827-R000830.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Running every Java block of the tutorials, README and the other guides
through jetshell found code written against APIs that changed or never
existed, and outputs that went stale, because nothing runs them:

- iso8601().past() / future() (and pastOrPresent / futureOrPresent in the
  README) never existed: a decoder does not read the clock. The tutorial
  now compares with a time passed in, and the README says why.
- README's localDateTime() is dateTime().
- A single field(...) is a CombinePart, so variant(...) of one needs
  asDecoder(); list(...) of a Map decoder needs nested(...).
- combine(...).map(...) is a Decoder, not a JsonDecoder / MapDecoder, so
  the README, comparisons and composition-patterns declare
  Decoder<JsonNode, T> / Decoder<Map<String, Object>, T>, and
  boundary-modules shows field(...) as a CombinePart.
- Outputs: nonBlank() says "must not be blank", the list constraint and
  strict messages changed wording, oneOf fails at /contacts/0 with "no
  variant matched", and bytes() returns the array it was given.
- Fragments that read as JDK types (Period::parse, Currency) name their
  own types.

CLAUDE.md's jetshell notes said MapDecoders re-exports string() and that
nonBlank() gives required; both are wrong. The template now imports
ObjectDecoders, and the gotchas cover CombinePart vs Decoder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kawasima
kawasima merged commit 513f372 into develop Oct 1, 2026
3 checks passed
@kawasima
kawasima deleted the feature/issue-164 branch October 1, 2026 12:17
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