From 57f59b1c17b61e7b2c25c543af69255318f53861 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Sun, 1 Nov 2026 10:21:54 -0800 Subject: [PATCH 01/12] Report property name as found in input in deserialization error paths Companion to FasterXML/jackson-databind#6364: Afterburner and Blackbird now report the same name as stock deserializers when a property value fails. Also stops `SuperSonicBeanDeserializer` from wrapping failures of `_deserializeDisordered()` twice, which added a bogus path segment. Co-Authored-By: Claude Opus 5.5 --- .../afterburner/deser/SuperSonicBDBase.java | 4 +- .../deser/SuperSonicBeanDeserializer.java | 43 +++--- .../afterburner/deser/AliasErrorPathTest.java | 127 ++++++++++++++++++ .../blackbird/codegen/ReaderCodegen.java | 2 +- .../internal/GeneratedBuilderBase.java | 32 ++--- .../internal/GeneratedReaderBase.java | 15 ++- .../codegen/BeanReaderGenerator.java | 102 ++++++-------- .../deser/BBCodecStockChecksTest.java | 59 +++++++- 8 files changed, 273 insertions(+), 111 deletions(-) create mode 100644 afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java diff --git a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java index 3701979ec..0f6465724 100644 --- a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java +++ b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java @@ -133,12 +133,14 @@ protected final Object _deserializeDisordered(JsonParser p, DeserializationConte { for (int ix = p.currentNameMatch(_propNameMatcher); ; ix = p.nextNameMatch(_propNameMatcher)) { if (ix >= 0) { + // Report name as found in input (may be alias), as stock does + final String inputName = p.currentName(); p.nextToken(); SettableBeanProperty prop = _propsByIndex[ix]; try { prop.deserializeAndSet(p, ctxt, bean); } catch (Exception e) { - wrapAndThrow(e, bean, prop.getName(), ctxt); + wrapAndThrow(e, bean, inputName, ctxt); } continue; } diff --git a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java index 80a230b05..5e868982a 100644 --- a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java +++ b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java @@ -134,10 +134,11 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt) } } } - return _deserializeDisordered(p, ctxt, bean); } catch (Exception e) { wrapAndThrow(e, bean, prop.getName(), ctxt); } + // Outside of `try`: reports its own failures, must not get wrapped again + return _deserializeDisordered(p, ctxt, bean); } while (false); for (int i = 4, len = _orderedProperties.length; i < len; ++i) { @@ -187,16 +188,17 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean } // We also we have at least 6 properties, so roll out first few SettableBeanProperty prop = _orderedProperties[0]; + // Outside of `try`: reports its own failures, must not get wrapped again + if (p.isExpectedStartObjectToken()) { + if (!p.nextName(_orderedPropertyNames[0])) { + return _deserializeDisordered(p, ctxt, bean); + } + } else if (!p.hasToken(JsonToken.PROPERTY_NAME) + || !prop.getName().equals(p.currentName())) { + return _deserializeDisordered(p, ctxt, bean); + } do { try { - if (p.isExpectedStartObjectToken()) { - if (!p.nextName(_orderedPropertyNames[0])) { - return _deserializeDisordered(p, ctxt, bean); - } - } else if (!p.hasToken(JsonToken.PROPERTY_NAME) - || !prop.getName().equals(p.currentName())) { - return _deserializeDisordered(p, ctxt, bean); - } p.nextToken(); prop.deserializeAndSet(p, ctxt, bean); if (p.nextName(_orderedPropertyNames[1])) { @@ -215,10 +217,11 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean } } } - return _deserializeDisordered(p, ctxt, bean); } catch (Exception e) { wrapAndThrow(e, bean, prop.getName(), ctxt); } + // Outside of `try`: reports its own failures, must not get wrapped again + return _deserializeDisordered(p, ctxt, bean); } while (false); // then rest of properties @@ -291,16 +294,17 @@ public Object deserializeFromObject(JsonParser p, DeserializationContext ctxt) } // We also we have at least 6 properties, so roll out first few SettableBeanProperty prop = _orderedProperties[0]; + // Outside of `try`: reports its own failures, must not get wrapped again + if (p.isExpectedStartObjectToken()) { + if (!p.nextName(_orderedPropertyNames[0])) { + return _deserializeDisordered(p, ctxt, bean); + } + } else if (!p.hasToken(JsonToken.PROPERTY_NAME) + || !prop.getName().equals(p.currentName())) { + return _deserializeDisordered(p, ctxt, bean); + } do { try { - if (p.isExpectedStartObjectToken()) { - if (!p.nextName(_orderedPropertyNames[0])) { - return _deserializeDisordered(p, ctxt, bean); - } - } else if (!p.hasToken(JsonToken.PROPERTY_NAME) - || !prop.getName().equals(p.currentName())) { - return _deserializeDisordered(p, ctxt, bean); - } p.nextToken(); prop.deserializeAndSet(p, ctxt, bean); if (p.nextName(_orderedPropertyNames[1])) { @@ -319,10 +323,11 @@ public Object deserializeFromObject(JsonParser p, DeserializationContext ctxt) } } } - return _deserializeDisordered(p, ctxt, bean); } catch (Exception e) { wrapAndThrow(e, bean, prop.getName(), ctxt); } + // Outside of `try`: reports its own failures, must not get wrapped again + return _deserializeDisordered(p, ctxt, bean); } while (false); // then rest of properties diff --git a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java new file mode 100644 index 000000000..7124e043b --- /dev/null +++ b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java @@ -0,0 +1,127 @@ +package tools.jackson.module.afterburner.deser; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonAlias; + +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.databind.*; +import tools.jackson.databind.exc.InvalidFormatException; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.module.afterburner.AfterburnerModule; +import tools.jackson.module.afterburner.AfterburnerTestBase; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Path of an exception for a failing property must name the property as found + * in input (which may be an alias), and only once. + */ +public class AliasErrorPathTest extends AfterburnerTestBase +{ + // Up to 6 properties: SuperSonicUnrolledDeserializer + public static class SmallBean { + @JsonAlias("a1") public int a; + @JsonAlias("b1") public int b; + } + + // More than 6 properties: SuperSonicBeanDeserializer + public static class BigBean { + @JsonAlias("a1") public int a; + @JsonAlias("b1") public int b; + public int c, d, e, f; + @JsonAlias("g1") public int g; + } + + // Afterburner's optimized deserializers need "vanilla" processing + private final ObjectMapper MAPPER = JsonMapper.builder() + .addModule(new AfterburnerModule()) + .enable(MapperFeature.DEFAULT_VIEW_INCLUSION) + .build(); + + @Test + public void unrolledAlias() throws Exception { + _verifyPath(SmallBean.class, """ + {"a1":"bad"} + """, "a1"); + _verifyPath(SmallBean.class, """ + {"a":1,"b1":"bad"} + """, "b1"); + } + + @Test + public void unrolledCanonical() throws Exception { + _verifyPath(SmallBean.class, """ + {"a":"bad"} + """, "a"); + } + + @Test + public void orderedAlias() throws Exception { + _verifyPath(BigBean.class, """ + {"a1":"bad"} + """, "a1"); + _verifyPath(BigBean.class, """ + {"a":1,"b1":"bad"} + """, "b1"); + _verifyPath(BigBean.class, """ + {"a":1,"b":2,"c":3,"d":4,"e":5,"f":6,"g1":"bad"} + """, "g1"); + } + + @Test + public void orderedCanonical() throws Exception { + _verifyPath(BigBean.class, """ + {"a":"bad"} + """, "a"); + _verifyPath(BigBean.class, """ + {"a":1,"b":2,"c":3,"d":4,"e":5,"f":6,"g":"bad"} + """, "g"); + } + + @Test + public void orderedForUpdate() throws Exception { + _verifyPath(MAPPER.readerForUpdating(new BigBean()), """ + {"a1":"bad"} + """, "a1"); + _verifyPath(MAPPER.readerForUpdating(new BigBean()), """ + {"a":1,"b1":"bad"} + """, "b1"); + } + + // Parser already pointing to the first property name + @Test + public void orderedFromPropertyName() throws Exception { + try (JsonParser p = MAPPER.createParser(""" + {"a1":"bad"} + """)) { + assertToken(JsonToken.START_OBJECT, p.nextToken()); + assertToken(JsonToken.PROPERTY_NAME, p.nextToken()); + InvalidFormatException e = assertThrows(InvalidFormatException.class, + () -> MAPPER.readerFor(BigBean.class).readValue(p)); + _verifyException(e, "a1"); + } + } + + private void _verifyPath(Class type, String json, String expName) { + _verifyPath(MAPPER.readerFor(type), json, expName); + } + + private void _verifyPath(ObjectReader r, String json, String expName) { + _verifyException(assertThrows(InvalidFormatException.class, () -> r.readValue(json)), + expName); + } + + private void _verifyException(InvalidFormatException e, String expName) { + assertEquals(List.of(expName), e.getPath().stream() + .map(ref -> ref.getPropertyName()) + .toList()); + assertTrue(Arrays.stream(e.getStackTrace()).anyMatch( + f -> f.getClassName().contains("SuperSonic")), + "should go through Afterburner's deserializer"); + } +} diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/ReaderCodegen.java b/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/ReaderCodegen.java index 8b74ccc74..2f37b9a2c 100644 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/ReaderCodegen.java +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/ReaderCodegen.java @@ -97,7 +97,7 @@ public static Construction record(MethodHandle ctor) { * only), which name the component locals in the debug table. * inputNames tells whether a matched name can differ from the * property's own name (aliases, case-insensitive matching), which - * builder-mode error messages then need. + * error paths then need. * vanillaLoop (POJO mode only) tells whether stock reads the bean with * its vanilla loop, which ends quietly on an odd token after an unknown * name. diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedBuilderBase.java b/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedBuilderBase.java index 907197137..9185dee37 100644 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedBuilderBase.java +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedBuilderBase.java @@ -59,35 +59,21 @@ protected final Object _finishBuild(DeserializationContext ctxt, Object builder) return finishBuild(ctxt, builder); } - // The property name that stock reports for a failing property: its - // general loop (deserializeFromObject) reports the name as written in - // the input (an alias, another case), its vanilla loop and view loop - // the property's own name. inputName is the name the generated arm read, - // or null for a bean without aliases or case-insensitive matching, where - // the two always agree; startObject tells whether the call entered on - // START_OBJECT (the stock vanilla loop's entry) or on a property name. - // Resolved here, on the failure path, so the entry pays nothing for it. - private String _failedName(SettableBeanProperty prop, String inputName, - boolean startObject, DeserializationContext ctxt) { - if ((inputName == null) - || (_vanillaProcessing && startObject) - || (_needViewProcesing && ctxt.getActiveView() != null)) { - return prop.getName(); - } - return inputName; - } - + // As in stock, a failing property is reported under the name as written + // in the input (an alias, another case). inputName is the name the + // generated arm read, or null for a bean without aliases or + // case-insensitive matching, where it always is the property's own name; + // resolved here, so the arms pay nothing for such beans. protected final RuntimeException _propertyException(Throwable t, Object builder, - SettableBeanProperty prop, String inputName, boolean startObject, - DeserializationContext ctxt) { - throw wrapAndThrow(t, builder, _failedName(prop, inputName, startObject, ctxt), ctxt); + SettableBeanProperty prop, String inputName, DeserializationContext ctxt) { + throw wrapAndThrow(t, builder, _failedPropertyName(inputName, prop), ctxt); } protected final RuntimeException _storeFailure(Throwable t, Object builder, Object childValue, SettableBeanProperty prop, JsonParser p, - String inputName, boolean startObject, DeserializationContext ctxt) { + String inputName, DeserializationContext ctxt) { throw wrapAndThrow(ReaderSupport.storeException(t, childValue, prop, p), builder, - _failedName(prop, inputName, startObject, ctxt), ctxt); + _failedPropertyName(inputName, prop), ctxt); } protected final void _injectValues(DeserializationContext ctxt, Object builder) { diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedReaderBase.java b/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedReaderBase.java index 34ef61ab7..15500eb95 100644 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedReaderBase.java +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedReaderBase.java @@ -94,21 +94,24 @@ protected final Object _instantiationFailure(DeserializationContext ctxt, Throwa // Called from the generated exception handler that covers every property // arm: the stock loop's wrapAndThrow, so property errors carry the - // reference path. Always throws; the return type only satisfies the - // generated athrow. + // reference path. inputName is the name the generated arm read, or null + // for a bean without aliases or case-insensitive matching, where it always + // is the property's own name; resolved here, so the arms pay nothing for + // such beans. Always throws; the return type only satisfies the generated + // athrow. protected final RuntimeException _propertyException(Throwable t, Object beanOrNull, - SettableBeanProperty prop, DeserializationContext ctxt) { + SettableBeanProperty prop, String inputName, DeserializationContext ctxt) { Object ref = (beanOrNull == null) ? handledType() : beanOrNull; - throw wrapAndThrow(t, ref, prop.getName(), ctxt); + throw wrapAndThrow(t, ref, _failedPropertyName(inputName, prop), ctxt); } // A generated store (setter call or field write) threw: what the stock // property throws for it, through the stock loop's wrapAndThrow. protected final RuntimeException _storeFailure(Throwable t, Object bean, Object childValue, SettableBeanProperty prop, JsonParser p, - DeserializationContext ctxt) { + String inputName, DeserializationContext ctxt) { throw wrapAndThrow(ReaderSupport.storeException(t, childValue, prop, p), bean, - prop.getName(), ctxt); + _failedPropertyName(inputName, prop), ctxt); } /* diff --git a/blackbird/src/main/java25/tools/jackson/module/blackbird/codegen/BeanReaderGenerator.java b/blackbird/src/main/java25/tools/jackson/module/blackbird/codegen/BeanReaderGenerator.java index 8f817a81a..d6242cddb 100644 --- a/blackbird/src/main/java25/tools/jackson/module/blackbird/codegen/BeanReaderGenerator.java +++ b/blackbird/src/main/java25/tools/jackson/module/blackbird/codegen/BeanReaderGenerator.java @@ -121,19 +121,13 @@ final class BeanReaderGenerator CD_DESER_CONTEXT, CD_SETTABLE_PROP, CD_FORWARD_REF, CD_OBJECT); private static final MethodTypeDesc MTD_CREATION_FAILURE = MethodTypeDesc.of(CD_OBJECT, CD_DESER_CONTEXT, CD_THROWABLE); + // Input name (or null, see Names) comes before the DeserializationContext. private static final MethodTypeDesc MTD_PROP_WRAP = MethodTypeDesc.of( - CD_RTE, CD_THROWABLE, CD_OBJECT, CD_SETTABLE_PROP, CD_DESER_CONTEXT); - private static final MethodTypeDesc MTD_STORE_FAILURE = MethodTypeDesc.of(CD_RTE, - CD_THROWABLE, CD_OBJECT, CD_OBJECT, CD_SETTABLE_PROP, CD_JSON_PARSER, - CD_DESER_CONTEXT); - // Builder codecs: the same with the input name and the entry kind (see - // GeneratedBuilderBase._failedName) before the DeserializationContext. - private static final MethodTypeDesc MTD_BUILDER_PROP_WRAP = MethodTypeDesc.of( CD_RTE, CD_THROWABLE, CD_OBJECT, CD_SETTABLE_PROP, ConstantDescs.CD_String, - ConstantDescs.CD_boolean, CD_DESER_CONTEXT); - private static final MethodTypeDesc MTD_BUILDER_STORE_FAILURE = MethodTypeDesc.of(CD_RTE, + CD_DESER_CONTEXT); + private static final MethodTypeDesc MTD_STORE_FAILURE = MethodTypeDesc.of(CD_RTE, CD_THROWABLE, CD_OBJECT, CD_OBJECT, CD_SETTABLE_PROP, CD_JSON_PARSER, - ConstantDescs.CD_String, ConstantDescs.CD_boolean, CD_DESER_CONTEXT); + ConstantDescs.CD_String, CD_DESER_CONTEXT); private static final MethodTypeDesc MTD_IS_ENABLED = MethodTypeDesc.of(ConstantDescs.CD_boolean, CD_DESER_FEATURE); private static final MethodTypeDesc MTD_GET_ACTIVE_VIEW = @@ -344,18 +338,15 @@ private static void buildDeserialize(CodeBuilder cob, ReaderCodegen.Plan plan) { final int excSlot = 7; final int maskSlot = 8; final int valueSlot = 10; - // Builder mode with names that can differ from the property's own - // (see GeneratedBuilderBase._failedName): each arm reads the input - // name, and the first match records the entry token. - final boolean inputNames = builderMode && plan.inputNames(); + // Names that can differ from the property's own: each arm reads the + // input name, for its failure handlers. + final boolean inputNames = plan.inputNames(); final int inputNameSlot = 11; - final int startObjectSlot = 12; // Set once a name has gone to unknown-name handling; tracked only // where stock reads with its vanilla loop. final boolean trackUnknown = plan.vanillaLoop(); - final int afterUnknownSlot = 13; - final Names names = !builderMode ? null - : inputNames ? new Names(inputNameSlot, startObjectSlot) : Names.NONE; + final int afterUnknownSlot = 12; + final Names names = inputNames ? new Names(inputNameSlot) : Names.NONE; Label propHandler = cob.newLabel(); Label storeHandler = cob.newLabel(); @@ -425,7 +416,7 @@ private static void buildDeserialize(CodeBuilder cob, ReaderCodegen.Plan plan) { Label oddToken = cob.newLabel(); Label defaultCase = cob.newLabel(); - emitFirstMatch(cob, parser, matcherSlot, ixSlot, inputNames ? startObjectSlot : -1); + emitFirstMatch(cob, parser, matcherSlot, ixSlot); Label[] caseLabels = emitLoopHead(cob, plan, ixSlot, loop, endObject, unknown, oddToken, defaultCase); @@ -436,9 +427,7 @@ private static void buildDeserialize(CodeBuilder cob, ReaderCodegen.Plan plan) { plan.stockName()[i], loop); } if (inputNames) { - // As stock's general loop: the name of the value token. - cob.aload(parser).invokevirtual(CD_JSON_PARSER, "currentName", MTD_GET_STRING); - cob.astore(inputNameSlot); + emitReadInputName(cob, parser, inputNameSlot); } Label armEnd = beginArm(cob, plan.stockName()[i], propSlot, propHandler); GenProp prop = props.get(i); @@ -509,14 +498,9 @@ private static void buildDeserialize(CodeBuilder cob, ReaderCodegen.Plan plan) { cob.astore(excSlot); cob.aload(0).aload(excSlot).aload(beanSlot).aload(valueSlot).aload(propSlot) .aload(parser); - if (builderMode) { - names.load(cob); - cob.aload(ctxt); - cob.invokevirtual(base, "_storeFailure", MTD_BUILDER_STORE_FAILURE); - } else { - cob.aload(ctxt); - cob.invokevirtual(base, "_storeFailure", MTD_STORE_FAILURE); - } + names.load(cob); + cob.aload(ctxt); + cob.invokevirtual(base, "_storeFailure", MTD_STORE_FAILURE); cob.athrow(); } if (ctorHandler != null) { @@ -548,8 +532,6 @@ private static void buildDeserialize(CodeBuilder cob, ReaderCodegen.Plan plan) { if (inputNames) { cob.localVariable(inputNameSlot, "inputName", ConstantDescs.CD_String, scopeStart, scopeEnd); - cob.localVariable(startObjectSlot, "startObject", ConstantDescs.CD_boolean, - scopeStart, scopeEnd); } if (trackUnknown) { cob.localVariable(afterUnknownSlot, "afterUnknown", ConstantDescs.CD_boolean, @@ -744,6 +726,10 @@ private static void buildRecordDeserialize(CodeBuilder cob, ReaderCodegen.Plan p final int beanSlot = next++; final int pendingSlot = next++; final int referringsSlot = next++; + // Names that can differ from the component's own: see the POJO loop. + final boolean inputNames = plan.inputNames(); + final int inputNameSlot = next++; + final Names names = inputNames ? new Names(inputNameSlot) : Names.NONE; // Primitive components: the stock value when it is not exactly the // wrapper (see emitStockValueToLocal). int[] rawSlot = new int[n]; @@ -789,7 +775,7 @@ private static void buildRecordDeserialize(CodeBuilder cob, ReaderCodegen.Plan p Label oddToken = cob.newLabel(); Label defaultCase = cob.newLabel(); - emitFirstMatch(cob, parser, matcherSlot, ixSlot, -1); + emitFirstMatch(cob, parser, matcherSlot, ixSlot); Label[] caseLabels = emitLoopHead(cob, plan, ixSlot, loop, endObject, unknown, oddToken, defaultCase); @@ -804,6 +790,9 @@ private static void buildRecordDeserialize(CodeBuilder cob, ReaderCodegen.Plan p emitArmViewCheck(cob, base, parser, ctxt, maskSlot, matcherSlot, ixSlot, i, plan.stockName()[i], loop); } + if (inputNames) { + emitReadInputName(cob, parser, inputNameSlot); + } GenProp prop = props.get(i); // A stock component value may be a forward Object Id reference, // which the stock loop catches; the region registers before the @@ -979,7 +968,7 @@ private static void buildRecordDeserialize(CodeBuilder cob, ReaderCodegen.Plan p cob.labelBinding(oddToken); cob.goto_(endObject); - emitPropertyHandler(cob, base, propHandler, ctxt, propSlot, excSlot, -1, null); + emitPropertyHandler(cob, base, propHandler, ctxt, propSlot, excSlot, -1, names); emitDelegateTail(cob, base, parser, ctxt, delegate); Label scopeEnd = cob.newBoundLabel(); @@ -1182,23 +1171,28 @@ private static Label beginArm(CodeBuilder cob, String stockName, int propSlot, L return armEnd; } - // The builder failure handlers' name arguments: the locals holding the - // input name and the entry kind, or null and false where the names - // cannot differ (NONE). - private record Names(int inputNameSlot, int startObjectSlot) { - static final Names NONE = new Names(-1, -1); + // As stock's loops: the name of the value token, which arrives after + // nextNameMatchAndToken has already moved past the name. + private static void emitReadInputName(CodeBuilder cob, int parser, int inputNameSlot) { + cob.aload(parser).invokevirtual(CD_JSON_PARSER, "currentName", MTD_GET_STRING); + cob.astore(inputNameSlot); + } + + // The failure handlers' name argument: the local holding the input name, + // or null where the names cannot differ (NONE). + private record Names(int inputNameSlot) { + static final Names NONE = new Names(-1); void load(CodeBuilder cob) { if (inputNameSlot < 0) { - cob.aconst_null().iconst_0(); + cob.aconst_null(); } else { - cob.aload(inputNameSlot).iload(startObjectSlot); + cob.aload(inputNameSlot); } } } - // receiverSlot < 0 selects aconst_null (record mode: no instance yet); - // non-null names (builder mode) passes the name arguments. + // receiverSlot < 0 selects aconst_null (record mode: no instance yet). private static void emitPropertyHandler(CodeBuilder cob, ClassDesc base, Label handler, int ctxt, int propSlot, int excSlot, int receiverSlot, Names names) { @@ -1212,14 +1206,9 @@ private static void emitPropertyHandler(CodeBuilder cob, ClassDesc base, Label h cob.aload(receiverSlot); } cob.aload(propSlot); - if (names != null) { - names.load(cob); - cob.aload(ctxt); - cob.invokevirtual(base, "_propertyException", MTD_BUILDER_PROP_WRAP); - } else { - cob.aload(ctxt); - cob.invokevirtual(base, "_propertyException", MTD_PROP_WRAP); - } + names.load(cob); + cob.aload(ctxt); + cob.invokevirtual(base, "_propertyException", MTD_PROP_WRAP); cob.athrow(); } @@ -1238,23 +1227,16 @@ private static void nextNameMatch(CodeBuilder cob, int parser, int matcherSlot, // the current token, so a matched name entry advances to its value here; // fusing the entry itself would double-advance (the current name is // already consumed). Negative results stay on the name, as the unknown - // arm expects. startObjectSlot >= 0 records which entry was taken. - private static void emitFirstMatch(CodeBuilder cob, int parser, int matcherSlot, int ixSlot, - int startObjectSlot) { + // arm expects. + private static void emitFirstMatch(CodeBuilder cob, int parser, int matcherSlot, int ixSlot) { Label nameEntry = cob.newLabel(); Label done = cob.newLabel(); cob.aload(parser).invokevirtual(CD_JSON_PARSER, "currentToken", MTD_NEXT_TOKEN); cob.getstatic(CD_JSON_TOKEN, "PROPERTY_NAME", CD_JSON_TOKEN); cob.if_acmpeq(nameEntry); - if (startObjectSlot >= 0) { - cob.iconst_1().istore(startObjectSlot); - } nextNameMatch(cob, parser, matcherSlot, ixSlot); cob.goto_(done); cob.labelBinding(nameEntry); - if (startObjectSlot >= 0) { - cob.iconst_0().istore(startObjectSlot); - } cob.aload(parser).aload(matcherSlot) .invokevirtual(CD_JSON_PARSER, "currentNameMatch", MTD_NEXT_NAME_MATCH) .istore(ixSlot); diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecStockChecksTest.java b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecStockChecksTest.java index fb484c36d..8f756d9c6 100644 --- a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecStockChecksTest.java +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecStockChecksTest.java @@ -18,6 +18,7 @@ import tools.jackson.core.JsonParser; import tools.jackson.databind.DeserializationContext; import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.MapperFeature; import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.ValueDeserializer; import tools.jackson.databind.annotation.JsonDeserialize; @@ -180,6 +181,21 @@ public static class RefDoc { public List items; } + // A POJO property with an alias; a negative value fails in the setter. + public static class AliasedPojo { + int v; + + @JsonAlias("alt") + public void setV(int v) { + if (v < 0) { + throw new IllegalArgumentException("negative"); + } + this.v = v; + } + } + + public record AliasedRecord(@JsonAlias("alt") int v) { } + private static final ObjectMapper VANILLA = newVanillaJSONMapper(); private static final ObjectMapper MODULE = newObjectMapper(); @@ -298,8 +314,49 @@ public Object handleInstantiationProblem(DeserializationContext ctxt, r -> r.ref().get().name + "/" + r.items().get(0).name + "/" + r.tag()); } + // As builder properties below: POJO properties and record components are + // reported under the name in the input, with default settings and with + // those for stock's vanilla loop. + @Test + public void testPojoAndRecordFailureReportsInputName() throws Exception { + _assertFailureNames(VANILLA, MODULE); + _assertFailureNames( + JsonMapper.builder().enable(MapperFeature.DEFAULT_VIEW_INCLUSION).build(), + blackbirdMapperBuilder().enable(MapperFeature.DEFAULT_VIEW_INCLUSION).build()); + } + + @Test + public void testFailureUnderCaseInsensitiveMapper() throws Exception { + ObjectMapper vanilla = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES).build(); + ObjectMapper module = blackbirdMapperBuilder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES).build(); + _assertSameFailure(vanilla, module, "{\"V\":-1}", AliasedPojo.class, "V"); + _assertSameFailure(vanilla, module, "{\"V\":\"x\"}", AliasedRecord.class, "V"); + } + + private void _assertFailureNames(ObjectMapper vanilla, ObjectMapper module) throws Exception { + assertGeneratedReader(module, AliasedPojo.class); + assertGeneratedReader(module, AliasedRecord.class); + // Scalar read, then setter (the store handler) + _assertSameFailure(vanilla, module, "{\"alt\":\"x\"}", AliasedPojo.class, "alt"); + _assertSameFailure(vanilla, module, "{\"alt\":-1}", AliasedPojo.class, "alt"); + _assertSameFailure(vanilla, module, "{\"v\":-1}", AliasedPojo.class, "v"); + _assertSameFailure(vanilla, module, "{\"alt\":\"x\"}", AliasedRecord.class, "alt"); + _assertSameFailure(vanilla, module, "{\"v\":\"x\"}", AliasedRecord.class, "v"); + } + + private void _assertSameFailure(ObjectMapper vanilla, ObjectMapper module, + String doc, Class type, String expName) { + Exception expected = assertThrows(Exception.class, () -> vanilla.readValue(doc, type)); + Exception actual = assertThrows(Exception.class, () -> module.readValue(doc, type)); + assertEquals(expected.getClass(), actual.getClass(), doc); + assertEquals(expected.getMessage(), actual.getMessage(), doc); + assertTrue(actual.getMessage().contains("[\"" + expName + "\"]"), actual.getMessage()); + } + // A failing builder property is reported under the name in the input, - // as stock's general builder loop does: here the alias. + // as stock does: here the alias. @Test public void testBuilderFailureReportsInputName() throws Exception { assertGeneratedReader(MODULE, Aliased.class); From a121cda86d094df1f92754d3a3179367ba0d4947 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Sun, 1 Nov 2026 10:22:11 -0800 Subject: [PATCH 02/12] Add release notes for #368 Co-Authored-By: Claude Opus 5.5 --- release-notes/VERSION | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/release-notes/VERSION b/release-notes/VERSION index 07209d008..e28ee2a1c 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -43,6 +43,10 @@ Active maintainers: `tools.jackson.module.blackbird`, as the JPMS module does. (contributed by @stevenschlansker) #362: Bump `byte-buddy` dependency from 1.17.8 to 1.18.15 +#368: (afterburner, blackbird) Report property name as found in input (alias or + different case), not canonical name, in deserialization error paths; (afterburner) + no more duplicate path segment for failures with out-of-order properties + (fix by @cowtowncoder, w/ Claude code) 3.2.5 (not yet released) From 755be3bfd25c1fa3ab29bd85e51df5db1adaffd8 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Sun, 1 Nov 2026 10:50:44 -0800 Subject: [PATCH 03/12] Afterburner: keep parser failures out of property error paths Reading of property names and value tokens is no longer within `try` blocks that blame a property, so parser failures have no path, as with stock deserializers. Applies to both `SuperSonicBeanDeserializer` and `SuperSonicUnrolledDeserializer`. `_deserializeDisordered()` only reads the input name for beans with aliases or case-insensitive matching, at the value token, and goes through `_failedPropertyName()`, same as stock and Blackbird. Tests now also cover `deserializeFromObject()` and update from a property name, which `orderedFromPropertyName` did not reach, plus case-insensitive matching, structured values, unknown properties and problem handler failures. Co-Authored-By: Claude Opus 5.5 --- .../afterburner/deser/SuperSonicBDBase.java | 26 +- .../deser/SuperSonicBeanDeserializer.java | 143 +++------ .../deser/SuperSonicUnrolledDeserializer.java | 294 +++++++----------- .../afterburner/deser/AliasErrorPathTest.java | 219 ++++++++++++- release-notes/VERSION | 5 +- 5 files changed, 399 insertions(+), 288 deletions(-) diff --git a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java index 0f6465724..4c1617154 100644 --- a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java +++ b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java @@ -127,20 +127,40 @@ public void resolve(DeserializationContext ctxt) // public final Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean) throws IOException;; // public final Object deserializeFromObject(JsonParser p, DeserializationContext ctxt) throws IOException; + /** + * Reads and sets the value of a property matched by expected order, with parser + * pointing to its name. Name matched exactly, so logical name is the input name. + * Only failures of the value get the property in their path: as with stock + * deserializers, parser failures for the next token or next name do not. + */ + protected final void _readAndSet(JsonParser p, DeserializationContext ctxt, + Object bean, SettableBeanProperty prop) + throws JacksonException + { + p.nextToken(); + try { + prop.deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + wrapAndThrow(e, bean, prop.getName(), ctxt); + } + } + protected final Object _deserializeDisordered(JsonParser p, DeserializationContext ctxt, Object bean) throws JacksonException { + // Input name can only differ from logical one with these + final boolean inputNames = _beanProperties.hasAliases() || _beanProperties.isCaseInsensitive(); for (int ix = p.currentNameMatch(_propNameMatcher); ; ix = p.nextNameMatch(_propNameMatcher)) { if (ix >= 0) { - // Report name as found in input (may be alias), as stock does - final String inputName = p.currentName(); p.nextToken(); + // Report name as found in input, read at the value token as stock does + final String inputName = inputNames ? p.currentName() : null; SettableBeanProperty prop = _propsByIndex[ix]; try { prop.deserializeAndSet(p, ctxt, bean); } catch (Exception e) { - wrapAndThrow(e, bean, inputName, ctxt); + wrapAndThrow(e, bean, _failedPropertyName(inputName, prop), ctxt); } continue; } diff --git a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java index 5e868982a..9aff94792 100644 --- a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java +++ b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java @@ -111,38 +111,24 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt) } // We also we have at least 6 properties, so roll out first few - SettableBeanProperty prop = _orderedProperties[0]; - do { - try { - if (p.nextName(_orderedPropertyNames[0])) { - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - if (p.nextName(_orderedPropertyNames[1])) { - prop = _orderedProperties[1]; - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - if (p.nextName(_orderedPropertyNames[2])) { - prop = _orderedProperties[2]; - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - if (p.nextName(_orderedPropertyNames[3])) { - prop = _orderedProperties[3]; - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - break; // yay! We did it! - } - } - } - } - } catch (Exception e) { - wrapAndThrow(e, bean, prop.getName(), ctxt); - } - // Outside of `try`: reports its own failures, must not get wrapped again + if (!p.nextName(_orderedPropertyNames[0])) { + return _deserializeDisordered(p, ctxt, bean); + } + _readAndSet(p, ctxt, bean, _orderedProperties[0]); + if (!p.nextName(_orderedPropertyNames[1])) { + return _deserializeDisordered(p, ctxt, bean); + } + _readAndSet(p, ctxt, bean, _orderedProperties[1]); + if (!p.nextName(_orderedPropertyNames[2])) { return _deserializeDisordered(p, ctxt, bean); - } while (false); + } + _readAndSet(p, ctxt, bean, _orderedProperties[2]); + if (!p.nextName(_orderedPropertyNames[3])) { + return _deserializeDisordered(p, ctxt, bean); + } + _readAndSet(p, ctxt, bean, _orderedProperties[3]); for (int i = 4, len = _orderedProperties.length; i < len; ++i) { - prop = _orderedProperties[i]; if (!p.nextName(_orderedPropertyNames[i])) { // miss... if (p.currentToken() == JsonToken.END_OBJECT) { return bean; @@ -150,12 +136,7 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt) // we likely point to FIELD_NAME still; offline return _deserializeDisordered(p, ctxt, bean); } - p.nextToken(); // skip field, returns value token - try { - prop.deserializeAndSet(p, ctxt, bean); - } catch (Exception e) { - wrapAndThrow(e, bean, prop.getName(), ctxt); - } + _readAndSet(p, ctxt, bean, _orderedProperties[i]); } // also, need to ensure we get closing END_OBJECT... if (p.nextToken() != JsonToken.END_OBJECT) { @@ -188,7 +169,6 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean } // We also we have at least 6 properties, so roll out first few SettableBeanProperty prop = _orderedProperties[0]; - // Outside of `try`: reports its own failures, must not get wrapped again if (p.isExpectedStartObjectToken()) { if (!p.nextName(_orderedPropertyNames[0])) { return _deserializeDisordered(p, ctxt, bean); @@ -197,32 +177,19 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean || !prop.getName().equals(p.currentName())) { return _deserializeDisordered(p, ctxt, bean); } - do { - try { - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - if (p.nextName(_orderedPropertyNames[1])) { - prop = _orderedProperties[1]; - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - if (p.nextName(_orderedPropertyNames[2])) { - prop = _orderedProperties[2]; - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - if (p.nextName(_orderedPropertyNames[3])) { - prop = _orderedProperties[3]; - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - break; // yay! We did it! - } - } - } - } catch (Exception e) { - wrapAndThrow(e, bean, prop.getName(), ctxt); - } - // Outside of `try`: reports its own failures, must not get wrapped again + _readAndSet(p, ctxt, bean, prop); + if (!p.nextName(_orderedPropertyNames[1])) { return _deserializeDisordered(p, ctxt, bean); - } while (false); + } + _readAndSet(p, ctxt, bean, _orderedProperties[1]); + if (!p.nextName(_orderedPropertyNames[2])) { + return _deserializeDisordered(p, ctxt, bean); + } + _readAndSet(p, ctxt, bean, _orderedProperties[2]); + if (!p.nextName(_orderedPropertyNames[3])) { + return _deserializeDisordered(p, ctxt, bean); + } + _readAndSet(p, ctxt, bean, _orderedProperties[3]); // then rest of properties for (int i = 4, len = _orderedProperties.length; i < len; ++i) { @@ -233,13 +200,7 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean // we likely point to FIELD_NAME, so can just call parent impl return super.deserialize(p, ctxt, bean); } - prop = _orderedProperties[i]; - p.nextToken(); // skip field, returns value token - try { - prop.deserializeAndSet(p, ctxt, bean); - } catch (Exception e) { - wrapAndThrow(e, bean, prop.getName(), ctxt); - } + _readAndSet(p, ctxt, bean, _orderedProperties[i]); } // also, need to ensure we get closing END_OBJECT... if (p.nextToken() != JsonToken.END_OBJECT) { @@ -294,7 +255,6 @@ public Object deserializeFromObject(JsonParser p, DeserializationContext ctxt) } // We also we have at least 6 properties, so roll out first few SettableBeanProperty prop = _orderedProperties[0]; - // Outside of `try`: reports its own failures, must not get wrapped again if (p.isExpectedStartObjectToken()) { if (!p.nextName(_orderedPropertyNames[0])) { return _deserializeDisordered(p, ctxt, bean); @@ -303,32 +263,19 @@ public Object deserializeFromObject(JsonParser p, DeserializationContext ctxt) || !prop.getName().equals(p.currentName())) { return _deserializeDisordered(p, ctxt, bean); } - do { - try { - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - if (p.nextName(_orderedPropertyNames[1])) { - prop = _orderedProperties[1]; - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - if (p.nextName(_orderedPropertyNames[2])) { - prop = _orderedProperties[2]; - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - if (p.nextName(_orderedPropertyNames[3])) { - prop = _orderedProperties[3]; - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - break; // yay! We did it! - } - } - } - } catch (Exception e) { - wrapAndThrow(e, bean, prop.getName(), ctxt); - } - // Outside of `try`: reports its own failures, must not get wrapped again + _readAndSet(p, ctxt, bean, prop); + if (!p.nextName(_orderedPropertyNames[1])) { return _deserializeDisordered(p, ctxt, bean); - } while (false); + } + _readAndSet(p, ctxt, bean, _orderedProperties[1]); + if (!p.nextName(_orderedPropertyNames[2])) { + return _deserializeDisordered(p, ctxt, bean); + } + _readAndSet(p, ctxt, bean, _orderedProperties[2]); + if (!p.nextName(_orderedPropertyNames[3])) { + return _deserializeDisordered(p, ctxt, bean); + } + _readAndSet(p, ctxt, bean, _orderedProperties[3]); // then rest of properties for (int i = 4, len = _orderedProperties.length; i < len; ++i) { @@ -339,13 +286,7 @@ public Object deserializeFromObject(JsonParser p, DeserializationContext ctxt) // we likely point to FIELD_NAME, so can just call parent impl return super.deserialize(p, ctxt, bean); } - prop = _orderedProperties[i]; - p.nextToken(); // skip field, returns value token - try { - prop.deserializeAndSet(p, ctxt, bean); - } catch (Exception e) { - wrapAndThrow(e, bean, prop.getName(), ctxt); - } + _readAndSet(p, ctxt, bean, _orderedProperties[i]); } // also, need to ensure we get closing END_OBJECT... if (p.nextToken() != JsonToken.END_OBJECT) { diff --git a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicUnrolledDeserializer.java b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicUnrolledDeserializer.java index 886a11686..45269e8d0 100644 --- a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicUnrolledDeserializer.java +++ b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicUnrolledDeserializer.java @@ -179,65 +179,47 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt) if (_injectables != null) { injectValues(ctxt, bean); } - SettableBeanProperty prop = null; - - try { - switch (_propCount) { - default: - //case 6: - prop = _prop1; - if (!p.nextName(_name1)) { - break; - } - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - // fall through - case 5: - prop = _prop2; - if (!p.nextName(_name2)) { - break; - } - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - // fall through - case 4: - prop = _prop3; - if (!p.nextName(_name3)) { - break; - } - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - // fall through - case 3: - prop = _prop4; - if (!p.nextName(_name4)) { - break; - } - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - // fall through - case 2: - prop = _prop5; - if (!p.nextName(_name5)) { - break; - } - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - // fall through - case 1: - prop = _prop6; - if (!p.nextName(_name6)) { - break; - } - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - // also, need to ensure we get closing END_OBJECT... - if (p.nextToken() == JsonToken.END_OBJECT) { - return bean; - } - } - } catch (Exception e) { - wrapAndThrow(e, bean, prop.getName(), ctxt); + switch (_propCount) { + default: + //case 6: + if (!p.nextName(_name1)) { + break; + } + _readAndSet(p, ctxt, bean, _prop1); + // fall through + case 5: + if (!p.nextName(_name2)) { + break; + } + _readAndSet(p, ctxt, bean, _prop2); + // fall through + case 4: + if (!p.nextName(_name3)) { + break; + } + _readAndSet(p, ctxt, bean, _prop3); + // fall through + case 3: + if (!p.nextName(_name4)) { + break; + } + _readAndSet(p, ctxt, bean, _prop4); + // fall through + case 2: + if (!p.nextName(_name5)) { + break; + } + _readAndSet(p, ctxt, bean, _prop5); + // fall through + case 1: + if (!p.nextName(_name6)) { + break; + } + _readAndSet(p, ctxt, bean, _prop6); + // also, need to ensure we get closing END_OBJECT... + if (p.nextToken() == JsonToken.END_OBJECT) { + return bean; + } } // may have encountered pre-mature END_OBJECT too if (p.currentToken() == JsonToken.END_OBJECT) { @@ -279,65 +261,46 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean // no, something funky, use base impl for special cases return super.deserialize(p, ctxt, bean); } - p.nextToken(); - try { - prop.deserializeAndSet(p, ctxt, bean); - } catch (Exception e) { - wrapAndThrow(e, bean, prop.getName(), ctxt); - } + _readAndSet(p, ctxt, bean, prop); // then rest of properties. NOTE! Off-by-one, to skip one we did above - try { - switch (_propCount) { - default: - //case 6: - prop = _prop2; - if (!p.nextName(_name2)) { - break; - } - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - // fall through - case 5: - prop = _prop3; - if (!p.nextName(_name3)) { - break; - } - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - // fall through - case 4: - prop = _prop4; - if (!p.nextName(_name4)) { - break; - } - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - // fall through - case 3: - prop = _prop5; - if (!p.nextName(_name5)) { - break; - } - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - // fall through - case 2: - prop = _prop6; - if (!p.nextName(_name6)) { - break; - } - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - // fall through - case 1: - // NOTE! skip "last" one to compensate for OBO - // But we still need to ensure we get closing END_OBJECT... - if (p.nextToken() == JsonToken.END_OBJECT) { - return bean; - } - } - } catch (Exception e) { - wrapAndThrow(e, bean, prop.getName(), ctxt); + switch (_propCount) { + default: + //case 6: + if (!p.nextName(_name2)) { + break; + } + _readAndSet(p, ctxt, bean, _prop2); + // fall through + case 5: + if (!p.nextName(_name3)) { + break; + } + _readAndSet(p, ctxt, bean, _prop3); + // fall through + case 4: + if (!p.nextName(_name4)) { + break; + } + _readAndSet(p, ctxt, bean, _prop4); + // fall through + case 3: + if (!p.nextName(_name5)) { + break; + } + _readAndSet(p, ctxt, bean, _prop5); + // fall through + case 2: + if (!p.nextName(_name6)) { + break; + } + _readAndSet(p, ctxt, bean, _prop6); + // fall through + case 1: + // NOTE! skip "last" one to compensate for OBO + // But we still need to ensure we get closing END_OBJECT... + if (p.nextToken() == JsonToken.END_OBJECT) { + return bean; + } } // may have encountered pre-mature END_OBJECT too if (p.currentToken() == JsonToken.END_OBJECT) { @@ -401,66 +364,47 @@ public Object deserializeFromObject(JsonParser p, DeserializationContext ctxt) return _deserializeDisordered(p, ctxt, bean); } // and deserialize - p.nextToken(); - try { - prop.deserializeAndSet(p, ctxt, bean); - } catch (Exception e) { - wrapAndThrow(e, bean, prop.getName(), ctxt); - } + _readAndSet(p, ctxt, bean, prop); // then rest of properties. NOTE! Off-by-one, to skip one we did above - try { - switch (_propCount) { - default: - //case 6: - prop = _prop2; - if (!p.nextName(_name2)) { - break; - } - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - // fall through - case 5: - prop = _prop3; - if (!p.nextName(_name3)) { - break; - } - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - // fall through - case 4: - prop = _prop4; - if (!p.nextName(_name4)) { - break; - } - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - // fall through - case 3: - prop = _prop5; - if (!p.nextName(_name5)) { - break; - } - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - // fall through - case 2: - prop = _prop6; - if (!p.nextName(_name6)) { - break; - } - p.nextToken(); - prop.deserializeAndSet(p, ctxt, bean); - // fall through - case 1: - // NOTE! skip "last" one to compensate for OBO - // But we still need to ensure we get closing END_OBJECT... - if (p.nextToken() == JsonToken.END_OBJECT) { - return bean; - } - } - } catch (Exception e) { - wrapAndThrow(e, bean, prop.getName(), ctxt); + switch (_propCount) { + default: + //case 6: + if (!p.nextName(_name2)) { + break; + } + _readAndSet(p, ctxt, bean, _prop2); + // fall through + case 5: + if (!p.nextName(_name3)) { + break; + } + _readAndSet(p, ctxt, bean, _prop3); + // fall through + case 4: + if (!p.nextName(_name4)) { + break; + } + _readAndSet(p, ctxt, bean, _prop4); + // fall through + case 3: + if (!p.nextName(_name5)) { + break; + } + _readAndSet(p, ctxt, bean, _prop5); + // fall through + case 2: + if (!p.nextName(_name6)) { + break; + } + _readAndSet(p, ctxt, bean, _prop6); + // fall through + case 1: + // NOTE! skip "last" one to compensate for OBO + // But we still need to ensure we get closing END_OBJECT... + if (p.nextToken() == JsonToken.END_OBJECT) { + return bean; + } } // may have encountered pre-mature END_OBJECT too if (p.currentToken() == JsonToken.END_OBJECT) { diff --git a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java index 7124e043b..4b608c4a6 100644 --- a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java +++ b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java @@ -7,10 +7,15 @@ import com.fasterxml.jackson.annotation.JsonAlias; +import tools.jackson.core.JacksonException; import tools.jackson.core.JsonParser; import tools.jackson.core.JsonToken; +import tools.jackson.core.exc.StreamReadException; import tools.jackson.databind.*; +import tools.jackson.databind.annotation.JsonDeserialize; +import tools.jackson.databind.deser.DeserializationProblemHandler; import tools.jackson.databind.exc.InvalidFormatException; +import tools.jackson.databind.exc.MismatchedInputException; import tools.jackson.databind.json.JsonMapper; import tools.jackson.module.afterburner.AfterburnerModule; import tools.jackson.module.afterburner.AfterburnerTestBase; @@ -37,6 +42,38 @@ public static class BigBean { @JsonAlias("g1") public int g; } + public static class PlainBigBean { + public int a, b, c, d, e, f, g; + } + + // Databind only calls `deserializeFromObject()` of a vanilla deserializer through + // this kind of direct call: from START_OBJECT, or from first property name + public static class FromObjectDeserializer extends ValueDeserializer { + protected boolean advance() { return false; } + + @Override + public Object deserialize(JsonParser p, DeserializationContext ctxt) { + ValueDeserializer deser = ctxt.findRootValueDeserializer( + ctxt.constructType(BigBean.class)); + assertInstanceOf(SuperSonicBeanDeserializer.class, deser); + if (advance()) { + p.nextToken(); + } + return ((SuperSonicBeanDeserializer) deser).deserializeFromObject(p, ctxt); + } + } + + public static class FromNameDeserializer extends FromObjectDeserializer { + @Override + protected boolean advance() { return true; } + } + + @JsonDeserialize(using = FromObjectDeserializer.class) + public static class FromObjectHolder { } + + @JsonDeserialize(using = FromNameDeserializer.class) + public static class FromNameHolder { } + // Afterburner's optimized deserializers need "vanilla" processing private final ObjectMapper MAPPER = JsonMapper.builder() .addModule(new AfterburnerModule()) @@ -93,20 +130,188 @@ public void orderedForUpdate() throws Exception { """, "b1"); } - // Parser already pointing to the first property name + // Parser already pointing to the first property name. Handled by databind's + // `BeanDeserializer`, since `_deserializeOther()` calls its own vanilla method @Test public void orderedFromPropertyName() throws Exception { - try (JsonParser p = MAPPER.createParser(""" + try (JsonParser p = _parserAtFirstName(""" {"a1":"bad"} """)) { - assertToken(JsonToken.START_OBJECT, p.nextToken()); - assertToken(JsonToken.PROPERTY_NAME, p.nextToken()); InvalidFormatException e = assertThrows(InvalidFormatException.class, () -> MAPPER.readerFor(BigBean.class).readValue(p)); _verifyException(e, "a1"); } } + // As above, but for update: handled by `SuperSonicBeanDeserializer` + @Test + public void orderedForUpdateFromPropertyName() throws Exception { + try (JsonParser p = _parserAtFirstName(""" + {"a1":"bad"} + """)) { + InvalidFormatException e = assertThrows(InvalidFormatException.class, + () -> MAPPER.readerForUpdating(new BigBean()).readValue(p)); + _verifyException(e, "a1"); + _verifyFrames(e, "_deserializeDisordered"); + } + } + + @Test + public void orderedFromObject() throws Exception { + for (Class type : List.of(FromObjectHolder.class, FromNameHolder.class)) { + InvalidFormatException e = assertThrows(InvalidFormatException.class, + () -> MAPPER.readValue(""" + {"a1":"bad"} + """, type)); + _verifyException(e, "a1"); + _verifyFrames(e, "deserializeFromObject", "_deserializeDisordered"); + + // Matched by the fast path + e = assertThrows(InvalidFormatException.class, + () -> MAPPER.readValue(""" + {"a":"bad"} + """, type)); + _verifyException(e, "a"); + _verifyFrames(e, "deserializeFromObject"); + } + } + + // Other case: input name differs from logical one without aliases + @Test + public void caseInsensitive() throws Exception { + final ObjectMapper mapper = _mapperBuilder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES) + .build(); + for (Class type : List.of(SmallBean.class, BigBean.class)) { + _verifyPath(mapper.readerFor(type), """ + {"A":"bad"} + """, "A"); + } + } + + // No aliases, no case-insensitivity: input name is not read + @Test + public void noInputNames() throws Exception { + _verifyPath(PlainBigBean.class, """ + {"b":"bad"} + """, "b"); + } + + // Name must be found also when the value is not scalar + @Test + public void aliasWithStructuredValue() throws Exception { + for (Class type : List.of(SmallBean.class, BigBean.class)) { + for (String json : List.of(""" + {"a1":{}} + """, """ + {"a1":[]} + """)) { + MismatchedInputException e = assertThrows(MismatchedInputException.class, + () -> MAPPER.readerFor(type).readValue(json)); + _verifyException(e, "a1"); + } + } + } + + // Failure from property handling is reported once, not also for earlier property + @Test + public void unknownProperty() throws Exception { + final ObjectMapper mapper = _mapperBuilder() + .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .build(); + for (Class type : List.of(SmallBean.class, BigBean.class)) { + for (String json : List.of(""" + {"zzz":1} + """, """ + {"a":1,"zzz":1} + """)) { + DatabindException e = assertThrows(DatabindException.class, + () -> mapper.readerFor(type).readValue(json)); + assertEquals(List.of("zzz"), _path(e)); + } + } + } + + // Exception that a problem handler throws is not wrapped, same as stock + @Test + public void unknownPropertyHandlerFailure() throws Exception { + final DeserializationProblemHandler handler = new DeserializationProblemHandler() { + @Override + public boolean handleUnknownProperty(DeserializationContext ctxt, JsonParser p, + ValueDeserializer deser, Object beanOrClass, String propName) { + throw new IllegalStateException("handler failure"); + } + }; + final ObjectMapper afterburner = _mapperBuilder().addHandler(handler).build(); + final ObjectMapper stock = JsonMapper.builder().addHandler(handler).build(); + for (Class type : List.of(SmallBean.class, BigBean.class)) { + for (String json : List.of(""" + {"zzz":1} + """, """ + {"a":1,"zzz":1} + """)) { + assertThrows(IllegalStateException.class, + () -> stock.readerFor(type).readValue(json)); + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> afterburner.readerFor(type).readValue(json)); + assertTrue(_hasFrame(e, "_deserializeDisordered")); + } + } + } + + // Parser failures are not failures of a property: no path, same as stock + @Test + public void parserFailures() throws Exception { + final ObjectMapper stock = JsonMapper.builder().build(); + for (Class type : List.of(SmallBean.class, BigBean.class)) { + for (String json : List.of( + "{ bad", + "{\"a\":bad", + "{\"a\":1, bad", + "{\"a\":1,\"b\":bad", + "{\"b\":1, bad")) { + for (ObjectReader r : List.of(stock.readerFor(type), + MAPPER.readerFor(type))) { + assertEquals(List.of(), _path( + assertThrows(StreamReadException.class, () -> r.readValue(json))), + json); + } + assertEquals(List.of(), _path(assertThrows(StreamReadException.class, + () -> MAPPER.readerForUpdating(type.getDeclaredConstructor().newInstance()) + .readValue(json))), json); + } + } + } + + private static JsonMapper.Builder _mapperBuilder() { + return afterburnerMapperBuilder() + .enable(MapperFeature.DEFAULT_VIEW_INCLUSION); + } + + private JsonParser _parserAtFirstName(String json) { + JsonParser p = MAPPER.createParser(json); + assertToken(JsonToken.START_OBJECT, p.nextToken()); + assertToken(JsonToken.PROPERTY_NAME, p.nextToken()); + return p; + } + + private static List _path(JacksonException e) { + return e.getPath().stream() + .map(ref -> ref.getPropertyName()) + .toList(); + } + + private static boolean _hasFrame(Throwable e, String method) { + return Arrays.stream(e.getStackTrace()).anyMatch( + f -> f.getClassName().contains("SuperSonic") && f.getMethodName().equals(method)); + } + + private static void _verifyFrames(Throwable e, String... methods) { + for (String method : methods) { + assertTrue(_hasFrame(e, method), "should go through " + method + "()"); + } + } + private void _verifyPath(Class type, String json, String expName) { _verifyPath(MAPPER.readerFor(type), json, expName); } @@ -116,10 +321,8 @@ private void _verifyPath(ObjectReader r, String json, String expName) { expName); } - private void _verifyException(InvalidFormatException e, String expName) { - assertEquals(List.of(expName), e.getPath().stream() - .map(ref -> ref.getPropertyName()) - .toList()); + private void _verifyException(DatabindException e, String expName) { + assertEquals(List.of(expName), _path(e)); assertTrue(Arrays.stream(e.getStackTrace()).anyMatch( f -> f.getClassName().contains("SuperSonic")), "should go through Afterburner's deserializer"); diff --git a/release-notes/VERSION b/release-notes/VERSION index e28ee2a1c..2c90a914b 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -45,7 +45,10 @@ Active maintainers: #362: Bump `byte-buddy` dependency from 1.17.8 to 1.18.15 #368: (afterburner, blackbird) Report property name as found in input (alias or different case), not canonical name, in deserialization error paths; (afterburner) - no more duplicate path segment for failures with out-of-order properties + no more duplicate path segment for failures with out-of-order properties. + Afterburner now matches stock deserializers in two more ways: parser failures have + no property in their path, and non-Jackson exceptions from handling of unknown + properties (like ones `DeserializationProblemHandler` throws) are not wrapped (fix by @cowtowncoder, w/ Claude code) 3.2.5 (not yet released) From 8517bcd99e0400df420ec157f90a2df629c68118 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Sun, 1 Nov 2026 11:01:04 -0800 Subject: [PATCH 04/12] Blackbird: cover input names for structured values and views; simplify codegen `BBCodecStockChecksTest` now compares failures against stock for aliased properties with structured values (child codecs, stock reads, field stores, failing setters) and under an active view. Codegen: drop the `Names` record for a plain slot that is negative when unused, so record codecs only allocate it when needed, and name the `inputName` local of record codecs in debug metadata, as for POJO codecs. `AliasErrorPathTest` mapper now comes from the test base helper. Co-Authored-By: Claude Opus 5.5 --- .../afterburner/deser/AliasErrorPathTest.java | 6 +- .../codegen/BeanReaderGenerator.java | 48 +++++------ .../deser/BBCodecStockChecksTest.java | 81 ++++++++++++++++++- 3 files changed, 102 insertions(+), 33 deletions(-) diff --git a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java index 4b608c4a6..8e7bd5136 100644 --- a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java +++ b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java @@ -17,7 +17,6 @@ import tools.jackson.databind.exc.InvalidFormatException; import tools.jackson.databind.exc.MismatchedInputException; import tools.jackson.databind.json.JsonMapper; -import tools.jackson.module.afterburner.AfterburnerModule; import tools.jackson.module.afterburner.AfterburnerTestBase; import static org.junit.jupiter.api.Assertions.*; @@ -75,10 +74,7 @@ public static class FromObjectHolder { } public static class FromNameHolder { } // Afterburner's optimized deserializers need "vanilla" processing - private final ObjectMapper MAPPER = JsonMapper.builder() - .addModule(new AfterburnerModule()) - .enable(MapperFeature.DEFAULT_VIEW_INCLUSION) - .build(); + private final ObjectMapper MAPPER = _mapperBuilder().build(); @Test public void unrolledAlias() throws Exception { diff --git a/blackbird/src/main/java25/tools/jackson/module/blackbird/codegen/BeanReaderGenerator.java b/blackbird/src/main/java25/tools/jackson/module/blackbird/codegen/BeanReaderGenerator.java index d6242cddb..497d12e73 100644 --- a/blackbird/src/main/java25/tools/jackson/module/blackbird/codegen/BeanReaderGenerator.java +++ b/blackbird/src/main/java25/tools/jackson/module/blackbird/codegen/BeanReaderGenerator.java @@ -121,7 +121,7 @@ final class BeanReaderGenerator CD_DESER_CONTEXT, CD_SETTABLE_PROP, CD_FORWARD_REF, CD_OBJECT); private static final MethodTypeDesc MTD_CREATION_FAILURE = MethodTypeDesc.of(CD_OBJECT, CD_DESER_CONTEXT, CD_THROWABLE); - // Input name (or null, see Names) comes before the DeserializationContext. + // Input name (or null, see loadInputName) comes before the DeserializationContext. private static final MethodTypeDesc MTD_PROP_WRAP = MethodTypeDesc.of( CD_RTE, CD_THROWABLE, CD_OBJECT, CD_SETTABLE_PROP, ConstantDescs.CD_String, CD_DESER_CONTEXT); @@ -339,14 +339,12 @@ private static void buildDeserialize(CodeBuilder cob, ReaderCodegen.Plan plan) { final int maskSlot = 8; final int valueSlot = 10; // Names that can differ from the property's own: each arm reads the - // input name, for its failure handlers. - final boolean inputNames = plan.inputNames(); - final int inputNameSlot = 11; + // input name, for its failure handlers. Negative if they cannot. + final int inputNameSlot = plan.inputNames() ? 11 : -1; // Set once a name has gone to unknown-name handling; tracked only // where stock reads with its vanilla loop. final boolean trackUnknown = plan.vanillaLoop(); final int afterUnknownSlot = 12; - final Names names = inputNames ? new Names(inputNameSlot) : Names.NONE; Label propHandler = cob.newLabel(); Label storeHandler = cob.newLabel(); @@ -426,7 +424,7 @@ private static void buildDeserialize(CodeBuilder cob, ReaderCodegen.Plan plan) { emitArmViewCheck(cob, base, parser, ctxt, maskSlot, matcherSlot, ixSlot, i, plan.stockName()[i], loop); } - if (inputNames) { + if (inputNameSlot >= 0) { emitReadInputName(cob, parser, inputNameSlot); } Label armEnd = beginArm(cob, plan.stockName()[i], propSlot, propHandler); @@ -492,13 +490,13 @@ private static void buildDeserialize(CodeBuilder cob, ReaderCodegen.Plan plan) { .invokevirtual(base, "_unexpectedToken", MTD_UNEXPECTED) .areturn(); - emitPropertyHandler(cob, base, propHandler, ctxt, propSlot, excSlot, beanSlot, names); + emitPropertyHandler(cob, base, propHandler, ctxt, propSlot, excSlot, beanSlot, inputNameSlot); if (anyStore) { cob.labelBinding(storeHandler); cob.astore(excSlot); cob.aload(0).aload(excSlot).aload(beanSlot).aload(valueSlot).aload(propSlot) .aload(parser); - names.load(cob); + loadInputName(cob, inputNameSlot); cob.aload(ctxt); cob.invokevirtual(base, "_storeFailure", MTD_STORE_FAILURE); cob.athrow(); @@ -529,7 +527,7 @@ private static void buildDeserialize(CodeBuilder cob, ReaderCodegen.Plan plan) { if (anyStore) { cob.localVariable(valueSlot, "value", CD_OBJECT, scopeStart, scopeEnd); } - if (inputNames) { + if (inputNameSlot >= 0) { cob.localVariable(inputNameSlot, "inputName", ConstantDescs.CD_String, scopeStart, scopeEnd); } @@ -727,9 +725,7 @@ private static void buildRecordDeserialize(CodeBuilder cob, ReaderCodegen.Plan p final int pendingSlot = next++; final int referringsSlot = next++; // Names that can differ from the component's own: see the POJO loop. - final boolean inputNames = plan.inputNames(); - final int inputNameSlot = next++; - final Names names = inputNames ? new Names(inputNameSlot) : Names.NONE; + final int inputNameSlot = plan.inputNames() ? next++ : -1; // Primitive components: the stock value when it is not exactly the // wrapper (see emitStockValueToLocal). int[] rawSlot = new int[n]; @@ -790,7 +786,7 @@ private static void buildRecordDeserialize(CodeBuilder cob, ReaderCodegen.Plan p emitArmViewCheck(cob, base, parser, ctxt, maskSlot, matcherSlot, ixSlot, i, plan.stockName()[i], loop); } - if (inputNames) { + if (inputNameSlot >= 0) { emitReadInputName(cob, parser, inputNameSlot); } GenProp prop = props.get(i); @@ -968,7 +964,7 @@ private static void buildRecordDeserialize(CodeBuilder cob, ReaderCodegen.Plan p cob.labelBinding(oddToken); cob.goto_(endObject); - emitPropertyHandler(cob, base, propHandler, ctxt, propSlot, excSlot, -1, names); + emitPropertyHandler(cob, base, propHandler, ctxt, propSlot, excSlot, -1, inputNameSlot); emitDelegateTail(cob, base, parser, ctxt, delegate); Label scopeEnd = cob.newBoundLabel(); @@ -991,6 +987,10 @@ private static void buildRecordDeserialize(CodeBuilder cob, ReaderCodegen.Plan p cob.localVariable(beanSlot, "bean", CD_OBJECT, scopeStart, scopeEnd); cob.localVariable(pendingSlot, "ctorFailure", CD_THROWABLE, scopeStart, scopeEnd); cob.localVariable(referringsSlot, "referrings", CD_OBJECT, scopeStart, scopeEnd); + if (inputNameSlot >= 0) { + cob.localVariable(inputNameSlot, "inputName", ConstantDescs.CD_String, + scopeStart, scopeEnd); + } if (views == ViewStrategy.MASK) { cob.localVariable(maskSlot, "viewMask", ConstantDescs.CD_long, scopeStart, scopeEnd); } @@ -1179,23 +1179,19 @@ private static void emitReadInputName(CodeBuilder cob, int parser, int inputName } // The failure handlers' name argument: the local holding the input name, - // or null where the names cannot differ (NONE). - private record Names(int inputNameSlot) { - static final Names NONE = new Names(-1); - - void load(CodeBuilder cob) { - if (inputNameSlot < 0) { - cob.aconst_null(); - } else { - cob.aload(inputNameSlot); - } + // or null where the names cannot differ (negative slot). + private static void loadInputName(CodeBuilder cob, int inputNameSlot) { + if (inputNameSlot < 0) { + cob.aconst_null(); + } else { + cob.aload(inputNameSlot); } } // receiverSlot < 0 selects aconst_null (record mode: no instance yet). private static void emitPropertyHandler(CodeBuilder cob, ClassDesc base, Label handler, int ctxt, - int propSlot, int excSlot, int receiverSlot, Names names) { + int propSlot, int excSlot, int receiverSlot, int inputNameSlot) { cob.labelBinding(handler); cob.astore(excSlot); cob.aload(0); @@ -1206,7 +1202,7 @@ private static void emitPropertyHandler(CodeBuilder cob, ClassDesc base, Label h cob.aload(receiverSlot); } cob.aload(propSlot); - names.load(cob); + loadInputName(cob, inputNameSlot); cob.aload(ctxt); cob.invokevirtual(base, "_propertyException", MTD_PROP_WRAP); cob.athrow(); diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecStockChecksTest.java b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecStockChecksTest.java index 8f756d9c6..180a34262 100644 --- a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecStockChecksTest.java +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecStockChecksTest.java @@ -12,6 +12,7 @@ import com.fasterxml.jackson.annotation.JsonSetter; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonView; import com.fasterxml.jackson.annotation.Nulls; import com.fasterxml.jackson.annotation.ObjectIdGenerators; @@ -20,6 +21,7 @@ import tools.jackson.databind.DeserializationFeature; import tools.jackson.databind.MapperFeature; import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectReader; import tools.jackson.databind.ValueDeserializer; import tools.jackson.databind.annotation.JsonDeserialize; import tools.jackson.databind.deser.DeserializationProblemHandler; @@ -196,6 +198,40 @@ public void setV(int v) { public record AliasedRecord(@JsonAlias("alt") int v) { } + public static class AliasedChild { + public int n; + } + + // Aliased properties with structured values: the input name gets read at + // START_OBJECT or START_ARRAY. Fields and setters, child codecs and stock. + public static class AliasedStructured { + @JsonAlias("c1") public AliasedChild child; + @JsonAlias("l1") public List list; + @JsonAlias("f1") public int count; + + @JsonAlias("t1") + public void setThrowing(AliasedChild c) { + throw new IllegalArgumentException("setter failure"); + } + } + + public record AliasedStructuredRecord(@JsonAlias("c1") AliasedChild child, + @JsonAlias("l1") List list) { } + + public static class ViewA { } + public static class ViewB { } + + // Under an active view, with properties that the view hides + public static class AliasedViews { + @JsonView(ViewA.class) @JsonAlias("a1") public int a; + @JsonView(ViewB.class) @JsonAlias("b1") public int b; + @JsonView(ViewA.class) @JsonAlias("c1") public AliasedChild c; + } + + public record AliasedViewsRecord(@JsonView(ViewA.class) @JsonAlias("a1") int a, + @JsonView(ViewB.class) @JsonAlias("b1") int b, + @JsonView(ViewA.class) @JsonAlias("c1") AliasedChild c) { } + private static final ObjectMapper VANILLA = newVanillaJSONMapper(); private static final ObjectMapper MODULE = newObjectMapper(); @@ -346,10 +382,51 @@ private void _assertFailureNames(ObjectMapper vanilla, ObjectMapper module) thro _assertSameFailure(vanilla, module, "{\"v\":\"x\"}", AliasedRecord.class, "v"); } + @Test + public void testFailureNameWithStructuredValue() throws Exception { + assertGeneratedReader(MODULE, AliasedStructured.class); + assertGeneratedReader(MODULE, AliasedStructuredRecord.class); + for (Class type : List.of(AliasedStructured.class, AliasedStructuredRecord.class)) { + // Failures at the structured value itself: read at the child, stock read + _assertSameFailure(VANILLA, MODULE, "{\"c1\":[]}", type, "c1"); + _assertSameFailure(VANILLA, MODULE, "{\"l1\":{}}", type, "l1"); + // Failures within: first name stays, and later ones do not replace it + _assertSameFailure(VANILLA, MODULE, "{\"c1\":{\"n\":\"x\"}}", type, "c1"); + _assertSameFailure(VANILLA, MODULE, "{\"l1\":[\"x\"]}", type, "l1"); + _assertSameFailure(VANILLA, MODULE, "{\"c1\":{\"n\":1},\"l1\":{}}", type, "l1"); + // Canonical name + _assertSameFailure(VANILLA, MODULE, "{\"child\":[]}", type, "child"); + } + // Field, scalar value + _assertSameFailure(VANILLA, MODULE, "{\"f1\":\"x\"}", AliasedStructured.class, "f1"); + // Setter fails after child has been read: name was read before that + _assertSameFailure(VANILLA, MODULE, "{\"t1\":{\"n\":1}}", AliasedStructured.class, "t1"); + } + + @Test + public void testFailureNameUnderActiveView() throws Exception { + assertGeneratedReader(MODULE, AliasedViews.class); + assertGeneratedReader(MODULE, AliasedViewsRecord.class); + for (Class type : List.of(AliasedViews.class, AliasedViewsRecord.class)) { + final ObjectReader vanilla = VANILLA.readerFor(type).withView(ViewA.class); + final ObjectReader module = MODULE.readerFor(type).withView(ViewA.class); + _assertSameFailure(vanilla, module, "{\"a1\":\"x\"}", "a1"); + _assertSameFailure(vanilla, module, "{\"c1\":[]}", "c1"); + // Past a property hidden by the view, with invalid value that gets skipped + _assertSameFailure(vanilla, module, "{\"b1\":\"x\",\"a1\":\"y\"}", "a1"); + _assertSameFailure(vanilla, module, "{\"b1\":{\"z\":1},\"c1\":[]}", "c1"); + } + } + private void _assertSameFailure(ObjectMapper vanilla, ObjectMapper module, String doc, Class type, String expName) { - Exception expected = assertThrows(Exception.class, () -> vanilla.readValue(doc, type)); - Exception actual = assertThrows(Exception.class, () -> module.readValue(doc, type)); + _assertSameFailure(vanilla.readerFor(type), module.readerFor(type), doc, expName); + } + + private void _assertSameFailure(ObjectReader vanilla, ObjectReader module, + String doc, String expName) { + Exception expected = assertThrows(Exception.class, () -> vanilla.readValue(doc)); + Exception actual = assertThrows(Exception.class, () -> module.readValue(doc)); assertEquals(expected.getClass(), actual.getClass(), doc); assertEquals(expected.getMessage(), actual.getMessage(), doc); assertTrue(actual.getMessage().contains("[\"" + expName + "\"]"), actual.getMessage()); From e6b7b32b874d3b78e18c5273f456ffc3e00cc98e Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Sun, 1 Nov 2026 11:10:52 -0800 Subject: [PATCH 05/12] Afterburner: remove unreachable `deserializeFromObject()` overrides The fast path of these overrides needs vanilla processing, but databind only calls `deserializeFromObject()` for non-vanilla deserializers, for which the overrides just delegated to `super`. Removes them from both `SuperSonicBeanDeserializer` and `SuperSonicUnrolledDeserializer`, along with the test that reached them through a direct call. Co-Authored-By: Claude Opus 5.5 --- .../afterburner/deser/SuperSonicBDBase.java | 1 - .../deser/SuperSonicBeanDeserializer.java | 87 --------------- .../deser/SuperSonicUnrolledDeserializer.java | 104 ------------------ .../afterburner/deser/AliasErrorPathTest.java | 49 --------- .../deser/UnknownProperty343Test.java | 7 +- 5 files changed, 3 insertions(+), 245 deletions(-) diff --git a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java index 4c1617154..3c3282d97 100644 --- a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java +++ b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java @@ -125,7 +125,6 @@ public void resolve(DeserializationContext ctxt) // public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException // public final Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean) throws IOException;; -// public final Object deserializeFromObject(JsonParser p, DeserializationContext ctxt) throws IOException; /** * Reads and sets the value of a property matched by expected order, with parser diff --git a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java index 9aff94792..46061f1d0 100644 --- a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java +++ b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java @@ -208,91 +208,4 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean } return bean; } - - // much of below is cut'n pasted from BeanSerializer - @Override - public Object deserializeFromObject(JsonParser p, DeserializationContext ctxt) - throws JacksonException - { - // The ordered-property fast path below is only safe when no view filtering, - // non-standard creation or other non-vanilla features are in play; otherwise - // fall back to the parent implementation which handles all of those. - if (!_vanillaProcessing) { - return super.deserializeFromObject(p, ctxt); - } - // See BeanDeserializer.deserializeFromObject [databind#622] - // Allow Object Id references to come in as JSON Objects as well... - if ((_objectIdReader != null) && _objectIdReader.maySerializeAsObject()) { - if (p.hasTokenId(JsonTokenId.ID_PROPERTY_NAME) - && _objectIdReader.isValidReferencePropertyName(p.currentName(), p)) { - return deserializeFromObjectId(p, ctxt); - } - } - if (_nonStandardCreation) { - if (_unwrappedPropertyHandler != null) { - return deserializeWithUnwrapped(p, ctxt); - } - if (_externalTypeIdHandler != null) { - return deserializeWithExternalTypeId(p, ctxt); - } - Object bean = deserializeFromObjectUsingNonDefault(p, ctxt); - if (_injectables != null) { - injectValues(ctxt, bean); - } - return bean; - } - final Object bean = _valueInstantiator.createUsingDefault(ctxt); - // [databind#631]: Assign current value, to be accessible by custom serializers - p.assignCurrentValue(bean); - if (p.canReadObjectId()) { - Object id = p.getObjectId(); - if (id != null) { - _handleTypedObjectId(p, ctxt, bean, id); - } - } - if (_injectables != null) { - injectValues(ctxt, bean); - } - // We also we have at least 6 properties, so roll out first few - SettableBeanProperty prop = _orderedProperties[0]; - if (p.isExpectedStartObjectToken()) { - if (!p.nextName(_orderedPropertyNames[0])) { - return _deserializeDisordered(p, ctxt, bean); - } - } else if (!p.hasToken(JsonToken.PROPERTY_NAME) - || !prop.getName().equals(p.currentName())) { - return _deserializeDisordered(p, ctxt, bean); - } - _readAndSet(p, ctxt, bean, prop); - if (!p.nextName(_orderedPropertyNames[1])) { - return _deserializeDisordered(p, ctxt, bean); - } - _readAndSet(p, ctxt, bean, _orderedProperties[1]); - if (!p.nextName(_orderedPropertyNames[2])) { - return _deserializeDisordered(p, ctxt, bean); - } - _readAndSet(p, ctxt, bean, _orderedProperties[2]); - if (!p.nextName(_orderedPropertyNames[3])) { - return _deserializeDisordered(p, ctxt, bean); - } - _readAndSet(p, ctxt, bean, _orderedProperties[3]); - - // then rest of properties - for (int i = 4, len = _orderedProperties.length; i < len; ++i) { - if (!p.nextName(_orderedPropertyNames[i])) { // miss... - if (p.hasToken(JsonToken.END_OBJECT)) { - return bean; - } - // we likely point to FIELD_NAME, so can just call parent impl - return super.deserialize(p, ctxt, bean); - } - _readAndSet(p, ctxt, bean, _orderedProperties[i]); - } - // also, need to ensure we get closing END_OBJECT... - if (p.nextToken() != JsonToken.END_OBJECT) { - // [modules-base#343]: trailing (possibly unknown) properties remain - return _deserializeDisordered(p, ctxt, bean); - } - return bean; - } } diff --git a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicUnrolledDeserializer.java b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicUnrolledDeserializer.java index 45269e8d0..6c159e06c 100644 --- a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicUnrolledDeserializer.java +++ b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicUnrolledDeserializer.java @@ -308,108 +308,4 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean } return _deserializeDisordered(p, ctxt, bean); } - - // much of below is cut'n pasted from BeanSerializer - @Override - public Object deserializeFromObject(JsonParser p, DeserializationContext ctxt) - throws JacksonException - { - // The ordered-property fast path below is only safe when no view filtering, - // non-standard creation or other non-vanilla features are in play; otherwise - // fall back to the parent implementation which handles all of those. - if (!_vanillaProcessing) { - return super.deserializeFromObject(p, ctxt); - } - // See BeanDeserializer.deserializeFromObject [databind#622] - // Allow Object Id references to come in as JSON Objects as well... - if ((_objectIdReader != null) && _objectIdReader.maySerializeAsObject()) { - if (p.hasTokenId(JsonTokenId.ID_PROPERTY_NAME) - && _objectIdReader.isValidReferencePropertyName(p.currentName(), p)) { - return deserializeFromObjectId(p, ctxt); - } - } - if (_nonStandardCreation) { - if (_unwrappedPropertyHandler != null) { - return deserializeWithUnwrapped(p, ctxt); - } - if (_externalTypeIdHandler != null) { - return deserializeWithExternalTypeId(p, ctxt); - } - Object bean = deserializeFromObjectUsingNonDefault(p, ctxt); - if (_injectables != null) { - injectValues(ctxt, bean); - } - return bean; - } - final Object bean = _valueInstantiator.createUsingDefault(ctxt); - // [databind#631]: Assign current value, to be accessible by custom serializers - p.assignCurrentValue(bean); - if (p.canReadObjectId()) { - Object id = p.getObjectId(); - if (id != null) { - _handleTypedObjectId(p, ctxt, bean, id); - } - } - if (_injectables != null) { - injectValues(ctxt, bean); - } - SettableBeanProperty prop = _orderedProperties[0]; - // First: verify that first name is expected - if (p.isExpectedStartObjectToken()) { - if (!p.nextName(_orderedPropertyNames[0])) { - return _deserializeDisordered(p, ctxt, bean); - } - } else if (!p.hasToken(JsonToken.PROPERTY_NAME) - || !prop.getName().equals(p.currentName())) { - return _deserializeDisordered(p, ctxt, bean); - } - // and deserialize - _readAndSet(p, ctxt, bean, prop); - - // then rest of properties. NOTE! Off-by-one, to skip one we did above - switch (_propCount) { - default: - //case 6: - if (!p.nextName(_name2)) { - break; - } - _readAndSet(p, ctxt, bean, _prop2); - // fall through - case 5: - if (!p.nextName(_name3)) { - break; - } - _readAndSet(p, ctxt, bean, _prop3); - // fall through - case 4: - if (!p.nextName(_name4)) { - break; - } - _readAndSet(p, ctxt, bean, _prop4); - // fall through - case 3: - if (!p.nextName(_name5)) { - break; - } - _readAndSet(p, ctxt, bean, _prop5); - // fall through - case 2: - if (!p.nextName(_name6)) { - break; - } - _readAndSet(p, ctxt, bean, _prop6); - // fall through - case 1: - // NOTE! skip "last" one to compensate for OBO - // But we still need to ensure we get closing END_OBJECT... - if (p.nextToken() == JsonToken.END_OBJECT) { - return bean; - } - } - // may have encountered pre-mature END_OBJECT too - if (p.currentToken() == JsonToken.END_OBJECT) { - return bean; - } - return _deserializeDisordered(p, ctxt, bean); - } } diff --git a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java index 8e7bd5136..514623caa 100644 --- a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java +++ b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java @@ -12,7 +12,6 @@ import tools.jackson.core.JsonToken; import tools.jackson.core.exc.StreamReadException; import tools.jackson.databind.*; -import tools.jackson.databind.annotation.JsonDeserialize; import tools.jackson.databind.deser.DeserializationProblemHandler; import tools.jackson.databind.exc.InvalidFormatException; import tools.jackson.databind.exc.MismatchedInputException; @@ -45,34 +44,6 @@ public static class PlainBigBean { public int a, b, c, d, e, f, g; } - // Databind only calls `deserializeFromObject()` of a vanilla deserializer through - // this kind of direct call: from START_OBJECT, or from first property name - public static class FromObjectDeserializer extends ValueDeserializer { - protected boolean advance() { return false; } - - @Override - public Object deserialize(JsonParser p, DeserializationContext ctxt) { - ValueDeserializer deser = ctxt.findRootValueDeserializer( - ctxt.constructType(BigBean.class)); - assertInstanceOf(SuperSonicBeanDeserializer.class, deser); - if (advance()) { - p.nextToken(); - } - return ((SuperSonicBeanDeserializer) deser).deserializeFromObject(p, ctxt); - } - } - - public static class FromNameDeserializer extends FromObjectDeserializer { - @Override - protected boolean advance() { return true; } - } - - @JsonDeserialize(using = FromObjectDeserializer.class) - public static class FromObjectHolder { } - - @JsonDeserialize(using = FromNameDeserializer.class) - public static class FromNameHolder { } - // Afterburner's optimized deserializers need "vanilla" processing private final ObjectMapper MAPPER = _mapperBuilder().build(); @@ -152,26 +123,6 @@ public void orderedForUpdateFromPropertyName() throws Exception { } } - @Test - public void orderedFromObject() throws Exception { - for (Class type : List.of(FromObjectHolder.class, FromNameHolder.class)) { - InvalidFormatException e = assertThrows(InvalidFormatException.class, - () -> MAPPER.readValue(""" - {"a1":"bad"} - """, type)); - _verifyException(e, "a1"); - _verifyFrames(e, "deserializeFromObject", "_deserializeDisordered"); - - // Matched by the fast path - e = assertThrows(InvalidFormatException.class, - () -> MAPPER.readValue(""" - {"a":"bad"} - """, type)); - _verifyException(e, "a"); - _verifyFrames(e, "deserializeFromObject"); - } - } - // Other case: input name differs from logical one without aliases @Test public void caseInsensitive() throws Exception { diff --git a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/UnknownProperty343Test.java b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/UnknownProperty343Test.java index bd2b0d6e2..d480a154b 100644 --- a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/UnknownProperty343Test.java +++ b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/UnknownProperty343Test.java @@ -13,14 +13,13 @@ // [modules-base#343]: SuperSonicBeanDeserializer.deserializeFromObject threw on a // trailing unknown property after all known ordered properties were consumed, even -// with FAIL_ON_UNKNOWN_PROPERTIES disabled. +// with FAIL_ON_UNKNOWN_PROPERTIES disabled. That override no longer exists: this +// stays as a regression test for the same input. public class UnknownProperty343Test extends AfterburnerTestBase { // Marker class used purely to force the bean deserializer to operate in // non-vanilla mode (view processing active), which routes through - // BeanDeserializer.deserializeFromObject rather than the vanilla fast path — - // and thus through SuperSonicBeanDeserializer.deserializeFromObject, which is - // where issue #343 lives. + // BeanDeserializer.deserializeFromObject rather than the vanilla fast path. static class DefaultView { } // 8 properties -> SuperSonicBeanDeserializer (threshold is > 6) From b92112c1dddceaea0e47b4d21ab343500e974891 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Sun, 1 Nov 2026 11:13:03 -0800 Subject: [PATCH 06/12] Add `@since 3.3` to `SuperSonicBDBase._readAndSet()` Co-Authored-By: Claude Opus 5.5 --- .../jackson/module/afterburner/deser/SuperSonicBDBase.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java index 3c3282d97..5352000cb 100644 --- a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java +++ b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java @@ -131,6 +131,8 @@ public void resolve(DeserializationContext ctxt) * pointing to its name. Name matched exactly, so logical name is the input name. * Only failures of the value get the property in their path: as with stock * deserializers, parser failures for the next token or next name do not. + * + * @since 3.3 */ protected final void _readAndSet(JsonParser p, DeserializationContext ctxt, Object bean, SettableBeanProperty prop) From c7137d118f55039f406d764eff1a801f6208d481 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Sun, 1 Nov 2026 11:34:13 -0800 Subject: [PATCH 07/12] Blackbird: report unexpected token after unknown property, as stock now does jackson-databind#6373 (partial fix of #6357) made stock's `_vanillaDeserializeWithUnknown()` report a token that is neither a name nor END_OBJECT, instead of returning the bean. Generated POJO codecs copied the old behavior, so `BBReaderOddTokenTest` fails with databind snapshots that have the change. Removes the tracking of unknown names from generated code, and the `vanillaLoop` plan flag that was only used for it. Co-Authored-By: Claude Opus 5.5 --- .../blackbird/codegen/ReaderCodegen.java | 9 +++---- .../blackbird/deser/BBBuilderPlaceholder.java | 2 +- .../blackbird/deser/BBReaderFactory.java | 8 +++--- .../blackbird/deser/BBReaderPlaceholder.java | 2 +- .../codegen/BeanReaderGenerator.java | 22 ---------------- .../codegen/ReaderPlanCheckTest.java | 2 +- .../blackbird/deser/BBReaderOddTokenTest.java | 26 ++++++------------- release-notes/VERSION | 4 ++- 8 files changed, 20 insertions(+), 55 deletions(-) diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/ReaderCodegen.java b/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/ReaderCodegen.java index 2f37b9a2c..02ca2f8ab 100644 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/ReaderCodegen.java +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/codegen/ReaderCodegen.java @@ -98,13 +98,10 @@ public static Construction record(MethodHandle ctor) { * inputNames tells whether a matched name can differ from the * property's own name (aliases, case-insensitive matching), which * error paths then need. - * vanillaLoop (POJO mode only) tells whether stock reads the bean with - * its vanilla loop, which ends quietly on an odd token after an unknown - * name. */ record Plan(String className, List props, String[] stockName, String[] childName, String[] handleName, Mode mode, boolean injectables, - boolean vanillaLoop, ViewStrategy views, int[] matchArms, String[] localNames, boolean inputNames) {} + ViewStrategy views, int[] matchArms, String[] localNames, boolean inputNames) {} private ReaderCodegen() {} @@ -117,7 +114,7 @@ private ReaderCodegen() {} public static ValueDeserializer generate(Class beanClass, List props, PropertyNameMatcher matcher, int[] matchArms, BeanDeserializerBase source, BeanPropertyMap beanProps, Construction construction, boolean injectables, - boolean vanillaLoop, ViewStrategy views, String[] localNames) + ViewStrategy views, String[] localNames) throws ReflectiveOperationException { Map classData = new LinkedHashMap<>(); classData.put("matcher", matcher); @@ -164,7 +161,7 @@ public static ValueDeserializer generate(Class beanClass, List generateWithStockMatcher( } return ReaderCodegen.generate(beanClass, props, beanProps.getNameMatcher(), matchArms, delegate, beanProps, construction, stock.injectables(), - (builderClass == null) && stock.vanilla(), viewStrategy(ctxt, delegate, props, declaresViews), null); } @@ -391,7 +389,7 @@ private static ValueDeserializer generateRecord(BeanDeserializerBase del beanProps.initMatcher(ctxt.tokenStreamFactory()); RecordNames names = recordNames(ctxt, creator, beanProps, props); return ReaderCodegen.generate(beanClass, props, names.matcher(), names.matchArms(), - delegate, beanProps, Construction.record(recordCtor), false, false, + delegate, beanProps, Construction.record(recordCtor), false, viewStrategy(ctxt, delegate, props, declaresViews), localNames); } diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/BBReaderPlaceholder.java b/blackbird/src/main/java/tools/jackson/module/blackbird/deser/BBReaderPlaceholder.java index f359b96f4..b9c47146f 100644 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/deser/BBReaderPlaceholder.java +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/deser/BBReaderPlaceholder.java @@ -50,7 +50,7 @@ public void resolve(DeserializationContext ctxt) { CodegenDebug.log(() -> "reader resolve " + handledType().getName()); super.resolve(ctxt); ValueDeserializer codec = BBReaderFactory.tryGenerate(new BBReaderFactory.Source( - this, _anySetter, _injectables != null, _vanillaProcessing, _beanProperties, + this, _anySetter, _injectables != null, _beanProperties, _propertyBasedCreator, _nonStandardCreation, (_delegateDeserializer != null) || (_arrayDelegateDeserializer != null), _externalTypeIdHandler != null, _unwrappedPropertyHandler != null, diff --git a/blackbird/src/main/java25/tools/jackson/module/blackbird/codegen/BeanReaderGenerator.java b/blackbird/src/main/java25/tools/jackson/module/blackbird/codegen/BeanReaderGenerator.java index 497d12e73..f1de74fb2 100644 --- a/blackbird/src/main/java25/tools/jackson/module/blackbird/codegen/BeanReaderGenerator.java +++ b/blackbird/src/main/java25/tools/jackson/module/blackbird/codegen/BeanReaderGenerator.java @@ -341,10 +341,6 @@ private static void buildDeserialize(CodeBuilder cob, ReaderCodegen.Plan plan) { // Names that can differ from the property's own: each arm reads the // input name, for its failure handlers. Negative if they cannot. final int inputNameSlot = plan.inputNames() ? 11 : -1; - // Set once a name has gone to unknown-name handling; tracked only - // where stock reads with its vanilla loop. - final boolean trackUnknown = plan.vanillaLoop(); - final int afterUnknownSlot = 12; Label propHandler = cob.newLabel(); Label storeHandler = cob.newLabel(); @@ -402,9 +398,6 @@ private static void buildDeserialize(CodeBuilder cob, ReaderCodegen.Plan plan) { .invokevirtual(base, "_injectValues", MTD_INJECT); } - if (trackUnknown) { - cob.iconst_0().istore(afterUnknownSlot); - } ldcData(cob, "matcher", CD_NAME_MATCHER); cob.astore(matcherSlot); @@ -469,23 +462,12 @@ private static void buildDeserialize(CodeBuilder cob, ReaderCodegen.Plan plan) { } cob.labelBinding(unknown); - if (trackUnknown) { - cob.iconst_1().istore(afterUnknownSlot); - } cob.aload(0).aload(parser).aload(ctxt).aload(beanSlot); cob.invokevirtual(base, "_handleUnknown", MTD_HANDLE_UNKNOWN); nextNameMatch(cob, parser, matcherSlot, ixSlot); cob.goto_(loop); cob.labelBinding(oddToken); - if (trackUnknown) { - // Once an unknown name has been seen, stock's vanilla loop - // (_vanillaDeserializeWithUnknown) returns the bean as it is. - Label beforeUnknown = cob.newLabel(); - cob.iload(afterUnknownSlot).ifeq(beforeUnknown); - cob.aload(beanSlot).areturn(); - cob.labelBinding(beforeUnknown); - } cob.aload(0).aload(parser).aload(ctxt).aload(beanSlot) .invokevirtual(base, "_unexpectedToken", MTD_UNEXPECTED) .areturn(); @@ -531,10 +513,6 @@ private static void buildDeserialize(CodeBuilder cob, ReaderCodegen.Plan plan) { cob.localVariable(inputNameSlot, "inputName", ConstantDescs.CD_String, scopeStart, scopeEnd); } - if (trackUnknown) { - cob.localVariable(afterUnknownSlot, "afterUnknown", ConstantDescs.CD_boolean, - scopeStart, scopeEnd); - } } // BuilderBasedDeserializer.finishBuild: a failing build method goes diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/codegen/ReaderPlanCheckTest.java b/blackbird/src/test/java/tools/jackson/module/blackbird/codegen/ReaderPlanCheckTest.java index 3f4f5871a..2610aa0ce 100644 --- a/blackbird/src/test/java/tools/jackson/module/blackbird/codegen/ReaderPlanCheckTest.java +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/codegen/ReaderPlanCheckTest.java @@ -30,7 +30,7 @@ public void scalarArmWithoutStoreFails() { IllegalStateException e = assertThrows(IllegalStateException.class, () -> ReaderCodegen.generate(Bean.class, List.of(new GenProp("x", kind, null)), null, new int[] { 0 }, null, null, Construction.instantiator(null), - false, false, ViewStrategy.NONE, null)); + false, ViewStrategy.NONE, null)); assertTrue(e.getMessage().contains("no store handle for " + kind), e.getMessage()); } } diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBReaderOddTokenTest.java b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBReaderOddTokenTest.java index 1813f52e6..01232bf1f 100644 --- a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBReaderOddTokenTest.java +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBReaderOddTokenTest.java @@ -17,13 +17,12 @@ /** * A token that is neither a name nor END_OBJECT where a name is expected - * (possible from a non-JSON parser or a replayed buffer). Stock's vanilla - * loop reports it as unexpected, unless an unknown name came before it: then - * _vanillaDeserializeWithUnknown returns the bean as it is. Stock takes the - * vanilla loop only with DEFAULT_VIEW_INCLUSION on (off by default in 3.x); - * otherwise its general loop reports the token either way. A record (stock's - * property-based loop, which runs only while it sees names) ends its loop on - * the token and builds as at END_OBJECT. Every case compares against a + * (possible from a non-JSON parser or a replayed buffer). Stock's loops report + * it as unexpected, with or without an unknown name before it, including + * _vanillaDeserializeWithUnknown since [databind#6357]. Stock takes the vanilla + * loop only with DEFAULT_VIEW_INCLUSION on (off by default in 3.x). A record + * (stock's property-based loop, which runs only while it sees names) ends its + * loop on the token and builds as at END_OBJECT. Every case compares against a * vanilla mapper. */ public class BBReaderOddTokenTest extends BlackbirdTestBase @@ -46,14 +45,10 @@ public record RequiredRec(String s, @JsonProperty(required = true) int i) { } .enable(MapperFeature.DEFAULT_VIEW_INCLUSION).build(); @Test - public void oddTokenAfterUnknownReturnsBean() throws Exception + public void oddTokenAfterUnknownFailsWithVanillaLoop() throws Exception { assertGeneratedReader(viewInclusionMapper, Plain.class); - Plain expected = read(viewInclusionVanilla, true); - Plain actual = read(viewInclusionMapper, true); - assertEquals("a", expected.s); - assertEquals("a", actual.s); - assertEquals(expected.i, actual.i); + assertSameFailure(viewInclusionVanilla, viewInclusionMapper, true); } @Test @@ -107,11 +102,6 @@ private static void assertSameFailure(ObjectMapper stock, ObjectMapper bb, assertEquals(expected.getOriginalMessage(), actual.getOriginalMessage()); } - private static Plain read(ObjectMapper m, boolean withUnknown) throws Exception - { - return read(m, withUnknown, Plain.class); - } - // {"s":"a", ["x":1,] 3, "i":5}: a bare number where a name belongs. private static T read(ObjectMapper m, boolean withUnknown, Class type) throws Exception diff --git a/release-notes/VERSION b/release-notes/VERSION index 2c90a914b..560c16cfa 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -48,7 +48,9 @@ Active maintainers: no more duplicate path segment for failures with out-of-order properties. Afterburner now matches stock deserializers in two more ways: parser failures have no property in their path, and non-Jackson exceptions from handling of unknown - properties (like ones `DeserializationProblemHandler` throws) are not wrapped + properties (like ones `DeserializationProblemHandler` throws) are not wrapped. + (blackbird) Reports unexpected token after unknown property, following + [databind#6357] (fix by @cowtowncoder, w/ Claude code) 3.2.5 (not yet released) From 052a4f443a18a186631d367c9caa39434c7e7e8e Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Sun, 1 Nov 2026 11:35:04 -0800 Subject: [PATCH 08/12] Afterburner: restore per-property call sites, fix update from non-Object `_readAndSet()` put all `deserializeAndSet()` calls on one call site, which would mix the type profiles of all properties on Afterburner's hot path. Each property has its own call site again, with parser calls still out of `try`. `SuperSonicBeanDeserializer.deserialize(p, ctxt, bean)` failed on a token that is neither START_OBJECT nor PROPERTY_NAME, for example with `readerForUpdating()` or `@JsonMerge` and a scalar value. It now leaves the bean as is, same as stock and `SuperSonicUnrolledDeserializer`. `UnknownProperty343Test` only went through stock databind: adds vanilla and update cases that reach Afterburner's own code. Minor `AliasErrorPathTest` cleanup. Co-Authored-By: Claude Opus 5.5 --- .../afterburner/deser/SuperSonicBDBase.java | 20 ----- .../deser/SuperSonicBeanDeserializer.java | 82 +++++++++++++++--- .../deser/SuperSonicUnrolledDeserializer.java | 86 ++++++++++++++++--- .../afterburner/deser/AliasErrorPathTest.java | 34 ++++++-- .../deser/UnknownProperty343Test.java | 40 +++++++-- 5 files changed, 202 insertions(+), 60 deletions(-) diff --git a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java index 5352000cb..12d346642 100644 --- a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java +++ b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java @@ -126,26 +126,6 @@ public void resolve(DeserializationContext ctxt) // public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException // public final Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean) throws IOException;; - /** - * Reads and sets the value of a property matched by expected order, with parser - * pointing to its name. Name matched exactly, so logical name is the input name. - * Only failures of the value get the property in their path: as with stock - * deserializers, parser failures for the next token or next name do not. - * - * @since 3.3 - */ - protected final void _readAndSet(JsonParser p, DeserializationContext ctxt, - Object bean, SettableBeanProperty prop) - throws JacksonException - { - p.nextToken(); - try { - prop.deserializeAndSet(p, ctxt, bean); - } catch (Exception e) { - wrapAndThrow(e, bean, prop.getName(), ctxt); - } - } - protected final Object _deserializeDisordered(JsonParser p, DeserializationContext ctxt, Object bean) throws JacksonException diff --git a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java index 46061f1d0..0d06ce679 100644 --- a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java +++ b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java @@ -110,23 +110,44 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt) injectValues(ctxt, bean); } - // We also we have at least 6 properties, so roll out first few + // We also we have at least 6 properties, so roll out first few. Each with a call site + // of its own, for type profiles; parser calls outside of `try`, as stock has them if (!p.nextName(_orderedPropertyNames[0])) { return _deserializeDisordered(p, ctxt, bean); } - _readAndSet(p, ctxt, bean, _orderedProperties[0]); + p.nextToken(); + try { + _orderedProperties[0].deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, _orderedProperties[0].getName(), ctxt); + } if (!p.nextName(_orderedPropertyNames[1])) { return _deserializeDisordered(p, ctxt, bean); } - _readAndSet(p, ctxt, bean, _orderedProperties[1]); + p.nextToken(); + try { + _orderedProperties[1].deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, _orderedProperties[1].getName(), ctxt); + } if (!p.nextName(_orderedPropertyNames[2])) { return _deserializeDisordered(p, ctxt, bean); } - _readAndSet(p, ctxt, bean, _orderedProperties[2]); + p.nextToken(); + try { + _orderedProperties[2].deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, _orderedProperties[2].getName(), ctxt); + } if (!p.nextName(_orderedPropertyNames[3])) { return _deserializeDisordered(p, ctxt, bean); } - _readAndSet(p, ctxt, bean, _orderedProperties[3]); + p.nextToken(); + try { + _orderedProperties[3].deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, _orderedProperties[3].getName(), ctxt); + } for (int i = 4, len = _orderedProperties.length; i < len; ++i) { if (!p.nextName(_orderedPropertyNames[i])) { // miss... @@ -136,7 +157,12 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt) // we likely point to FIELD_NAME still; offline return _deserializeDisordered(p, ctxt, bean); } - _readAndSet(p, ctxt, bean, _orderedProperties[i]); + p.nextToken(); + try { + _orderedProperties[i].deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, _orderedProperties[i].getName(), ctxt); + } } // also, need to ensure we get closing END_OBJECT... if (p.nextToken() != JsonToken.END_OBJECT) { @@ -167,29 +193,52 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean if (_externalTypeIdHandler != null) { return deserializeWithExternalTypeId(p, ctxt, bean); } - // We also we have at least 6 properties, so roll out first few + // We also we have at least 6 properties, so roll out first few. Each with a call site + // of its own, for type profiles; parser calls outside of `try`, as stock has them SettableBeanProperty prop = _orderedProperties[0]; if (p.isExpectedStartObjectToken()) { if (!p.nextName(_orderedPropertyNames[0])) { return _deserializeDisordered(p, ctxt, bean); } - } else if (!p.hasToken(JsonToken.PROPERTY_NAME) - || !prop.getName().equals(p.currentName())) { + } else if (!p.hasToken(JsonToken.PROPERTY_NAME)) { + // not an Object at all: stock leaves the bean as is + return super.deserialize(p, ctxt, bean); + } else if (!prop.getName().equals(p.currentName())) { return _deserializeDisordered(p, ctxt, bean); } - _readAndSet(p, ctxt, bean, prop); + p.nextToken(); + try { + prop.deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, prop.getName(), ctxt); + } if (!p.nextName(_orderedPropertyNames[1])) { return _deserializeDisordered(p, ctxt, bean); } - _readAndSet(p, ctxt, bean, _orderedProperties[1]); + p.nextToken(); + try { + _orderedProperties[1].deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, _orderedProperties[1].getName(), ctxt); + } if (!p.nextName(_orderedPropertyNames[2])) { return _deserializeDisordered(p, ctxt, bean); } - _readAndSet(p, ctxt, bean, _orderedProperties[2]); + p.nextToken(); + try { + _orderedProperties[2].deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, _orderedProperties[2].getName(), ctxt); + } if (!p.nextName(_orderedPropertyNames[3])) { return _deserializeDisordered(p, ctxt, bean); } - _readAndSet(p, ctxt, bean, _orderedProperties[3]); + p.nextToken(); + try { + _orderedProperties[3].deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, _orderedProperties[3].getName(), ctxt); + } // then rest of properties for (int i = 4, len = _orderedProperties.length; i < len; ++i) { @@ -200,7 +249,12 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean // we likely point to FIELD_NAME, so can just call parent impl return super.deserialize(p, ctxt, bean); } - _readAndSet(p, ctxt, bean, _orderedProperties[i]); + p.nextToken(); + try { + _orderedProperties[i].deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, _orderedProperties[i].getName(), ctxt); + } } // also, need to ensure we get closing END_OBJECT... if (p.nextToken() != JsonToken.END_OBJECT) { diff --git a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicUnrolledDeserializer.java b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicUnrolledDeserializer.java index 6c159e06c..6eb603bf5 100644 --- a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicUnrolledDeserializer.java +++ b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicUnrolledDeserializer.java @@ -179,43 +179,75 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt) if (_injectables != null) { injectValues(ctxt, bean); } + // Each property with a call site of its own, for type profiles; parser calls + // outside of `try`, as stock has them switch (_propCount) { default: //case 6: if (!p.nextName(_name1)) { break; } - _readAndSet(p, ctxt, bean, _prop1); + p.nextToken(); + try { + _prop1.deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, _prop1.getName(), ctxt); + } // fall through case 5: if (!p.nextName(_name2)) { break; } - _readAndSet(p, ctxt, bean, _prop2); + p.nextToken(); + try { + _prop2.deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, _prop2.getName(), ctxt); + } // fall through case 4: if (!p.nextName(_name3)) { break; } - _readAndSet(p, ctxt, bean, _prop3); + p.nextToken(); + try { + _prop3.deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, _prop3.getName(), ctxt); + } // fall through case 3: if (!p.nextName(_name4)) { break; } - _readAndSet(p, ctxt, bean, _prop4); + p.nextToken(); + try { + _prop4.deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, _prop4.getName(), ctxt); + } // fall through case 2: if (!p.nextName(_name5)) { break; } - _readAndSet(p, ctxt, bean, _prop5); + p.nextToken(); + try { + _prop5.deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, _prop5.getName(), ctxt); + } // fall through case 1: if (!p.nextName(_name6)) { break; } - _readAndSet(p, ctxt, bean, _prop6); + p.nextToken(); + try { + _prop6.deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, _prop6.getName(), ctxt); + } // also, need to ensure we get closing END_OBJECT... if (p.nextToken() == JsonToken.END_OBJECT) { return bean; @@ -261,7 +293,12 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean // no, something funky, use base impl for special cases return super.deserialize(p, ctxt, bean); } - _readAndSet(p, ctxt, bean, prop); + p.nextToken(); + try { + prop.deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, prop.getName(), ctxt); + } // then rest of properties. NOTE! Off-by-one, to skip one we did above switch (_propCount) { default: @@ -269,31 +306,56 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean if (!p.nextName(_name2)) { break; } - _readAndSet(p, ctxt, bean, _prop2); + p.nextToken(); + try { + _prop2.deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, _prop2.getName(), ctxt); + } // fall through case 5: if (!p.nextName(_name3)) { break; } - _readAndSet(p, ctxt, bean, _prop3); + p.nextToken(); + try { + _prop3.deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, _prop3.getName(), ctxt); + } // fall through case 4: if (!p.nextName(_name4)) { break; } - _readAndSet(p, ctxt, bean, _prop4); + p.nextToken(); + try { + _prop4.deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, _prop4.getName(), ctxt); + } // fall through case 3: if (!p.nextName(_name5)) { break; } - _readAndSet(p, ctxt, bean, _prop5); + p.nextToken(); + try { + _prop5.deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, _prop5.getName(), ctxt); + } // fall through case 2: if (!p.nextName(_name6)) { break; } - _readAndSet(p, ctxt, bean, _prop6); + p.nextToken(); + try { + _prop6.deserializeAndSet(p, ctxt, bean); + } catch (Exception e) { + throw wrapAndThrow(e, bean, _prop6.getName(), ctxt); + } // fall through case 1: // NOTE! skip "last" one to compensate for OBO diff --git a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java index 514623caa..f938c7e43 100644 --- a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java +++ b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java @@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test; import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonMerge; import tools.jackson.core.JacksonException; import tools.jackson.core.JsonParser; @@ -44,6 +45,10 @@ public static class PlainBigBean { public int a, b, c, d, e, f, g; } + public static class MergeHolder { + @JsonMerge public BigBean big = new BigBean(); + } + // Afterburner's optimized deserializers need "vanilla" processing private final ObjectMapper MAPPER = _mapperBuilder().build(); @@ -119,7 +124,24 @@ public void orderedForUpdateFromPropertyName() throws Exception { InvalidFormatException e = assertThrows(InvalidFormatException.class, () -> MAPPER.readerForUpdating(new BigBean()).readValue(p)); _verifyException(e, "a1"); - _verifyFrames(e, "_deserializeDisordered"); + assertTrue(_hasFrame(e, "_deserializeDisordered")); + } + } + + // Not an Object: nothing to update, bean is left as is, same as stock + @Test + public void updateFromNonObject() throws Exception { + final ObjectMapper stock = newVanillaJSONMapper(); + for (ObjectMapper mapper : List.of(stock, MAPPER)) { + for (String json : List.of("12", "\"x\"", "true")) { + for (Object bean : List.of(new SmallBean(), new BigBean())) { + assertSame(bean, mapper.readerForUpdating(bean).readValue(json), json); + } + } + MergeHolder holder = mapper.readValue(""" + {"big":12} + """, MergeHolder.class); + assertNotNull(holder.big); } } @@ -190,7 +212,7 @@ public boolean handleUnknownProperty(DeserializationContext ctxt, JsonParser p, } }; final ObjectMapper afterburner = _mapperBuilder().addHandler(handler).build(); - final ObjectMapper stock = JsonMapper.builder().addHandler(handler).build(); + final ObjectMapper stock = newVanillaJSONMapper().rebuild().addHandler(handler).build(); for (Class type : List.of(SmallBean.class, BigBean.class)) { for (String json : List.of(""" {"zzz":1} @@ -209,7 +231,7 @@ public boolean handleUnknownProperty(DeserializationContext ctxt, JsonParser p, // Parser failures are not failures of a property: no path, same as stock @Test public void parserFailures() throws Exception { - final ObjectMapper stock = JsonMapper.builder().build(); + final ObjectMapper stock = newVanillaJSONMapper(); for (Class type : List.of(SmallBean.class, BigBean.class)) { for (String json : List.of( "{ bad", @@ -253,12 +275,6 @@ private static boolean _hasFrame(Throwable e, String method) { f -> f.getClassName().contains("SuperSonic") && f.getMethodName().equals(method)); } - private static void _verifyFrames(Throwable e, String... methods) { - for (String method : methods) { - assertTrue(_hasFrame(e, method), "should go through " + method + "()"); - } - } - private void _verifyPath(Class type, String json, String expName) { _verifyPath(MAPPER.readerFor(type), json, expName); } diff --git a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/UnknownProperty343Test.java b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/UnknownProperty343Test.java index d480a154b..d7e5612e9 100644 --- a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/UnknownProperty343Test.java +++ b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/UnknownProperty343Test.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.annotation.JsonView; import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.MapperFeature; import tools.jackson.databind.ObjectMapper; import tools.jackson.module.afterburner.AfterburnerTestBase; @@ -35,21 +36,50 @@ public static class POJO343 { @JsonProperty("h") public String h; } + // No views: Afterburner's own ordered fast paths handle this one + public static class Vanilla343 { + public String a, b, c, d, e, f, g, h; + } + + private final static String JSON = "{" + + "\"a\":\"a\",\"b\":\"b\",\"c\":\"c\",\"d\":\"d\"," + + "\"e\":\"e\",\"f\":\"f\",\"g\":\"g\",\"h\":\"h\"," + + "\"i\":\"i\"}"; + private final ObjectMapper MAPPER = afterburnerMapperBuilder() .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .build(); + // Afterburner's optimized deserializers need "vanilla" processing + private final ObjectMapper VANILLA_MAPPER = afterburnerMapperBuilder() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(MapperFeature.DEFAULT_VIEW_INCLUSION) + .build(); + @Test public void testTrailingUnknownProperty() throws Exception { - String json = "{" - + "\"a\":\"a\",\"b\":\"b\",\"c\":\"c\",\"d\":\"d\"," - + "\"e\":\"e\",\"f\":\"f\",\"g\":\"g\",\"h\":\"h\"," - + "\"i\":\"i\"}"; POJO343 result = MAPPER.readerWithView(DefaultView.class) .forType(POJO343.class) - .readValue(json); + .readValue(JSON); + assertEquals("a", result.a); + assertEquals("h", result.h); + } + + @Test + public void testTrailingUnknownPropertyVanilla() throws Exception + { + Vanilla343 result = VANILLA_MAPPER.readValue(JSON, Vanilla343.class); assertEquals("a", result.a); assertEquals("h", result.h); } + + @Test + public void testTrailingUnknownPropertyVanillaForUpdate() throws Exception + { + Vanilla343 bean = new Vanilla343(); + assertSame(bean, VANILLA_MAPPER.readerForUpdating(bean).readValue(JSON)); + assertEquals("a", bean.a); + assertEquals("h", bean.h); + } } From b6c4ad425ff1ce56b5d706fe99efae9a9777c331 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Sun, 1 Nov 2026 11:52:45 -0800 Subject: [PATCH 09/12] Simplify tests --- .../afterburner/deser/AliasErrorPathTest.java | 227 +++++------------- .../deser/UnknownProperty343Test.java | 6 +- .../deser/BBCodecStockChecksTest.java | 51 ++-- 3 files changed, 85 insertions(+), 199 deletions(-) diff --git a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java index f938c7e43..7992b02af 100644 --- a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java +++ b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java @@ -14,8 +14,6 @@ import tools.jackson.core.exc.StreamReadException; import tools.jackson.databind.*; import tools.jackson.databind.deser.DeserializationProblemHandler; -import tools.jackson.databind.exc.InvalidFormatException; -import tools.jackson.databind.exc.MismatchedInputException; import tools.jackson.databind.json.JsonMapper; import tools.jackson.module.afterburner.AfterburnerTestBase; @@ -49,81 +47,66 @@ public static class MergeHolder { @JsonMerge public BigBean big = new BigBean(); } + private static final List> BEANS = List.of(SmallBean.class, BigBean.class); + // Afterburner's optimized deserializers need "vanilla" processing private final ObjectMapper MAPPER = _mapperBuilder().build(); @Test - public void unrolledAlias() throws Exception { - _verifyPath(SmallBean.class, """ - {"a1":"bad"} - """, "a1"); - _verifyPath(SmallBean.class, """ - {"a":1,"b1":"bad"} - """, "b1"); - } - - @Test - public void unrolledCanonical() throws Exception { - _verifyPath(SmallBean.class, """ - {"a":"bad"} - """, "a"); + public void aliasAndCanonicalName() throws Exception { + for (Class type : BEANS) { + ObjectReader r = MAPPER.readerFor(type); + _verifyPath(r, "{'a1':'bad'}", "a1"); + _verifyPath(r, "{'a':1,'b1':'bad'}", "b1"); + _verifyPath(r, "{'a':'bad'}", "a"); + // Not scalar: name must be found at these tokens too + _verifyPath(r, "{'a1':{}}", "a1"); + _verifyPath(r, "{'a1':[]}", "a1"); + } } + // Past the rolled out first properties of SuperSonicBeanDeserializer @Test - public void orderedAlias() throws Exception { - _verifyPath(BigBean.class, """ - {"a1":"bad"} - """, "a1"); - _verifyPath(BigBean.class, """ - {"a":1,"b1":"bad"} - """, "b1"); - _verifyPath(BigBean.class, """ - {"a":1,"b":2,"c":3,"d":4,"e":5,"f":6,"g1":"bad"} - """, "g1"); + public void laterProperties() throws Exception { + ObjectReader r = MAPPER.readerFor(BigBean.class); + _verifyPath(r, "{'a':1,'b':2,'c':3,'d':4,'e':5,'f':6,'g1':'bad'}", "g1"); + _verifyPath(r, "{'a':1,'b':2,'c':3,'d':4,'e':5,'f':6,'g':'bad'}", "g"); } + // No aliases, no case-insensitivity: input name is not read @Test - public void orderedCanonical() throws Exception { - _verifyPath(BigBean.class, """ - {"a":"bad"} - """, "a"); - _verifyPath(BigBean.class, """ - {"a":1,"b":2,"c":3,"d":4,"e":5,"f":6,"g":"bad"} - """, "g"); + public void noInputNames() throws Exception { + _verifyPath(MAPPER.readerFor(PlainBigBean.class), "{'b':'bad'}", "b"); } @Test - public void orderedForUpdate() throws Exception { - _verifyPath(MAPPER.readerForUpdating(new BigBean()), """ - {"a1":"bad"} - """, "a1"); - _verifyPath(MAPPER.readerForUpdating(new BigBean()), """ - {"a":1,"b1":"bad"} - """, "b1"); + public void caseInsensitive() throws Exception { + final ObjectMapper mapper = _mapperBuilder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES) + .build(); + for (Class type : BEANS) { + _verifyPath(mapper.readerFor(type), "{'A':'bad'}", "A"); + } } - // Parser already pointing to the first property name. Handled by databind's - // `BeanDeserializer`, since `_deserializeOther()` calls its own vanilla method @Test - public void orderedFromPropertyName() throws Exception { - try (JsonParser p = _parserAtFirstName(""" - {"a1":"bad"} - """)) { - InvalidFormatException e = assertThrows(InvalidFormatException.class, - () -> MAPPER.readerFor(BigBean.class).readValue(p)); - _verifyException(e, "a1"); + public void forUpdate() throws Exception { + for (Object bean : List.of(new SmallBean(), new BigBean())) { + ObjectReader r = MAPPER.readerForUpdating(bean); + _verifyPath(r, "{'a1':'bad'}", "a1"); + _verifyPath(r, "{'a':1,'b1':'bad'}", "b1"); } } - // As above, but for update: handled by `SuperSonicBeanDeserializer` + // Parser already pointing to the first property name @Test - public void orderedForUpdateFromPropertyName() throws Exception { - try (JsonParser p = _parserAtFirstName(""" - {"a1":"bad"} - """)) { - InvalidFormatException e = assertThrows(InvalidFormatException.class, + public void forUpdateFromPropertyName() throws Exception { + try (JsonParser p = MAPPER.createParser(a2q("{'a1':'bad'}"))) { + assertToken(JsonToken.START_OBJECT, p.nextToken()); + assertToken(JsonToken.PROPERTY_NAME, p.nextToken()); + DatabindException e = assertThrows(DatabindException.class, () -> MAPPER.readerForUpdating(new BigBean()).readValue(p)); - _verifyException(e, "a1"); + assertEquals(List.of("a1"), _path(e)); assertTrue(_hasFrame(e, "_deserializeDisordered")); } } @@ -131,73 +114,23 @@ public void orderedForUpdateFromPropertyName() throws Exception { // Not an Object: nothing to update, bean is left as is, same as stock @Test public void updateFromNonObject() throws Exception { - final ObjectMapper stock = newVanillaJSONMapper(); - for (ObjectMapper mapper : List.of(stock, MAPPER)) { - for (String json : List.of("12", "\"x\"", "true")) { - for (Object bean : List.of(new SmallBean(), new BigBean())) { - assertSame(bean, mapper.readerForUpdating(bean).readValue(json), json); - } + for (ObjectMapper mapper : List.of(newVanillaJSONMapper(), MAPPER)) { + for (Object bean : List.of(new SmallBean(), new BigBean())) { + assertSame(bean, mapper.readerForUpdating(bean).readValue("12")); } - MergeHolder holder = mapper.readValue(""" - {"big":12} - """, MergeHolder.class); - assertNotNull(holder.big); + assertNotNull(mapper.readValue(a2q("{'big':12}"), MergeHolder.class).big); } } - // Other case: input name differs from logical one without aliases - @Test - public void caseInsensitive() throws Exception { - final ObjectMapper mapper = _mapperBuilder() - .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES) - .build(); - for (Class type : List.of(SmallBean.class, BigBean.class)) { - _verifyPath(mapper.readerFor(type), """ - {"A":"bad"} - """, "A"); - } - } - - // No aliases, no case-insensitivity: input name is not read - @Test - public void noInputNames() throws Exception { - _verifyPath(PlainBigBean.class, """ - {"b":"bad"} - """, "b"); - } - - // Name must be found also when the value is not scalar - @Test - public void aliasWithStructuredValue() throws Exception { - for (Class type : List.of(SmallBean.class, BigBean.class)) { - for (String json : List.of(""" - {"a1":{}} - """, """ - {"a1":[]} - """)) { - MismatchedInputException e = assertThrows(MismatchedInputException.class, - () -> MAPPER.readerFor(type).readValue(json)); - _verifyException(e, "a1"); - } - } - } - - // Failure from property handling is reported once, not also for earlier property + // Failure is reported once, not also for earlier property @Test public void unknownProperty() throws Exception { final ObjectMapper mapper = _mapperBuilder() .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .build(); - for (Class type : List.of(SmallBean.class, BigBean.class)) { - for (String json : List.of(""" - {"zzz":1} - """, """ - {"a":1,"zzz":1} - """)) { - DatabindException e = assertThrows(DatabindException.class, - () -> mapper.readerFor(type).readValue(json)); - assertEquals(List.of("zzz"), _path(e)); - } + for (Class type : BEANS) { + _verifyPath(mapper.readerFor(type), "{'zzz':1}", "zzz"); + _verifyPath(mapper.readerFor(type), "{'a':1,'zzz':1}", "zzz"); } } @@ -213,18 +146,13 @@ public boolean handleUnknownProperty(DeserializationContext ctxt, JsonParser p, }; final ObjectMapper afterburner = _mapperBuilder().addHandler(handler).build(); final ObjectMapper stock = newVanillaJSONMapper().rebuild().addHandler(handler).build(); - for (Class type : List.of(SmallBean.class, BigBean.class)) { - for (String json : List.of(""" - {"zzz":1} - """, """ - {"a":1,"zzz":1} - """)) { - assertThrows(IllegalStateException.class, - () -> stock.readerFor(type).readValue(json)); - IllegalStateException e = assertThrows(IllegalStateException.class, - () -> afterburner.readerFor(type).readValue(json)); - assertTrue(_hasFrame(e, "_deserializeDisordered")); - } + final String json = a2q("{'a':1,'zzz':1}"); + for (Class type : BEANS) { + assertThrows(IllegalStateException.class, + () -> stock.readerFor(type).readValue(json)); + assertTrue(_hasFrame(assertThrows(IllegalStateException.class, + () -> afterburner.readerFor(type).readValue(json)), + "_deserializeDisordered")); } } @@ -232,22 +160,13 @@ public boolean handleUnknownProperty(DeserializationContext ctxt, JsonParser p, @Test public void parserFailures() throws Exception { final ObjectMapper stock = newVanillaJSONMapper(); - for (Class type : List.of(SmallBean.class, BigBean.class)) { - for (String json : List.of( - "{ bad", - "{\"a\":bad", - "{\"a\":1, bad", - "{\"a\":1,\"b\":bad", - "{\"b\":1, bad")) { - for (ObjectReader r : List.of(stock.readerFor(type), - MAPPER.readerFor(type))) { - assertEquals(List.of(), _path( - assertThrows(StreamReadException.class, () -> r.readValue(json))), - json); - } + for (String json : List.of("{ bad", "{'a':bad", "{'a':1, bad", "{'a':1,'b':bad", "{'b':1, bad")) { + for (ObjectReader r : List.of( + stock.readerFor(SmallBean.class), stock.readerFor(BigBean.class), + MAPPER.readerFor(SmallBean.class), MAPPER.readerFor(BigBean.class), + MAPPER.readerForUpdating(new SmallBean()), MAPPER.readerForUpdating(new BigBean()))) { assertEquals(List.of(), _path(assertThrows(StreamReadException.class, - () -> MAPPER.readerForUpdating(type.getDeclaredConstructor().newInstance()) - .readValue(json))), json); + () -> r.readValue(a2q(json)))), json); } } } @@ -257,11 +176,11 @@ private static JsonMapper.Builder _mapperBuilder() { .enable(MapperFeature.DEFAULT_VIEW_INCLUSION); } - private JsonParser _parserAtFirstName(String json) { - JsonParser p = MAPPER.createParser(json); - assertToken(JsonToken.START_OBJECT, p.nextToken()); - assertToken(JsonToken.PROPERTY_NAME, p.nextToken()); - return p; + private static void _verifyPath(ObjectReader r, String json, String expName) { + DatabindException e = assertThrows(DatabindException.class, + () -> r.readValue(a2q(json))); + assertEquals(List.of(expName), _path(e)); + assertTrue(_hasFrame(e, "deserialize"), "should go through Afterburner's deserializer"); } private static List _path(JacksonException e) { @@ -274,20 +193,4 @@ private static boolean _hasFrame(Throwable e, String method) { return Arrays.stream(e.getStackTrace()).anyMatch( f -> f.getClassName().contains("SuperSonic") && f.getMethodName().equals(method)); } - - private void _verifyPath(Class type, String json, String expName) { - _verifyPath(MAPPER.readerFor(type), json, expName); - } - - private void _verifyPath(ObjectReader r, String json, String expName) { - _verifyException(assertThrows(InvalidFormatException.class, () -> r.readValue(json)), - expName); - } - - private void _verifyException(DatabindException e, String expName) { - assertEquals(List.of(expName), _path(e)); - assertTrue(Arrays.stream(e.getStackTrace()).anyMatch( - f -> f.getClassName().contains("SuperSonic")), - "should go through Afterburner's deserializer"); - } } diff --git a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/UnknownProperty343Test.java b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/UnknownProperty343Test.java index d7e5612e9..71d4f807c 100644 --- a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/UnknownProperty343Test.java +++ b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/UnknownProperty343Test.java @@ -72,13 +72,9 @@ public void testTrailingUnknownPropertyVanilla() throws Exception Vanilla343 result = VANILLA_MAPPER.readValue(JSON, Vanilla343.class); assertEquals("a", result.a); assertEquals("h", result.h); - } - @Test - public void testTrailingUnknownPropertyVanillaForUpdate() throws Exception - { Vanilla343 bean = new Vanilla343(); - assertSame(bean, VANILLA_MAPPER.readerForUpdating(bean).readValue(JSON)); + VANILLA_MAPPER.readerForUpdating(bean).readValue(JSON); assertEquals("a", bean.a); assertEquals("h", bean.h); } diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecStockChecksTest.java b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecStockChecksTest.java index 180a34262..dc1e2d2cf 100644 --- a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecStockChecksTest.java +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBCodecStockChecksTest.java @@ -384,37 +384,32 @@ private void _assertFailureNames(ObjectMapper vanilla, ObjectMapper module) thro @Test public void testFailureNameWithStructuredValue() throws Exception { - assertGeneratedReader(MODULE, AliasedStructured.class); - assertGeneratedReader(MODULE, AliasedStructuredRecord.class); for (Class type : List.of(AliasedStructured.class, AliasedStructuredRecord.class)) { - // Failures at the structured value itself: read at the child, stock read - _assertSameFailure(VANILLA, MODULE, "{\"c1\":[]}", type, "c1"); - _assertSameFailure(VANILLA, MODULE, "{\"l1\":{}}", type, "l1"); - // Failures within: first name stays, and later ones do not replace it - _assertSameFailure(VANILLA, MODULE, "{\"c1\":{\"n\":\"x\"}}", type, "c1"); - _assertSameFailure(VANILLA, MODULE, "{\"l1\":[\"x\"]}", type, "l1"); - _assertSameFailure(VANILLA, MODULE, "{\"c1\":{\"n\":1},\"l1\":{}}", type, "l1"); - // Canonical name - _assertSameFailure(VANILLA, MODULE, "{\"child\":[]}", type, "child"); + assertGeneratedReader(MODULE, type); + _assertSameFailure(VANILLA, MODULE, a2q("{'c1':[]}"), type, "c1"); + _assertSameFailure(VANILLA, MODULE, a2q("{'l1':{}}"), type, "l1"); + // Failures within: names within do not replace this name + _assertSameFailure(VANILLA, MODULE, a2q("{'c1':{'n':'x'}}"), type, "c1"); + _assertSameFailure(VANILLA, MODULE, a2q("{'l1':['x']}"), type, "l1"); + _assertSameFailure(VANILLA, MODULE, a2q("{'c1':{'n':1},'l1':{}}"), type, "l1"); + _assertSameFailure(VANILLA, MODULE, a2q("{'child':[]}"), type, "child"); } - // Field, scalar value - _assertSameFailure(VANILLA, MODULE, "{\"f1\":\"x\"}", AliasedStructured.class, "f1"); - // Setter fails after child has been read: name was read before that - _assertSameFailure(VANILLA, MODULE, "{\"t1\":{\"n\":1}}", AliasedStructured.class, "t1"); + _assertSameFailure(VANILLA, MODULE, a2q("{'f1':'x'}"), AliasedStructured.class, "f1"); + // Setter fails after child has been read + _assertSameFailure(VANILLA, MODULE, a2q("{'t1':{'n':1}}"), AliasedStructured.class, "t1"); } @Test public void testFailureNameUnderActiveView() throws Exception { - assertGeneratedReader(MODULE, AliasedViews.class); - assertGeneratedReader(MODULE, AliasedViewsRecord.class); for (Class type : List.of(AliasedViews.class, AliasedViewsRecord.class)) { + assertGeneratedReader(MODULE, type); final ObjectReader vanilla = VANILLA.readerFor(type).withView(ViewA.class); final ObjectReader module = MODULE.readerFor(type).withView(ViewA.class); - _assertSameFailure(vanilla, module, "{\"a1\":\"x\"}", "a1"); - _assertSameFailure(vanilla, module, "{\"c1\":[]}", "c1"); + _assertSameFailure(vanilla, module, a2q("{'a1':'x'}"), "a1"); + _assertSameFailure(vanilla, module, a2q("{'c1':[]}"), "c1"); // Past a property hidden by the view, with invalid value that gets skipped - _assertSameFailure(vanilla, module, "{\"b1\":\"x\",\"a1\":\"y\"}", "a1"); - _assertSameFailure(vanilla, module, "{\"b1\":{\"z\":1},\"c1\":[]}", "c1"); + _assertSameFailure(vanilla, module, a2q("{'b1':'x','a1':'y'}"), "a1"); + _assertSameFailure(vanilla, module, a2q("{'b1':{'z':1},'c1':[]}"), "c1"); } } @@ -437,17 +432,9 @@ private void _assertSameFailure(ObjectReader vanilla, ObjectReader module, @Test public void testBuilderFailureReportsInputName() throws Exception { assertGeneratedReader(MODULE, Aliased.class); - for (String doc : new String[] { "{\"alt\":\"x\"}", "{\"alt\":-1}", "{\"v\":-1}" }) { - Exception expected = assertThrows(Exception.class, - () -> VANILLA.readValue(doc, Aliased.class)); - Exception actual = assertThrows(Exception.class, - () -> MODULE.readValue(doc, Aliased.class)); - assertEquals(expected.getClass(), actual.getClass(), doc); - assertEquals(expected.getMessage(), actual.getMessage(), doc); - } - Exception e = assertThrows(Exception.class, - () -> MODULE.readValue("{\"alt\":-1}", Aliased.class)); - assertTrue(e.getMessage().contains("[\"alt\"]"), e.getMessage()); + _assertSameFailure(VANILLA, MODULE, "{\"alt\":\"x\"}", Aliased.class, "alt"); + _assertSameFailure(VANILLA, MODULE, "{\"alt\":-1}", Aliased.class, "alt"); + _assertSameFailure(VANILLA, MODULE, "{\"v\":-1}", Aliased.class, "v"); } // The same after a type id that follows a buffered property: the From 1bc50cf7484d5617293300fc904ad1306d887654 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Sun, 1 Nov 2026 12:08:24 -0800 Subject: [PATCH 10/12] Blackbird: report unexpected token in record codecs, as stock now does jackson-databind#6362 (fix of #6357) made stock's property-based loop report a token that is neither a name nor END_OBJECT, instead of building the value as at END_OBJECT. Generated record codecs now do the same. `BBReaderOddTokenTest` needs a databind snapshot with that fix. Co-Authored-By: Claude Opus 5.5 --- .../codegen/BeanReaderGenerator.java | 8 ++-- .../blackbird/deser/BBReaderOddTokenTest.java | 44 +++++++++++++------ release-notes/VERSION | 4 +- 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/blackbird/src/main/java25/tools/jackson/module/blackbird/codegen/BeanReaderGenerator.java b/blackbird/src/main/java25/tools/jackson/module/blackbird/codegen/BeanReaderGenerator.java index f1de74fb2..c3fbffacc 100644 --- a/blackbird/src/main/java25/tools/jackson/module/blackbird/codegen/BeanReaderGenerator.java +++ b/blackbird/src/main/java25/tools/jackson/module/blackbird/codegen/BeanReaderGenerator.java @@ -937,10 +937,12 @@ private static void buildRecordDeserialize(CodeBuilder cob, ReaderCodegen.Plan p nextNameMatch(cob, parser, matcherSlot, ixSlot); cob.goto_(loop); - // Stock's property-based loop runs only while it sees names: any - // other token ends it, and the record builds as at END_OBJECT. + // As stock's property-based loop: the record is only built at END_OBJECT, + // any other token gets reported. cob.labelBinding(oddToken); - cob.goto_(endObject); + cob.aload(0).aload(parser).aload(ctxt).aconst_null() + .invokevirtual(base, "_unexpectedToken", MTD_UNEXPECTED) + .areturn(); emitPropertyHandler(cob, base, propHandler, ctxt, propSlot, excSlot, -1, inputNameSlot); emitDelegateTail(cob, base, parser, ctxt, delegate); diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBReaderOddTokenTest.java b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBReaderOddTokenTest.java index 01232bf1f..25cebed04 100644 --- a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBReaderOddTokenTest.java +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBReaderOddTokenTest.java @@ -5,10 +5,14 @@ import org.junit.jupiter.api.Test; import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; import tools.jackson.core.ObjectReadContext; +import tools.jackson.databind.DeserializationContext; import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.JavaType; import tools.jackson.databind.MapperFeature; import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.deser.DeserializationProblemHandler; import tools.jackson.databind.exc.MismatchedInputException; import tools.jackson.databind.util.TokenBuffer; import tools.jackson.module.blackbird.BlackbirdTestBase; @@ -17,13 +21,11 @@ /** * A token that is neither a name nor END_OBJECT where a name is expected - * (possible from a non-JSON parser or a replayed buffer). Stock's loops report - * it as unexpected, with or without an unknown name before it, including - * _vanillaDeserializeWithUnknown since [databind#6357]. Stock takes the vanilla - * loop only with DEFAULT_VIEW_INCLUSION on (off by default in 3.x). A record - * (stock's property-based loop, which runs only while it sees names) ends its - * loop on the token and builds as at END_OBJECT. Every case compares against a - * vanilla mapper. + * (possible from a non-JSON parser or a replayed buffer). Since [databind#6357] + * all of stock's loops report it as unexpected, with or without an unknown name + * before it, and a record does not get built. Stock takes the vanilla loop only + * with DEFAULT_VIEW_INCLUSION on (off by default in 3.x). Every case compares + * against a vanilla mapper. */ public class BBReaderOddTokenTest extends BlackbirdTestBase { @@ -68,24 +70,38 @@ public void oddTokenWithoutUnknownFails() throws Exception } @Test - public void oddTokenEndsRecord() throws Exception + public void oddTokenInRecordFails() throws Exception { assertGeneratedReader(mapper, Rec.class); for (boolean withUnknown : new boolean[] { false, true }) { - Rec expected = read(vanilla, withUnknown, Rec.class); - assertEquals(new Rec("a", 0), expected); - assertEquals(expected, read(mapper, withUnknown, Rec.class)); + assertSameFailure(vanilla, mapper, withUnknown, Rec.class); } } - // The component after the token is missing, as at END_OBJECT. + // Reported before missing required component would be @Test - public void oddTokenEndsRecordMissingRequired() throws Exception + public void oddTokenInRecordMissingRequiredFails() throws Exception { assertGeneratedReader(mapper, RequiredRec.class); assertSameFailure(vanilla, mapper, false, RequiredRec.class); } + // Problem handler gets to provide the value, in place of building the record + @Test + public void oddTokenInRecordWithProblemHandler() throws Exception + { + final DeserializationProblemHandler handler = new DeserializationProblemHandler() { + @Override + public Object handleUnexpectedToken(DeserializationContext ctxt, JavaType targetType, + JsonToken t, JsonParser p, String failureMsg) { + return new Rec("handler", 1); + } + }; + Rec expected = new Rec("handler", 1); + assertEquals(expected, read(vanilla.rebuild().addHandler(handler).build(), false, Rec.class)); + assertEquals(expected, read(mapper.rebuild().addHandler(handler).build(), false, Rec.class)); + } + private static void assertSameFailure(ObjectMapper stock, ObjectMapper bb, boolean withUnknown) { @@ -120,7 +136,7 @@ private static T read(ObjectMapper m, boolean withUnknown, Class type) tb.writeEndObject(); try (JsonParser p = tb.asParser(ObjectReadContext.empty())) { return m.readerFor(type) - // Stock's early return leaves the rest of the object + // A problem handler's value leaves the rest of the object // unread. .without(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, DeserializationFeature.FAIL_ON_TRAILING_TOKENS) diff --git a/release-notes/VERSION b/release-notes/VERSION index 560c16cfa..4c94849db 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -49,8 +49,8 @@ Active maintainers: Afterburner now matches stock deserializers in two more ways: parser failures have no property in their path, and non-Jackson exceptions from handling of unknown properties (like ones `DeserializationProblemHandler` throws) are not wrapped. - (blackbird) Reports unexpected token after unknown property, following - [databind#6357] + (blackbird) Reports unexpected token within Object, also for records and after + unknown property, following [databind#6357] (fix by @cowtowncoder, w/ Claude code) 3.2.5 (not yet released) From ec07c0866a081a2dfd54bc89972f2e7b9022ffad Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Sun, 1 Nov 2026 12:21:54 -0800 Subject: [PATCH 11/12] Afterburner: wrap non-Jackson exceptions from unknown property handling `_deserializeDisordered()` now wraps them with the unknown property as the path, like an exception thrown by `DeserializationProblemHandler`. Jackson's own exceptions have their path already and are passed as they are. Update paths use `_deserializeDisordered()` instead of falling back to stock for names they do not expect, so that this applies to them too. Stock deserializers and Blackbird do not wrap these exceptions. Co-Authored-By: Claude Opus 5.5 --- .../afterburner/deser/SuperSonicBDBase.java | 9 ++++- .../deser/SuperSonicBeanDeserializer.java | 6 ++-- .../deser/SuperSonicUnrolledDeserializer.java | 11 +++--- .../afterburner/deser/AliasErrorPathTest.java | 36 +++++++++++++------ release-notes/VERSION | 7 ++-- 5 files changed, 47 insertions(+), 22 deletions(-) diff --git a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java index 12d346642..011c2851b 100644 --- a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java +++ b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java @@ -151,8 +151,15 @@ protected final Object _deserializeDisordered(JsonParser p, DeserializationConte if (ix != PropertyNameMatcher.MATCH_UNKNOWN_NAME) { return _handleUnexpectedWithin(p, ctxt, bean); } + final String unknownName = p.currentName(); p.nextToken(); - handleUnknownVanilla(p, ctxt, bean, p.currentName()); + try { + handleUnknownVanilla(p, ctxt, bean, unknownName); + } catch (JacksonException e) { // has its path already + throw e; + } catch (Exception e) { // like one from `DeserializationProblemHandler` + throw wrapAndThrow(e, bean, unknownName, ctxt); + } } } } diff --git a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java index 0d06ce679..bb425e64a 100644 --- a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java +++ b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java @@ -246,8 +246,8 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean if (p.hasToken(JsonToken.END_OBJECT)) { return bean; } - // we likely point to FIELD_NAME, so can just call parent impl - return super.deserialize(p, ctxt, bean); + // we likely point to FIELD_NAME; offline + return _deserializeDisordered(p, ctxt, bean); } p.nextToken(); try { @@ -258,7 +258,7 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean } // also, need to ensure we get closing END_OBJECT... if (p.nextToken() != JsonToken.END_OBJECT) { - return super.deserialize(p, ctxt, bean); + return _deserializeDisordered(p, ctxt, bean); } return bean; } diff --git a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicUnrolledDeserializer.java b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicUnrolledDeserializer.java index 6eb603bf5..615bd0869 100644 --- a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicUnrolledDeserializer.java +++ b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicUnrolledDeserializer.java @@ -286,12 +286,13 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean // First: verify that first name is expected if (p.isExpectedStartObjectToken()) { if (!p.nextName(_orderedPropertyNames[0])) { - return super.deserialize(p, ctxt, bean); + return _deserializeDisordered(p, ctxt, bean); } - } else if (!p.hasToken(JsonToken.PROPERTY_NAME) - || !prop.getName().equals(p.currentName())) { - // no, something funky, use base impl for special cases - return super.deserialize(p, ctxt, bean); + } else if (!p.hasToken(JsonToken.PROPERTY_NAME)) { + // not an Object at all: stock leaves the bean as is + return super.deserialize(p, ctxt, bean); + } else if (!prop.getName().equals(p.currentName())) { + return _deserializeDisordered(p, ctxt, bean); } p.nextToken(); try { diff --git a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java index 7992b02af..df85da551 100644 --- a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java +++ b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java @@ -131,10 +131,12 @@ public void unknownProperty() throws Exception { for (Class type : BEANS) { _verifyPath(mapper.readerFor(type), "{'zzz':1}", "zzz"); _verifyPath(mapper.readerFor(type), "{'a':1,'zzz':1}", "zzz"); + _verifyPath(mapper.readerForUpdating(new SmallBean()), "{'zzz':1}", "zzz"); + _verifyPath(mapper.readerForUpdating(new BigBean()), "{'zzz':1}", "zzz"); } } - // Exception that a problem handler throws is not wrapped, same as stock + // Exception that a problem handler throws gets wrapped, with property in path @Test public void unknownPropertyHandlerFailure() throws Exception { final DeserializationProblemHandler handler = new DeserializationProblemHandler() { @@ -144,16 +146,23 @@ public boolean handleUnknownProperty(DeserializationContext ctxt, JsonParser p, throw new IllegalStateException("handler failure"); } }; - final ObjectMapper afterburner = _mapperBuilder().addHandler(handler).build(); - final ObjectMapper stock = newVanillaJSONMapper().rebuild().addHandler(handler).build(); - final String json = a2q("{'a':1,'zzz':1}"); - for (Class type : BEANS) { - assertThrows(IllegalStateException.class, - () -> stock.readerFor(type).readValue(json)); - assertTrue(_hasFrame(assertThrows(IllegalStateException.class, - () -> afterburner.readerFor(type).readValue(json)), - "_deserializeDisordered")); + final ObjectMapper mapper = _mapperBuilder().addHandler(handler).build(); + for (String json : List.of("{'zzz':1}", "{'a':1,'zzz':1}", "{'a':1,'b':1,'zzz':1}")) { + _verifyWrapped(mapper.readerFor(SmallBean.class), json); + _verifyWrapped(mapper.readerForUpdating(new SmallBean()), json); + _verifyWrapped(mapper.readerFor(BigBean.class), json); + _verifyWrapped(mapper.readerForUpdating(new BigBean()), json); + } + // Past the rolled out first properties of SuperSonicBeanDeserializer, and past all + for (String json : List.of("{'a':1,'b':1,'c':1,'d':1,'e':1,'zzz':1}", + "{'a':1,'b':1,'c':1,'d':1,'e':1,'f':1,'g':1,'zzz':1}")) { + _verifyWrapped(mapper.readerFor(BigBean.class), json); + _verifyWrapped(mapper.readerForUpdating(new BigBean()), json); } + // Unless wrapping is disabled + assertThrows(IllegalStateException.class, () -> mapper.readerFor(BigBean.class) + .without(DeserializationFeature.WRAP_EXCEPTIONS) + .readValue(a2q("{'zzz':1}"))); } // Parser failures are not failures of a property: no path, same as stock @@ -176,6 +185,13 @@ private static JsonMapper.Builder _mapperBuilder() { .enable(MapperFeature.DEFAULT_VIEW_INCLUSION); } + private static void _verifyWrapped(ObjectReader r, String json) { + DatabindException e = assertThrows(DatabindException.class, + () -> r.readValue(a2q(json)), json); + assertInstanceOf(IllegalStateException.class, e.getCause(), json); + assertEquals(List.of("zzz"), _path(e), json); + } + private static void _verifyPath(ObjectReader r, String json, String expName) { DatabindException e = assertThrows(DatabindException.class, () -> r.readValue(a2q(json))); diff --git a/release-notes/VERSION b/release-notes/VERSION index 4c94849db..b9fb09a4c 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -46,9 +46,10 @@ Active maintainers: #368: (afterburner, blackbird) Report property name as found in input (alias or different case), not canonical name, in deserialization error paths; (afterburner) no more duplicate path segment for failures with out-of-order properties. - Afterburner now matches stock deserializers in two more ways: parser failures have - no property in their path, and non-Jackson exceptions from handling of unknown - properties (like ones `DeserializationProblemHandler` throws) are not wrapped. + Afterburner parser failures have no property in their path, as with stock + deserializers, and non-Jackson exceptions from handling of unknown properties (like + ones `DeserializationProblemHandler` throws) get wrapped on all paths, with that + property as their path, unless `WRAP_EXCEPTIONS` is disabled. (blackbird) Reports unexpected token within Object, also for records and after unknown property, following [databind#6357] (fix by @cowtowncoder, w/ Claude code) From cd8af3520ea7373dbc5b0fbd426691636342b413 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Sun, 1 Nov 2026 12:38:20 -0800 Subject: [PATCH 12/12] Wrap unknown property handling failures through handleUnknownProperty override Afterburner: override handleUnknownProperty in SuperSonicBDBase, replacing the try/catch of the previous commit, and restore the update paths. This also covers inherited stock code: non-vanilla settings, creators and records. Blackbird: same override in GeneratedReaderBase and GeneratedBuilderBase. Co-Authored-By: Claude Opus 5.5 --- .../afterburner/deser/SuperSonicBDBase.java | 27 ++-- .../deser/SuperSonicBeanDeserializer.java | 6 +- .../deser/SuperSonicUnrolledDeserializer.java | 11 +- .../afterburner/deser/AliasErrorPathTest.java | 20 +++ .../internal/GeneratedBuilderBase.java | 18 +++ .../internal/GeneratedReaderBase.java | 18 +++ .../deser/BBUnknownPropertyFailureTest.java | 127 ++++++++++++++++++ release-notes/VERSION | 10 +- 8 files changed, 212 insertions(+), 25 deletions(-) create mode 100644 blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBUnknownPropertyFailureTest.java diff --git a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java index 011c2851b..c1186f0c3 100644 --- a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java +++ b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBDBase.java @@ -126,6 +126,24 @@ public void resolve(DeserializationContext ctxt) // public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException // public final Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean) throws IOException;; + // Non-Jackson exceptions (like ones from a `DeserializationProblemHandler`) get the + // property as their path; stock lets them through as they are. All routes to + // unknown-property handling come here, including those of inherited stock code. + @Override + protected void handleUnknownProperty(JsonParser p, DeserializationContext ctxt, + Object beanOrClass, String propName) + throws JacksonException + { + try { + super.handleUnknownProperty(p, ctxt, beanOrClass, propName); + } catch (JacksonException e) { // has its path already + throw e; + } catch (Exception e) { + throw wrapAndThrow(e, (beanOrClass == null) ? handledType() : beanOrClass, + propName, ctxt); + } + } + protected final Object _deserializeDisordered(JsonParser p, DeserializationContext ctxt, Object bean) throws JacksonException @@ -151,15 +169,8 @@ protected final Object _deserializeDisordered(JsonParser p, DeserializationConte if (ix != PropertyNameMatcher.MATCH_UNKNOWN_NAME) { return _handleUnexpectedWithin(p, ctxt, bean); } - final String unknownName = p.currentName(); p.nextToken(); - try { - handleUnknownVanilla(p, ctxt, bean, unknownName); - } catch (JacksonException e) { // has its path already - throw e; - } catch (Exception e) { // like one from `DeserializationProblemHandler` - throw wrapAndThrow(e, bean, unknownName, ctxt); - } + handleUnknownVanilla(p, ctxt, bean, p.currentName()); } } } diff --git a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java index bb425e64a..0d06ce679 100644 --- a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java +++ b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicBeanDeserializer.java @@ -246,8 +246,8 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean if (p.hasToken(JsonToken.END_OBJECT)) { return bean; } - // we likely point to FIELD_NAME; offline - return _deserializeDisordered(p, ctxt, bean); + // we likely point to FIELD_NAME, so can just call parent impl + return super.deserialize(p, ctxt, bean); } p.nextToken(); try { @@ -258,7 +258,7 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean } // also, need to ensure we get closing END_OBJECT... if (p.nextToken() != JsonToken.END_OBJECT) { - return _deserializeDisordered(p, ctxt, bean); + return super.deserialize(p, ctxt, bean); } return bean; } diff --git a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicUnrolledDeserializer.java b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicUnrolledDeserializer.java index 615bd0869..6eb603bf5 100644 --- a/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicUnrolledDeserializer.java +++ b/afterburner/src/main/java/tools/jackson/module/afterburner/deser/SuperSonicUnrolledDeserializer.java @@ -286,13 +286,12 @@ public Object deserialize(JsonParser p, DeserializationContext ctxt, Object bean // First: verify that first name is expected if (p.isExpectedStartObjectToken()) { if (!p.nextName(_orderedPropertyNames[0])) { - return _deserializeDisordered(p, ctxt, bean); + return super.deserialize(p, ctxt, bean); } - } else if (!p.hasToken(JsonToken.PROPERTY_NAME)) { - // not an Object at all: stock leaves the bean as is - return super.deserialize(p, ctxt, bean); - } else if (!prop.getName().equals(p.currentName())) { - return _deserializeDisordered(p, ctxt, bean); + } else if (!p.hasToken(JsonToken.PROPERTY_NAME) + || !prop.getName().equals(p.currentName())) { + // no, something funky, use base impl for special cases + return super.deserialize(p, ctxt, bean); } p.nextToken(); try { diff --git a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java index df85da551..3dec4d214 100644 --- a/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java +++ b/afterburner/src/test/java/tools/jackson/module/afterburner/deser/AliasErrorPathTest.java @@ -6,7 +6,9 @@ import org.junit.jupiter.api.Test; import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonMerge; +import com.fasterxml.jackson.annotation.JsonProperty; import tools.jackson.core.JacksonException; import tools.jackson.core.JsonParser; @@ -43,6 +45,15 @@ public static class PlainBigBean { public int a, b, c, d, e, f, g; } + public static class CreatorBean { + final int a; + + @JsonCreator + public CreatorBean(@JsonProperty("a") int a) { this.a = a; } + } + + public record RecordBean(int a) { } + public static class MergeHolder { @JsonMerge public BigBean big = new BigBean(); } @@ -159,6 +170,15 @@ public boolean handleUnknownProperty(DeserializationContext ctxt, JsonParser p, _verifyWrapped(mapper.readerFor(BigBean.class), json); _verifyWrapped(mapper.readerForUpdating(new BigBean()), json); } + // Other settings: not "vanilla", so goes through stock code that is inherited + final ObjectMapper nonVanilla = afterburnerMapperBuilder().addHandler(handler).build(); + _verifyWrapped(nonVanilla.readerFor(SmallBean.class), "{'a':1,'zzz':1}"); + _verifyWrapped(nonVanilla.readerFor(BigBean.class), "{'a':1,'zzz':1}"); + // With Creator, and record + for (Class type : List.of(CreatorBean.class, RecordBean.class)) { + _verifyWrapped(mapper.readerFor(type), "{'a':1,'zzz':1}"); + _verifyWrapped(nonVanilla.readerFor(type), "{'a':1,'zzz':1}"); + } // Unless wrapping is disabled assertThrows(IllegalStateException.class, () -> mapper.readerFor(BigBean.class) .without(DeserializationFeature.WRAP_EXCEPTIONS) diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedBuilderBase.java b/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedBuilderBase.java index 9185dee37..959c1fa3c 100644 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedBuilderBase.java +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedBuilderBase.java @@ -1,5 +1,6 @@ package tools.jackson.module.blackbird.internal; +import tools.jackson.core.JacksonException; import tools.jackson.core.JsonParser; import tools.jackson.databind.DeserializationContext; import tools.jackson.databind.deser.SettableBeanProperty; @@ -87,6 +88,23 @@ protected final void _handleUnknown(JsonParser p, DeserializationContext ctxt, handleUnknownVanilla(p, ctxt, builder, name); } + // Non-Jackson exceptions (like ones from a DeserializationProblemHandler) get + // the property as their path; stock lets them through as they are. All routes + // to unknown-property handling come here, including those of stock code the + // codec inherits, and buffered unknown properties of records. + @Override + protected void handleUnknownProperty(JsonParser p, DeserializationContext ctxt, + Object beanOrClass, String propName) { + try { + super.handleUnknownProperty(p, ctxt, beanOrClass, propName); + } catch (JacksonException e) { // has its path already + throw e; + } catch (Exception e) { + throw wrapAndThrow(e, (beanOrClass == null) ? handledType() : beanOrClass, + propName, ctxt); + } + } + // As in the stock vanilla loop, whatever the unexpected-token handling // returns is then built. protected final Object _unexpectedToken(JsonParser p, DeserializationContext ctxt, diff --git a/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedReaderBase.java b/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedReaderBase.java index 15500eb95..32b12a9ba 100644 --- a/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedReaderBase.java +++ b/blackbird/src/main/java/tools/jackson/module/blackbird/internal/GeneratedReaderBase.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.List; +import tools.jackson.core.JacksonException; import tools.jackson.core.JsonParser; import tools.jackson.databind.DeserializationContext; import tools.jackson.databind.ValueDeserializer; @@ -136,6 +137,23 @@ protected final void _handleUnknown(JsonParser p, DeserializationContext ctxt, handleUnknownVanilla(p, ctxt, bean, name); } + // Non-Jackson exceptions (like ones from a DeserializationProblemHandler) get + // the property as their path; stock lets them through as they are. All routes + // to unknown-property handling come here, including those of stock code the + // codec inherits, and buffered unknown properties of records. + @Override + protected void handleUnknownProperty(JsonParser p, DeserializationContext ctxt, + Object beanOrClass, String propName) { + try { + super.handleUnknownProperty(p, ctxt, beanOrClass, propName); + } catch (JacksonException e) { // has its path already + throw e; + } catch (Exception e) { + throw wrapAndThrow(e, (beanOrClass == null) ? handledType() : beanOrClass, + propName, ctxt); + } + } + // Neither a name nor the end of the object where a name was expected. protected final Object _unexpectedToken(JsonParser p, DeserializationContext ctxt, Object bean) { diff --git a/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBUnknownPropertyFailureTest.java b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBUnknownPropertyFailureTest.java new file mode 100644 index 000000000..1d44dd94f --- /dev/null +++ b/blackbird/src/test/java/tools/jackson/module/blackbird/deser/BBUnknownPropertyFailureTest.java @@ -0,0 +1,127 @@ +package tools.jackson.module.blackbird.deser; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonView; + +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DatabindException; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.MapperFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectReader; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.annotation.JsonDeserialize; +import tools.jackson.databind.deser.DeserializationProblemHandler; +import tools.jackson.databind.exc.UnrecognizedPropertyException; +import tools.jackson.module.blackbird.BlackbirdTestBase; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Non-Jackson exception from handling of an unknown property gets the property + * as its path. Unlike most of Blackbird's behavior this is not same as stock, + * which does not wrap these. + */ +public class BBUnknownPropertyFailureTest extends BlackbirdTestBase +{ + public static class Pojo { + public int a; + } + + public record Rec(int a) { } + + @JsonDeserialize(builder = Built.Builder.class) + public static class Built { + public static class Builder { + int a; + public Builder withA(int v) { a = v; return this; } + public Built build() { return new Built(); } + } + } + + public static class ViewA { } + + public static class ViewPojo { + @JsonView(ViewA.class) public int a; + } + + private static final List> TYPES = List.of(Pojo.class, Rec.class, Built.class); + private static final List DOCS = List.of("{'zzz':1}", "{'a':1,'zzz':1}", "{'zzz':1,'a':1}"); + + private final DeserializationProblemHandler HANDLER = new DeserializationProblemHandler() { + @Override + public boolean handleUnknownProperty(DeserializationContext ctxt, JsonParser p, + ValueDeserializer deser, Object beanOrClass, String propName) { + throw new IllegalStateException("handler failure"); + } + }; + + private final ObjectMapper MAPPER = mapperBuilder().addHandler(HANDLER).build(); + + @Test + public void wrappedWithDefaultSettings() throws Exception { + for (Class type : TYPES) { + assertGeneratedReader(MAPPER, type); + _verifyWrapped(MAPPER.readerFor(type)); + } + } + + // Settings for stock's vanilla loop + @Test + public void wrappedWithVanillaSettings() throws Exception { + final ObjectMapper mapper = mapperBuilder().addHandler(HANDLER) + .enable(MapperFeature.DEFAULT_VIEW_INCLUSION).build(); + for (Class type : TYPES) { + assertGeneratedReader(mapper, type); + _verifyWrapped(mapper.readerFor(type)); + } + } + + @Test + public void wrappedUnderActiveView() throws Exception { + assertGeneratedReader(MAPPER, ViewPojo.class); + _verifyWrapped(MAPPER.readerFor(ViewPojo.class).withView(ViewA.class)); + } + + // Goes through stock code that the codec inherits + @Test + public void wrappedForUpdate() throws Exception { + _verifyWrapped(MAPPER.readerForUpdating(new Pojo())); + } + + @Test + public void notWrappedIfDisabled() throws Exception { + for (Class type : TYPES) { + assertThrows(IllegalStateException.class, () -> MAPPER.readerFor(type) + .without(DeserializationFeature.WRAP_EXCEPTIONS) + .readValue(a2q("{'zzz':1}"))); + } + } + + // Has its path already: must not get the property twice + @Test + public void jacksonExceptionPassedAsIs() throws Exception { + final ObjectMapper mapper = mapperBuilder() + .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build(); + for (Class type : TYPES) { + UnrecognizedPropertyException e = assertThrows(UnrecognizedPropertyException.class, + () -> mapper.readerFor(type).readValue(a2q("{'zzz':1}"))); + assertEquals(1, e.getPath().size(), e.getMessage()); + assertEquals("zzz", e.getPath().get(0).getPropertyName()); + } + } + + private static void _verifyWrapped(ObjectReader r) { + for (String doc : DOCS) { + DatabindException e = assertThrows(DatabindException.class, + () -> r.readValue(a2q(doc)), doc); + assertInstanceOf(IllegalStateException.class, e.getCause(), doc); + assertEquals(1, e.getPath().size(), doc); + assertEquals("zzz", e.getPath().get(0).getPropertyName(), doc); + } + } +} diff --git a/release-notes/VERSION b/release-notes/VERSION index b9fb09a4c..6d8ba34ba 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -44,14 +44,8 @@ Active maintainers: (contributed by @stevenschlansker) #362: Bump `byte-buddy` dependency from 1.17.8 to 1.18.15 #368: (afterburner, blackbird) Report property name as found in input (alias or - different case), not canonical name, in deserialization error paths; (afterburner) - no more duplicate path segment for failures with out-of-order properties. - Afterburner parser failures have no property in their path, as with stock - deserializers, and non-Jackson exceptions from handling of unknown properties (like - ones `DeserializationProblemHandler` throws) get wrapped on all paths, with that - property as their path, unless `WRAP_EXCEPTIONS` is disabled. - (blackbird) Reports unexpected token within Object, also for records and after - unknown property, following [databind#6357] + different case), not canonical name, in deserialization error paths, and wrap + non-Jackson exceptions from handling of unknown properties (fix by @cowtowncoder, w/ Claude code) 3.2.5 (not yet released)