From e2cafa6d76893f1b831198cac1988361fa55e2f7 Mon Sep 17 00:00:00 2001 From: Parker Winchester Date: Mon, 2 Nov 2026 15:17:03 -0500 Subject: [PATCH 1/9] Blackbird: use generated accessors for primitive double properties --- .../module/blackbird/BaseBenchmark.java | 22 + .../jackson/module/blackbird/DoubleBean.java | 51 +++ .../blackbird/DoubleBeanWithBuilder.java | 51 +++ .../deser/BBDeserializerModifier.java | 18 +- .../deser/SettableDoubleProperty.java | 107 +++++ .../blackbird/ser/BBSerializerModifier.java | 11 + .../blackbird/ser/DoublePropertyWriter.java | 115 +++++ .../blackbird/deser/DoubleSetterTest.java | 403 ++++++++++++++++++ .../module/blackbird/deser/TestBuilders.java | 175 ++++++-- .../blackbird/deser/TestSetterFailures.java | 22 +- .../ser/DoublePropertyWriterTest.java | 392 +++++++++++++++++ release-notes/CREDITS-2.x | 3 + release-notes/VERSION-2.x | 2 + 13 files changed, 1322 insertions(+), 50 deletions(-) create mode 100644 blackbird/benchmarks/src/main/java/com/fasterxml/jackson/module/blackbird/DoubleBean.java create mode 100644 blackbird/benchmarks/src/main/java/com/fasterxml/jackson/module/blackbird/DoubleBeanWithBuilder.java create mode 100644 blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/deser/SettableDoubleProperty.java create mode 100644 blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/DoublePropertyWriter.java create mode 100644 blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/deser/DoubleSetterTest.java create mode 100644 blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/DoublePropertyWriterTest.java diff --git a/blackbird/benchmarks/src/main/java/com/fasterxml/jackson/module/blackbird/BaseBenchmark.java b/blackbird/benchmarks/src/main/java/com/fasterxml/jackson/module/blackbird/BaseBenchmark.java index 48fc7657..f0f14715 100644 --- a/blackbird/benchmarks/src/main/java/com/fasterxml/jackson/module/blackbird/BaseBenchmark.java +++ b/blackbird/benchmarks/src/main/java/com/fasterxml/jackson/module/blackbird/BaseBenchmark.java @@ -43,6 +43,8 @@ public abstract class BaseBenchmark { byte[] classicBeansJson; ObjectWriter classicBeanWriter; ObjectReader classicBeanReader; + DoubleBean[] doubleBeans; + byte[] doubleBeansBytes; public static void main(final String[] args) throws RunnerException { final Options options = new OptionsBuilder() @@ -71,6 +73,11 @@ public void setup() throws Exception { classicBean.setUp(); classicBeans = Collections.nCopies(1000, classicBean).toArray(new ClassicBean[0]); classicBeansJson = classicBeanWriter.writeValueAsBytes(classicBeans); + + doubleBeans = IntStream.range(0, 1000) + .mapToObj(i -> DoubleBean.random(random)) + .toArray(DoubleBean[]::new); + doubleBeansBytes = mapper.writeValueAsBytes(doubleBeans); } @Benchmark @@ -93,6 +100,21 @@ public BeanWithBuilder[] builderArrayDeser() throws Exception { return mapper.readValue(beansBytes, BeanWithBuilder[].class); } + @Benchmark + public byte[] doubleBeanArraySer() throws Exception { + return mapper.writeValueAsBytes(doubleBeans); + } + + @Benchmark + public DoubleBean[] doubleBeanArrayDeser() throws Exception { + return mapper.readValue(doubleBeansBytes, DoubleBean[].class); + } + + @Benchmark + public DoubleBeanWithBuilder[] doubleBuilderArrayDeser() throws Exception { + return mapper.readValue(doubleBeansBytes, DoubleBeanWithBuilder[].class); + } + @Benchmark public void classicMediaItemSer(final Blackhole bh) throws Exception { mediaItemWriter.writeValue(new NopOutputStream(bh), MediaItem.SAMPLE); diff --git a/blackbird/benchmarks/src/main/java/com/fasterxml/jackson/module/blackbird/DoubleBean.java b/blackbird/benchmarks/src/main/java/com/fasterxml/jackson/module/blackbird/DoubleBean.java new file mode 100644 index 00000000..d52614f7 --- /dev/null +++ b/blackbird/benchmarks/src/main/java/com/fasterxml/jackson/module/blackbird/DoubleBean.java @@ -0,0 +1,51 @@ +package com.fasterxml.jackson.module.blackbird; + +import java.util.Random; + +// Mostly primitive doubles, with values as short as most real ones +public class DoubleBean { + private int id; + private double latitude; + private double longitude; + private double altitude; + private double speed; + private double heading; + private double accuracy; + + public int getId() { return id; } + public void setId(int id) { this.id = id; } + + public double getLatitude() { return latitude; } + public void setLatitude(double latitude) { this.latitude = latitude; } + + public double getLongitude() { return longitude; } + public void setLongitude(double longitude) { this.longitude = longitude; } + + public double getAltitude() { return altitude; } + public void setAltitude(double altitude) { this.altitude = altitude; } + + public double getSpeed() { return speed; } + public void setSpeed(double speed) { this.speed = speed; } + + public double getHeading() { return heading; } + public void setHeading(double heading) { this.heading = heading; } + + public double getAccuracy() { return accuracy; } + public void setAccuracy(double accuracy) { this.accuracy = accuracy; } + + static DoubleBean random(Random random) { + final DoubleBean result = new DoubleBean(); + result.setId(random.nextInt()); + result.setLatitude(round(random.nextDouble() * 180.0 - 90.0, 1e6)); + result.setLongitude(round(random.nextDouble() * 360.0 - 180.0, 1e6)); + result.setAltitude(round(random.nextDouble() * 9000.0, 1e2)); + result.setSpeed(round(random.nextDouble() * 300.0, 1e2)); + result.setHeading(round(random.nextDouble() * 360.0, 1e2)); + result.setAccuracy(round(random.nextDouble() * 50.0, 1e2)); + return result; + } + + private static double round(double value, double scale) { + return Math.round(value * scale) / scale; + } +} diff --git a/blackbird/benchmarks/src/main/java/com/fasterxml/jackson/module/blackbird/DoubleBeanWithBuilder.java b/blackbird/benchmarks/src/main/java/com/fasterxml/jackson/module/blackbird/DoubleBeanWithBuilder.java new file mode 100644 index 00000000..24e283ce --- /dev/null +++ b/blackbird/benchmarks/src/main/java/com/fasterxml/jackson/module/blackbird/DoubleBeanWithBuilder.java @@ -0,0 +1,51 @@ +package com.fasterxml.jackson.module.blackbird; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +@JsonDeserialize(builder = DoubleBeanWithBuilder.Builder.class) +public class DoubleBeanWithBuilder extends DoubleBean { + private DoubleBeanWithBuilder() { } + + public static class Builder { + private final DoubleBeanWithBuilder bean = new DoubleBeanWithBuilder(); + + public Builder withId(int id) { + bean.setId(id); + return this; + } + + public Builder withLatitude(double latitude) { + bean.setLatitude(latitude); + return this; + } + + public Builder withLongitude(double longitude) { + bean.setLongitude(longitude); + return this; + } + + public Builder withAltitude(double altitude) { + bean.setAltitude(altitude); + return this; + } + + public Builder withSpeed(double speed) { + bean.setSpeed(speed); + return this; + } + + public Builder withHeading(double heading) { + bean.setHeading(heading); + return this; + } + + public Builder withAccuracy(double accuracy) { + bean.setAccuracy(accuracy); + return this; + } + + public DoubleBeanWithBuilder build() { + return bean; + } + } +} diff --git a/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/deser/BBDeserializerModifier.java b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/deser/BBDeserializerModifier.java index 0a793bca..85e31dca 100644 --- a/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/deser/BBDeserializerModifier.java +++ b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/deser/BBDeserializerModifier.java @@ -13,6 +13,7 @@ import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.Function; +import java.util.function.ObjDoubleConsumer; import java.util.function.ObjIntConsumer; import java.util.function.ObjLongConsumer; import java.util.function.UnaryOperator; @@ -182,7 +183,8 @@ private void nextProperty(SettableBeanProperty prop, } type = ((AnnotatedMethod) member).getRawParameterType(0); // Only these get a generated setter, so don't check (or load) anything for others - if (type.isPrimitive() && (type != Integer.TYPE) && (type != Long.TYPE) && (type != Boolean.TYPE)) { + if (type.isPrimitive() && (type != Integer.TYPE) && (type != Long.TYPE) + && (type != Double.TYPE) && (type != Boolean.TYPE)) { return; } // A generated setter that would fail on every call (like one taking a @@ -232,6 +234,15 @@ private void nextProperty(SettableBeanProperty prop, : (bean, v) -> { consumer.accept(bean, v); return null; }; newProps.add(new SettableLongProperty(prop, consumer, thunk, receiverType, forBuilder)); + } else if (type == Double.TYPE) { + ObjDoubleBiFunction returning = createThunk(lookup, ObjDoubleBiFunction.class, double.class, setter); + ObjDoubleConsumer consumer = (returning == null) + ? createConsumer(lookup, ObjDoubleConsumer.class, double.class, setter) + : (bean, v) -> returning.apply(bean, v); + ObjDoubleBiFunction thunk = !forBuilder ? null : (returning != null) ? returning + : (bean, v) -> { consumer.accept(bean, v); return null; }; + newProps.add(new SettableDoubleProperty(prop, consumer, + thunk, receiverType, forBuilder)); } else if (type == Boolean.TYPE) { ObjBooleanBiFunction returning = createThunk(lookup, ObjBooleanBiFunction.class, boolean.class, setter); ObjBooleanConsumer consumer = (returning == null) @@ -356,6 +367,11 @@ public interface ObjLongBiFunction { Object apply(Object bean, long value); } + @FunctionalInterface + public interface ObjDoubleBiFunction { + Object apply(Object bean, double value); + } + @FunctionalInterface public interface ObjBooleanBiFunction { Object apply(Object bean, boolean value); diff --git a/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/deser/SettableDoubleProperty.java b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/deser/SettableDoubleProperty.java new file mode 100644 index 00000000..c58491b1 --- /dev/null +++ b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/deser/SettableDoubleProperty.java @@ -0,0 +1,107 @@ +package com.fasterxml.jackson.module.blackbird.deser; + +import java.io.IOException; +import java.util.function.ObjDoubleConsumer; + +import com.fasterxml.jackson.core.*; +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.deser.SettableBeanProperty; +import com.fasterxml.jackson.module.blackbird.deser.BBDeserializerModifier.ObjDoubleBiFunction; + +final class SettableDoubleProperty + extends OptimizedSettableBeanProperty +{ + private static final long serialVersionUID = 1L; + private ObjDoubleConsumer _optimizedSetter; + private ObjDoubleBiFunction _optimizedSetterAndReturn; + + public SettableDoubleProperty(SettableBeanProperty src, ObjDoubleConsumer optimizedSetter, + ObjDoubleBiFunction optimizedSetterAndReturn, Class receiverType, boolean forBuilder) + { + super(src, receiverType, forBuilder); + _optimizedSetter = optimizedSetter; + _optimizedSetterAndReturn = optimizedSetterAndReturn; + } + + @Override + protected SettableBeanProperty withDelegate(SettableBeanProperty del) { + return new SettableDoubleProperty(del, _optimizedSetter, _optimizedSetterAndReturn, _receiverType, _forBuilder); + } + + /* + /********************************************************************** + /* Deserialization + /********************************************************************** + */ + + // Only a float token is read directly, as the default deserializer reads it + // (without coercion checks); anything else, including integers, Strings (like + // "NaN") and nulls, is left to the delegate and its coercion rules + + @Override + public void deserializeAndSet(JsonParser p, DeserializationContext ctxt, + Object bean) throws IOException + { + if (!p.hasToken(JsonToken.VALUE_NUMBER_FLOAT)) { + delegate.deserializeAndSet(p, ctxt, bean); + return; + } + final double v = p.getDoubleValue(); + try { + _optimizedSetter.accept(bean, v); + } catch (Throwable e) { + throw _reportProblem(p, e); + } + } + + @Override + public Object deserializeSetAndReturn(JsonParser p, + DeserializationContext ctxt, Object instance) throws IOException + { + if ((_optimizedSetterAndReturn != null) && _receiverType.isInstance(instance) + && p.hasToken(JsonToken.VALUE_NUMBER_FLOAT)) { + return _setAndReturn(p, instance, p.getDoubleValue()); + } + return delegate.deserializeSetAndReturn(p, ctxt, instance); + } + + @Override + public void set(Object bean, Object value) throws IOException { + // the delegate skips a null, or reports it; and anything else (another type, + // which reflection may widen or reject) or a builder the generated setter + // can't take is reported, or set, as reflection does + if (!(value instanceof Double) || (_forBuilder && !_receiverType.isInstance(bean))) { + delegate.set(bean, value); + return; + } + // not optimal (due to boxing), but better than using reflection: + final double v = ((Double) value).doubleValue(); + try { + _optimizedSetter.accept(bean, v); + } catch (Throwable e) { + throw _reportProblem(null, e); + } + } + + @Override + public Object setAndReturn(Object instance, Object value) throws IOException { + // a setter only reflection may call, anything else (null, or another type that + // reflection may widen or reject), or an instance the generated setter can't take, + // is left to the delegate + if ((_optimizedSetterAndReturn == null) || !(value instanceof Double) + || !_receiverType.isInstance(instance)) { + return delegate.setAndReturn(instance, value); + } + // not optimal (due to boxing), but better than using reflection: + return _setAndReturn(null, instance, ((Double) value).doubleValue()); + } + + private Object _setAndReturn(JsonParser p, Object instance, double v) throws IOException { + try { + Object result = _optimizedSetterAndReturn.apply(instance, v); + return (result == null) ? instance : result; + } catch (Throwable e) { + throw _reportProblem(p, e); + } + } +} diff --git a/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/BBSerializerModifier.java b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/BBSerializerModifier.java index 6e83fc05..925c253d 100644 --- a/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/BBSerializerModifier.java +++ b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/BBSerializerModifier.java @@ -9,6 +9,7 @@ import java.lang.reflect.Modifier; import java.util.*; import java.util.function.Function; +import java.util.function.ToDoubleFunction; import java.util.function.ToIntFunction; import java.util.function.ToLongFunction; import java.util.function.UnaryOperator; @@ -150,6 +151,16 @@ protected void createProperty(ListIterator it, Lookup lookup getter.type()) .getTarget().invokeExact(); it.set(new LongPropertyWriter(bpw, accessor, null)); + } else if (type == Double.TYPE) { + ToDoubleFunction accessor = (ToDoubleFunction) LambdaMetafactory.metafactory( + lookup, + "applyAsDouble", + methodType(ToDoubleFunction.class), + methodType(double.class, Object.class), + getter, + getter.type()) + .getTarget().invokeExact(); + it.set(new DoublePropertyWriter(bpw, accessor, null)); } else if (type == Boolean.TYPE) { ToBooleanFunction accessor = (ToBooleanFunction) LambdaMetafactory.metafactory( lookup, diff --git a/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/DoublePropertyWriter.java b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/DoublePropertyWriter.java new file mode 100644 index 00000000..3720f57b --- /dev/null +++ b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/DoublePropertyWriter.java @@ -0,0 +1,115 @@ +package com.fasterxml.jackson.module.blackbird.ser; + +import java.util.function.ToDoubleFunction; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.PropertyName; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.BeanPropertyWriter; + +final class DoublePropertyWriter + extends OptimizedBeanPropertyWriter +{ + private static final long serialVersionUID = 1L; + + // `BeanPropertyWriter` suppresses a value that `equals` the boxed suppressable + // one, so compare as `Double.equals` does: `-0.0` isn't `0.0`, and `NaN` is `NaN` + private final long _suppressableBits; + private final boolean _suppressableSet; + + // A suppressable value that isn't a `Double`, like a custom inclusion filter, + // decides through its own `equals`, which is given the boxed value as + // `BeanPropertyWriter` does. (`NON_EMPTY` never suppresses a double: the + // default serializer's `isEmpty` only checks for null.) + private final boolean _suppressableFilter; + + private final ToDoubleFunction _acc; + + public DoublePropertyWriter(BeanPropertyWriter src, ToDoubleFunction acc, JsonSerializer ser) { + super(src, ser); + _acc = acc; + + if (_suppressableValue instanceof Double) { + _suppressableBits = Double.doubleToLongBits((Double) _suppressableValue); + _suppressableSet = true; + _suppressableFilter = false; + } else { + _suppressableBits = 0L; + _suppressableSet = false; + _suppressableFilter = (_suppressableValue != null) && (_suppressableValue != MARKER_FOR_EMPTY); + } + } + + protected DoublePropertyWriter(DoublePropertyWriter base, PropertyName name) { + super(base, name); + _suppressableSet = base._suppressableSet; + _suppressableBits = base._suppressableBits; + _suppressableFilter = base._suppressableFilter; + _acc = base._acc; + } + + @Override + protected BeanPropertyWriter _new(PropertyName newName) { + return new DoublePropertyWriter(this, newName); + } + + @Override + public BeanPropertyWriter withSerializer(JsonSerializer ser) { + return new DoublePropertyWriter(this, _acc, ser); + } + + /* + /********************************************************** + /* Overrides + /********************************************************** + */ + + @Override + public final void serializeAsField(Object bean, JsonGenerator gen, SerializerProvider prov) throws Exception + { + if (broken) { + fallbackWriter.serializeAsField(bean, gen, prov); + return; + } + double value; + try { + value = _acc.applyAsDouble(bean); + } catch (Throwable t) { + _handleProblem(bean, gen, prov, t, false); + return; + } + if (!_isSuppressed(value)) { + gen.writeFieldName(_fastName); + gen.writeNumber(value); + } + } + + @Override + public final void serializeAsElement(Object bean, JsonGenerator gen, SerializerProvider prov) throws Exception + { + if (broken) { + fallbackWriter.serializeAsElement(bean, gen, prov); + return; + } + double value; + try { + value = _acc.applyAsDouble(bean); + } catch (Throwable t) { + _handleProblem(bean, gen, prov, t, true); + return; + } + if (!_isSuppressed(value)) { + gen.writeNumber(value); + } else { // important: MUST output a placeholder + serializeAsPlaceholder(bean, gen, prov); + } + } + + private boolean _isSuppressed(double value) { + if (_suppressableSet) { + return _suppressableBits == Double.doubleToLongBits(value); + } + return _suppressableFilter && _suppressableValue.equals(value); + } +} diff --git a/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/deser/DoubleSetterTest.java b/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/deser/DoubleSetterTest.java new file mode 100644 index 00000000..21754200 --- /dev/null +++ b/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/deser/DoubleSetterTest.java @@ -0,0 +1,403 @@ +package com.fasterxml.jackson.module.blackbird.deser; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.UnaryOperator; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.json.JsonReadFeature; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.cfg.CoercionAction; +import com.fasterxml.jackson.databind.cfg.CoercionInputShape; +import com.fasterxml.jackson.databind.deser.BeanDeserializerBase; +import com.fasterxml.jackson.databind.deser.DefaultDeserializationContext; +import com.fasterxml.jackson.databind.deser.SettableBeanProperty; +import com.fasterxml.jackson.databind.deser.impl.MethodProperty; +import com.fasterxml.jackson.databind.exc.MismatchedInputException; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.type.LogicalType; +import com.fasterxml.jackson.module.blackbird.BlackbirdTestBase; + +import static org.junit.jupiter.api.Assertions.*; + +// Primitive double setters should go through generated accessors, not reflection +public class DoubleSetterTest extends BlackbirdTestBase +{ + interface HasDouble { + double d(); + int reflectiveCalls(); + } + + static class DoubleBean implements HasDouble + { + double d = 7.0; + int reflectiveCalls; + + public void setD(double v) { + reflectiveCalls += reflectiveCall(); + d = v; + } + + @Override public double d() { return d; } + @Override public int reflectiveCalls() { return reflectiveCalls; } + } + + static class FluentDoubleBean implements HasDouble + { + double d = 7.0; + int reflectiveCalls; + + public FluentDoubleBean setD(double v) { + reflectiveCalls += reflectiveCall(); + d = v; + return this; + } + + @Override public double d() { return d; } + @Override public int reflectiveCalls() { return reflectiveCalls; } + } + + // `d` before `id` is buffered, and assigned through `set()` + static class CreatorDoubleBean extends DoubleBean + { + @JsonCreator + CreatorDoubleBean(@JsonProperty("id") int id) { } + } + + // Each setter returns a new builder, so the returned value must be used + static final class DoubleBuilder + { + final double d; + final int reflectiveCalls; + + public DoubleBuilder() { + this(7.0, 0); + } + + DoubleBuilder(double d, int reflectiveCalls) { + this.d = d; + this.reflectiveCalls = reflectiveCalls; + } + + public DoubleBuilder withD(double v) { + return new DoubleBuilder(v, reflectiveCalls + reflectiveCall()); + } + + public DoubleBuilt build() { + return new DoubleBuilt(d, reflectiveCalls); + } + } + + @JsonDeserialize(builder=DoubleBuilder.class) + static class DoubleBuilt implements HasDouble + { + final double d; + final int reflectiveCalls; + + DoubleBuilt(double d, int reflectiveCalls) { + this.d = d; + this.reflectiveCalls = reflectiveCalls; + } + + @Override public double d() { return d; } + @Override public int reflectiveCalls() { return reflectiveCalls; } + } + + static final class SameDoubleBuilder + { + double d = 7.0; + int reflectiveCalls; + + public SameDoubleBuilder withD(double v) { + reflectiveCalls += reflectiveCall(); + d = v; + return this; + } + + public SameDoubleBuilt build() { + return new SameDoubleBuilt(d, reflectiveCalls); + } + } + + @JsonDeserialize(builder=SameDoubleBuilder.class) + static class SameDoubleBuilt extends DoubleBuilt { + SameDoubleBuilt(double d, int reflectiveCalls) { super(d, reflectiveCalls); } + } + + // A null result keeps the current builder + static final class NullDoubleBuilder + { + double d = 7.0; + int reflectiveCalls; + + public NullDoubleBuilder withD(double v) { + reflectiveCalls += reflectiveCall(); + d = v; + return null; + } + + public NullDoubleBuilt build() { + return new NullDoubleBuilt(d, reflectiveCalls); + } + } + + @JsonDeserialize(builder=NullDoubleBuilder.class) + static class NullDoubleBuilt extends DoubleBuilt { + NullDoubleBuilt(double d, int reflectiveCalls) { super(d, reflectiveCalls); } + } + + // Buffered before a creator property, so assigned through `set()` + static final class CreatorDoubleBuilder + { + double d = 7.0; + int reflectiveCalls; + + @JsonCreator + CreatorDoubleBuilder(@JsonProperty("id") int id) { } + + public CreatorDoubleBuilder withD(double v) { + reflectiveCalls += reflectiveCall(); + d = v; + return this; + } + + public CreatorDoubleBuilt build() { + return new CreatorDoubleBuilt(d, reflectiveCalls); + } + } + + @JsonDeserialize(builder=CreatorDoubleBuilder.class) + static class CreatorDoubleBuilt extends DoubleBuilt { + CreatorDoubleBuilt(double d, int reflectiveCalls) { super(d, reflectiveCalls); } + } + + private static final List> TYPES = Arrays.asList( + DoubleBean.class, FluentDoubleBean.class, CreatorDoubleBean.class, + DoubleBuilt.class, SameDoubleBuilt.class, NullDoubleBuilt.class, CreatorDoubleBuilt.class); + + static int reflectiveCall() { + for (StackTraceElement elem : new Throwable().getStackTrace()) { + if (elem.getClassName().equals(MethodProperty.class.getName()) + || elem.getMethodName().equals("_reflectiveSetAndReturn")) { + return 1; + } + } + return 0; + } + + private final ObjectMapper MAPPER = newObjectMapper(); + + private static String json(Class type, String value) { + // the creator property after `d`, so `d` is buffered + boolean creator = (type == CreatorDoubleBean.class) || (type == CreatorDoubleBuilt.class); + return "{\"d\":" + value + (creator ? ",\"id\":1}" : "}"); + } + + @Test + public void testSkipsReflection() throws Exception + { + for (Class type : TYPES) { + assertTrue(_findProperty(MAPPER, type, "d") instanceof SettableDoubleProperty, type.getSimpleName()); + for (String value : new String[] { "1.25", "-0.0", "1e308", "4.9e-324" }) { + HasDouble bean = MAPPER.readValue(json(type, value), type); + String desc = type.getSimpleName() + ", " + value; + assertEquals(Double.parseDouble(value), bean.d(), desc); + assertEquals(Double.doubleToRawLongBits(Double.parseDouble(value)), + Double.doubleToRawLongBits(bean.d()), desc); + assertEquals(0, bean.reflectiveCalls(), desc); + } + } + } + + // Sanity check that the reflection probe works at all + @Test + public void testVanillaUsesReflection() throws Exception + { + ObjectMapper vanilla = newVanillaJSONMapper(); + for (Class type : TYPES) { + assertEquals(1, vanilla.readValue(json(type, "1.25"), type).reflectiveCalls(), type.getSimpleName()); + } + } + + // Non-float tokens go through the stock deserializer, including coercion rules + @Test + public void testNonFloatTokens() throws Exception + { + for (Class type : TYPES) { + String desc = type.getSimpleName(); + assertEquals(1.0, MAPPER.readValue(json(type, "1"), type).d(), desc); + assertEquals(1.5, MAPPER.readValue(json(type, "\"1.5\""), type).d(), desc); + assertTrue(Double.isNaN(MAPPER.readValue(json(type, "\"NaN\""), type).d()), desc); + assertEquals(0.0, MAPPER.readValue(json(type, "null"), type).d(), desc); + } + } + + @Test + public void testIntCoercionFailure() throws Exception + { + ObjectMapper mapper = mapperBuilder() + .withCoercionConfig(LogicalType.Float, + cfg -> cfg.setCoercion(CoercionInputShape.Integer, CoercionAction.Fail)) + .build(); + for (Class type : TYPES) { + assertThrows(MismatchedInputException.class, () -> mapper.readValue(json(type, "1"), type), + type.getSimpleName()); + // while floats aren't coerced at all + assertEquals(1.5, mapper.readValue(json(type, "1.5"), type).d(), type.getSimpleName()); + } + } + + @Test + public void testNullForPrimitiveFailure() throws Exception + { + ObjectMapper mapper = mapperBuilder() + .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .build(); + for (Class type : TYPES) { + assertThrows(MismatchedInputException.class, () -> mapper.readValue(json(type, "null"), type), + type.getSimpleName()); + } + } + + // Every input, and every setting that could affect it, as plain Jackson reads it + @Test + public void testInputsMatchVanilla() throws Exception + { + String[] values = { + "1.25", "-0.0", "0.0", "1e308", "1e400", "-1e400", "4.9e-324", "2e-324", + "0.1000000000000000055511151231257827", "123456789012345678901234567890.5", + "1", "-1", "0", "9007199254740993", "123456789012345678901234567890", + "\"1.5\"", "\"NaN\"", "\"Infinity\"", "\"-Infinity\"", "\"\"", "\" \"", "\"x\"", + "null", "true", "[1.5]", "[1]", "[]", "[null]", "[[1.5]]", "{}", "NaN", "Infinity" + }; + List> configs = Arrays.asList( + b -> b, + b -> b.enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES), + b -> b.enable(DeserializationFeature.UNWRAP_SINGLE_VALUE_ARRAYS), + b -> b.enable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT), + b -> b.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS), + b -> b.enable(JsonReadFeature.ALLOW_NON_NUMERIC_NUMBERS), + b -> b.enable(StreamReadFeature.USE_FAST_DOUBLE_PARSER), + b -> b.withCoercionConfig(LogicalType.Float, + cfg -> cfg.setCoercion(CoercionInputShape.Integer, CoercionAction.Fail)), + b -> b.withCoercionConfig(LogicalType.Float, + cfg -> cfg.setCoercion(CoercionInputShape.Float, CoercionAction.Fail) + .setCoercion(CoercionInputShape.String, CoercionAction.Fail) + .setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsEmpty)), + b -> b.withCoercionConfigDefaults(cfg -> cfg.setCoercion(CoercionInputShape.Integer, CoercionAction.AsNull)) + ); + for (int i = 0; i < configs.size(); i++) { + ObjectMapper vanilla = configs.get(i).apply(JsonMapper.builder()).build(); + ObjectMapper blackbird = configs.get(i).apply(mapperBuilder()).build(); + for (Class type : TYPES) { + assertTrue(_findProperty(blackbird, type, "d") instanceof SettableDoubleProperty); + for (String value : values) { + String json = json(type, value); + String desc = "config " + i + ", " + type.getSimpleName() + ", " + json; + assertEquals(_read(vanilla, json, type), _read(blackbird, json, type), desc); + } + } + } + } + + // Through a buffer or a tree, a float token may hold another number type + @Test + public void testBufferedNumbersMatchVanilla() throws Exception + { + ObjectMapper vanilla = newVanillaJSONMapper(); + JsonNodeFactory nodes = JsonNodeFactory.instance; + List numbers = Arrays.asList(0.1f, Float.NaN, -0.0f, 1.5, Double.NaN, -0.0, + new BigDecimal("0.1"), new BigDecimal("1e400"), BigInteger.TEN.pow(400), 7, 9007199254740993L); + List trees = Arrays.asList(nodes.numberNode(0.1f), nodes.numberNode(Double.NaN), + nodes.numberNode(new BigDecimal("0.1")), nodes.numberNode(new BigDecimal("1e400")), + nodes.numberNode(9007199254740993L), nodes.textNode("2.5")); + for (Class type : TYPES) { + boolean creator = (type == CreatorDoubleBean.class) || (type == CreatorDoubleBuilt.class); + for (Object number : numbers) { + Map map = new LinkedHashMap<>(); + map.put("d", number); + if (creator) { + map.put("id", 1); + } + String desc = type.getSimpleName() + ", " + number + " (" + number.getClass().getSimpleName() + ")"; + assertEquals(_convert(vanilla, map, type), _convert(MAPPER, map, type), desc); + } + for (JsonNode node : trees) { + JsonNode tree = nodes.objectNode().set("d", node); + if (creator) { + ((com.fasterxml.jackson.databind.node.ObjectNode) tree).put("id", 1); + } + String desc = type.getSimpleName() + ", " + tree; + assertEquals(_treeToValue(vanilla, tree, type), _treeToValue(MAPPER, tree, type), desc); + } + } + // and a float still goes through the generated setter + DoubleBean bean = MAPPER.convertValue(Collections.singletonMap("d", 0.1f), DoubleBean.class); + assertEquals((double) 0.1f, bean.d); + assertEquals(0, bean.reflectiveCalls); + } + + private static String _read(ObjectMapper mapper, String json, Class type) { + try { + return _describe(mapper.readValue(json, type)); + } catch (Exception e) { + return _describe(e); + } + } + + private static String _convert(ObjectMapper mapper, Object value, Class type) { + try { + return _describe(mapper.convertValue(value, type)); + } catch (Exception e) { + return _describe(e); + } + } + + private static String _treeToValue(ObjectMapper mapper, JsonNode tree, Class type) { + try { + return _describe(mapper.treeToValue(tree, type)); + } catch (Exception e) { + return _describe(e); + } + } + + private static String _describe(HasDouble bean) { + return "d=" + bean.d() + " (" + Long.toHexString(Double.doubleToRawLongBits(bean.d())) + ")"; + } + + private static String _describe(Exception e) { + String desc = e.getClass().getName(); + if (e instanceof JsonMappingException) { + JsonMappingException jme = (JsonMappingException) e; + desc += ": " + jme.getOriginalMessage() + " at " + jme.getPathReference() + " " + jme.getLocation(); + } else { + desc += ": " + e.getMessage(); + } + Throwable cause = e.getCause(); + return (cause == null) ? desc : desc + ", caused by " + cause.getClass().getName() + ": " + cause.getMessage(); + } + + private static SettableBeanProperty _findProperty(ObjectMapper mapper, Class type, String name) + throws Exception + { + DefaultDeserializationContext ctxt = ((DefaultDeserializationContext) mapper.getDeserializationContext()) + .createInstance(mapper.getDeserializationConfig(), null, null); + BeanDeserializerBase deser = (BeanDeserializerBase) ctxt.findRootValueDeserializer( + mapper.constructType(type)); + return deser.findProperty(name); + } +} diff --git a/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/deser/TestBuilders.java b/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/deser/TestBuilders.java index 91097729..b4b3a080 100644 --- a/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/deser/TestBuilders.java +++ b/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/deser/TestBuilders.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.function.BiConsumer; import java.util.function.BiFunction; +import java.util.function.ObjDoubleConsumer; import java.util.function.Supplier; import org.junit.jupiter.api.Test; @@ -73,6 +74,7 @@ static final class MutableBuilder List list; int i; long l; + double d; boolean b; int reflectiveCalls; @@ -100,6 +102,12 @@ public MutableBuilder withL(long v) { return this; } + public MutableBuilder withD(double v) { + reflectiveCalls += reflectiveCall(); + d = v; + return this; + } + public MutableBuilder withB(boolean v) { reflectiveCalls += reflectiveCall(); b = v; @@ -107,14 +115,14 @@ public MutableBuilder withB(boolean v) { } public MutableBuilt build() { - return new MutableBuilt(str, list, i, l, b, reflectiveCalls); + return new MutableBuilt(str, list, i, l, d, b, reflectiveCalls); } } @JsonDeserialize(builder=MutableBuilder.class) static class MutableBuilt extends Values { - MutableBuilt(String str, List list, int i, long l, boolean b, int reflectiveCalls) { - super(str, list, i, l, b, reflectiveCalls); + MutableBuilt(String str, List list, int i, long l, double d, boolean b, int reflectiveCalls) { + super(str, list, i, l, d, b, reflectiveCalls); } } @@ -125,51 +133,57 @@ static final class CopyingBuilder final List list; final int i; final long l; + final double d; final boolean b; final int reflectiveCalls; public CopyingBuilder() { - this(null, null, 0, 0L, false, 0); + this(null, null, 0, 0L, 0.0, false, 0); } - CopyingBuilder(String str, List list, int i, long l, boolean b, int reflectiveCalls) { + CopyingBuilder(String str, List list, int i, long l, double d, boolean b, int reflectiveCalls) { this.str = str; this.list = list; this.i = i; this.l = l; + this.d = d; this.b = b; this.reflectiveCalls = reflectiveCalls; } public CopyingBuilder withStr(String v) { - return new CopyingBuilder(v, list, i, l, b, reflectiveCalls + reflectiveCall()); + return new CopyingBuilder(v, list, i, l, d, b, reflectiveCalls + reflectiveCall()); } public CopyingBuilder withList(List v) { - return new CopyingBuilder(str, v, i, l, b, reflectiveCalls + reflectiveCall()); + return new CopyingBuilder(str, v, i, l, d, b, reflectiveCalls + reflectiveCall()); } public CopyingBuilder withI(int v) { - return new CopyingBuilder(str, list, v, l, b, reflectiveCalls + reflectiveCall()); + return new CopyingBuilder(str, list, v, l, d, b, reflectiveCalls + reflectiveCall()); } public CopyingBuilder withL(long v) { - return new CopyingBuilder(str, list, i, v, b, reflectiveCalls + reflectiveCall()); + return new CopyingBuilder(str, list, i, v, d, b, reflectiveCalls + reflectiveCall()); + } + + public CopyingBuilder withD(double v) { + return new CopyingBuilder(str, list, i, l, v, b, reflectiveCalls + reflectiveCall()); } public CopyingBuilder withB(boolean v) { - return new CopyingBuilder(str, list, i, l, v, reflectiveCalls + reflectiveCall()); + return new CopyingBuilder(str, list, i, l, d, v, reflectiveCalls + reflectiveCall()); } public CopyingBuilt build() { - return new CopyingBuilt(str, list, i, l, b, reflectiveCalls); + return new CopyingBuilt(str, list, i, l, d, b, reflectiveCalls); } } @JsonDeserialize(builder=CopyingBuilder.class) static class CopyingBuilt extends Values { - CopyingBuilt(String str, List list, int i, long l, boolean b, int reflectiveCalls) { - super(str, list, i, l, b, reflectiveCalls); + CopyingBuilt(String str, List list, int i, long l, double d, boolean b, int reflectiveCalls) { + super(str, list, i, l, d, b, reflectiveCalls); } } @@ -197,7 +211,7 @@ public NullReturningBuilt build() { @JsonDeserialize(builder=NullReturningBuilder.class) static class NullReturningBuilt extends Values { NullReturningBuilt(String str, int i) { - super(str, null, i, 0L, false, 0); + super(str, null, i, 0L, 0.0, false, 0); } } @@ -208,6 +222,7 @@ static final class VoidSetterBuilder List list; int i; long l; + double d; boolean b; int reflectiveCalls; @@ -215,17 +230,18 @@ static final class VoidSetterBuilder public void withList(List v) { list = v; reflectiveCalls += reflectiveCall(); } public void withI(int v) { i = v; reflectiveCalls += reflectiveCall(); } public void withL(long v) { l = v; reflectiveCalls += reflectiveCall(); } + public void withD(double v) { d = v; reflectiveCalls += reflectiveCall(); } public void withB(boolean v) { b = v; reflectiveCalls += reflectiveCall(); } public VoidSetterBuilt build() { - return new VoidSetterBuilt(str, list, i, l, b, reflectiveCalls); + return new VoidSetterBuilt(str, list, i, l, d, b, reflectiveCalls); } } @JsonDeserialize(builder=VoidSetterBuilder.class) static class VoidSetterBuilt extends Values { - VoidSetterBuilt(String str, List list, int i, long l, boolean b, int reflectiveCalls) { - super(str, list, i, l, b, reflectiveCalls); + VoidSetterBuilt(String str, List list, int i, long l, double d, boolean b, int reflectiveCalls) { + super(str, list, i, l, d, b, reflectiveCalls); } } @@ -255,7 +271,7 @@ public SkipNullsBuilt build() { @JsonDeserialize(builder=SkipNullsBuilder.class) static class SkipNullsBuilt extends Values { SkipNullsBuilt(String str, List list) { - super(str, list, 0, 0L, false, 0); + super(str, list, 0, 0L, 0.0, false, 0); } } @@ -267,6 +283,7 @@ static final class CreatorSkipNullsBuilder List list = Arrays.asList("keep"); int i = 7; long l = 8L; + double d = 9.5; boolean b = true; @JsonCreator @@ -296,6 +313,12 @@ public CreatorSkipNullsBuilder withL(long v) { return this; } + @JsonSetter(nulls=Nulls.SKIP) + public CreatorSkipNullsBuilder withD(double v) { + d = v; + return this; + } + @JsonSetter(nulls=Nulls.SKIP) public CreatorSkipNullsBuilder withB(boolean v) { b = v; @@ -303,14 +326,14 @@ public CreatorSkipNullsBuilder withB(boolean v) { } public CreatorSkipNullsBuilt build() { - return new CreatorSkipNullsBuilt(str, list, i, l, b); + return new CreatorSkipNullsBuilt(str, list, i, l, d, b); } } @JsonDeserialize(builder=CreatorSkipNullsBuilder.class) static class CreatorSkipNullsBuilt extends Values { - CreatorSkipNullsBuilt(String str, List list, int i, long l, boolean b) { - super(str, list, i, l, b, 0); + CreatorSkipNullsBuilt(String str, List list, int i, long l, double d, boolean b) { + super(str, list, i, l, d, b, 0); } } @@ -336,6 +359,10 @@ public ThrowingBuilder withL(long v) throws Exception { return fail(); } + public ThrowingBuilder withD(double v) throws Exception { + return fail(); + } + public ThrowingBuilder withB(boolean v) throws Exception { return fail(); } @@ -388,6 +415,11 @@ public CreatorThrowingBuilder withL(long v) throws Exception { return this; } + public CreatorThrowingBuilder withD(double v) throws Exception { + ThrowingBuilder.fail(); + return this; + } + public CreatorThrowingBuilder withB(boolean v) throws Exception { ThrowingBuilder.fail(); return this; @@ -442,6 +474,7 @@ public Object withFirst(int v) { public UnrelatedBuilder withX(int v) { return this; } public UnrelatedBuilder withL(long v) { return this; } + public UnrelatedBuilder withD(double v) { return this; } public UnrelatedBuilder withB(boolean v) { return this; } public UnrelatedBuilder withS(String v) { return this; } public UnrelatedBuilder withList(List v) { return this; } @@ -463,6 +496,7 @@ public static NullCreatorBuilder create() { public NullCreatorBuilder withX(int v) { return this; } public NullCreatorBuilder withL(long v) { return this; } + public NullCreatorBuilder withD(double v) { return this; } public NullCreatorBuilder withB(boolean v) { return this; } public NullCreatorBuilder withS(String v) { return this; } public NullCreatorBuilder withList(List v) { return this; } @@ -730,25 +764,29 @@ static class ItemBuilt { } static final class NullReturningAllBuilder { long l; + double d; boolean b; List list; public NullReturningAllBuilder withL(long v) { l = v; return null; } + public NullReturningAllBuilder withD(double v) { d = v; return null; } public NullReturningAllBuilder withB(boolean v) { b = v; return null; } public NullReturningAllBuilder withList(List v) { list = v; return null; } - public NullReturningAllBuilt build() { return new NullReturningAllBuilt(l, b, list); } + public NullReturningAllBuilt build() { return new NullReturningAllBuilt(l, d, b, list); } } @JsonDeserialize(builder=NullReturningAllBuilder.class) static class NullReturningAllBuilt { final long l; + final double d; final boolean b; final List list; - NullReturningAllBuilt(long l, boolean b, List list) { + NullReturningAllBuilt(long l, double d, boolean b, List list) { this.l = l; + this.d = d; this.b = b; this.list = list; } @@ -792,14 +830,16 @@ static class Values { final List list; final int i; final long l; + final double d; final boolean b; final int reflectiveCalls; - Values(String str, List list, int i, long l, boolean b, int reflectiveCalls) { + Values(String str, List list, int i, long l, double d, boolean b, int reflectiveCalls) { this.str = str; this.list = list; this.i = i; this.l = l; + this.d = d; this.b = b; this.reflectiveCalls = reflectiveCalls; } @@ -818,7 +858,7 @@ static int reflectiveCall() { } private static final String VALUES_JSON = a2q( - "{'str':'foo','list':['a','b'],'i':123456,'l':12345678901,'b':true}"); + "{'str':'foo','list':['a','b'],'i':123456,'l':12345678901,'d':-1.25,'b':true}"); /* /********************************************************** @@ -862,10 +902,10 @@ public void testVanillaBuilderUsesReflection() throws Exception ObjectMapper vanilla = newVanillaJSONMapper(); Values v = vanilla.readValue(VALUES_JSON, MutableBuilt.class); _verifyValues(v); - assertEquals(5, v.reflectiveCalls); + assertEquals(6, v.reflectiveCalls); v = vanilla.readValue(VALUES_JSON, CopyingBuilt.class); _verifyValues(v); - assertEquals(5, v.reflectiveCalls); + assertEquals(6, v.reflectiveCalls); } @Test @@ -904,7 +944,7 @@ public void testVoidSetterBuilder() throws Exception _verifyValues(v); assertEquals(0, v.reflectiveCalls); // as reflection would, with Jackson's own access rules - assertEquals(5, newVanillaJSONMapper().readValue(VALUES_JSON, VoidSetterBuilt.class).reflectiveCalls); + assertEquals(6, newVanillaJSONMapper().readValue(VALUES_JSON, VoidSetterBuilt.class).reflectiveCalls); } @Test @@ -918,15 +958,17 @@ public void testSkipNullsBuilder() throws Exception @Test public void testSkipNullsBeforeCreatorProperty() throws Exception { - String json = a2q("{'str':null,'list':null,'i':null,'l':null,'b':null,'id':'x'}"); + String json = a2q("{'str':null,'list':null,'i':null,'l':null,'d':null,'b':null,'id':'x'}"); Values expected = newVanillaJSONMapper().readValue(json, CreatorSkipNullsBuilt.class); Values v = MAPPER.readValue(json, CreatorSkipNullsBuilt.class); assertEquals("default", expected.str); assertEquals(7, expected.i); + assertEquals(9.5, expected.d); assertEquals(expected.str, v.str); assertEquals(expected.list, v.list); assertEquals(expected.i, v.i); assertEquals(expected.l, v.l); + assertEquals(expected.d, v.d); assertEquals(expected.b, v.b); } @@ -953,7 +995,7 @@ public void testThrowingSetterMatchesVanilla() throws Exception .configure(DeserializationFeature.WRAP_EXCEPTIONS, wrap).build(); for (Supplier failure : failures) { for (String json : new String[] { a2q("{'str':'foo'}"), a2q("{'list':['a']}"), - a2q("{'i':42}"), a2q("{'l':42}"), a2q("{'b':true}"), voidJson }) { + a2q("{'i':42}"), a2q("{'l':42}"), a2q("{'d':4.5}"), a2q("{'b':true}"), voidJson }) { toThrow = failure; setterCalls = 0; Throwable expected = assertThrows(Throwable.class, @@ -993,7 +1035,7 @@ public void testThrowingBufferedSetterMatchesVanilla() throws Exception .configure(DeserializationFeature.WRAP_EXCEPTIONS, wrap).build(); for (Supplier failure : failures) { for (String prop : new String[] { "'str':'foo'", "'list':['a']", - "'i':42", "'l':42", "'b':true" }) { + "'i':42", "'l':42", "'d':4.5", "'b':true" }) { for (String json : new String[] { a2q("{" + prop + ",'id':'a'}"), a2q("{'id':'a'," + prop + "}") }) { toThrow = failure; @@ -1035,7 +1077,7 @@ public void testUnusableBuilderInstanceMatchesVanilla() throws Exception .configure(DeserializationFeature.WRAP_EXCEPTIONS, wrap).build(); ObjectMapper blackbird = mapperBuilder() .configure(DeserializationFeature.WRAP_EXCEPTIONS, wrap).build(); - for (String value : new String[] { "'x':2", "'l':2", "'b':true", "'s':'a'", "'list':['a']" }) { + for (String value : new String[] { "'x':2", "'l':2", "'d':2.5", "'b':true", "'s':'a'", "'list':['a']" }) { // the unrelated builder comes from `withFirst`, the null one from its creator for (String json : new String[] { a2q("{'first':1," + value + "}"), a2q("{" + value + "}") }) { Class type = json.contains("first") ? UnrelatedBuilt.class : NullCreatorBuilt.class; @@ -1094,6 +1136,36 @@ public Object apply(Object bean, Object v) { assertNotNull(e.getLocation()); } assertEquals(0, setterCalls); + + // and the same for a primitive double + SettableDoubleProperty doubleProp = new SettableDoubleProperty(deser.findProperty("d"), + new ObjDoubleConsumer() { + @Override + public void accept(Object bean, double v) { + throw failure; + } + }, + new BBDeserializerModifier.ObjDoubleBiFunction() { + @Override + public Object apply(Object bean, double v) { + throw failure; + } + }, + ThrowingBuilder.class, true); + e = assertThrows(JsonMappingException.class, () -> doubleProp.setAndReturn(new ThrowingBuilder(), 1.5)); + assertSame(failure, e.getCause()); + try (JsonParser p = vanilla.createParser(a2q("{'d':1.5}"))) { + p.nextToken(); + p.nextToken(); + p.nextToken(); + DefaultDeserializationContext ctxt = ((DefaultDeserializationContext) vanilla.getDeserializationContext()) + .createInstance(vanilla.getDeserializationConfig(), p, null); + e = assertThrows(JsonMappingException.class, + () -> doubleProp.deserializeSetAndReturn(p, ctxt, new ThrowingBuilder())); + assertSame(failure, e.getCause()); + assertNotNull(e.getLocation()); + } + assertEquals(0, setterCalls); } @Test @@ -1123,7 +1195,8 @@ public void testInjectedPackagePrivateSetterInOtherPackage() throws Exception public void testSetAndReturnWithUnusableBuilder() throws Exception { ObjectMapper vanilla = newVanillaJSONMapper(); - Object[][] cases = { { "i", 1 }, { "l", 1L }, { "b", true }, { "str", "x" }, { "list", Arrays.asList("x") } }; + Object[][] cases = { { "i", 1 }, { "l", 1L }, { "d", 1.5 }, { "b", true }, { "str", "x" }, + { "list", Arrays.asList("x") } }; for (Object[] c : cases) { String name = (String) c[0]; for (Object instance : new Object[] { "not a builder", null }) { @@ -1148,17 +1221,21 @@ public void testSetAndReturnWithUnusableBuilder() throws Exception @Test public void testNullReturningBuilderForAllTypes() throws Exception { - for (String name : new String[] { "l", "b", "list" }) { + for (String name : new String[] { "l", "d", "b", "list" }) { assertTrue(_findProperty(MAPPER, NullReturningAllBuilt.class, name) instanceof OptimizedSettableBeanProperty, name); } - NullReturningAllBuilt v = MAPPER.readValue(a2q("{'l':7,'b':true,'list':['x']}"), NullReturningAllBuilt.class); + NullReturningAllBuilt v = MAPPER.readValue(a2q("{'l':7,'d':7.5,'b':true,'list':['x']}"), + NullReturningAllBuilt.class); assertEquals(7L, v.l); + assertEquals(7.5, v.d); assertTrue(v.b); assertEquals(Arrays.asList("x"), v.list); // and through setAndReturn NullReturningAllBuilder builder = new NullReturningAllBuilder(); assertSame(builder, _findProperty(MAPPER, NullReturningAllBuilt.class, "l").setAndReturn(builder, 3L)); + assertSame(builder, _findProperty(MAPPER, NullReturningAllBuilt.class, "d").setAndReturn(builder, 3.5)); + assertEquals(3.5, builder.d); assertSame(builder, _findProperty(MAPPER, NullReturningAllBuilt.class, "b").setAndReturn(builder, true)); assertSame(builder, _findProperty(MAPPER, NullReturningAllBuilt.class, "list") .setAndReturn(builder, Collections.singletonList("y"))); @@ -1187,7 +1264,7 @@ public void testBuilderNullHandlingMatchesVanilla() throws Exception public void testSetAndReturnNullPrimitives() throws Exception { ObjectMapper vanilla = newVanillaJSONMapper(); - for (String name : new String[] { "i", "l", "b" }) { + for (String name : new String[] { "i", "l", "d", "b" }) { SettableBeanProperty expectedProperty = _findProperty(vanilla, name); SettableBeanProperty actualProperty = _findProperty(MAPPER, name); assertTrue(actualProperty instanceof OptimizedSettableBeanProperty, name); @@ -1299,6 +1376,8 @@ public void testSetWithValueOfAnotherTypeMatchesVanilla() throws Exception { "list", Arrays.asList("a"), "x", 42 }, { "i", 1, 42L, "x", true, (short) 3, 'c' }, { "l", 1L, "x", 2.5, 3, (byte) 4 }, + { "d", 1.5, "x", 2.5f, Float.NaN, 3, 9007199254740993L, (short) 5, (byte) 6, 'c', true, + new java.math.BigDecimal("0.1") }, { "b", true, "x", 1 }, }; for (Object[] c : cases) { @@ -1321,6 +1400,8 @@ public void testSetWithValueOfAnotherTypeMatchesVanilla() throws Exception actualProp.set(actualBuilder, value); assertEquals(expectedBuilder.i, actualBuilder.i, desc); assertEquals(expectedBuilder.l, actualBuilder.l, desc); + assertEquals(Double.doubleToRawLongBits(expectedBuilder.d), + Double.doubleToRawLongBits(actualBuilder.d), desc); } else { Throwable actual = assertThrows(Throwable.class, () -> actualProp.set(actualBuilder, value), desc); _assertSameFailure(desc + ", " + expected, expected, actual); @@ -1371,6 +1452,8 @@ public void testSetAndReturnWithOtherValueTypes() throws Exception Object[][] cases = { { "i", 'A' }, { "i", (short) 7 }, { "i", 4294967297L }, { "i", 3.9 }, { "i", "x" }, { "l", 'A' }, { "l", 7 }, { "l", 3.9 }, + { "d", 'A' }, { "d", (short) 7 }, { "d", 7 }, { "d", 9007199254740993L }, { "d", 0.1f }, + { "d", Float.NaN }, { "d", new java.math.BigDecimal("0.1") }, { "d", "1.5" }, { "b", 1 }, { "b", "true" }, { "str", 1 }, { "list", "x" }, }; @@ -1382,8 +1465,8 @@ public void testSetAndReturnWithOtherValueTypes() throws Exception name + "=" + c[1] + " (" + c[1].getClass().getSimpleName() + ")"); } // while values of the setter's own type still use the generated setters - for (Object[] c : new Object[][] { { "i", 42 }, { "l", 42L }, { "b", true }, { "str", "x" }, - { "list", Arrays.asList("x") } }) { + for (Object[] c : new Object[][] { { "i", 42 }, { "l", 42L }, { "d", 4.5 }, { "d", -0.0 }, + { "d", Double.NaN }, { "b", true }, { "str", "x" }, { "list", Arrays.asList("x") } }) { MutableBuilder b = (MutableBuilder) _findProperty(MAPPER, (String) c[0]) .setAndReturn(new MutableBuilder(), c[1]); assertEquals(0, b.reflectiveCalls, String.valueOf(c[0])); @@ -1393,7 +1476,8 @@ public void testSetAndReturnWithOtherValueTypes() throws Exception private static String _setAndReturn(ObjectMapper mapper, String name, Object value) throws Exception { try { MutableBuilder b = (MutableBuilder) _findProperty(mapper, name).setAndReturn(new MutableBuilder(), value); - return "str=" + b.str + " list=" + b.list + " i=" + b.i + " l=" + b.l + " b=" + b.b; + return "str=" + b.str + " list=" + b.list + " i=" + b.i + " l=" + b.l + + " d=" + b.d + " (" + Long.toHexString(Double.doubleToRawLongBits(b.d)) + ") b=" + b.b; } catch (Exception e) { return e.getClass().getName() + ": " + e.getMessage(); } @@ -1418,25 +1502,32 @@ private static void _assertSameFailure(String desc, Throwable expected, Throwabl if (expected instanceof JsonMappingException) { JsonMappingException e = (JsonMappingException) expected; JsonMappingException a = (JsonMappingException) actual; - assertEquals(e.getOriginalMessage(), a.getOriginalMessage(), desc); + assertEquals(_withoutHashes(e.getOriginalMessage()), _withoutHashes(a.getOriginalMessage()), desc); assertEquals(e.getPathReference(), a.getPathReference(), desc); assertEquals(e.getLocation(), a.getLocation(), desc); } else { - assertEquals(expected.getMessage(), actual.getMessage(), desc); + assertEquals(_withoutHashes(expected.getMessage()), _withoutHashes(actual.getMessage()), desc); } Throwable ec = expected.getCause(); Throwable ac = actual.getCause(); assertEquals((ec == null) ? null : ec.getClass(), (ac == null) ? null : ac.getClass(), desc); if (ec != null) { - assertEquals(ec.getMessage(), ac.getMessage(), desc); + assertEquals(_withoutHashes(ec.getMessage()), _withoutHashes(ac.getMessage()), desc); } } + // Once a method has been called often enough, reflection on Java 17 and earlier + // reports a bad receiver or argument with the identity hash of a `ClassCastException` + private static String _withoutHashes(String message) { + return (message == null) ? null : message.replaceAll("(Exception)@[0-9a-f]+", "$1@..."); + } + private void _verifyValues(Values v) { assertEquals("foo", v.str); assertEquals(Arrays.asList("a", "b"), v.list); assertEquals(123456, v.i); assertEquals(12345678901L, v.l); + assertEquals(-1.25, v.d); assertTrue(v.b); } } diff --git a/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/deser/TestSetterFailures.java b/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/deser/TestSetterFailures.java index 4c9bc392..266cdfcd 100644 --- a/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/deser/TestSetterFailures.java +++ b/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/deser/TestSetterFailures.java @@ -53,6 +53,7 @@ static class VoidBean { public void setObj(List value) { failOnce(); } public void setI(int value) { failOnce(); } public void setL(long value) { failOnce(); } + public void setD(double value) { failOnce(); } public void setB(boolean value) { failOnce(); } } @@ -61,6 +62,7 @@ static class FluentBean { public FluentBean setObj(List value) { failOnce(); return this; } public FluentBean setI(int value) { failOnce(); return this; } public FluentBean setL(long value) { failOnce(); return this; } + public FluentBean setD(double value) { failOnce(); return this; } public FluentBean setB(boolean value) { failOnce(); return this; } } @@ -70,6 +72,7 @@ static class FluentBean { static class AlwaysFailingBean { public void setStr(String value) { alwaysFail(); } public void setI(int value) { alwaysFail(); } + public void setD(double value) { alwaysFail(); } } private static void alwaysFail() { @@ -88,6 +91,7 @@ static class SkipNullsBean { List list = Arrays.asList("keep"); int i = 7; long l = 8L; + double d = 9.5; boolean b = true; @JsonCreator @@ -99,6 +103,9 @@ static class SkipNullsBean { @JsonSetter(nulls=Nulls.SKIP) public void setL(long value) { calls++; l = value; } + @JsonSetter(nulls=Nulls.SKIP) + public void setD(double value) { calls++; d = value; } + @JsonSetter(nulls=Nulls.SKIP) public void setB(boolean value) { calls++; b = value; } @@ -201,7 +208,7 @@ public void testDeserializeDoesNotRetrySetterFailures() throws Exception { .configure(DeserializationFeature.WRAP_EXCEPTIONS, wrap).build(); for (Class beanType : new Class[] { VoidBean.class, FluentBean.class }) { for (String json : new String[] { a2q("{'str':'foo'}"), a2q("{'obj':['foo']}"), - a2q("{'i':42}"), a2q("{'l':12345678901}"), a2q("{'b':true}") }) { + a2q("{'i':42}"), a2q("{'l':12345678901}"), a2q("{'d':1.5}"), a2q("{'b':true}") }) { for (Supplier nextFailure : failures()) { failure = nextFailure; assertRunsOnce(() -> mapper.readValue(json, beanType), @@ -215,8 +222,8 @@ public void testDeserializeDoesNotRetrySetterFailures() throws Exception { @Test public void testSetDoesNotRetrySetterFailures() throws Exception { ObjectMapper mapper = newObjectMapper(); - String[] names = { "str", "obj", "i", "l", "b" }; - Object[] values = { "foo", Arrays.asList("foo"), 42, 12345678901L, true }; + String[] names = { "str", "obj", "i", "l", "d", "b" }; + Object[] values = { "foo", Arrays.asList("foo"), 42, 12345678901L, 1.5, true }; List> beans = Arrays.asList(VoidBean::new, FluentBean::new); for (Supplier newBean : beans) { Class beanType = newBean.get().getClass(); @@ -242,7 +249,7 @@ public void testSetterFailuresAreReportedAsVanilla() throws Exception { ObjectMapper blackbird = mapperBuilder().configure(DeserializationFeature.WRAP_EXCEPTIONS, wrap).build(); for (Throwable t : new Throwable[] { new SecurityException("se"), new IllegalAccessError("iae"), new IllegalStateException("ise"), new NoClassDefFoundError("ncdfe"), new AssertionError("ae") }) { - for (String json : new String[] { a2q("{'str':'foo'}"), a2q("{'i':42}") }) { + for (String json : new String[] { a2q("{'str':'foo'}"), a2q("{'i':42}"), a2q("{'d':1.5}") }) { String desc = "wrap=" + wrap + ", " + t + ", " + json; alwaysThrow = t; calls = 0; @@ -263,7 +270,7 @@ public void testSetterFailuresAreReportedAsVanilla() throws Exception { @Test public void testSetSkipsNulls() throws Exception { - for (String name : new String[] { "str", "list", "i", "l", "b" }) { + for (String name : new String[] { "str", "list", "i", "l", "d", "b" }) { SettableBeanProperty property = property(newObjectMapper(), SkipNullsBean.class, name); assertTrue(property instanceof OptimizedSettableBeanProperty, name); SkipNullsBean bean = new SkipNullsBean(1); @@ -272,7 +279,7 @@ public void testSetSkipsNulls() throws Exception { assertEquals(0, calls, name); } // with the nulls before the creator property, so they're buffered - String json = a2q("{'str':null,'list':null,'i':null,'l':null,'b':null,'id':1}"); + String json = a2q("{'str':null,'list':null,'i':null,'l':null,'d':null,'b':null,'id':1}"); for (ObjectMapper mapper : new ObjectMapper[] { newVanillaJSONMapper(), newObjectMapper() }) { calls = 0; SkipNullsBean bean = mapper.readValue(json, SkipNullsBean.class); @@ -280,6 +287,7 @@ public void testSetSkipsNulls() throws Exception { assertEquals(Arrays.asList("keep"), bean.list); assertEquals(7, bean.i); assertEquals(8L, bean.l); + assertEquals(9.5, bean.d); assertTrue(bean.b); assertEquals(0, calls); } @@ -290,7 +298,7 @@ public void testSetSkipsNulls() throws Exception { public void testSetNullPrimitiveWithoutSkipping() throws Exception { ObjectMapper vanilla = newVanillaJSONMapper(); ObjectMapper blackbird = newObjectMapper(); - for (String name : new String[] { "i", "l", "b" }) { + for (String name : new String[] { "i", "l", "d", "b" }) { SettableBeanProperty expectedProperty = property(vanilla, VoidBean.class, name); SettableBeanProperty actualProperty = property(blackbird, VoidBean.class, name); assertTrue(actualProperty instanceof OptimizedSettableBeanProperty, name); diff --git a/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/DoublePropertyWriterTest.java b/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/DoublePropertyWriterTest.java new file mode 100644 index 00000000..439d562d --- /dev/null +++ b/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/DoublePropertyWriterTest.java @@ -0,0 +1,392 @@ +package com.fasterxml.jackson.module.blackbird.ser; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.function.UnaryOperator; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonRawValue; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.StreamWriteFeature; +import com.fasterxml.jackson.core.json.JsonWriteFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.PropertyWriter; +import com.fasterxml.jackson.databind.ser.std.BeanSerializerBase; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.fasterxml.jackson.databind.util.StdConverter; +import com.fasterxml.jackson.module.blackbird.BlackbirdTestBase; + +import static org.junit.jupiter.api.Assertions.*; + +// Primitive double getters should go through generated accessors, and write +// exactly what `BeanPropertyWriter` writes +public class DoublePropertyWriterTest extends BlackbirdTestBase +{ + static abstract class Holder { + double value; + int reflectiveCalls; + + double get() { + reflectiveCalls += reflectiveCall(); + return value; + } + } + + static class Plain extends Holder { + public double getD() { return get(); } + } + + static class Always extends Holder { + @JsonInclude(JsonInclude.Include.ALWAYS) + public double getD() { return get(); } + } + + static class NonNull extends Holder { + @JsonInclude(JsonInclude.Include.NON_NULL) + public double getD() { return get(); } + } + + static class NonAbsent extends Holder { + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public double getD() { return get(); } + } + + static class NonEmpty extends Holder { + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public double getD() { return get(); } + } + + static class NonDefault extends Holder { + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + public double getD() { return get(); } + } + + // Suppresses negative values (and only through `equals`) + static class NegativeFilter { + @Override + public boolean equals(Object o) { + return (o instanceof Double) && ((Double) o < 0.0); + } + + @Override + public int hashCode() { return 0; } + } + + static class Custom extends Holder { + @JsonInclude(value = JsonInclude.Include.CUSTOM, valueFilter = NegativeFilter.class) + public double getD() { return get(); } + } + + static class AsString extends Holder { + @JsonFormat(shape = JsonFormat.Shape.STRING) + public double getD() { return get(); } + } + + // With a class-level NON_DEFAULT, the default is the value of a new instance + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + static class DefaultZero extends Holder { + public double getD() { return get(); } + } + + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + static class DefaultNegativeZero extends Holder { + public DefaultNegativeZero() { value = -0.0; } + public double getD() { return get(); } + } + + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + static class DefaultNaN extends Holder { + public DefaultNaN() { value = Double.NaN; } + public double getD() { return get(); } + } + + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + static class DefaultOnePointFive extends Holder { + public DefaultOnePointFive() { value = 1.5; } + public double getD() { return get(); } + } + + // As an array, which writes each property as an element (and a placeholder for + // a suppressed one) + @JsonFormat(shape = JsonFormat.Shape.ARRAY) + @JsonPropertyOrder({ "d", "x" }) + static class ArrayNonDefault extends Holder { + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + public double getD() { return get(); } + public int getX() { return 1; } + } + + @JsonFormat(shape = JsonFormat.Shape.ARRAY) + @JsonPropertyOrder({ "d", "x" }) + static class ArrayCustom extends Holder { + @JsonInclude(value = JsonInclude.Include.CUSTOM, valueFilter = NegativeFilter.class) + public double getD() { return get(); } + public int getX() { return 1; } + } + + @JsonFormat(shape = JsonFormat.Shape.ARRAY) + @JsonPropertyOrder({ "d", "x" }) + static class ArrayAsString extends Holder { + @JsonFormat(shape = JsonFormat.Shape.STRING) + public double getD() { return get(); } + public int getX() { return 1; } + } + + // Neither is optimized, as their serializers aren't the default + static class Raw extends Holder { + @JsonRawValue + public double getD() { return get(); } + } + + static class Negate extends StdConverter { + @Override + public Double convert(Double value) { return -value; } + } + + static class Converted extends Holder { + @JsonSerialize(converter = Negate.class) + public double getD() { return get(); } + } + + static class NegatingSerializer extends StdSerializer { + private static final long serialVersionUID = 1L; + + NegatingSerializer() { super(Double.class); } + + @Override + public void serialize(Double value, JsonGenerator gen, SerializerProvider provider) throws IOException { + gen.writeNumber(-value); + } + } + + // As in `TestAccessFallback`: the getter fails when called through the generated + // accessor, and works through reflection (the fallback writer) + static double failFromGeneratedAccessor(double value) { + for (StackTraceElement elem : new Throwable().getStackTrace()) { + if (elem.getClassName().equals("java.lang.reflect.Method")) { + return value; + } + if (elem.getClassName().equals(DoublePropertyWriter.class.getName())) { + throw new IllegalAccessError("simulated"); + } + } + return value; + } + + static class FailingAccessor { + public double getD() { return failFromGeneratedAccessor(2.5); } + } + + @JsonFormat(shape = JsonFormat.Shape.ARRAY) + @JsonPropertyOrder({ "d", "x" }) + static class ArrayFailingAccessor { + public double getD() { return failFromGeneratedAccessor(2.5); } + public int getX() { return 1; } + } + + // Unwrapping with a prefix renames each property of the inner bean + static class Unwrapping { + @JsonUnwrapped(prefix = "p_") + public Holder inner; + + Unwrapping(Holder inner) { this.inner = inner; } + } + + private static final List> TYPES = Arrays.asList( + Plain.class, Always.class, NonNull.class, NonAbsent.class, NonEmpty.class, NonDefault.class, + Custom.class, AsString.class, DefaultZero.class, DefaultNegativeZero.class, DefaultNaN.class, + DefaultOnePointFive.class, ArrayNonDefault.class, ArrayCustom.class, ArrayAsString.class); + + private static final double[] VALUES = { + 0.0, -0.0, 1.5, -1.5, 0.1, 1e-7, 1e21, 1e22, 123456789.125, + Double.NaN, Double.longBitsToDouble(0x7ff0000000000001L), Double.longBitsToDouble(0xfff8000000000000L), + Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, + Double.MAX_VALUE, -Double.MAX_VALUE, Double.MIN_VALUE, -Double.MIN_VALUE, Double.MIN_NORMAL + }; + + private static final List> CONFIGS = Arrays.asList( + b -> b, + b -> b.disable(JsonWriteFeature.WRITE_NAN_AS_STRINGS), + b -> b.enable(JsonWriteFeature.WRITE_NUMBERS_AS_STRINGS), + b -> b.enable(StreamWriteFeature.USE_FAST_DOUBLE_WRITER), + b -> b.defaultPropertyInclusion(JsonInclude.Value.construct( + JsonInclude.Include.NON_DEFAULT, JsonInclude.Include.NON_DEFAULT)), + b -> b.defaultPropertyInclusion(JsonInclude.Value.construct( + JsonInclude.Include.NON_EMPTY, JsonInclude.Include.NON_EMPTY)), + b -> b.withConfigOverride(Double.TYPE, o -> o.setInclude(JsonInclude.Value.construct( + JsonInclude.Include.NON_DEFAULT, JsonInclude.Include.NON_DEFAULT))), + b -> b.withConfigOverride(Double.TYPE, o -> o.setInclude(JsonInclude.Value.construct( + JsonInclude.Include.CUSTOM, JsonInclude.Include.ALWAYS, NegativeFilter.class, null))), + b -> b.withConfigOverride(Double.TYPE, o -> o.setFormat(JsonFormat.Value.forShape(JsonFormat.Shape.STRING))), + b -> b.addModule(new SimpleModule().addSerializer(Double.TYPE, new NegatingSerializer())) + ); + + // Whatever called the getter: reflection, or the generated accessor (whose frame + // is hidden, so its caller shows instead) + static int reflectiveCall() { + StackTraceElement[] stack = new Throwable().getStackTrace(); + for (int i = 0; i < stack.length - 1; i++) { + if (stack[i].getMethodName().equals("getD")) { + String caller = stack[i + 1].getClassName(); + return (caller.startsWith("java.lang.reflect.") || caller.startsWith("jdk.internal.reflect.") + || caller.startsWith("sun.reflect.")) ? 1 : 0; + } + } + throw new AssertionError("not called from a getter"); + } + + private final ObjectMapper MAPPER = newObjectMapper(); + + private static T holder(Class type, double value) throws Exception { + T holder = type.getDeclaredConstructor().newInstance(); + holder.value = value; + return holder; + } + + @Test + public void testUsesGeneratedGetter() throws Exception + { + for (Class type : TYPES) { + assertEquals(DoublePropertyWriter.class, _findWriter(MAPPER, type, "d").getClass(), type.getSimpleName()); + } + Holder bean = holder(Plain.class, 2.5); + assertEquals(a2q("{'d':2.5}"), MAPPER.writeValueAsString(bean)); + assertEquals(0, bean.reflectiveCalls); + bean = holder(ArrayNonDefault.class, 2.5); + assertEquals("[2.5,1]", MAPPER.writeValueAsString(bean)); + assertEquals(0, bean.reflectiveCalls); + // Sanity check that the reflection probe works at all + bean = holder(Plain.class, 2.5); + assertEquals(a2q("{'d':2.5}"), newVanillaJSONMapper().writeValueAsString(bean)); + assertEquals(1, bean.reflectiveCalls); + } + + @Test + public void testOutputMatchesVanilla() throws Exception + { + for (int i = 0; i < CONFIGS.size(); i++) { + ObjectMapper vanilla = CONFIGS.get(i).apply(JsonMapper.builder()).build(); + ObjectMapper blackbird = CONFIGS.get(i).apply(mapperBuilder()).build(); + List> types = new ArrayList<>(TYPES); + types.add(Raw.class); + types.add(Converted.class); + for (Class type : types) { + for (double value : VALUES) { + String desc = "config " + i + ", " + type.getSimpleName() + ", " + value + + " (" + Long.toHexString(Double.doubleToRawLongBits(value)) + ")"; + String expected = vanilla.writeValueAsString(holder(type, value)); + assertEquals(expected, blackbird.writeValueAsString(holder(type, value)), desc); + // and through a buffer + assertEquals(String.valueOf(vanilla.valueToTree(holder(type, value))), + String.valueOf(blackbird.valueToTree(holder(type, value))), desc); + } + } + } + } + + // (Not with the STRING shape, which Blackbird already ignores for every type of + // property in an unwrapped bean, as a renamed writer doesn't know it's broken) + @Test + public void testUnwrappedMatchesVanilla() throws Exception + { + ObjectMapper vanilla = newVanillaJSONMapper(); + for (Class type : TYPES) { + if (type.getSimpleName().startsWith("Array") || (type == AsString.class)) { + continue; + } + for (double value : VALUES) { + String desc = type.getSimpleName() + ", " + value; + assertEquals(vanilla.writeValueAsString(new Unwrapping(holder(type, value))), + MAPPER.writeValueAsString(new Unwrapping(holder(type, value))), desc); + } + } + assertEquals("{}", MAPPER.writeValueAsString(new Unwrapping(holder(Custom.class, -1.5)))); + assertEquals(a2q("{'p_d':-0.0}"), MAPPER.writeValueAsString(new Unwrapping(holder(NonDefault.class, -0.0)))); + } + + // The cases decided only through the generated getter, on the values where + // `!=` and `Double.equals` disagree + @Test + public void testSuppressionComparesAsDoubleEquals() throws Exception + { + assertEquals(a2q("{'d':-0.0}"), MAPPER.writeValueAsString(holder(NonDefault.class, -0.0))); + assertEquals("{}", MAPPER.writeValueAsString(holder(NonDefault.class, 0.0))); + assertEquals(a2q("{'d':0.0}"), MAPPER.writeValueAsString(holder(DefaultNegativeZero.class, 0.0))); + assertEquals("{}", MAPPER.writeValueAsString(holder(DefaultNegativeZero.class, -0.0))); + assertEquals("{}", MAPPER.writeValueAsString(holder(DefaultNaN.class, Double.NaN))); + assertEquals("{}", MAPPER.writeValueAsString( + holder(DefaultNaN.class, Double.longBitsToDouble(0x7ff0000000000001L)))); + assertEquals(a2q("{'d':0.0}"), MAPPER.writeValueAsString(holder(DefaultNaN.class, 0.0))); + assertEquals("[null,1]", MAPPER.writeValueAsString(holder(ArrayNonDefault.class, 0.0))); + assertEquals("[-0.0,1]", MAPPER.writeValueAsString(holder(ArrayNonDefault.class, -0.0))); + // and NON_EMPTY suppresses nothing + assertEquals(a2q("{'d':0.0}"), MAPPER.writeValueAsString(holder(NonEmpty.class, 0.0))); + } + + @Test + public void testCustomFilter() throws Exception + { + assertEquals("{}", MAPPER.writeValueAsString(holder(Custom.class, -1.5))); + assertEquals(a2q("{'d':1.5}"), MAPPER.writeValueAsString(holder(Custom.class, 1.5))); + assertEquals("[null,1]", MAPPER.writeValueAsString(holder(ArrayCustom.class, -1.5))); + assertEquals("[1.5,1]", MAPPER.writeValueAsString(holder(ArrayCustom.class, 1.5))); + } + + // [modules-base#327] + @Test + public void testStringShape() throws Exception + { + assertEquals(a2q("{'d':'1.5'}"), MAPPER.writeValueAsString(holder(AsString.class, 1.5))); + assertEquals(a2q("{'d':'NaN'}"), MAPPER.writeValueAsString(holder(AsString.class, Double.NaN))); + assertEquals("[\"1.5\",1]", MAPPER.writeValueAsString(holder(ArrayAsString.class, 1.5))); + ObjectMapper overridden = mapperBuilder() + .withConfigOverride(Double.TYPE, o -> o.setFormat(JsonFormat.Value.forShape(JsonFormat.Shape.STRING))) + .build(); + assertEquals(a2q("{'d':'1.5'}"), overridden.writeValueAsString(holder(Plain.class, 1.5))); + } + + @Test + public void testNonFiniteValues() throws Exception + { + assertEquals(a2q("{'d':'NaN'}"), MAPPER.writeValueAsString(holder(Plain.class, Double.NaN))); + assertEquals(a2q("{'d':'-Infinity'}"), MAPPER.writeValueAsString(holder(Plain.class, Double.NEGATIVE_INFINITY))); + ObjectMapper raw = mapperBuilder().disable(JsonWriteFeature.WRITE_NAN_AS_STRINGS).build(); + assertEquals(a2q("{'d':NaN}"), raw.writeValueAsString(holder(Plain.class, Double.NaN))); + assertEquals(a2q("{'d':Infinity}"), raw.writeValueAsString(holder(Plain.class, Double.POSITIVE_INFINITY))); + } + + // An access error falls back to reflection, as a field or as an element + @Test + public void testAccessErrorFallsBack() throws Exception + { + assertEquals(DoublePropertyWriter.class, _findWriter(MAPPER, FailingAccessor.class, "d").getClass()); + assertEquals(DoublePropertyWriter.class, _findWriter(MAPPER, ArrayFailingAccessor.class, "d").getClass()); + assertEquals(a2q("{'d':2.5}"), newObjectMapper().writeValueAsString(new FailingAccessor())); + assertEquals("[2.5,1]", newObjectMapper().writeValueAsString(new ArrayFailingAccessor())); + } + + private static PropertyWriter _findWriter(ObjectMapper mapper, Class type, String name) throws Exception + { + BeanSerializerBase ser = (BeanSerializerBase) mapper.getSerializerProviderInstance() + .findValueSerializer(type); + for (Iterator it = ser.properties(); it.hasNext(); ) { + PropertyWriter writer = it.next(); + if (writer.getName().equals(name)) { + return writer; + } + } + throw new AssertionError("No property '" + name + "' for " + type.getName()); + } +} diff --git a/release-notes/CREDITS-2.x b/release-notes/CREDITS-2.x index a5d22c9d..fc74be65 100644 --- a/release-notes/CREDITS-2.x +++ b/release-notes/CREDITS-2.x @@ -178,3 +178,6 @@ Parker Winchester (@parker89) (2.23.0) * Contributed #367: (blackbird) Don't retry failed bean setters through reflection (2.23.0) +* Contributed #NNN: (blackbird) Use generated accessors for primitive `double` setters + and getters + (2.23.0) diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index 536ae5db..ba528b93 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -35,6 +35,8 @@ Active maintainers: #367: (blackbird) Don't retry failed bean setters through reflection; report anything a setter throws as `JsonMappingException`, as plain databind does (contributed by Parker W) +#NNN: (blackbird) Use generated accessors for primitive `double` setters and getters + (contributed by Parker W) 2.22.4 (17-Oct-2026) 2.22.3 (21-Sep-2026) From 1d2e7ee2568490e70f44a069785015eb1a7c965c Mon Sep 17 00:00:00 2001 From: Parker Winchester Date: Mon, 2 Nov 2026 16:11:47 -0500 Subject: [PATCH 2/9] List double among Blackbird's specialized types in the README --- blackbird/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/blackbird/README.md b/blackbird/README.md index 6aa01735..8789c5d5 100644 --- a/blackbird/README.md +++ b/blackbird/README.md @@ -73,13 +73,13 @@ Following things are optimized: * For serialization (POJOs to JSON): * Getter methods are inlined using generated lambdas instead of reflection - * Serializers for small number of 'primitive' types (`int`, `long`, `boolean`, `String`) are replaced with lambda-specialized generators, instead of getting delegated to `JsonSerializer`s + * Serializers for small number of 'primitive' types (`int`, `long`, `double`, `boolean`, `String`) are replaced with lambda-specialized generators, instead of getting delegated to `JsonSerializer`s * For deserialization (JSON to POJOs): * Calls to default (no-argument) constructors are lambda-fied instead of using reflection * Calls to @JsonCreate factory methods and constructors with arguments get a slightly less efficient lambda based implementation - This is new in Blackbird; Afterburner never did this * Setter methods are called using lambdas instead of reflection - * Deserializers for small number of 'primitive' types (`int`, `long`, `boolean`, `String`) are replaced with lambda-specialized parsers, instead of getting delegated to `JsonDeserializer`s + * Deserializers for small number of 'primitive' types (`int`, `long`, `double`, `boolean`, `String`) are replaced with lambda-specialized parsers, instead of getting delegated to `JsonDeserializer`s ### ... and what is not? From b25045a55d82d8097ed55a1a7835c07ebb6b0110 Mon Sep 17 00:00:00 2001 From: Parker Winchester Date: Mon, 2 Nov 2026 16:51:59 -0500 Subject: [PATCH 3/9] Benchmark double beans through a TokenBuffer --- .../module/blackbird/BaseBenchmark.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/blackbird/benchmarks/src/main/java/com/fasterxml/jackson/module/blackbird/BaseBenchmark.java b/blackbird/benchmarks/src/main/java/com/fasterxml/jackson/module/blackbird/BaseBenchmark.java index f0f14715..2506fb48 100644 --- a/blackbird/benchmarks/src/main/java/com/fasterxml/jackson/module/blackbird/BaseBenchmark.java +++ b/blackbird/benchmarks/src/main/java/com/fasterxml/jackson/module/blackbird/BaseBenchmark.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.util.TokenBuffer; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.Fork; @@ -45,6 +46,9 @@ public abstract class BaseBenchmark { ObjectReader classicBeanReader; DoubleBean[] doubleBeans; byte[] doubleBeansBytes; + ObjectReader doubleBeanReader; + ObjectWriter doubleBeanWriter; + TokenBuffer doubleBeansBuffer; public static void main(final String[] args) throws RunnerException { final Options options = new OptionsBuilder() @@ -78,6 +82,10 @@ public void setup() throws Exception { .mapToObj(i -> DoubleBean.random(random)) .toArray(DoubleBean[]::new); doubleBeansBytes = mapper.writeValueAsBytes(doubleBeans); + doubleBeanReader = mapper.readerFor(DoubleBean[].class); + doubleBeanWriter = mapper.writerFor(DoubleBean[].class); + doubleBeansBuffer = new TokenBuffer(mapper, false); + doubleBeanWriter.writeValue(doubleBeansBuffer, doubleBeans); } @Benchmark @@ -110,6 +118,19 @@ public DoubleBean[] doubleBeanArrayDeser() throws Exception { return mapper.readValue(doubleBeansBytes, DoubleBean[].class); } + // Through a buffer, as with `convertValue`, so without number parsing or formatting + @Benchmark + public TokenBuffer doubleBeanArrayBufferSer() throws Exception { + TokenBuffer buffer = new TokenBuffer(mapper, false); + doubleBeanWriter.writeValue(buffer, doubleBeans); + return buffer; + } + + @Benchmark + public DoubleBean[] doubleBeanArrayBufferDeser() throws Exception { + return doubleBeanReader.readValue(doubleBeansBuffer.asParser()); + } + @Benchmark public DoubleBeanWithBuilder[] doubleBuilderArrayDeser() throws Exception { return mapper.readValue(doubleBeansBytes, DoubleBeanWithBuilder[].class); From e08ebb2f3b8d3ff1b56fd87dae58cd65288208f6 Mon Sep 17 00:00:00 2001 From: Parker Winchester Date: Mon, 2 Nov 2026 17:04:46 -0500 Subject: [PATCH 4/9] Leave Blackbird double getters with a type serializer to BeanPropertyWriter --- .../blackbird/ser/BBSerializerModifier.java | 6 ++ .../ser/DoublePropertyWriterTest.java | 61 +++++++++++++++++-- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/BBSerializerModifier.java b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/BBSerializerModifier.java index 925c253d..3133434c 100644 --- a/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/BBSerializerModifier.java +++ b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/BBSerializerModifier.java @@ -152,6 +152,12 @@ protected void createProperty(ListIterator it, Lookup lookup .getTarget().invokeExact(); it.set(new LongPropertyWriter(bpw, accessor, null)); } else if (type == Double.TYPE) { + // `DoubleSerializer` writes a type id for non-finite values, so a property + // with a type serializer (which no standard resolver gives a primitive) + // is left to `BeanPropertyWriter` + if (bpw.getTypeSerializer() != null) { + return; + } ToDoubleFunction accessor = (ToDoubleFunction) LambdaMetafactory.metafactory( lookup, "applyAsDouble", diff --git a/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/DoublePropertyWriterTest.java b/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/DoublePropertyWriterTest.java index 439d562d..56ad8261 100644 --- a/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/DoublePropertyWriterTest.java +++ b/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/DoublePropertyWriterTest.java @@ -13,13 +13,18 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.fasterxml.jackson.annotation.JsonRawValue; +import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonUnwrapped; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.StreamWriteFeature; import com.fasterxml.jackson.core.json.JsonWriteFeature; +import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.annotation.JsonTypeResolver; +import com.fasterxml.jackson.databind.cfg.MapperConfig; +import com.fasterxml.jackson.databind.jsontype.impl.StdTypeResolverBuilder; import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.ser.PropertyWriter; @@ -196,6 +201,28 @@ static class ArrayFailingAccessor { public int getX() { return 1; } } + // A resolver that gives primitives a type serializer, which `DoubleSerializer` + // uses for non-finite values + public static class PrimitiveTypeResolver extends StdTypeResolverBuilder { + @Override + protected boolean allowPrimitiveTypes(MapperConfig config, JavaType baseType) { + return true; + } + } + + static class Typed extends Holder { + @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.WRAPPER_ARRAY) + @JsonTypeResolver(PrimitiveTypeResolver.class) + public double getD() { return get(); } + } + + @JsonFormat(shape = JsonFormat.Shape.ARRAY) + static class ArrayTyped extends Holder { + @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.WRAPPER_ARRAY) + @JsonTypeResolver(PrimitiveTypeResolver.class) + public double getD() { return get(); } + } + // Unwrapping with a prefix renames each property of the inner bean static class Unwrapping { @JsonUnwrapped(prefix = "p_") @@ -282,15 +309,15 @@ public void testOutputMatchesVanilla() throws Exception List> types = new ArrayList<>(TYPES); types.add(Raw.class); types.add(Converted.class); + types.add(Typed.class); + types.add(ArrayTyped.class); for (Class type : types) { for (double value : VALUES) { String desc = "config " + i + ", " + type.getSimpleName() + ", " + value + " (" + Long.toHexString(Double.doubleToRawLongBits(value)) + ")"; - String expected = vanilla.writeValueAsString(holder(type, value)); - assertEquals(expected, blackbird.writeValueAsString(holder(type, value)), desc); + assertEquals(_write(vanilla, holder(type, value)), _write(blackbird, holder(type, value)), desc); // and through a buffer - assertEquals(String.valueOf(vanilla.valueToTree(holder(type, value))), - String.valueOf(blackbird.valueToTree(holder(type, value))), desc); + assertEquals(_toTree(vanilla, holder(type, value)), _toTree(blackbird, holder(type, value)), desc); } } } @@ -357,6 +384,15 @@ public void testStringShape() throws Exception assertEquals(a2q("{'d':'1.5'}"), overridden.writeValueAsString(holder(Plain.class, 1.5))); } + @Test + public void testTypedNonFiniteValues() throws Exception + { + assertEquals(a2q("{'d':['java.lang.Double','NaN']}"), MAPPER.writeValueAsString(holder(Typed.class, Double.NaN))); + assertEquals(a2q("{'d':1.5}"), MAPPER.writeValueAsString(holder(Typed.class, 1.5))); + assertEquals(a2q("[['java.lang.Double','Infinity']]"), + MAPPER.writeValueAsString(holder(ArrayTyped.class, Double.POSITIVE_INFINITY))); + } + @Test public void testNonFiniteValues() throws Exception { @@ -377,6 +413,23 @@ public void testAccessErrorFallsBack() throws Exception assertEquals("[2.5,1]", newObjectMapper().writeValueAsString(new ArrayFailingAccessor())); } + // The output, or the failure (as with a custom serializer that can't write a type id) + private static String _write(ObjectMapper mapper, Object value) { + try { + return mapper.writeValueAsString(value); + } catch (Exception e) { + return e.getClass().getName() + ": " + e.getMessage(); + } + } + + private static String _toTree(ObjectMapper mapper, Object value) { + try { + return String.valueOf(mapper.valueToTree(value)); + } catch (Exception e) { + return e.getClass().getName() + ": " + e.getMessage(); + } + } + private static PropertyWriter _findWriter(ObjectMapper mapper, Class type, String name) throws Exception { BeanSerializerBase ser = (BeanSerializerBase) mapper.getSerializerProviderInstance() From 875a3c604ac28776b1fd454420c8c08f4f03eab2 Mon Sep 17 00:00:00 2001 From: Parker Winchester Date: Tue, 3 Nov 2026 10:11:31 -0500 Subject: [PATCH 5/9] Keep Blackbird writer state and fallback name when renamed for unwrapping --- .../ser/OptimizedBeanPropertyWriter.java | 23 +++- .../ser/DoublePropertyWriterTest.java | 4 +- .../blackbird/ser/TestUnwrappedWriters.java | 127 ++++++++++++++++++ release-notes/VERSION-2.x | 3 +- 4 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/TestUnwrappedWriters.java diff --git a/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/OptimizedBeanPropertyWriter.java b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/OptimizedBeanPropertyWriter.java index 7e34496f..80e6d39e 100644 --- a/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/OptimizedBeanPropertyWriter.java +++ b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/OptimizedBeanPropertyWriter.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.jsontype.TypeSerializer; import com.fasterxml.jackson.databind.ser.BeanPropertyWriter; +import com.fasterxml.jackson.databind.util.NameTransformer; /** * Intermediate base class that is used for concrete @@ -40,10 +41,28 @@ protected OptimizedBeanPropertyWriter(BeanPropertyWriter src, JsonSerializer base, PropertyName name) { super(base, name); - // 24-Oct-2017, tatu: Should this be changed too? - fallbackWriter = base.fallbackWriter; + // Renamed (as for an unwrapped property, after its serializer was assigned): + // the fallback must write the new name, and a writer that already uses it + // (like one with a non-default serializer) must keep doing so + fallbackWriter = _renamed(base.fallbackWriter, name); _serializer = base._serializer; _fastName = getSerializedName(); + broken = base.broken; + } + + private static BeanPropertyWriter _renamed(BeanPropertyWriter writer, PropertyName name) { + final String newName = name.getSimpleName(); + return writer.rename(new NameTransformer() { + @Override + public String transform(String original) { + return newName; + } + + @Override + public String reverse(String transformed) { + return null; + } + }); } private BeanPropertyWriter unwrapFallbackWriter(BeanPropertyWriter srcIn) diff --git a/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/DoublePropertyWriterTest.java b/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/DoublePropertyWriterTest.java index 56ad8261..c90fe21f 100644 --- a/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/DoublePropertyWriterTest.java +++ b/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/DoublePropertyWriterTest.java @@ -323,14 +323,12 @@ public void testOutputMatchesVanilla() throws Exception } } - // (Not with the STRING shape, which Blackbird already ignores for every type of - // property in an unwrapped bean, as a renamed writer doesn't know it's broken) @Test public void testUnwrappedMatchesVanilla() throws Exception { ObjectMapper vanilla = newVanillaJSONMapper(); for (Class type : TYPES) { - if (type.getSimpleName().startsWith("Array") || (type == AsString.class)) { + if (type.getSimpleName().startsWith("Array")) { continue; } for (double value : VALUES) { diff --git a/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/TestUnwrappedWriters.java b/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/TestUnwrappedWriters.java new file mode 100644 index 00000000..be24309b --- /dev/null +++ b/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/TestUnwrappedWriters.java @@ -0,0 +1,127 @@ +package com.fasterxml.jackson.module.blackbird.ser; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.function.UnaryOperator; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.fasterxml.jackson.module.blackbird.BlackbirdTestBase; + +import static org.junit.jupiter.api.Assertions.*; + +// Unwrapping with a prefix renames the optimized writers after their serializers +// were assigned; a renamed writer must still write what `BeanPropertyWriter` writes +public class TestUnwrappedWriters extends BlackbirdTestBase +{ + @JsonPropertyOrder({ "i", "l", "d", "b", "s", "list" }) + public static class Plain { + public int getI() { return 1; } + public long getL() { return 2L; } + public double getD() { return 3.5; } + public boolean isB() { return true; } + public String getS() { return "s"; } + public List getList() { return Arrays.asList("x"); } + } + + @JsonPropertyOrder({ "i", "l", "d", "b", "s", "list" }) + public static class Shaped { + @JsonFormat(shape = JsonFormat.Shape.STRING) + public int getI() { return 1; } + @JsonFormat(shape = JsonFormat.Shape.STRING) + public long getL() { return 2L; } + @JsonFormat(shape = JsonFormat.Shape.STRING) + public double getD() { return 3.5; } + @JsonFormat(shape = JsonFormat.Shape.NUMBER) + public boolean isB() { return true; } + } + + public static class Outer { + @JsonUnwrapped(prefix = "p_") + public Object inner; + + Outer(Object inner) { this.inner = inner; } + } + + // Writes the negated value, for each primitive type + static class Negating extends StdSerializer { + private static final long serialVersionUID = 1L; + + Negating(Class type) { super(type); } + + @Override + public void serialize(T value, JsonGenerator gen, SerializerProvider provider) throws IOException { + gen.writeNumber(-value.doubleValue()); + } + } + + // Fails when called through the generated accessor, as in `TestAccessFallback` + static T failFromGeneratedAccessor(T value) { + for (StackTraceElement elem : new Throwable().getStackTrace()) { + if (elem.getClassName().equals("java.lang.reflect.Method")) { + return value; + } + if (elem.getClassName().startsWith("com.fasterxml.jackson.module.blackbird.ser.") + && elem.getClassName().endsWith("PropertyWriter")) { + throw new IllegalAccessError("simulated"); + } + } + return value; + } + + @JsonPropertyOrder({ "i", "l", "d", "b", "s", "list" }) + public static class Failing { + public int getI() { return failFromGeneratedAccessor(1); } + public long getL() { return failFromGeneratedAccessor(2L); } + public double getD() { return failFromGeneratedAccessor(3.5); } + public boolean isB() { return failFromGeneratedAccessor(true); } + public String getS() { return failFromGeneratedAccessor("s"); } + public List getList() { return failFromGeneratedAccessor(Arrays.asList("x")); } + } + + private static final List> CONFIGS = Arrays.asList( + b -> b, + b -> b.addModule(new SimpleModule() + .addSerializer(Integer.TYPE, new Negating<>(Integer.class)) + .addSerializer(Long.TYPE, new Negating<>(Long.class)) + .addSerializer(Double.TYPE, new Negating<>(Double.class))), + b -> b.withConfigOverride(Long.TYPE, o -> o.setFormat(JsonFormat.Value.forShape(JsonFormat.Shape.STRING))) + ); + + @Test + public void testMatchesVanilla() throws Exception + { + for (int i = 0; i < CONFIGS.size(); i++) { + ObjectMapper vanilla = CONFIGS.get(i).apply(JsonMapper.builder()).build(); + ObjectMapper blackbird = CONFIGS.get(i).apply(mapperBuilder()).build(); + for (Object inner : new Object[] { new Plain(), new Shaped() }) { + String desc = "config " + i + ", " + inner.getClass().getSimpleName(); + // the inner bean on its own, then unwrapped + assertEquals(vanilla.writeValueAsString(inner), blackbird.writeValueAsString(inner), desc); + assertEquals(vanilla.writeValueAsString(new Outer(inner)), + blackbird.writeValueAsString(new Outer(inner)), desc); + } + } + assertEquals(a2q("{'p_i':'1','p_l':'2','p_d':'3.5','p_b':1}"), + newObjectMapper().writeValueAsString(new Outer(new Shaped()))); + } + + // and an access error falls back to a writer with the new name + @Test + public void testAccessErrorFallsBackWithNewName() throws Exception + { + String expected = newVanillaJSONMapper().writeValueAsString(new Outer(new Failing())); + assertEquals(a2q("{'p_i':1,'p_l':2,'p_d':3.5,'p_b':true,'p_s':'s','p_list':['x']}"), expected); + assertEquals(expected, newObjectMapper().writeValueAsString(new Outer(new Failing()))); + } +} diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index ba528b93..a4e1e0e7 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -35,7 +35,8 @@ Active maintainers: #367: (blackbird) Don't retry failed bean setters through reflection; report anything a setter throws as `JsonMappingException`, as plain databind does (contributed by Parker W) -#NNN: (blackbird) Use generated accessors for primitive `double` setters and getters +#NNN: (blackbird) Use generated accessors for primitive `double` setters and getters; + keep `@JsonFormat(shape = STRING)` and custom serializers for unwrapped properties (contributed by Parker W) 2.22.4 (17-Oct-2026) From 29dbfc8d583dd806a619e434d1e7c2293f7794f2 Mon Sep 17 00:00:00 2001 From: Parker Winchester Date: Tue, 3 Nov 2026 10:11:31 -0500 Subject: [PATCH 6/9] Fall back as an element after an access error in LongPropertyWriter --- .../blackbird/ser/LongPropertyWriter.java | 2 +- .../module/blackbird/TestAccessFallback.java | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/LongPropertyWriter.java b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/LongPropertyWriter.java index 7372aa20..01959507 100644 --- a/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/LongPropertyWriter.java +++ b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/LongPropertyWriter.java @@ -85,7 +85,7 @@ public final void serializeAsElement(Object bean, JsonGenerator gen, SerializerP try { value = _acc.applyAsLong(bean); } catch (Throwable t) { - _handleProblem(bean, gen, prov, t, false); + _handleProblem(bean, gen, prov, t, true); return; } if (!_suppressableSet || _suppressableLong != value) { diff --git a/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/TestAccessFallback.java b/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/TestAccessFallback.java index c103e318..368867ee 100644 --- a/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/TestAccessFallback.java +++ b/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/TestAccessFallback.java @@ -4,6 +4,9 @@ import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + import com.fasterxml.jackson.databind.ObjectMapper; import static org.junit.jupiter.api.Assertions.*; @@ -56,6 +59,35 @@ public String getE() private static final String BEAN_JSON = "{\"e\":\"a\"}"; + // As an array, a failed generated getter falls back to writing an element + @JsonFormat(shape = JsonFormat.Shape.ARRAY) + @JsonPropertyOrder({ "i", "l", "b", "s" }) + public static class ArrayBean + { + public int getI() { return fail(1); } + public long getL() { return fail(2L); } + public boolean isB() { return fail(true); } + public String getS() { return fail("s"); } + + static T fail(T value) { + for (StackTraceElement elem : new Throwable().getStackTrace()) { + if (elem.getClassName().equals("java.lang.reflect.Method")) { + return value; + } + if (elem.getClassName().startsWith("com.fasterxml.jackson.module.blackbird.ser.")) { + throw new BogusTestError("boom!"); + } + } + return value; + } + } + + @Test + public void testSerializeAccessAsArray() throws Exception + { + assertEquals("[1,2,true,\"s\"]", newObjectMapper().writeValueAsString(new ArrayBean())); + } + @Test public void testSerializeAccess() throws Exception { From 10a3ea442f0a52d9dea2c0640c71f4ebfa603805 Mon Sep 17 00:00:00 2001 From: Parker Winchester Date: Tue, 3 Nov 2026 10:24:11 -0500 Subject: [PATCH 7/9] Add PR number to release notes --- release-notes/CREDITS-2.x | 2 +- release-notes/VERSION-2.x | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/release-notes/CREDITS-2.x b/release-notes/CREDITS-2.x index fc74be65..ed5eca67 100644 --- a/release-notes/CREDITS-2.x +++ b/release-notes/CREDITS-2.x @@ -178,6 +178,6 @@ Parker Winchester (@parker89) (2.23.0) * Contributed #367: (blackbird) Don't retry failed bean setters through reflection (2.23.0) -* Contributed #NNN: (blackbird) Use generated accessors for primitive `double` setters +* Contributed #375: (blackbird) Use generated accessors for primitive `double` setters and getters (2.23.0) diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index a4e1e0e7..bed5a9ee 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -35,7 +35,7 @@ Active maintainers: #367: (blackbird) Don't retry failed bean setters through reflection; report anything a setter throws as `JsonMappingException`, as plain databind does (contributed by Parker W) -#NNN: (blackbird) Use generated accessors for primitive `double` setters and getters; +#375: (blackbird) Use generated accessors for primitive `double` setters and getters; keep `@JsonFormat(shape = STRING)` and custom serializers for unwrapped properties (contributed by Parker W) From e2390e2588d40ee6d1ee14f2ed0a97d4a476cad6 Mon Sep 17 00:00:00 2001 From: Parker Winchester Date: Tue, 3 Nov 2026 11:06:03 -0500 Subject: [PATCH 8/9] Don't treat NullSerializer as a default serializer in Blackbird --- .../module/blackbird/ser/SerializerUtil.java | 5 ++ .../blackbird/ser/TestNullSerializer.java | 77 +++++++++++++++++++ release-notes/VERSION-2.x | 3 +- 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/TestNullSerializer.java diff --git a/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/SerializerUtil.java b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/SerializerUtil.java index 8c0e5776..c32f627f 100644 --- a/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/SerializerUtil.java +++ b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/SerializerUtil.java @@ -1,6 +1,7 @@ package com.fasterxml.jackson.module.blackbird.ser; import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ser.std.NullSerializer; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import com.fasterxml.jackson.databind.util.ClassUtil; @@ -28,6 +29,10 @@ public static boolean isDefaultSerializer(JsonSerializer ser) if (ser instanceof ToStringSerializer) { return false; } + // Nor one that writes null whatever the value (as used to hide one) + if (ser instanceof NullSerializer) { + return false; + } return true; } return false; diff --git a/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/TestNullSerializer.java b/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/TestNullSerializer.java new file mode 100644 index 00000000..95869dbb --- /dev/null +++ b/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/TestNullSerializer.java @@ -0,0 +1,77 @@ +package com.fasterxml.jackson.module.blackbird.ser; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.NullSerializer; +import com.fasterxml.jackson.module.blackbird.BlackbirdTestBase; + +import static org.junit.jupiter.api.Assertions.*; + +// `NullSerializer` (a standard serializer, often used to hide a value) must +// still write null for every optimized property type +public class TestNullSerializer extends BlackbirdTestBase +{ + @JsonPropertyOrder({ "i", "l", "d", "b", "s", "list" }) + public static class Annotated { + @JsonSerialize(using = NullSerializer.class) + public int getI() { return 1; } + @JsonSerialize(using = NullSerializer.class) + public long getL() { return 2L; } + @JsonSerialize(using = NullSerializer.class) + public double getD() { return 3.5; } + @JsonSerialize(using = NullSerializer.class) + public boolean isB() { return true; } + @JsonSerialize(using = NullSerializer.class) + public String getS() { return "s"; } + @JsonSerialize(using = NullSerializer.class) + public List getList() { return Arrays.asList("x"); } + } + + @JsonPropertyOrder({ "i", "l", "d", "b", "s" }) + public static class Plain { + public int getI() { return 1; } + public long getL() { return 2L; } + public double getD() { return 3.5; } + public boolean isB() { return true; } + public String getS() { return "s"; } + } + + @JsonFormat(shape = JsonFormat.Shape.ARRAY) + @JsonPropertyOrder({ "i", "l", "d", "b", "s" }) + public static class AsArray extends Plain { } + + private static final String NULLS = a2q("{'i':null,'l':null,'d':null,'b':null,'s':null}"); + + @Test + public void testAnnotated() throws Exception + { + String expected = newVanillaJSONMapper().writeValueAsString(new Annotated()); + assertEquals(a2q("{'i':null,'l':null,'d':null,'b':null,'s':null,'list':null}"), expected); + assertEquals(expected, newObjectMapper().writeValueAsString(new Annotated())); + } + + // and registered for each type, so assigned when the serializer is resolved + @Test + public void testRegistered() throws Exception + { + SimpleModule nulls = new SimpleModule(); + for (Class type : new Class[] { int.class, long.class, double.class, boolean.class, String.class }) { + nulls.addSerializer(type, NullSerializer.instance); + } + ObjectMapper vanilla = JsonMapper.builder().addModule(nulls).build(); + ObjectMapper blackbird = mapperBuilder().addModule(nulls).build(); + assertEquals(NULLS, vanilla.writeValueAsString(new Plain())); + assertEquals(NULLS, blackbird.writeValueAsString(new Plain())); + assertEquals("[null,null,null,null,null]", vanilla.writeValueAsString(new AsArray())); + assertEquals("[null,null,null,null,null]", blackbird.writeValueAsString(new AsArray())); + } +} diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index bed5a9ee..07d5b493 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -36,7 +36,8 @@ Active maintainers: anything a setter throws as `JsonMappingException`, as plain databind does (contributed by Parker W) #375: (blackbird) Use generated accessors for primitive `double` setters and getters; - keep `@JsonFormat(shape = STRING)` and custom serializers for unwrapped properties + fix `@JsonFormat(shape = STRING)` and custom serializers ignored for unwrapped + properties, and `NullSerializer` ignored for primitive and String properties (contributed by Parker W) 2.22.4 (17-Oct-2026) From ccf107d1f884a5559490f616a7c5438b00e9bb84 Mon Sep 17 00:00:00 2001 From: Tatu Saloranta Date: Tue, 3 Nov 2026 10:14:17 -0800 Subject: [PATCH 9/9] Honor custom inclusion filters in Blackbird int, long and boolean writers --- .../blackbird/ser/BooleanPropertyWriter.java | 19 ++- .../blackbird/ser/IntPropertyWriter.java | 19 ++- .../blackbird/ser/LongPropertyWriter.java | 19 ++- .../ser/PrimitiveInclusionFilterTest.java | 149 ++++++++++++++++++ release-notes/VERSION-2.x | 3 +- 5 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/PrimitiveInclusionFilterTest.java diff --git a/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/BooleanPropertyWriter.java b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/BooleanPropertyWriter.java index f7ca9291..202abc8c 100644 --- a/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/BooleanPropertyWriter.java +++ b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/BooleanPropertyWriter.java @@ -14,6 +14,11 @@ final class BooleanPropertyWriter private final boolean _suppressableSet; private final boolean _suppressableBoolean; + // A suppressable value that isn't of the property type, like a custom inclusion + // filter, decides through its own `equals`, which is given the boxed value as + // `BeanPropertyWriter` does + private final boolean _suppressableFilter; + private final ToBooleanFunction _acc; public BooleanPropertyWriter(BeanPropertyWriter src, ToBooleanFunction acc, JsonSerializer ser) { @@ -23,15 +28,18 @@ public BooleanPropertyWriter(BeanPropertyWriter src, ToBooleanFunction acc, Json if (_suppressableValue instanceof Boolean) { _suppressableBoolean = ((Boolean)_suppressableValue).booleanValue(); _suppressableSet = true; + _suppressableFilter = false; } else { _suppressableBoolean = false; _suppressableSet = false; + _suppressableFilter = (_suppressableValue != null) && (_suppressableValue != MARKER_FOR_EMPTY); } } protected BooleanPropertyWriter(BooleanPropertyWriter base, PropertyName name) { super(base, name); _suppressableSet = base._suppressableSet; + _suppressableFilter = base._suppressableFilter; _suppressableBoolean = base._suppressableBoolean; _acc = base._acc; } @@ -66,7 +74,7 @@ public final void serializeAsField(Object bean, JsonGenerator gen, SerializerPro _handleProblem(bean, gen, prov, t, false); return; } - if (!_suppressableSet || _suppressableBoolean != value) { + if (!_isSuppressed(value)) { gen.writeFieldName(_fastName); gen.writeBoolean(value); } @@ -86,10 +94,17 @@ public final void serializeAsElement(Object bean, JsonGenerator gen, SerializerP _handleProblem(bean, gen, prov, t, true); return; } - if (!_suppressableSet || _suppressableBoolean != value) { + if (!_isSuppressed(value)) { gen.writeBoolean(value); } else { // important: MUST output a placeholder serializeAsPlaceholder(bean, gen, prov); } } + + private boolean _isSuppressed(boolean value) { + if (_suppressableSet) { + return _suppressableBoolean == value; + } + return _suppressableFilter && _suppressableValue.equals(value); + } } diff --git a/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/IntPropertyWriter.java b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/IntPropertyWriter.java index 12c7472f..145b0ddd 100644 --- a/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/IntPropertyWriter.java +++ b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/IntPropertyWriter.java @@ -16,6 +16,11 @@ final class IntPropertyWriter private final int _suppressableInt; private final boolean _suppressableIntSet; + // A suppressable value that isn't of the property type, like a custom inclusion + // filter, decides through its own `equals`, which is given the boxed value as + // `BeanPropertyWriter` does + private final boolean _suppressableFilter; + private final ToIntFunction _acc; public IntPropertyWriter(BeanPropertyWriter src, ToIntFunction acc, JsonSerializer ser) { @@ -25,9 +30,11 @@ public IntPropertyWriter(BeanPropertyWriter src, ToIntFunction acc, Json if (_suppressableValue instanceof Integer) { _suppressableInt = (Integer)_suppressableValue; _suppressableIntSet = true; + _suppressableFilter = false; } else { _suppressableInt = 0; _suppressableIntSet = false; + _suppressableFilter = (_suppressableValue != null) && (_suppressableValue != MARKER_FOR_EMPTY); } } @@ -35,6 +42,7 @@ protected IntPropertyWriter(IntPropertyWriter base, PropertyName name) { super(base, name); _suppressableInt = base._suppressableInt; _suppressableIntSet = base._suppressableIntSet; + _suppressableFilter = base._suppressableFilter; _acc = base._acc; } @@ -68,7 +76,7 @@ public final void serializeAsField(Object bean, JsonGenerator gen, SerializerPro _handleProblem(bean, gen, prov, t, false); return; } - if (!_suppressableIntSet || _suppressableInt != value) { + if (!_isSuppressed(value)) { gen.writeFieldName(_fastName); gen.writeNumber(value); } @@ -88,10 +96,17 @@ public final void serializeAsElement(Object bean, JsonGenerator gen, SerializerP _handleProblem(bean, gen, prov, t, true); return; } - if (!_suppressableIntSet || _suppressableInt != value) { + if (!_isSuppressed(value)) { gen.writeNumber(value); } else { // important: MUST output a placeholder serializeAsPlaceholder(bean, gen, prov); } } + + private boolean _isSuppressed(int value) { + if (_suppressableIntSet) { + return _suppressableInt == value; + } + return _suppressableFilter && _suppressableValue.equals(value); + } } diff --git a/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/LongPropertyWriter.java b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/LongPropertyWriter.java index 01959507..ba6009f9 100644 --- a/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/LongPropertyWriter.java +++ b/blackbird/src/main/java/com/fasterxml/jackson/module/blackbird/ser/LongPropertyWriter.java @@ -16,6 +16,11 @@ final class LongPropertyWriter private final long _suppressableLong; private final boolean _suppressableSet; + // A suppressable value that isn't of the property type, like a custom inclusion + // filter, decides through its own `equals`, which is given the boxed value as + // `BeanPropertyWriter` does + private final boolean _suppressableFilter; + private final ToLongFunction _acc; public LongPropertyWriter(BeanPropertyWriter src, ToLongFunction acc, JsonSerializer ser) { @@ -25,15 +30,18 @@ public LongPropertyWriter(BeanPropertyWriter src, ToLongFunction acc, Js if (_suppressableValue instanceof Long) { _suppressableLong = (Long)_suppressableValue; _suppressableSet = true; + _suppressableFilter = false; } else { _suppressableLong = 0L; _suppressableSet = false; + _suppressableFilter = (_suppressableValue != null) && (_suppressableValue != MARKER_FOR_EMPTY); } } protected LongPropertyWriter(LongPropertyWriter base, PropertyName name) { super(base, name); _suppressableSet = base._suppressableSet; + _suppressableFilter = base._suppressableFilter; _suppressableLong = base._suppressableLong; _acc = base._acc; } @@ -68,7 +76,7 @@ public final void serializeAsField(Object bean, JsonGenerator gen, SerializerPro _handleProblem(bean, gen, prov, t, false); return; } - if (!_suppressableSet || _suppressableLong != value) { + if (!_isSuppressed(value)) { gen.writeFieldName(_fastName); gen.writeNumber(value); } @@ -88,10 +96,17 @@ public final void serializeAsElement(Object bean, JsonGenerator gen, SerializerP _handleProblem(bean, gen, prov, t, true); return; } - if (!_suppressableSet || _suppressableLong != value) { + if (!_isSuppressed(value)) { gen.writeNumber(value); } else { // important: MUST output a placeholder serializeAsPlaceholder(bean, gen, prov); } } + + private boolean _isSuppressed(long value) { + if (_suppressableSet) { + return _suppressableLong == value; + } + return _suppressableFilter && _suppressableValue.equals(value); + } } diff --git a/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/PrimitiveInclusionFilterTest.java b/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/PrimitiveInclusionFilterTest.java new file mode 100644 index 00000000..5a5c3ad6 --- /dev/null +++ b/blackbird/src/test/java/com/fasterxml/jackson/module/blackbird/ser/PrimitiveInclusionFilterTest.java @@ -0,0 +1,149 @@ +package com.fasterxml.jackson.module.blackbird.ser; + +import java.util.Iterator; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ser.PropertyWriter; +import com.fasterxml.jackson.databind.ser.std.BeanSerializerBase; +import com.fasterxml.jackson.module.blackbird.BlackbirdTestBase; + +import static org.junit.jupiter.api.Assertions.*; + +// A custom inclusion filter on a primitive getter suppresses what its `equals` +// matches, as with `BeanPropertyWriter` +public class PrimitiveInclusionFilterTest extends BlackbirdTestBase +{ + // Suppresses negative values (and only through `equals`) + static class NegativeFilter { + @Override + public boolean equals(Object o) { + return ((o instanceof Integer) && ((Integer) o < 0)) + || ((o instanceof Long) && ((Long) o < 0L)); + } + + @Override + public int hashCode() { return 0; } + } + + // Suppresses `true` (and only through `equals`) + static class TrueFilter { + @Override + public boolean equals(Object o) { + return Boolean.TRUE.equals(o); + } + + @Override + public int hashCode() { return 0; } + } + + @JsonPropertyOrder({ "i", "l", "b" }) + static class Custom { + int i; + long l; + boolean b; + + Custom(int i, long l, boolean b) { + this.i = i; + this.l = l; + this.b = b; + } + + @JsonInclude(value = JsonInclude.Include.CUSTOM, valueFilter = NegativeFilter.class) + public int getI() { return i; } + + @JsonInclude(value = JsonInclude.Include.CUSTOM, valueFilter = NegativeFilter.class) + public long getL() { return l; } + + @JsonInclude(value = JsonInclude.Include.CUSTOM, valueFilter = TrueFilter.class) + public boolean getB() { return b; } + } + + // As an array, which writes each property as an element (and a placeholder for + // a suppressed one) + @JsonFormat(shape = JsonFormat.Shape.ARRAY) + static class ArrayCustom extends Custom { + ArrayCustom(int i, long l, boolean b) { + super(i, l, b); + } + } + + // And the inclusions decided without a filter + @JsonPropertyOrder({ "i", "l", "b" }) + static class Other { + int i; + long l; + boolean b; + + Other(int i, long l, boolean b) { + this.i = i; + this.l = l; + this.b = b; + } + + @JsonInclude(JsonInclude.Include.NON_DEFAULT) + public int getI() { return i; } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public long getL() { return l; } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + public boolean getB() { return b; } + } + + private final ObjectMapper MAPPER = newObjectMapper(); + + @Test + public void testUsesGeneratedGetters() throws Exception + { + for (Class type : new Class[] { Custom.class, ArrayCustom.class, Other.class }) { + assertEquals(IntPropertyWriter.class, _findWriter(MAPPER, type, "i").getClass()); + assertEquals(LongPropertyWriter.class, _findWriter(MAPPER, type, "l").getClass()); + assertEquals(BooleanPropertyWriter.class, _findWriter(MAPPER, type, "b").getClass()); + } + } + + @Test + public void testCustomFilter() throws Exception + { + assertEquals(a2q("{'i':1,'l':2,'b':false}"), MAPPER.writeValueAsString(new Custom(1, 2L, false))); + assertEquals("{}", MAPPER.writeValueAsString(new Custom(-1, -2L, true))); + assertEquals("[1,2,false]", MAPPER.writeValueAsString(new ArrayCustom(1, 2L, false))); + assertEquals("[null,null,null]", MAPPER.writeValueAsString(new ArrayCustom(-1, -2L, true))); + } + + @Test + public void testOutputMatchesVanilla() throws Exception + { + ObjectMapper vanilla = newVanillaJSONMapper(); + for (int i : new int[] { -1, 0, 1 }) { + for (boolean b : new boolean[] { false, true }) { + Object[] beans = { new Custom(i, i, b), new ArrayCustom(i, i, b), new Other(i, i, b) }; + for (Object bean : beans) { + String desc = bean.getClass().getSimpleName() + ", " + i + ", " + b; + assertEquals(vanilla.writeValueAsString(bean), MAPPER.writeValueAsString(bean), desc); + // and through a buffer + assertEquals(String.valueOf(vanilla.valueToTree(bean)), + String.valueOf(MAPPER.valueToTree(bean)), desc); + } + } + } + } + + private static PropertyWriter _findWriter(ObjectMapper mapper, Class type, String name) throws Exception + { + BeanSerializerBase ser = (BeanSerializerBase) mapper.getSerializerProviderInstance() + .findValueSerializer(type); + for (Iterator it = ser.properties(); it.hasNext(); ) { + PropertyWriter writer = it.next(); + if (writer.getName().equals(name)) { + return writer; + } + } + throw new AssertionError("No property '" + name + "' for " + type.getName()); + } +} diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index 07d5b493..ece81aff 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -37,7 +37,8 @@ Active maintainers: (contributed by Parker W) #375: (blackbird) Use generated accessors for primitive `double` setters and getters; fix `@JsonFormat(shape = STRING)` and custom serializers ignored for unwrapped - properties, and `NullSerializer` ignored for primitive and String properties + properties, `NullSerializer` ignored for primitive and String properties, and + custom inclusion filters ignored for `int`, `long` and `boolean` getters (contributed by Parker W) 2.22.4 (17-Oct-2026)