From 6f8a7c95bd27b192f977bd5dd6b908c7db435d80 Mon Sep 17 00:00:00 2001 From: Evgeniy Timokhov Date: Wed, 5 Aug 2026 19:17:44 +0100 Subject: [PATCH] Add supportsNullValues to StructMemberConsumer So deserializers can distinguish absent fields from explicitly-null ones. Previously null members were always skipped; now consumers can opt in per-member to receive them. --- .../json/jackson/JacksonJsonDeserializer.java | 5 +- .../json/smithy/SmithyJsonDeserializer.java | 3 +- .../java/json/JsonDeserializerTest.java | 58 +++++++++++++++++++ .../smithy/java/codegen/SymbolProperties.java | 9 +++ .../StructureDeserializerGenerator.java | 41 ++++++++++++- .../java/core/serde/ShapeDeserializer.java | 14 +++++ 6 files changed, 126 insertions(+), 4 deletions(-) diff --git a/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/jackson/JacksonJsonDeserializer.java b/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/jackson/JacksonJsonDeserializer.java index 74c0bf1d8e..c53d78b74d 100644 --- a/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/jackson/JacksonJsonDeserializer.java +++ b/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/jackson/JacksonJsonDeserializer.java @@ -261,8 +261,9 @@ public void readStruct(Schema schema, T state, StructMemberConsumer struc try { var fieldToMember = settings.fieldMapper().fieldToMember(schema); for (var memberName = parser.nextName(); memberName != null; memberName = parser.nextName()) { - if (parser.nextToken() != VALUE_NULL) { - var member = fieldToMember.member(memberName); + var member = fieldToMember.member(memberName); + if (parser.nextToken() != VALUE_NULL + || (member != null && structMemberConsumer.supportsNullValues(member))) { if (member != null) { structMemberConsumer.accept(state, member, this); } else if (schema.type() == ShapeType.STRUCTURE) { diff --git a/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/smithy/SmithyJsonDeserializer.java b/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/smithy/SmithyJsonDeserializer.java index a4109c2576..312c8820d3 100644 --- a/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/smithy/SmithyJsonDeserializer.java +++ b/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/smithy/SmithyJsonDeserializer.java @@ -616,7 +616,8 @@ public void readStruct(Schema schema, T state, StructMemberConsumer struc && p + 4 <= localEnd && localBuf[p + 1] == 'u' && localBuf[p + 2] == 'l' - && localBuf[p + 3] == 'l') { + && localBuf[p + 3] == 'l' + && !structMemberConsumer.supportsNullValues(member)) { p += 4; } else { // Write pos back before callback, reload after diff --git a/codecs/json-codec/src/test/java/software/amazon/smithy/java/json/JsonDeserializerTest.java b/codecs/json-codec/src/test/java/software/amazon/smithy/java/json/JsonDeserializerTest.java index f5af18715b..f37ec68fa6 100644 --- a/codecs/json-codec/src/test/java/software/amazon/smithy/java/json/JsonDeserializerTest.java +++ b/codecs/json-codec/src/test/java/software/amazon/smithy/java/json/JsonDeserializerTest.java @@ -1340,6 +1340,64 @@ public void nullMemberValueSkippedInStruct(JsonSerdeProvider provider) { } } + @PerProvider + public void nullMemberValuePassedToConsumerWhenSupported(JsonSerdeProvider provider) { + try (var codec = codecBuilder(provider).useJsonName(true).build()) { + var de = codec.createDeserializer( + "{\"name\":null,\"Color\":\"red\"}".getBytes(StandardCharsets.UTF_8)); + Set members = new LinkedHashSet<>(); + List nullFlags = new ArrayList<>(); + de.readStruct(JsonTestData.BIRD, members, new ShapeDeserializer.StructMemberConsumer<>() { + @Override + public void accept(Set state, Schema member, ShapeDeserializer deser) { + state.add(member.memberName()); + nullFlags.add(deser.isNull()); + if (deser.isNull()) { + deser.readNull(); + } else { + deser.readString(member); + } + } + + @Override + public boolean supportsNullValues(Schema memberSchema) { + return true; + } + }); + // "name" was null but consumer was still called because supportsNullValues is true + assertThat(members, contains("name", "color")); + assertThat(nullFlags, contains(true, false)); + } + } + + @PerProvider + public void nullMemberValueSkippedWhenNotSupportedBySpecificMember(JsonSerdeProvider provider) { + try (var codec = codecBuilder(provider).useJsonName(true).build()) { + var de = codec.createDeserializer( + "{\"name\":null,\"Color\":null,\"nested\":\"hi\"}".getBytes(StandardCharsets.UTF_8)); + Set members = new LinkedHashSet<>(); + de.readStruct(JsonTestData.BIRD, members, new ShapeDeserializer.StructMemberConsumer<>() { + @Override + public void accept(Set state, Schema member, ShapeDeserializer deser) { + state.add(member.memberName()); + if (deser.isNull()) { + deser.readNull(); + } else { + deser.readString(member); + } + } + + @Override + public boolean supportsNullValues(Schema memberSchema) { + // Only support null for "name", not for "color" + return memberSchema.memberName().equals("name"); + } + }); + // "name" null is passed through, "color" null is skipped + assertThat(members, contains("name", "nested")); + } + } + @ParameterizedTest @MethodSource("smithyOnly") public void rejectsNonObjectForMap(JsonSerdeProvider provider) { diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/SymbolProperties.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/SymbolProperties.java index 6f69f5dda1..e141e70ef7 100644 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/SymbolProperties.java +++ b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/SymbolProperties.java @@ -99,5 +99,14 @@ public final class SymbolProperties { */ public static final Property IS_NULLABLE = Property.named("is-nullable"); + /** + * Indicates if a member supports explicit null values during deserialization. + * + *

When set to {@code true}, the generated deserializer will pass null values through to the + * struct member consumer rather than skipping them, allowing the member to distinguish between + * absent and explicitly null. + */ + public static final Property SUPPORTS_NULL_VALUES = Property.named("supports-null-values"); + private SymbolProperties() {} } diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/StructureDeserializerGenerator.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/StructureDeserializerGenerator.java index 938d294342..1dd5432f56 100644 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/StructureDeserializerGenerator.java +++ b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/StructureDeserializerGenerator.java @@ -8,10 +8,12 @@ import java.util.Map; import software.amazon.smithy.codegen.core.SymbolProvider; import software.amazon.smithy.java.codegen.CodegenUtils; +import software.amazon.smithy.java.codegen.SymbolProperties; import software.amazon.smithy.java.codegen.writer.JavaWriter; import software.amazon.smithy.java.core.schema.Schema; import software.amazon.smithy.java.core.serde.ShapeDeserializer; import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.MemberShape; import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.model.traits.ErrorTrait; @@ -56,7 +58,15 @@ public void accept(Builder builder, ${sdkSchema:T} member, ${shapeDeserializer:T @Override public void unknownMember(Builder builder, ${string:T} memberName) { builder.$$unknownMember(memberName); - }${/union} + }${/union}${?supportsNullValues} + + @Override + public boolean supportsNullValues(${sdkSchema:T} member) { + return switch (member.memberIndex()) { + ${nullValueCases:C|} + default -> false; + }; + }${/supportsNullValues} }"""; writer.putContext("shapeDeserializer", ShapeDeserializer.class); writer.putContext("sdkSchema", Schema.class); @@ -66,6 +76,8 @@ public void unknownMember(Builder builder, ${string:T} memberName) { writer.putContext("union", shape.isUnionShape()); writer.putContext("illegalArg", IllegalArgumentException.class); writer.putContext("isError", shape.hasTrait(ErrorTrait.class)); + writer.putContext("supportsNullValues", hasMembersThatSupportsNullValues(shape)); + writer.putContext("nullValueCases", writer.consumer(this::generateMemberNullValuesSwitchCases)); writer.write(template); writer.popState(); } @@ -83,4 +95,31 @@ private void generateMemberSwitchCases(JavaWriter writer) { writer.popState(); } } + + private void generateMemberNullValuesSwitchCases(JavaWriter writer) { + int idx = 0; + for (var iter = CodegenUtils.getSortedMembers(shape).iterator(); iter.hasNext(); idx++) { + var member = iter.next(); + + if (memberSupportsNullValues(member)) { + writer.write("case $L -> true;", idx); + } + } + } + + private boolean hasMembersThatSupportsNullValues(Shape shape) { + return shape.getAllMembers() + .values() + .stream() + .anyMatch(this::memberSupportsNullValues); + } + + private boolean memberSupportsNullValues(MemberShape member) { + var memberSymbol = symbolProvider.toSymbol(member); + if (memberSymbol == null) { + return false; + } + + return memberSymbol.getProperty(SymbolProperties.SUPPORTS_NULL_VALUES).orElse(false); + } } diff --git a/core/src/main/java/software/amazon/smithy/java/core/serde/ShapeDeserializer.java b/core/src/main/java/software/amazon/smithy/java/core/serde/ShapeDeserializer.java index d196ffc5cc..1b321d79db 100644 --- a/core/src/main/java/software/amazon/smithy/java/core/serde/ShapeDeserializer.java +++ b/core/src/main/java/software/amazon/smithy/java/core/serde/ShapeDeserializer.java @@ -254,6 +254,20 @@ interface StructMemberConsumer { void accept(T state, Schema memberSchema, ShapeDeserializer memberDeserializer); default void unknownMember(T state, String memberName) {} + + /** + * Returns whether a given member supports explicit null values during deserialization. + * + *

When this returns {@code true}, the deserializer will invoke {@link #accept} for the member + * even when the serialized value is null, allowing the consumer to distinguish between an absent + * field and an explicitly null value. + * + * @param memberSchema Schema of the member to check. + * @return {@code true} if the member should receive null values, {@code false} to skip them. + */ + default boolean supportsNullValues(Schema memberSchema) { + return false; + } } /**