fix: give withDefault's default for a null or absent input, from the boundary (#164) - #182
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #164.
Cause
Decoders.withDefault(dec, x)randecand gavexwhen every issue it returned had the coderequired, wherever those issues were. It was a weakrecover, reading the result, while the Raoh Specification defineswithDefaultby its input: the default for a JSON null or an absent value, the inner decoder for anything else. Oncedechas run, "the value is absent" and "the value is there but a member inside it is missing" both becomerequiredissues, and nothing can tell them apart afterwards. That gave the four failing cases:{}and{"a":1}againstobject(a, b)were given the default instead of the members'required.nullhanded to an object decoder failed withtype_mismatch, so it was not defaulted.withDefault(nullable(int), 42)gavenull, because the innernullablesaw the input first.shouldUseDefaultwas the only place in the library that branches on an issue code.Change
Decoders.withDefault(both overloads) andshouldUseDefaultare removed. A generic<I, T>combinator cannot know what null or absent means forI, and keeping it would keep a legal but non-conforming call for JSON input.ObjectDecoders.withDefault(dec, x)/(dec, Supplier): the default for a Javanull. AMapfield passesnullboth for an absent key and anullvalue; a jOOQ column holding SQLNULLgivesnull.JsonDecoders.withDefault(dec, x)/(dec, Supplier): the default for a JSONnull(NullNode), theMissingNodethatfieldpasses for a missing member, or a Javanull.decruns and returndec's result as it is otherwise: they never look at aResultor anIssue. The supplier runs only for the default.missing_fieldbyJooqRecordDecoders.fieldbefore the value decoder runs. That is unchanged and now documented onfield. To default both a missing column and SQLNULL, the docs giveoptionalField(c, withDefault(dec, x)).map(o -> o.orElse(x));optionalFieldalone passes SQLNULLto the value decoder, which refuses it withrequired.JooqDecoderTestpins all three compositions against a missing column, SQLNULLand a value.InputPresence<I>abstraction or 3-arg core:withDefaultis a predicate and a delegation, so sharing it would only inject the predicate.JsonDecoders.nullable's Javadoc said an absent value givesnull; it goes to the inner decoder (R000238), so the Javadoc now says so and contrasts it withwithDefault.Docs and examples
tutorial.md,tutorial.ja.md: the default goes inside the field,field("x", withDefault(d, v)). Afield(...)is aCombinePart, not aDecoder(since decode: replace FieldDecoder with explicit CombinePart values #114 in 0.7.0), sowithDefault(field(...), v)does not compile; a whole-input default wraps a decoder of the whole input, such aswithDefault(nested(combine(...).map(...)), v). The semantics are rewritten (input-based, what null/absent is per boundary, the jOOQ distinction), andwithDefaultis no longer listed as aDecoderscombinator. The CHANGELOG migrates fromfield("x", Decoders.withDefault(d, v)), the form 0.8.0 accepted.lazyandrecoversnippets did not compile ondevelopeither: afield(...)is aCombinePart, not aDecoder, so it cannot be passed torecoveror the oldwithDefault. They now putrecover/withDefaultinside the field and use a typed array withnestedfor recursion. All rewritten snippets were run with jetshell; the config example's error output was also stale (must not be blank, notis required).boundary-modules.mdlistswithDefaultfor each module (with the jOOQ note), and bothpackage-infos,MapEncoders, andCONTRIBUTING.mdno longer callwithDefaulta failure-handlingDecoderscombinator.JsonDecoders.withDefault, examples/schema-versioningObjectDecoders.withDefault; both build and pass.Other doc snippets found broken (same cause)
The broken
withDefault/recoversnippets came from nothing running the docs, so every Java block of the tutorials, README,boundary-modules,comparisonsandcomposition-patternswas run through jetshell and its// ==>outputs compared. Fixed here:iso8601().past()/future()(andpastOrPresent/futureOrPresentin the README list) never existed: a decoder does not read the clock. The tutorial compares with a time passed in instead.localDateTime()isdateTime().variant(...)needs.asDecoder();list(...)of a Map decoder needsnested(...).combine(...).map(...)is aDecoder, not aJsonDecoder/MapDecoder; README,comparisonsandcomposition-patternsdeclared it as one.boundary-modulesshowsfield(...)as aCombinePart.nonBlank()messages, list-size and duplicate messages,unknown field, theoneOffailure path,bytes().Period::parseandCurrencyin fragments read as JDK types; they now name their own.string(),nonBlank()givingrequired) were wrong and are corrected.What remains unchecked are fragments that depend on values the reader supplies (
input,json, anOrdertype) andMap.ofoutputs whose order is not fixed.Tests
ObjectDecodersWithDefaultTest(new) andJsonDecoderTest: R000824–R000830 with the specification's own fixtures (defaults 0/1 and 7/8) and R000836–R000839 (JSON viareadTree, Map analogues),MissingNode, a Javanull,nullablevswithDefaulton a missing member, an inner failure returned withassertSame, and the inner/supplier call counts for null, present-Ok and present-Err.DecodersCombinatorTestcases that pinned the old "all issues required" behaviour are removed.isMissingNodeorisNullfrom 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 compileon Zulu 25 and javadoc pass.Performance
ObjectDecoders.withDefaultagainst the oldDecoders.withDefaultaroundint_(), 5M decodes per run, each version in its own JVM: a present value 0.5 ns both;null1.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