diff --git a/gson/src/main/java/com/google/gson/Gson.java b/gson/src/main/java/com/google/gson/Gson.java index 45336a87e9..d6814fc5ea 100644 --- a/gson/src/main/java/com/google/gson/Gson.java +++ b/gson/src/main/java/com/google/gson/Gson.java @@ -151,6 +151,8 @@ public final class Gson { static final FieldNamingStrategy DEFAULT_FIELD_NAMING_STRATEGY = FieldNamingPolicy.IDENTITY; static final ToNumberStrategy DEFAULT_OBJECT_TO_NUMBER_STRATEGY = ToNumberPolicy.DOUBLE; static final ToNumberStrategy DEFAULT_NUMBER_TO_NUMBER_STRATEGY = ToNumberPolicy.LAZILY_PARSED_NUMBER; + static final MissingFieldValueStrategy DEFAULT_MISSING_FIELD_VALUE_STRATEGY = MissingFieldValueStrategy.DO_NOTHING; + static final UnknownFieldStrategy DEFAULT_UNKNOWN_FIELD_STRATEGY = UnknownFieldStrategy.IGNORE; private static final String JSON_NON_EXECUTABLE_PREFIX = ")]}'\n"; @@ -195,6 +197,8 @@ public final class Gson { final List builderHierarchyFactories; final ToNumberStrategy objectToNumberStrategy; final ToNumberStrategy numberToNumberStrategy; + final MissingFieldValueStrategy missingFieldValueStrategy; + final UnknownFieldStrategy unknownFieldStrategy; final List reflectionFilters; /** @@ -242,6 +246,7 @@ public Gson() { LongSerializationPolicy.DEFAULT, DEFAULT_DATE_PATTERN, DateFormat.DEFAULT, DateFormat.DEFAULT, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), DEFAULT_OBJECT_TO_NUMBER_STRATEGY, DEFAULT_NUMBER_TO_NUMBER_STRATEGY, + DEFAULT_MISSING_FIELD_VALUE_STRATEGY, DEFAULT_UNKNOWN_FIELD_STRATEGY, Collections.emptyList()); } @@ -255,6 +260,7 @@ public Gson() { List builderHierarchyFactories, List factoriesToBeAdded, ToNumberStrategy objectToNumberStrategy, ToNumberStrategy numberToNumberStrategy, + MissingFieldValueStrategy missingFieldValueStrategy, UnknownFieldStrategy unknownFieldStrategy, List reflectionFilters) { this.excluder = excluder; this.fieldNamingStrategy = fieldNamingStrategy; @@ -276,6 +282,8 @@ public Gson() { this.builderHierarchyFactories = builderHierarchyFactories; this.objectToNumberStrategy = objectToNumberStrategy; this.numberToNumberStrategy = numberToNumberStrategy; + this.missingFieldValueStrategy = missingFieldValueStrategy; + this.unknownFieldStrategy = unknownFieldStrategy; this.reflectionFilters = reflectionFilters; List factories = new ArrayList<>(); @@ -341,7 +349,8 @@ public Gson() { factories.add(jsonAdapterFactory); factories.add(TypeAdapters.ENUM_FACTORY); factories.add(new ReflectiveTypeAdapterFactory( - constructorConstructor, fieldNamingStrategy, excluder, jsonAdapterFactory, reflectionFilters)); + constructorConstructor, fieldNamingStrategy, excluder, jsonAdapterFactory, + missingFieldValueStrategy, unknownFieldStrategy, reflectionFilters)); this.factories = Collections.unmodifiableList(factories); } diff --git a/gson/src/main/java/com/google/gson/GsonBuilder.java b/gson/src/main/java/com/google/gson/GsonBuilder.java index f8f1b27f80..b9267eaef8 100644 --- a/gson/src/main/java/com/google/gson/GsonBuilder.java +++ b/gson/src/main/java/com/google/gson/GsonBuilder.java @@ -22,10 +22,12 @@ import static com.google.gson.Gson.DEFAULT_FORMATTING_STYLE; import static com.google.gson.Gson.DEFAULT_JSON_NON_EXECUTABLE; import static com.google.gson.Gson.DEFAULT_LENIENT; +import static com.google.gson.Gson.DEFAULT_MISSING_FIELD_VALUE_STRATEGY; import static com.google.gson.Gson.DEFAULT_NUMBER_TO_NUMBER_STRATEGY; import static com.google.gson.Gson.DEFAULT_OBJECT_TO_NUMBER_STRATEGY; import static com.google.gson.Gson.DEFAULT_SERIALIZE_NULLS; import static com.google.gson.Gson.DEFAULT_SPECIALIZE_FLOAT_VALUES; +import static com.google.gson.Gson.DEFAULT_UNKNOWN_FIELD_STRATEGY; import static com.google.gson.Gson.DEFAULT_USE_JDK_UNSAFE; import com.google.gson.annotations.Since; @@ -56,7 +58,7 @@ * use {@code new Gson()}. {@code GsonBuilder} is best used by creating it, and then invoking its * various configuration methods, and finally calling create.

* - *

The following is an example shows how to use the {@code GsonBuilder} to construct a Gson + *

The following example shows how to use the {@code GsonBuilder} to construct a Gson * instance: * *

@@ -73,8 +75,8 @@
  *
  * 

NOTES: *

    - *
  • the order of invocation of configuration methods does not matter.
  • - *
  • The default serialization of {@link Date} and its subclasses in Gson does + *
  • the order of invocation of configuration methods does not matter.
  • + *
  • the default serialization of {@link Date} and its subclasses in Gson does * not contain time-zone information. So, if you are using date/time instances, * use {@code GsonBuilder} and its {@code setDateFormat} methods.
  • *
@@ -104,6 +106,8 @@ public final class GsonBuilder { private boolean useJdkUnsafe = DEFAULT_USE_JDK_UNSAFE; private ToNumberStrategy objectToNumberStrategy = DEFAULT_OBJECT_TO_NUMBER_STRATEGY; private ToNumberStrategy numberToNumberStrategy = DEFAULT_NUMBER_TO_NUMBER_STRATEGY; + private MissingFieldValueStrategy missingFieldValueStrategy = DEFAULT_MISSING_FIELD_VALUE_STRATEGY; + private UnknownFieldStrategy unknownFieldStrategy = DEFAULT_UNKNOWN_FIELD_STRATEGY; private final ArrayDeque reflectionFilters = new ArrayDeque<>(); /** @@ -141,6 +145,8 @@ public GsonBuilder() { this.useJdkUnsafe = gson.useJdkUnsafe; this.objectToNumberStrategy = gson.objectToNumberStrategy; this.numberToNumberStrategy = gson.numberToNumberStrategy; + this.missingFieldValueStrategy = gson.missingFieldValueStrategy; + this.unknownFieldStrategy = gson.unknownFieldStrategy; this.reflectionFilters.addAll(gson.reflectionFilters); } @@ -388,6 +394,37 @@ public GsonBuilder setObjectToNumberStrategy(ToNumberStrategy objectToNumberStra return this; } + /** + * Configures Gson to apply a specific missing field value strategy during deserialization. + * The strategy is used during reflection-based deserialization when the JSON data does + * not contain a value for a field. A field with explicit JSON null is not considered missing. + * + * @param missingFieldValueStrategy strategy handling missing field values + * @return a reference to this {@code GsonBuilder} object to fulfill the "Builder" pattern + * @see MissingFieldValueStrategy#DO_NOTHING The default missing field value strategy + * @since $next-version$ + */ + public GsonBuilder setMissingFieldValueStrategy(MissingFieldValueStrategy missingFieldValueStrategy) { + this.missingFieldValueStrategy = Objects.requireNonNull(missingFieldValueStrategy); + return this; + } + + /** + * Configures Gson to apply a specific unknown field strategy during deserialization. + * The strategy is used during reflection-based deserialization when an unknown field + * is encountered in the JSON data. If a field which is excluded from deserialization + * appears in the JSON data it is considered unknown as well. + * + * @param unknownFieldStrategy strategy handling unknown fields + * @return a reference to this {@code GsonBuilder} object to fulfill the "Builder" pattern + * @see UnknownFieldStrategy#IGNORE The default unknown field strategy + * @since $next-version$ + */ + public GsonBuilder setUnknownFieldStrategy(UnknownFieldStrategy unknownFieldStrategy) { + this.unknownFieldStrategy = Objects.requireNonNull(unknownFieldStrategy); + return this; + } + /** * Configures Gson to apply a specific number strategy during deserialization of {@link Number}. * @@ -782,7 +819,8 @@ public Gson create() { serializeSpecialFloatingPointValues, useJdkUnsafe, longSerializationPolicy, datePattern, dateStyle, timeStyle, new ArrayList<>(this.factories), new ArrayList<>(this.hierarchyFactories), factories, - objectToNumberStrategy, numberToNumberStrategy, new ArrayList<>(reflectionFilters)); + objectToNumberStrategy, numberToNumberStrategy, + missingFieldValueStrategy, unknownFieldStrategy, new ArrayList<>(reflectionFilters)); } private void addTypeAdaptersForDate(String datePattern, int dateStyle, int timeStyle, diff --git a/gson/src/main/java/com/google/gson/MissingFieldValueStrategy.java b/gson/src/main/java/com/google/gson/MissingFieldValueStrategy.java new file mode 100644 index 0000000000..8845b2b5a1 --- /dev/null +++ b/gson/src/main/java/com/google/gson/MissingFieldValueStrategy.java @@ -0,0 +1,77 @@ +package com.google.gson; + +import com.google.gson.internal.reflect.ReflectionHelper; +import com.google.gson.reflect.TypeToken; +import java.lang.reflect.Field; + +/** + * A strategy defining how to handle missing field values during reflection-based deserialization. + * + * @see GsonBuilder#setMissingFieldValueStrategy(MissingFieldValueStrategy) + * @since $next-version$ + */ +public interface MissingFieldValueStrategy { + /** + * This strategy does nothing when a missing field is detected, it preserves the initial field + * value, if any. + * + *

This is the default missing field value strategy. + */ + MissingFieldValueStrategy DO_NOTHING = new MissingFieldValueStrategy() { + @Override + public Object handleMissingField(TypeToken declaringType, Object instance, Field field, TypeToken resolvedFieldType) { + // Preserve initial field value + return null; + } + + @Override + public String toString() { + return "MissingFieldValueStrategy.DO_NOTHING"; + } + }; + + /** + * This strategy throws an exception when a missing field is detected. + */ + MissingFieldValueStrategy THROW_EXCEPTION = new MissingFieldValueStrategy() { + @Override + public Object handleMissingField(TypeToken declaringType, Object instance, Field field, TypeToken resolvedFieldType) { + // TODO: Proper exception + throw new RuntimeException("Missing value for field '" + ReflectionHelper.fieldToString(field) + "'"); + } + + @Override + public String toString() { + return "MissingFieldValueStrategy.THROW_EXCEPTION"; + } + }; + + /** + * Called when a missing field value is detected. Implementations can either throw an exception or + * return a default value. + * + *

Returning {@code null} will keep the initial field value, if any. For example when returning + * {@code null} for the field {@code String f = "default"}, the field will still have the value + * {@code "default"} afterwards (assuming the constructor of the class was called, see also + * {@link GsonBuilder#disableJdkUnsafe()}). The type of the returned value has to match the + * type of the field, no narrowing or widening numeric conversion is performed. + * + *

The {@code instance} represents an instance of the declaring type with the so far already + * deserialized fields. It is intended to be used for looking up existing field values to derive + * the missing field value from them. Manipulating {@code instance} in any way is not recommended.
+ * For Record classes (Java 16 feature) the {@code instance} is {@code null}. + * + *

{@code resolvedFieldType} is the type of the field with type variables being resolved, if + * possible. For example if {@code class MyClass} has a field {@code T myField} and + * {@code MyClass} is deserialized, then {@code resolvedFieldType} will be {@code String}. + * + * @param declaringType type declaring the field + * @param instance instance of the declaring type, {@code null} for Record classes + * @param field field whose value is missing + * @param resolvedFieldType resolved type of the field + * @return the field value, or {@code null} + */ + // TODO: Should this really expose `instance`? Only use case would be to derive value from other fields + // but besides that user should not directly manipulate `instance` but return new value instead + Object handleMissingField(TypeToken declaringType, Object instance, Field field, TypeToken resolvedFieldType); +} diff --git a/gson/src/main/java/com/google/gson/ReflectionAccessFilter.java b/gson/src/main/java/com/google/gson/ReflectionAccessFilter.java index 7736ec7aa0..7c1733b309 100644 --- a/gson/src/main/java/com/google/gson/ReflectionAccessFilter.java +++ b/gson/src/main/java/com/google/gson/ReflectionAccessFilter.java @@ -124,6 +124,10 @@ enum FilterResult { ? FilterResult.BLOCK_INACCESSIBLE : FilterResult.INDECISIVE; } + + @Override public String toString() { + return "ReflectionAccessFilter.BLOCK_INACCESSIBLE_JAVA"; + } }; /** @@ -149,6 +153,10 @@ enum FilterResult { ? FilterResult.BLOCK_ALL : FilterResult.INDECISIVE; } + + @Override public String toString() { + return "ReflectionAccessFilter.BLOCK_ALL_JAVA"; + } }; /** @@ -173,6 +181,10 @@ enum FilterResult { ? FilterResult.BLOCK_ALL : FilterResult.INDECISIVE; } + + @Override public String toString() { + return "ReflectionAccessFilter.BLOCK_ALL_ANDROID"; + } }; /** @@ -198,6 +210,10 @@ enum FilterResult { ? FilterResult.BLOCK_ALL : FilterResult.INDECISIVE; } + + @Override public String toString() { + return "ReflectionAccessFilter.BLOCK_ALL_PLATFORM"; + } }; /** diff --git a/gson/src/main/java/com/google/gson/UnknownFieldStrategy.java b/gson/src/main/java/com/google/gson/UnknownFieldStrategy.java new file mode 100644 index 0000000000..7ec4e5206f --- /dev/null +++ b/gson/src/main/java/com/google/gson/UnknownFieldStrategy.java @@ -0,0 +1,79 @@ +package com.google.gson; + +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import java.io.IOException; + +/** + * A strategy defining how to handle unknown fields during reflection-based deserialization. + * + * @see GsonBuilder#setUnknownFieldStrategy(UnknownFieldStrategy) + * @since $next-version$ + */ +public interface UnknownFieldStrategy { + /** + * This strategy ignores the unknown field. + * + *

This is the default unknown field strategy. + */ + UnknownFieldStrategy IGNORE = new UnknownFieldStrategy() { + @Override + public void handleUnknownField(TypeToken declaringType, Object instance, String fieldName, + JsonReader jsonReader, Gson gson) throws IOException { + jsonReader.skipValue(); + } + + @Override + public String toString() { + return "UnknownFieldStrategy.IGNORE"; + } + }; + + /** + * This strategy throws an exception when an unknown field is encountered. + * + *

Note: Be careful when using this strategy; while it might sound tempting + * to strictly validate that the JSON data matches the expected format, this strategy + * makes it difficult to add new fields to the JSON structure in a backward compatible way. + * Usually it suffices to use only {@link MissingFieldValueStrategy#THROW_EXCEPTION} for + * validation and to ignore unknown fields. + */ + UnknownFieldStrategy THROW_EXCEPTION = new UnknownFieldStrategy() { + @Override + public void handleUnknownField(TypeToken declaringType, Object instance, String fieldName, + JsonReader jsonReader, Gson gson) throws IOException { + // TODO: Proper exception + throw new RuntimeException("Unknown field '" + fieldName + "' for " + declaringType.getRawType() + " at path " + jsonReader.getPath()); + } + + @Override + public String toString() { + return "UnknownFieldStrategy.THROW_EXCEPTION"; + } + }; + + /** + * Called when an unknown field is encountered. Implementations can throw an exception, + * store the field value in {@code instance} or ignore the unknown field. + * + *

The {@code jsonReader} is positioned to read the value of the unknown field. If an + * implementation of this method does not throw an exception it must consume the value, either + * by reading it with methods like {@link JsonReader#nextString()} (possibly after peeking + * at the value type first), or by skipping it with {@link JsonReader#skipValue()}.
+ * The {@code gson} object can be used to read from the {@code jsonReader}. It is the same + * instance which was originally used to perform the deserialization. + * + *

The {@code instance} represents an instance of the declaring type with the so far already + * deserialized fields. It can be used to store the value of the unknown field, for example + * if it declares a {@code transient Map} field for all unknown values.
+ * For Record classes (Java 16 feature) the {@code instance} is {@code null}. + * + * @param declaringType type declaring the field + * @param instance instance of the declaring type, {@code null} for Record classes + * @param fieldName name of the unknown field + * @param jsonReader reader to be used to read or skip the field value + * @param gson {@code Gson} instance which can be used to read the field value from {@code jsonReader} + * @throws IOException if reading or skipping the field value fails + */ + void handleUnknownField(TypeToken declaringType, Object instance, String fieldName, JsonReader jsonReader, Gson gson) throws IOException; +} diff --git a/gson/src/main/java/com/google/gson/internal/bind/ReflectiveTypeAdapterFactory.java b/gson/src/main/java/com/google/gson/internal/bind/ReflectiveTypeAdapterFactory.java index 9194fc33bf..985d841e4b 100644 --- a/gson/src/main/java/com/google/gson/internal/bind/ReflectiveTypeAdapterFactory.java +++ b/gson/src/main/java/com/google/gson/internal/bind/ReflectiveTypeAdapterFactory.java @@ -21,10 +21,12 @@ import com.google.gson.JsonIOException; import com.google.gson.JsonParseException; import com.google.gson.JsonSyntaxException; +import com.google.gson.MissingFieldValueStrategy; import com.google.gson.ReflectionAccessFilter; import com.google.gson.ReflectionAccessFilter.FilterResult; import com.google.gson.TypeAdapter; import com.google.gson.TypeAdapterFactory; +import com.google.gson.UnknownFieldStrategy; import com.google.gson.annotations.JsonAdapter; import com.google.gson.annotations.SerializedName; import com.google.gson.internal.$Gson$Types; @@ -63,16 +65,21 @@ public final class ReflectiveTypeAdapterFactory implements TypeAdapterFactory { private final FieldNamingStrategy fieldNamingPolicy; private final Excluder excluder; private final JsonAdapterAnnotationTypeAdapterFactory jsonAdapterFactory; + private final MissingFieldValueStrategy missingFieldValueStrategy; + private final UnknownFieldStrategy unknownFieldStrategy; private final List reflectionFilters; public ReflectiveTypeAdapterFactory(ConstructorConstructor constructorConstructor, FieldNamingStrategy fieldNamingPolicy, Excluder excluder, JsonAdapterAnnotationTypeAdapterFactory jsonAdapterFactory, + MissingFieldValueStrategy missingFieldValueStrategy, UnknownFieldStrategy unknownFieldStrategy, List reflectionFilters) { this.constructorConstructor = constructorConstructor; this.fieldNamingPolicy = fieldNamingPolicy; this.excluder = excluder; this.jsonAdapterFactory = jsonAdapterFactory; + this.missingFieldValueStrategy = missingFieldValueStrategy; + this.unknownFieldStrategy = unknownFieldStrategy; this.reflectionFilters = reflectionFilters; } @@ -122,13 +129,15 @@ public TypeAdapter create(Gson gson, final TypeToken type) { // on JVMs that do not support records. if (ReflectionHelper.isRecord(raw)) { @SuppressWarnings("unchecked") - TypeAdapter adapter = (TypeAdapter) new RecordAdapter<>(raw, + TypeAdapter adapter = new RecordAdapter<>(gson, + missingFieldValueStrategy, unknownFieldStrategy, type, (Class) raw, getBoundFields(gson, type, raw, blockInaccessible, true), blockInaccessible); return adapter; } ObjectConstructor constructor = constructorConstructor.get(type); - return new FieldReflectionAdapter<>(constructor, getBoundFields(gson, type, raw, blockInaccessible, false)); + return new FieldReflectionAdapter<>(gson, missingFieldValueStrategy, unknownFieldStrategy, + constructor, type, getBoundFields(gson, type, raw, blockInaccessible, false)); } private static void checkAccessible(Object object, M member) { @@ -170,7 +179,7 @@ private BoundField createBoundField( // Will never actually be used, but we set it to avoid confusing nullness-analysis tools writeTypeAdapter = typeAdapter; } - return new BoundField(name, field, serialize, deserialize) { + return new BoundField(name, field, fieldType, serialize, deserialize) { @Override void write(JsonWriter writer, Object source) throws IOException, IllegalAccessException { if (!serialized) return; @@ -217,6 +226,11 @@ void readIntoArray(JsonReader reader, int index, Object[] target) throws IOExcep void readIntoField(JsonReader reader, Object target) throws IOException, IllegalAccessException { Object fieldValue = typeAdapter.read(reader); + putIntoField(fieldValue, target); + } + + @Override + void putIntoField(Object fieldValue, Object target) throws IllegalAccessException { if (fieldValue != null || !isPrimitive) { if (blockInaccessible) { checkAccessible(target, field); @@ -320,25 +334,30 @@ static abstract class BoundField { final Field field; /** Name of the underlying field */ final String fieldName; + final TypeToken resolvedType; final boolean serialized; final boolean deserialized; - protected BoundField(String name, Field field, boolean serialized, boolean deserialized) { + protected BoundField(String name, Field field, TypeToken resolvedType, boolean serialized, boolean deserialized) { this.name = name; this.field = field; this.fieldName = field.getName(); + this.resolvedType = resolvedType; this.serialized = serialized; this.deserialized = deserialized; } - /** Read this field value from the source, and append its JSON value to the writer */ + /** Reads this field value from the source, and append its JSON value to the writer */ abstract void write(JsonWriter writer, Object source) throws IOException, IllegalAccessException; - /** Read the value into the target array, used to provide constructor arguments for records */ + /** Reads the value into the target array, used to provide constructor arguments for records */ abstract void readIntoArray(JsonReader reader, int index, Object[] target) throws IOException, JsonParseException; - /** Read the value from the reader, and set it on the corresponding field on target via reflection */ + /** Reads the value from the reader, and set it on the corresponding field on target via reflection */ abstract void readIntoField(JsonReader reader, Object target) throws IOException, IllegalAccessException; + + /** Puts the field value {@code fieldValue} into the field of {@code target} */ + abstract void putIntoField(Object fieldValue, Object target) throws IllegalAccessException; } /** @@ -356,10 +375,33 @@ protected BoundField(String name, Field field, boolean serialized, boolean deser */ // This class is public because external projects check for this class with `instanceof` (even though it is internal) public static abstract class Adapter extends TypeAdapter { + final Gson gson; + final MissingFieldValueStrategy missingFieldValueStrategy; + final UnknownFieldStrategy unknownFieldStrategy; + final TypeToken type; final Map boundFields; - - Adapter(Map boundFields) { + /** Fields to consider for missing field handling; {@code null} if missing fields should be ignored */ + final Map missingFieldsToCheck; + + Adapter(Gson gson, MissingFieldValueStrategy missingFieldValueStrategy, UnknownFieldStrategy unknownFieldStrategy, + TypeToken type, Map boundFields) { + this.gson = gson; + this.missingFieldValueStrategy = missingFieldValueStrategy; + this.unknownFieldStrategy = unknownFieldStrategy; + this.type = type; this.boundFields = boundFields; + + if (missingFieldValueStrategy == MissingFieldValueStrategy.DO_NOTHING) { + missingFieldsToCheck = null; + } else { + // Track the underlying Field because there might be multiple BoundField entries when using @SerializedName + missingFieldsToCheck = new LinkedHashMap<>(boundFields.size()); + for (BoundField boundField : this.boundFields.values()) { + if (boundField.deserialized) { + missingFieldsToCheck.put(boundField.field, boundField); + } + } + } } @Override @@ -388,6 +430,7 @@ public T read(JsonReader in) throws IOException { } A accumulator = createAccumulator(); + Map missingFields = missingFieldsToCheck == null ? null : new LinkedHashMap<>(missingFieldsToCheck); try { in.beginObject(); @@ -395,9 +438,25 @@ public T read(JsonReader in) throws IOException { String name = in.nextName(); BoundField field = boundFields.get(name); if (field == null || !field.deserialized) { - in.skipValue(); + try { + unknownFieldStrategy.handleUnknownField(type, createObjectForFieldStrategy(accumulator), name, in, gson); + } catch (IOException e) { + // Don't wrap IOException; it is most likely unrelated to unknownFieldStrategy, but instead caused by JSON data + throw e; + } catch (Exception e) { + // UnknownFieldStrategy.THROW_EXCEPTION provides enough context, can directly rethrow + if (unknownFieldStrategy == UnknownFieldStrategy.THROW_EXCEPTION) { + throw e; + } + // TODO Proper exception type + throw new RuntimeException("Failed handling unknown field '" + name + "' for " + type.getRawType() + " at path " + in.getPath(), e); + } } else { readField(accumulator, in, field); + + if (missingFields != null) { + missingFields.remove(field.field); + } } } } catch (IllegalStateException e) { @@ -406,26 +465,70 @@ public T read(JsonReader in) throws IOException { throw ReflectionHelper.createExceptionForUnexpectedIllegalAccess(e); } in.endObject(); + + if (missingFields != null && !missingFields.isEmpty()) { + for (Map.Entry fieldEntry : missingFields.entrySet()) { + Field field = fieldEntry.getKey(); + BoundField boundField = fieldEntry.getValue(); + Object newValue; + try { + newValue = missingFieldValueStrategy.handleMissingField(type, createObjectForFieldStrategy(accumulator), field, boundField.resolvedType); + } catch (Exception e) { + // TODO Proper exception type + throw new RuntimeException("Failed handling missing field '" + ReflectionHelper.fieldToString(field) + "' at path " + in.getPath(), e); + } + + // For null values keep the existing initial value + if (newValue != null) { + try { + addMissingFieldValue(accumulator, boundField, newValue); + } catch (Exception e) { + // TODO Proper exception type + throw new RuntimeException("Failed storing " + newValue.getClass().getName() + " provided by " + missingFieldValueStrategy + " into field '" + ReflectionHelper.fieldToString(field) + "' at path " + in.getPreviousPath(), e); + } + } + } + } + return finalize(accumulator); } - /** Create the Object that will be used to collect each field value */ + /** Creates the Object that will be used to collect each field value */ abstract A createAccumulator(); + + /** + * Creates the Object based on the accumulator that will be passed as {@code instance} + * to the {@link MissingFieldValueStrategy} and {@link UnknownFieldStrategy}. + * + * @return the object for missing and unknown field strategies, can be {@code null} + */ + abstract Object createObjectForFieldStrategy(A accumulator); + /** - * Read a single BoundField into the accumulator. The JsonReader will be pointed at the + * Reads a single BoundField into the accumulator. The JsonReader will be pointed at the * start of the value for the BoundField to read from. */ abstract void readField(A accumulator, JsonReader in, BoundField field) throws IllegalAccessException, IOException; - /** Convert the accumulator to a final instance of T. */ + + /** + * Called for the {@link MissingFieldValueStrategy} to add {@code value} as value + * for {@code field}. + * + * @param value the field value, must not be {@code null} + */ + abstract void addMissingFieldValue(A accumulator, BoundField field, Object value); + + /** Converts the accumulator to a final instance of T. */ abstract T finalize(A accumulator); } private static final class FieldReflectionAdapter extends Adapter { private final ObjectConstructor constructor; - FieldReflectionAdapter(ObjectConstructor constructor, Map boundFields) { - super(boundFields); + FieldReflectionAdapter(Gson gson, MissingFieldValueStrategy missingFieldValueStrategy, UnknownFieldStrategy unknownFieldStrategy, + ObjectConstructor constructor, TypeToken type, Map boundFields) { + super(gson, missingFieldValueStrategy, unknownFieldStrategy, type, boundFields); this.constructor = constructor; } @@ -434,12 +537,27 @@ T createAccumulator() { return constructor.construct(); } + @Override + Object createObjectForFieldStrategy(T accumulator) { + // Let missing and unknown field strategies directly access constructed object + return accumulator; + } + @Override void readField(T accumulator, JsonReader in, BoundField field) throws IllegalAccessException, IOException { field.readIntoField(in, accumulator); } + @Override + void addMissingFieldValue(T accumulator, BoundField field, Object value) { + try { + field.putIntoField(value, accumulator); + } catch (IllegalAccessException e) { + throw ReflectionHelper.createExceptionForUnexpectedIllegalAccess(e); + } + } + @Override T finalize(T accumulator) { return accumulator; @@ -456,8 +574,9 @@ private static final class RecordAdapter extends Adapter { // Map from component names to index into the constructors arguments. private final Map componentIndices = new HashMap<>(); - RecordAdapter(Class raw, Map boundFields, boolean blockInaccessible) { - super(boundFields); + RecordAdapter(Gson gson, MissingFieldValueStrategy missingFieldValueStrategy, UnknownFieldStrategy unknownFieldStrategy, + TypeToken type, Class raw, Map boundFields, boolean blockInaccessible) { + super(gson, missingFieldValueStrategy, unknownFieldStrategy, type, boundFields); constructor = ReflectionHelper.getCanonicalRecordConstructor(raw); if (blockInaccessible) { @@ -501,19 +620,37 @@ Object[] createAccumulator() { } @Override - void readField(Object[] accumulator, JsonReader in, BoundField field) throws IOException { + Object createObjectForFieldStrategy(Object[] accumulator) { + // Don't let missing and unknown field strategies directly access internal accumulator object + // TODO: In the future maybe provide a Map-like object which encapsulates accumulator, + // but restricts operations only to valid component names / property names? + return null; + } + + private int getComponentIndex(String fieldName) { // Obtain the component index from the name of the field backing it - Integer componentIndex = componentIndices.get(field.fieldName); + Integer componentIndex = componentIndices.get(fieldName); if (componentIndex == null) { throw new IllegalStateException( "Could not find the index in the constructor '" + ReflectionHelper.constructorToString(constructor) + "'" - + " for field with name '" + field.fieldName + "'," + + " for field with name '" + fieldName + "'," + " unable to determine which argument in the constructor the field corresponds" + " to. This is unexpected behavior, as we expect the RecordComponents to have the" + " same names as the fields in the Java class, and that the order of the" + " RecordComponents is the same as the order of the canonical constructor parameters."); } - field.readIntoArray(in, componentIndex, accumulator); + return componentIndex; + } + + @Override + void readField(Object[] accumulator, JsonReader in, BoundField field) throws IOException { + field.readIntoArray(in, getComponentIndex(field.fieldName), accumulator); + } + + @Override + void addMissingFieldValue(Object[] accumulator, BoundField field, Object value) { + assert(value != null); + accumulator[getComponentIndex(field.fieldName)] = value; } @Override diff --git a/gson/src/test/java/com/google/gson/GsonTest.java b/gson/src/test/java/com/google/gson/GsonTest.java index c1e9e9d785..35259ffc21 100644 --- a/gson/src/test/java/com/google/gson/GsonTest.java +++ b/gson/src/test/java/com/google/gson/GsonTest.java @@ -68,6 +68,7 @@ public void testOverridesDefaultExcluder() { DateFormat.DEFAULT, new ArrayList(), new ArrayList(), new ArrayList(), CUSTOM_OBJECT_TO_NUMBER_STRATEGY, CUSTOM_NUMBER_TO_NUMBER_STRATEGY, + MissingFieldValueStrategy.THROW_EXCEPTION, UnknownFieldStrategy.THROW_EXCEPTION, Collections.emptyList()); assertThat(gson.excluder).isEqualTo(CUSTOM_EXCLUDER); @@ -85,6 +86,7 @@ public void testClonedTypeAdapterFactoryListsAreIndependent() { DateFormat.DEFAULT, new ArrayList(), new ArrayList(), new ArrayList(), CUSTOM_OBJECT_TO_NUMBER_STRATEGY, CUSTOM_NUMBER_TO_NUMBER_STRATEGY, + MissingFieldValueStrategy.THROW_EXCEPTION, UnknownFieldStrategy.THROW_EXCEPTION, Collections.emptyList()); Gson clone = original.newBuilder() diff --git a/gson/src/test/java/com/google/gson/Java17MissingFieldValueStrategyTest.java b/gson/src/test/java/com/google/gson/Java17MissingFieldValueStrategyTest.java new file mode 100644 index 0000000000..dfcb655eeb --- /dev/null +++ b/gson/src/test/java/com/google/gson/Java17MissingFieldValueStrategyTest.java @@ -0,0 +1,197 @@ +package com.google.gson; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; +import com.google.gson.reflect.TypeToken; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; + +public class Java17MissingFieldValueStrategyTest { + @Test + public void testDoNothing() { + Gson gson = new GsonBuilder().setMissingFieldValueStrategy(MissingFieldValueStrategy.DO_NOTHING).create(); + + CustomRecord deserialized = gson.fromJson("{\"a\": \"custom-a\"}", CustomRecord.class); + assertThat(deserialized.a).isEqualTo("custom-a"); + assertThat(deserialized.b).isEqualTo(null); + } + + @Test + public void testThrowException() { + Gson gson = new GsonBuilder().setMissingFieldValueStrategy(MissingFieldValueStrategy.THROW_EXCEPTION).create(); + + CustomRecord deserialized = gson.fromJson("{\"a\": \"custom-a\", \"b\": \"custom-b\"}", CustomRecord.class); + assertThat(deserialized.a).isEqualTo("custom-a"); + assertThat(deserialized.b).isEqualTo("custom-b"); + + try { + gson.fromJson("{\"a\": \"custom-a\"}", CustomRecord.class); + fail(); + } + // TODO: Adjust this once a more specific exception is thrown + catch (RuntimeException expected) { + assertThat(expected).hasMessageThat().isEqualTo("Failed handling missing field '" + CustomRecord.class.getName() + "#b' at path $"); + assertThat(expected).hasCauseThat().hasMessageThat().isEqualTo("Missing value for field '" + CustomRecord.class.getName() + "#b'"); + } + + try { + gson.fromJson("{\"b\": \"custom-b\"}", CustomRecord.class); + fail(); + } + // TODO: Adjust this once a more specific exception is thrown + catch (RuntimeException expected) { + assertThat(expected).hasMessageThat().isEqualTo("Failed handling missing field '" + CustomRecord.class.getName() + "#a' at path $"); + assertThat(expected).hasCauseThat().hasMessageThat().isEqualTo("Missing value for field '" + CustomRecord.class.getName() + "#a'"); + } + } + + + /** + * Should only report missing field once, even if {@code @SerializedName} specifies multiple names. + */ + @Test + public void testSerializedName() throws Exception { + List missingFields = new ArrayList<>(); + Gson gson = new GsonBuilder().setMissingFieldValueStrategy(new MissingFieldValueStrategy() { + @Override + public Object handleMissingField(TypeToken declaringType, Object instance, Field field, + TypeToken resolvedFieldType) { + missingFields.add(field); + return 1; + } + }).create(); + + WithSerializedName deserialized = gson.fromJson("{}", WithSerializedName.class); + assertThat(deserialized.a).isEqualTo(1); + Field field = WithSerializedName.class.getDeclaredField("a"); + assertThat(missingFields).containsExactly(field); + } + + /** + * Should handle serialization and deserialization exclusions correctly. + */ + @Test + public void testExcluded() throws Exception { + List missingFields = new ArrayList<>(); + Gson gson = new GsonBuilder().setMissingFieldValueStrategy(new MissingFieldValueStrategy() { + @Override + public Object handleMissingField(TypeToken declaringType, Object instance, Field field, + TypeToken resolvedFieldType) { + missingFields.add(field); + return null; + } + }).excludeFieldsWithoutExposeAnnotation().create(); + + gson.fromJson("{}", WithExclusions.class); + Field field1 = WithExclusions.class.getDeclaredField("both"); + Field field2 = WithExclusions.class.getDeclaredField("deserialize"); + assertThat(missingFields).containsExactly(field1, field2); + } + + @Test + public void testResolvedFieldType() { + List> fieldTypes = new ArrayList<>(); + Gson gson = new GsonBuilder().setMissingFieldValueStrategy(new MissingFieldValueStrategy() { + @Override + public Object handleMissingField(TypeToken declaringType, Object instance, Field field, + TypeToken resolvedFieldType) { + fieldTypes.add(resolvedFieldType); + return null; + } + }).create(); + + gson.fromJson("{}", new TypeToken>() {}); + assertThat(fieldTypes).containsExactly(TypeToken.get(String.class)); + } + + @Test + public void testCustom() { + Gson gson = new GsonBuilder().setMissingFieldValueStrategy(new MissingFieldValueStrategy() { + @Override + public Object handleMissingField(TypeToken declaringType, Object instance, Field field, TypeToken resolvedFieldType) { + assertThat(declaringType).isEqualTo(TypeToken.get(CustomRecord.class)); + // Due to how Record instances are constructed currently cannot provide access to instance + assertThat(instance).isNull(); + assertThat(field.getDeclaringClass()).isEqualTo(CustomRecord.class); + + if (field.getName().equals("a")) { + // Preserve existing value + return null; + } + return "field-" + field.getName(); + } + }).create(); + + CustomRecord deserialized = gson.fromJson("{}", CustomRecord.class); + assertThat(deserialized.a).isEqualTo(null); + assertThat(deserialized.b).isEqualTo("field-b"); + + + gson = new GsonBuilder().setMissingFieldValueStrategy(new MissingFieldValueStrategy() { + @Override + public Object handleMissingField(TypeToken declaringType, Object instance, Field field, TypeToken resolvedFieldType) { + // Preserve existing value + return null; + } + }).create(); + RecordWithInt deserialized2 = gson.fromJson("{}", RecordWithInt.class); + // Uses default value for primitive + assertThat(deserialized2.a).isEqualTo(0); + } + + @Test + public void testBadNewFieldValue() { + Gson gson = new GsonBuilder().setMissingFieldValueStrategy(new MissingFieldValueStrategy() { + @Override + public Object handleMissingField(TypeToken declaringType, Object instance, Field field, TypeToken resolvedFieldType) { + return 1; + } + + @Override + public String toString() { + return "my-strategy"; + } + }).create(); + + try { + gson.fromJson("{\"a\": \"custom-a\"}", CustomRecord.class); + fail(); + } + // TODO: Adjust this once a more specific exception is thrown + catch (RuntimeException expected) { + // Exception currently does not point out usage of MissingFieldValueStrategy + assertThat(expected).hasMessageThat().isEqualTo("Failed to invoke constructor 'com.google.gson.Java17MissingFieldValueStrategyTest$CustomRecord(String, String)'" + + " with args [custom-a, 1]"); + assertThat(expected).hasCauseThat().isNotNull(); + } + } + + record CustomRecord(String a, String b) {} + + record RecordWithInt(int a) {} + + record WithSerializedName( + @SerializedName(value = "b", alternate = {"c", "d", "e"}) + int a + ) {} + + record WithExclusions( + @Expose(deserialize = true, serialize = true) + int both, + @Expose(deserialize = true, serialize = false) + int deserialize, + @Expose(deserialize = false, serialize = true) + int serialize, + @Expose(deserialize = false, serialize = false) + int none + ) {} + + record WithTypeVariable( + T a + ) {} +} diff --git a/gson/src/test/java/com/google/gson/Java17UnknownFieldStrategyTest.java b/gson/src/test/java/com/google/gson/Java17UnknownFieldStrategyTest.java new file mode 100644 index 0000000000..2262fe14d8 --- /dev/null +++ b/gson/src/test/java/com/google/gson/Java17UnknownFieldStrategyTest.java @@ -0,0 +1,84 @@ +package com.google.gson; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.Test; + +public class Java17UnknownFieldStrategyTest { + @Test + public void testIgnore() { + Gson gson = new GsonBuilder().setUnknownFieldStrategy(UnknownFieldStrategy.IGNORE).create(); + + CustomRecord deserialized = gson.fromJson("{\"a\": 1, \"b\": 2}", CustomRecord.class); + assertThat(deserialized.a).isEqualTo(1); + } + + @Test + public void testThrowException() { + Gson gson = new GsonBuilder().setUnknownFieldStrategy(UnknownFieldStrategy.THROW_EXCEPTION).create(); + + CustomRecord deserialized = gson.fromJson("{\"a\": 1}", CustomRecord.class); + assertThat(deserialized.a).isEqualTo(1); + + try { + gson.fromJson("{\"a\": 1, \"b\": 2}", CustomRecord.class); + fail(); + } + // TODO: Adjust this once a more specific exception is thrown + catch (RuntimeException expected) { + assertThat(expected).hasMessageThat().isEqualTo("Unknown field 'b' for " + CustomRecord.class + " at path $.b"); + } + } + + @Test + public void testCustomThrowing() { + Gson gson = new GsonBuilder().setUnknownFieldStrategy(new UnknownFieldStrategy() { + @Override + public void handleUnknownField(TypeToken declaringType, Object instance, String fieldName, + JsonReader jsonReader, Gson gson) throws IOException { + throw new RuntimeException("my-exception"); + } + }).create(); + + try { + gson.fromJson("{\"a\": 1, \"b\": 2}", CustomRecord.class); + fail(); + } + // TODO: Adjust this once a more specific exception is thrown + catch (RuntimeException expected) { + assertThat(expected).hasMessageThat().isEqualTo("Failed handling unknown field 'b' for " + CustomRecord.class + " at path $.b"); + assertThat(expected).hasCauseThat().hasMessageThat().isEqualTo("my-exception"); + } + } + + @Test + public void testCustom() { + Map unknownFieldsMap = new LinkedHashMap<>(); + Gson gson = new GsonBuilder().setUnknownFieldStrategy(new UnknownFieldStrategy() { + @Override + public void handleUnknownField(TypeToken declaringType, Object instance, String fieldName, + JsonReader jsonReader, Gson gson) throws IOException { + assertThat(declaringType).isEqualTo(TypeToken.get(CustomRecord.class)); + // Due to how Record instances are constructed currently cannot provide access to instance + assertThat(instance).isNull(); + assertThat(jsonReader).isNotNull(); + assertThat(gson).isNotNull(); + + Object value = gson.fromJson(jsonReader, Object.class); + unknownFieldsMap.put(fieldName, value); + } + }).create(); + + CustomRecord deserialized = gson.fromJson("{\"a\": 1, \"b\": 2}", CustomRecord.class); + assertThat(deserialized.a).isEqualTo(1); + assertThat(unknownFieldsMap).containsExactly("b", 2.0); + } + + record CustomRecord(int a) { } +} diff --git a/gson/src/test/java/com/google/gson/MissingFieldValueStrategyTest.java b/gson/src/test/java/com/google/gson/MissingFieldValueStrategyTest.java new file mode 100644 index 0000000000..edd63cd53e --- /dev/null +++ b/gson/src/test/java/com/google/gson/MissingFieldValueStrategyTest.java @@ -0,0 +1,201 @@ +package com.google.gson; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; +import com.google.gson.reflect.TypeToken; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; + +public class MissingFieldValueStrategyTest { + @Test + public void testDoNothing() { + Gson gson = new GsonBuilder().setMissingFieldValueStrategy(MissingFieldValueStrategy.DO_NOTHING).create(); + + CustomClass deserialized = gson.fromJson("{\"a\": \"custom-a\"}", CustomClass.class); + assertThat(deserialized.a).isEqualTo("custom-a"); + assertThat(deserialized.b).isEqualTo("default-b"); + } + + @Test + public void testThrowException() { + Gson gson = new GsonBuilder().setMissingFieldValueStrategy(MissingFieldValueStrategy.THROW_EXCEPTION).create(); + + CustomClass deserialized = gson.fromJson("{\"a\": \"custom-a\", \"b\": \"custom-b\"}", CustomClass.class); + assertThat(deserialized.a).isEqualTo("custom-a"); + assertThat(deserialized.b).isEqualTo("custom-b"); + + try { + gson.fromJson("{\"a\": \"custom-a\"}", CustomClass.class); + fail(); + } + // TODO: Adjust this once a more specific exception is thrown + catch (RuntimeException expected) { + assertThat(expected).hasMessageThat().isEqualTo("Failed handling missing field '" + CustomClass.class.getName() + "#b' at path $"); + assertThat(expected).hasCauseThat().hasMessageThat().isEqualTo("Missing value for field '" + CustomClass.class.getName() + "#b'"); + } + + try { + gson.fromJson("{\"b\": \"custom-b\"}", CustomClass.class); + fail(); + } + // TODO: Adjust this once a more specific exception is thrown + catch (RuntimeException expected) { + assertThat(expected).hasMessageThat().isEqualTo("Failed handling missing field '" + CustomClass.class.getName() + "#a' at path $"); + assertThat(expected).hasCauseThat().hasMessageThat().isEqualTo("Missing value for field '" + CustomClass.class.getName() + "#a'"); + } + } + + /** + * Should only report missing field once, even if {@code @SerializedName} specifies multiple names. + */ + @Test + public void testSerializedName() throws Exception { + List missingFields = new ArrayList<>(); + Gson gson = new GsonBuilder().setMissingFieldValueStrategy(new MissingFieldValueStrategy() { + @Override + public Object handleMissingField(TypeToken declaringType, Object instance, Field field, + TypeToken resolvedFieldType) { + missingFields.add(field); + return 1; + } + }).create(); + + WithSerializedName deserialized = gson.fromJson("{}", WithSerializedName.class); + assertThat(deserialized.a).isEqualTo(1); + Field field = WithSerializedName.class.getDeclaredField("a"); + assertThat(missingFields).containsExactly(field); + } + + /** + * Should handle serialization and deserialization exclusions correctly. + */ + @Test + public void testExcluded() throws Exception { + List missingFields = new ArrayList<>(); + Gson gson = new GsonBuilder().setMissingFieldValueStrategy(new MissingFieldValueStrategy() { + @Override + public Object handleMissingField(TypeToken declaringType, Object instance, Field field, + TypeToken resolvedFieldType) { + missingFields.add(field); + return null; + } + }).excludeFieldsWithoutExposeAnnotation().create(); + + gson.fromJson("{}", WithExclusions.class); + Field field1 = WithExclusions.class.getDeclaredField("both"); + Field field2 = WithExclusions.class.getDeclaredField("deserialize"); + assertThat(missingFields).containsExactly(field1, field2); + } + + @Test + public void testResolvedFieldType() { + List> fieldTypes = new ArrayList<>(); + Gson gson = new GsonBuilder().setMissingFieldValueStrategy(new MissingFieldValueStrategy() { + @Override + public Object handleMissingField(TypeToken declaringType, Object instance, Field field, + TypeToken resolvedFieldType) { + fieldTypes.add(resolvedFieldType); + return null; + } + }).create(); + + gson.fromJson("{}", new TypeToken>() {}); + assertThat(fieldTypes).containsExactly(TypeToken.get(String.class)); + } + + @Test + public void testCustom() { + Gson gson = new GsonBuilder().setMissingFieldValueStrategy(new MissingFieldValueStrategy() { + @Override + public Object handleMissingField(TypeToken declaringType, Object instance, Field field, TypeToken resolvedFieldType) { + assertThat(declaringType).isEqualTo(TypeToken.get(CustomClass.class)); + assertThat(instance).isInstanceOf(CustomClass.class); + assertThat(field.getDeclaringClass()).isEqualTo(CustomClass.class); + + try { + Object existingValue = field.get(instance); + return "field-" + field.getName() + "-" + existingValue; + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + }).create(); + + CustomClass deserialized = gson.fromJson("{}", CustomClass.class); + assertThat(deserialized.a).isEqualTo("field-a-default-a"); + assertThat(deserialized.b).isEqualTo("field-b-default-b"); + + + gson = new GsonBuilder().setMissingFieldValueStrategy(new MissingFieldValueStrategy() { + @Override + public Object handleMissingField(TypeToken declaringType, Object instance, Field field, TypeToken resolvedFieldType) { + if (field.getName().equals("a")) { + // Preserve existing value + return null; + } + return "field-" + field.getName(); + } + }).create(); + + deserialized = gson.fromJson("{}", CustomClass.class); + assertThat(deserialized.a).isEqualTo("default-a"); + assertThat(deserialized.b).isEqualTo("field-b"); + } + + @Test + public void testBadNewFieldValue() { + Gson gson = new GsonBuilder().setMissingFieldValueStrategy(new MissingFieldValueStrategy() { + @Override + public Object handleMissingField(TypeToken declaringType, Object instance, Field field, TypeToken resolvedFieldType) { + return 1; + } + + @Override + public String toString() { + return "my-strategy"; + } + }).create(); + + try { + gson.fromJson("{\"a\": \"custom-a\"}", CustomClass.class); + fail(); + } + // TODO: Adjust this once a more specific exception is thrown + catch (RuntimeException expected) { + assertThat(expected).hasMessageThat().isEqualTo("Failed storing java.lang.Integer provided by my-strategy" + + " into field '" + CustomClass.class.getName() + "#b' at path $"); + assertThat(expected).hasCauseThat().isNotNull(); + } + } + + private static class CustomClass { + String a = "default-a"; + String b = "default-b"; + } + + static class WithSerializedName { + @SerializedName(value = "b", alternate = {"c", "d", "e"}) + int a; + } + + static class WithExclusions { + @Expose(deserialize = true, serialize = true) + int both; + @Expose(deserialize = true, serialize = false) + int deserialize; + @Expose(deserialize = false, serialize = true) + int serialize; + @Expose(deserialize = false, serialize = false) + int none; + } + + private static class WithTypeVariable { + @SuppressWarnings("unused") + T a; + } +} diff --git a/gson/src/test/java/com/google/gson/UnknownFieldStrategyTest.java b/gson/src/test/java/com/google/gson/UnknownFieldStrategyTest.java new file mode 100644 index 0000000000..5be0fb993c --- /dev/null +++ b/gson/src/test/java/com/google/gson/UnknownFieldStrategyTest.java @@ -0,0 +1,163 @@ +package com.google.gson; + + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + +import com.google.gson.annotations.Expose; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.Test; + +public class UnknownFieldStrategyTest { + @Test + public void testIgnore() { + Gson gson = new GsonBuilder().setUnknownFieldStrategy(UnknownFieldStrategy.IGNORE).create(); + + CustomClass deserialized = gson.fromJson("{\"a\": 1, \"b\": 2}", CustomClass.class); + assertThat(deserialized.a).isEqualTo(1); + assertThat(deserialized.unknownFields).isEmpty(); + } + + @Test + public void testThrowException() { + Gson gson = new GsonBuilder().setUnknownFieldStrategy(UnknownFieldStrategy.THROW_EXCEPTION).create(); + + CustomClass deserialized = gson.fromJson("{\"a\": 1}", CustomClass.class); + assertThat(deserialized.a).isEqualTo(1); + assertThat(deserialized.unknownFields).isEmpty(); + + try { + gson.fromJson("{\"a\": 1, \"b\": 2}", CustomClass.class); + fail(); + } + // TODO: Adjust this once a more specific exception is thrown + catch (RuntimeException expected) { + assertThat(expected).hasMessageThat().isEqualTo("Unknown field 'b' for " + CustomClass.class + " at path $.b"); + } + } + + @Test + public void testCustomThrowing() { + Gson gson = new GsonBuilder().setUnknownFieldStrategy(new UnknownFieldStrategy() { + @Override + public void handleUnknownField(TypeToken declaringType, Object instance, String fieldName, + JsonReader jsonReader, Gson gson) throws IOException { + throw new RuntimeException("my-exception"); + } + }).create(); + + try { + gson.fromJson("{\"a\": 1, \"b\": 2}", CustomClass.class); + fail(); + } + // TODO: Adjust this once a more specific exception is thrown + catch (RuntimeException expected) { + assertThat(expected).hasMessageThat().isEqualTo("Failed handling unknown field 'b' for " + CustomClass.class + " at path $.b"); + assertThat(expected).hasCauseThat().hasMessageThat().isEqualTo("my-exception"); + } + } + + @Test + public void testCustomThrowingAfterRead() { + Gson gson = new GsonBuilder().setUnknownFieldStrategy(new UnknownFieldStrategy() { + @Override + public void handleUnknownField(TypeToken declaringType, Object instance, String fieldName, + JsonReader jsonReader, Gson gson) throws IOException { + // Consume the value before throwing exception + jsonReader.skipValue(); + throw new RuntimeException("my-exception"); + } + }).create(); + + try { + gson.fromJson("{\"a\": 1, \"b\": 2}", CustomClass.class); + fail(); + } + // TODO: Adjust this once a more specific exception is thrown + catch (RuntimeException expected) { + assertThat(expected).hasMessageThat().isEqualTo("Failed handling unknown field 'b' for " + CustomClass.class + " at path $.b"); + assertThat(expected).hasCauseThat().hasMessageThat().isEqualTo("my-exception"); + } + } + + /** + * Provides a simple example for how to store unknown values in an extra field on the class. + * + *

Important: Do not use this code in production; it is not properly handling the + * case where no such field exists, or when the deserialized class is a Record. + */ + @Test + public void testCustom() { + Gson gson = new GsonBuilder().setUnknownFieldStrategy(new UnknownFieldStrategy() { + @Override + public void handleUnknownField(TypeToken declaringType, Object instance, String fieldName, + JsonReader jsonReader, Gson gson) throws IOException { + assertThat(declaringType).isEqualTo(TypeToken.get(CustomClass.class)); + assertThat(instance).isInstanceOf(CustomClass.class); + assertThat(jsonReader).isNotNull(); + assertThat(gson).isNotNull(); + + try { + Field unknownFieldsField = declaringType.getRawType().getDeclaredField("unknownFields"); + + @SuppressWarnings("unchecked") + Map unknownFieldsMap = (Map) unknownFieldsField.get(instance); + if (unknownFieldsMap.containsKey(fieldName)) { + throw new IllegalArgumentException("Already contains value for " + fieldName); + } + + Object value = gson.fromJson(jsonReader, Object.class); + unknownFieldsMap.put(fieldName, value); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + }).create(); + + CustomClass deserialized = gson.fromJson("{\"a\": 1, \"b\": 2}", CustomClass.class); + assertThat(deserialized.a).isEqualTo(1); + assertThat(deserialized.unknownFields).containsExactly("b", 2.0); + } + + @Test + public void testExcludedConsideredUnknown() { + List unknownFields = new ArrayList<>(); + Gson gson = new GsonBuilder().setUnknownFieldStrategy(new UnknownFieldStrategy() { + @Override + public void handleUnknownField(TypeToken declaringType, Object instance, String fieldName, + JsonReader jsonReader, Gson gson) throws IOException { + jsonReader.skipValue(); + unknownFields.add(fieldName); + } + }).excludeFieldsWithoutExposeAnnotation().create(); + + WithExcluded obj = new WithExcluded(); + obj.a = 1; + String json = gson.toJson(obj); + // Serialization should include field + assertThat(json).isEqualTo("{\"a\":1}"); + + WithExcluded deserialized = gson.fromJson(json, WithExcluded.class); + assertThat(deserialized.a).isEqualTo(0); + // Excluded field should be considered unknown + assertThat(unknownFields).containsExactly("a"); + } + + private static class CustomClass { + int a; + + transient Map unknownFields = new LinkedHashMap<>(); + } + + private static class WithExcluded { + @Expose(deserialize = false, serialize = true) + int a; + } +} diff --git a/gson/src/test/java/com/google/gson/functional/ArrayTest.java b/gson/src/test/java/com/google/gson/functional/ArrayTest.java index b88eda578e..570eef4b29 100644 --- a/gson/src/test/java/com/google/gson/functional/ArrayTest.java +++ b/gson/src/test/java/com/google/gson/functional/ArrayTest.java @@ -28,7 +28,6 @@ import java.lang.reflect.Type; import java.math.BigDecimal; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collection; import org.junit.Before; import org.junit.Test; diff --git a/gson/src/test/java/com/google/gson/functional/PrettyPrintingTest.java b/gson/src/test/java/com/google/gson/functional/PrettyPrintingTest.java index 5496065119..de3ca47c51 100644 --- a/gson/src/test/java/com/google/gson/functional/PrettyPrintingTest.java +++ b/gson/src/test/java/com/google/gson/functional/PrettyPrintingTest.java @@ -26,7 +26,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashMap; -import java.util.LinkedList; import java.util.List; import java.util.Map; import org.junit.Before; @@ -92,7 +91,7 @@ public void testPrettyPrintListOfPrimitiveArrays() { assertThat(json).isEqualTo("[\n [\n 1,\n 2\n ],\n [\n 3,\n 4\n ],\n [\n 5,\n 6\n ]," + "\n [\n 7,\n 8\n ],\n [\n 9,\n 0\n ],\n [\n 10\n ]\n]"); } - + @Test public void testMap() { Map map = new LinkedHashMap<>(); diff --git a/gson/src/test/java/com/google/gson/functional/UncategorizedTest.java b/gson/src/test/java/com/google/gson/functional/UncategorizedTest.java index 358fc5a9a1..8ec5b4af91 100644 --- a/gson/src/test/java/com/google/gson/functional/UncategorizedTest.java +++ b/gson/src/test/java/com/google/gson/functional/UncategorizedTest.java @@ -28,7 +28,6 @@ import com.google.gson.common.TestTypes.ClassOverridingEquals; import com.google.gson.reflect.TypeToken; import java.lang.reflect.Type; -import java.util.Arrays; import java.util.List; import org.junit.Before; import org.junit.Test;