diff --git a/genson/src/main/java/com/owlike/genson/Genson.java b/genson/src/main/java/com/owlike/genson/Genson.java index 23cb7659..92122704 100644 --- a/genson/src/main/java/com/owlike/genson/Genson.java +++ b/genson/src/main/java/com/owlike/genson/Genson.java @@ -9,6 +9,7 @@ import com.owlike.genson.reflect.BeanDescriptor; import com.owlike.genson.reflect.BeanDescriptorProvider; import com.owlike.genson.reflect.RuntimePropertyFilter; +import com.owlike.genson.reflect.UnknownPropertyHandler; import com.owlike.genson.stream.*; /** @@ -72,6 +73,7 @@ public final class Genson { private final EncodingAwareReaderFactory readerFactory = new EncodingAwareReaderFactory(); private final Map, Object> defaultValues; private final RuntimePropertyFilter runtimePropertyFilter; + private final UnknownPropertyHandler unknownPropertyHandler; /** * The default constructor will use the default configuration provided by the {@link GensonBuilder}. @@ -81,7 +83,8 @@ public Genson() { this(_default.converterFactory, _default.beanDescriptorFactory, _default.skipNull, _default.htmlSafe, _default.aliasClassMap, _default.withClassMetadata, _default.strictDoubleParse, _default.indent, - _default.withMetadata, _default.failOnMissingProperty, _default.defaultValues, _default.runtimePropertyFilter); + _default.withMetadata, _default.failOnMissingProperty, _default.defaultValues, + _default.runtimePropertyFilter, _default.unknownPropertyHandler); } /** @@ -108,11 +111,13 @@ public Genson() { * @param failOnMissingProperty throw a JsonBindingException when a key in the json stream does not match a property in the Java Class. * @param defaultValues contains a mapping from the raw class to the default value that should be used when the property is missing. * @param runtimePropertyFilter is used to define what bean properties should be excluded from ser/de at runtime. + * @param unknownPropertyHandler is used to handle unknown properties during ser/de. */ public Genson(Factory> converterFactory, BeanDescriptorProvider beanDescProvider, boolean skipNull, boolean htmlSafe, Map> classAliases, boolean withClassMetadata, boolean strictDoubleParse, boolean indent, boolean withMetadata, boolean failOnMissingProperty, - Map, Object> defaultValues, RuntimePropertyFilter runtimePropertyFilter) { + Map, Object> defaultValues, RuntimePropertyFilter runtimePropertyFilter, + UnknownPropertyHandler unknownPropertyHandler) { this.converterFactory = converterFactory; this.beanDescriptorFactory = beanDescProvider; this.skipNull = skipNull; @@ -129,6 +134,7 @@ public Genson(Factory> converterFactory, BeanDescriptorProvider bea this.indent = indent; this.withMetadata = withClassMetadata || withMetadata; this.failOnMissingProperty = failOnMissingProperty; + this.unknownPropertyHandler = unknownPropertyHandler; } /** @@ -609,6 +615,10 @@ public RuntimePropertyFilter runtimePropertyFilter() { return runtimePropertyFilter; } + public UnknownPropertyHandler unknownPropertyHandler() { + return unknownPropertyHandler; + } + /** * @deprecated use GensonBuilder */ diff --git a/genson/src/main/java/com/owlike/genson/GensonBuilder.java b/genson/src/main/java/com/owlike/genson/GensonBuilder.java index 147712d0..5932f9c7 100644 --- a/genson/src/main/java/com/owlike/genson/GensonBuilder.java +++ b/genson/src/main/java/com/owlike/genson/GensonBuilder.java @@ -76,6 +76,7 @@ public class GensonBuilder { private final Map, Object> defaultValues = new HashMap, Object>(); private boolean failOnNullPrimitive = false; private RuntimePropertyFilter runtimePropertyFilter = RuntimePropertyFilter.noFilter; + private UnknownPropertyHandler unknownPropertyHandler; public GensonBuilder() { defaultValues.put(int.class, 0); @@ -739,6 +740,11 @@ public GensonBuilder useRuntimePropertyFilter(RuntimePropertyFilter filter) { return this; } + public GensonBuilder useUnknownPropertyHandler(UnknownPropertyHandler handler) { + this.unknownPropertyHandler = handler; + return this; + } + /** * Creates an instance of Genson. You may use this method as many times you want. It wont * change the state of the builder, in sense that the returned instance will have always the @@ -824,7 +830,8 @@ protected Genson create(Factory> converterFactory, Map> classAliases) { return new Genson(converterFactory, getBeanDescriptorProvider(), isSkipNull(), isHtmlSafe(), classAliases, withClassMetadata, - strictDoubleParse, indent, metadata, failOnMissingProperty, defaultValues, runtimePropertyFilter); + strictDoubleParse, indent, metadata, failOnMissingProperty, + defaultValues, runtimePropertyFilter, unknownPropertyHandler); } /** diff --git a/genson/src/main/java/com/owlike/genson/reflect/BeanDescriptor.java b/genson/src/main/java/com/owlike/genson/reflect/BeanDescriptor.java index 753754b9..0e17d46a 100644 --- a/genson/src/main/java/com/owlike/genson/reflect/BeanDescriptor.java +++ b/genson/src/main/java/com/owlike/genson/reflect/BeanDescriptor.java @@ -1,12 +1,7 @@ package com.owlike.genson.reflect; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import java.util.*; +import java.util.function.Consumer; import com.owlike.genson.*; import com.owlike.genson.reflect.BeanCreator.BeanCreatorProperty; @@ -46,6 +41,7 @@ public class BeanDescriptor implements Converter { private final boolean _noArgCtr; private static final Object MISSING = new Object(); + // Used as a cache so we just copy it instead of recreating and assigning the default values private Object[] globalCreatorArgs; @@ -86,11 +82,16 @@ public boolean isWritable() { } public void serialize(T obj, ObjectWriter writer, Context ctx) { + RuntimePropertyFilter runtimePropertyFilter = ctx.genson.runtimePropertyFilter(); + UnknownPropertyHandler unknownPropertyHandler = ctx.genson.unknownPropertyHandler(); + writer.beginObject(); - RuntimePropertyFilter runtimePropertyFilter = ctx.genson.runtimePropertyFilter(); for (PropertyAccessor accessor : accessibleProperties) { if (runtimePropertyFilter.shouldInclude(accessor, ctx)) accessor.serialize(obj, writer, ctx); } + if (unknownPropertyHandler != null) { + unknownPropertyHandler.writeUnknownProperties(obj, writer, ctx); + } writer.endObject(); } @@ -110,8 +111,10 @@ public T deserialize(ObjectReader reader, Context ctx) { } public void deserialize(T into, ObjectReader reader, Context ctx) { + RuntimePropertyFilter runtimePropertyFilter = ctx.genson.runtimePropertyFilter(); + UnknownPropertyHandler unknownPropertyHandler = ctx.genson.unknownPropertyHandler(); + reader.beginObject(); - RuntimePropertyFilter runtimePropertyFilter = ctx.genson.runtimePropertyFilter(); for (; reader.hasNext(); ) { reader.next(); String propName = reader.name(); @@ -122,6 +125,8 @@ public void deserialize(T into, ObjectReader reader, Context ctx) { } else { reader.skipValue(); } + } else if (unknownPropertyHandler != null) { + unknownPropertyHandler.onUnknownProperty(into, propName, reader, ctx); } else if (failOnMissingProperty) throw missingPropertyException(propName); else reader.skipValue(); } @@ -130,9 +135,12 @@ public void deserialize(T into, ObjectReader reader, Context ctx) { protected T _deserWithCtrArgs(ObjectReader reader, Context ctx) { - List names = new ArrayList(); - List values = new ArrayList(); + List names = new ArrayList<>(); + List values = new ArrayList<>(); + List> unknownProperties = new ArrayList<>(); + RuntimePropertyFilter runtimePropertyFilter = ctx.genson.runtimePropertyFilter(); + UnknownPropertyHandler unknownPropertyHandler = ctx.genson.unknownPropertyHandler(); reader.beginObject(); for (; reader.hasNext(); ) { @@ -148,6 +156,9 @@ protected T _deserWithCtrArgs(ObjectReader reader, Context ctx) { } else { reader.skipValue(); } + } else if (unknownPropertyHandler != null) { + Consumer callback = unknownPropertyHandler.onUnknownProperty(null, propName, reader, ctx); + unknownProperties.add(callback); } else if (failOnMissingProperty) throw missingPropertyException(propName); else reader.skipValue(); } @@ -175,7 +186,12 @@ protected T _deserWithCtrArgs(ObjectReader reader, Context ctx) { T bean = ofClass.cast(creator.create(creatorArgs)); for (int i = 0; i < size; i++) { PropertyMutator property = mutableProperties.get(newNames[i]); - if (property != null) property.mutate(bean, newValues[i]); + if (property != null) { + property.mutate(bean, newValues[i]); + } + } + if (!unknownProperties.isEmpty()) { + unknownProperties.forEach(callback -> callback.accept(bean)); } reader.endObject(); return bean; diff --git a/genson/src/main/java/com/owlike/genson/reflect/Evolvable.java b/genson/src/main/java/com/owlike/genson/reflect/Evolvable.java new file mode 100644 index 00000000..43fd87f3 --- /dev/null +++ b/genson/src/main/java/com/owlike/genson/reflect/Evolvable.java @@ -0,0 +1,30 @@ +package com.owlike.genson.reflect; + +import java.util.Map; + +/** + * An interface that can be implemented by data classes + * in order to support schema evolution. + *

+ * This interface is used in combination with {@link EvolvableHandler} + * in order to prevent data loss during serialization across different + * versions of data classes. + * + * @author Aleksandar Seovic 2018.05.20 + */ +interface Evolvable { + /** + * Add unknown property to this instance. + * + * @param propName property name + * @param propValue property value + */ + void addUnknownProperty(String propName, Object propValue); + + /** + * Return a map of unknown properties. + * + * @return a map of unknown properties + */ + Map unknownProperties(); +} diff --git a/genson/src/main/java/com/owlike/genson/reflect/EvolvableHandler.java b/genson/src/main/java/com/owlike/genson/reflect/EvolvableHandler.java new file mode 100644 index 00000000..9f028f26 --- /dev/null +++ b/genson/src/main/java/com/owlike/genson/reflect/EvolvableHandler.java @@ -0,0 +1,64 @@ +package com.owlike.genson.reflect; + +import com.owlike.genson.Context; +import com.owlike.genson.GenericType; +import com.owlike.genson.stream.ObjectReader; +import com.owlike.genson.stream.ObjectWriter; + +import java.util.Map; +import java.util.function.Consumer; + +/** + * An implementation of an {@link UnknownPropertyHandler} that supports + * evolution of data classes via {@link Evolvable} interface. + *

+ * If the target object we are deserializing into is {@link Evolvable}, + * this handler will add any unknown properties encountered during + * deserialization into {@link Evolvable#unknownProperties()} map, + * and will write them out along with all known properties during + * subsequent serialization. + *

+ * This prevents data loss when serializing and deserializing the same + * JSON payload using different versions of Java data classes. + * + * @author Aleksandar Seovic 2018.05.20 + */ +public class EvolvableHandler implements UnknownPropertyHandler { + private static final GenericType UNKNOWN = new GenericType() {}; + + @Override + public Consumer onUnknownProperty(T target, String propName, ObjectReader reader, Context ctx) { + // TODO: change this to read property as an opaque value, using ObjectReader directly + Object propValue = ctx.genson.deserialize(UNKNOWN, reader, ctx); + + if (target == null) { + // this is a bit ugly... + // the issue is that we may not have a target object while parsing JSON when using creators, + // so we need to store the parsed value somewhere and apply it later + return objTarget -> { + if (objTarget instanceof Evolvable) { + ((Evolvable) objTarget).addUnknownProperty(propName, propValue); + } + }; + } + + if (target instanceof Evolvable) { + ((Evolvable) target).addUnknownProperty(propName, propValue); + } + return null; + } + + @Override + public void writeUnknownProperties(T source, ObjectWriter writer, Context ctx) { + if (source instanceof Evolvable) { + Map props = ((Evolvable) source).unknownProperties(); + if (props != null) { + for (String propName : props.keySet()) { + writer.writeName(propName); + // TODO: change this to write property as an opaque value, using ObjectWriter directly + ctx.genson.serialize(props.get(propName), writer, ctx); + } + } + } + } +} \ No newline at end of file diff --git a/genson/src/main/java/com/owlike/genson/reflect/EvolvableObject.java b/genson/src/main/java/com/owlike/genson/reflect/EvolvableObject.java new file mode 100644 index 00000000..4796b127 --- /dev/null +++ b/genson/src/main/java/com/owlike/genson/reflect/EvolvableObject.java @@ -0,0 +1,29 @@ +package com.owlike.genson.reflect; + +import com.owlike.genson.annotation.JsonIgnore; + +import java.util.HashMap; +import java.util.Map; + +/** + * Convenience base class for {@link Evolvable} data classes. + * + * @author Aleksandar Seovic 2018.05.20 + */ +public abstract class EvolvableObject implements Evolvable { + @JsonIgnore + private Map unknownProperties; + + @Override + public void addUnknownProperty(String propName, Object propValue) { + if (unknownProperties == null) { + unknownProperties = new HashMap<>(); + } + unknownProperties.put(propName, propValue); + } + + @Override + public Map unknownProperties() { + return unknownProperties; + } +} diff --git a/genson/src/main/java/com/owlike/genson/reflect/UnknownPropertyHandler.java b/genson/src/main/java/com/owlike/genson/reflect/UnknownPropertyHandler.java new file mode 100644 index 00000000..c3bf8e0b --- /dev/null +++ b/genson/src/main/java/com/owlike/genson/reflect/UnknownPropertyHandler.java @@ -0,0 +1,56 @@ +package com.owlike.genson.reflect; + +import com.owlike.genson.Context; +import com.owlike.genson.stream.ObjectReader; +import com.owlike.genson.stream.ObjectWriter; + +import java.util.function.Consumer; + +/** + * An interface that defines callbacks that will be called when an + * unknown properties are encountered during deserialization, as well + * as to check if there are any unknown properties that should be + * written out during serialization. + *

+ * The main purpose of this interface is to support schema evolution + * of objects that use JSON as a long term storage format, without + * loss of unknown properties across clients and severs using different + * versions of Java classes. + * + * @author Aleksandar Seovic 2018.05.09 + */ +public interface UnknownPropertyHandler { + /** + * Called whenever a property is encountered in a JSON document + * that doesn't have a corresponding {@link PropertyMutator}. + *

+ * Typically, the implementation of this interface concerned + * with schema evolution will handle this event by storing + * property value somewhere so it can be written later by the + * {@link #writeUnknownProperties} method. + * + * @param target the object we are deserializing JSON into, if known + * @param propName the name of the unknown property + * @param reader the ObjectReader to read property value from + * @param ctx deserialization context + * + * @return the optional Consumer that will be called once the target object is known + */ + Consumer onUnknownProperty(T target, String propName, ObjectReader reader, Context ctx); + + /** + * Write unknown properties encountered during deserialization. + *

+ * This method can be optionally implemented by {@code UnknownPropertyHandler}s + * that want to write unknown properties during serialization. The default + * implementation is a no-op. + * + * @param source the object we are serializing into JSON + * @param writer the ObjectReader to read property value from + * @param ctx serialization context + * + * @return a map of unknown properties + */ + default void writeUnknownProperties(T source, ObjectWriter writer, Context ctx) { + } +} diff --git a/genson/src/test/java/com/owlike/genson/reflect/UnknownPropertyHandlerTest.java b/genson/src/test/java/com/owlike/genson/reflect/UnknownPropertyHandlerTest.java new file mode 100644 index 00000000..8b28d78b --- /dev/null +++ b/genson/src/test/java/com/owlike/genson/reflect/UnknownPropertyHandlerTest.java @@ -0,0 +1,171 @@ +package com.owlike.genson.reflect; + +import com.owlike.genson.Genson; +import com.owlike.genson.GensonBuilder; + +import com.owlike.genson.annotation.JsonCreator; +import org.junit.Test; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +import static org.junit.Assert.assertEquals; + +/** + * @author Aleksandar Seovic 2018.05.09 + */ +public class UnknownPropertyHandlerTest { + private static final Genson GENSON = new GensonBuilder() + .useClassMetadata(true) + .useConstructorWithArguments(true) + .useUnknownPropertyHandler(new EvolvableHandler()) + .useIndentation(true) + .create(); + + @Test + public void testDeserialization() { + String json = "{\n" + + " \"@class\":\"com.owlike.genson.reflect.UnknownPropertyHandlerTest$EvolvablePerson\",\n" + + " \"age\":50,\n" + + " \"name\":\"Homer\",\n" + + " \"spouse\":{\n" + + " \"@class\":\"com.owlike.genson.reflect.UnknownPropertyHandlerTest$EvolvablePerson\",\n" + + " \"age\":40,\n" + + " \"name\":\"Marge\"\n" + + " },\n" + + " \"children\":[\n" + + " \"Bart\",\n" + + " \"Lisa\",\n" + + " \"Maggie\"\n" + + " ],\n" + + " \"salary\":10000.0,\n" + + " \"donutLover\":true\n" + + "}"; + + EvolvablePerson homer = GENSON.deserialize(json, EvolvablePerson.class); + assertEquals("Homer", homer.name); + assertEquals(50, homer.age); + assertEquals(Arrays.asList("Bart", "Lisa", "Maggie"), homer.unknownProperties.get("children")); + assertEquals(10_000d, homer.unknownProperties.get("salary")); + assertEquals(true, homer.unknownProperties.get("donutLover")); + } + + @Test + public void testCtorDeserialization() { + String json = "{\n" + + " \"@class\":\"com.owlike.genson.reflect.UnknownPropertyHandlerTest$CtorEvolvablePerson\",\n" + + " \"age\":50,\n" + + " \"name\":\"Homer\",\n" + + " \"spouse\":{\n" + + " \"@class\":\"com.owlike.genson.reflect.UnknownPropertyHandlerTest$CtorEvolvablePerson\",\n" + + " \"age\":40,\n" + + " \"name\":\"Marge\"\n" + + " },\n" + + " \"children\":[\n" + + " \"Bart\",\n" + + " \"Lisa\",\n" + + " \"Maggie\"\n" + + " ],\n" + + " \"salary\":10000.0,\n" + + " \"donutLover\":true\n" + + "}"; + + EvolvablePerson homer = GENSON.deserialize(json, CtorEvolvablePerson.class); + assertEquals("Homer", homer.name); + assertEquals(50, homer.age); + assertEquals(Arrays.asList("Bart", "Lisa", "Maggie"), homer.unknownProperties.get("children")); + assertEquals(10_000d, homer.unknownProperties.get("salary")); + assertEquals(true, homer.unknownProperties.get("donutLover")); + } + + @Test + public void testRoundTrip() { + EvolvablePerson homer = new EvolvablePerson("Homer", 50); + homer.unknownProperties().put("spouse", new EvolvablePerson("Marge", 40)); + homer.unknownProperties().put("children", Arrays.asList("Bart", "Lisa", "Maggie")); + homer.unknownProperties().put("salary", 10_000d); + homer.unknownProperties().put("donutLover", true); + + String json = GENSON.serialize(homer); + EvolvablePerson homer2 = GENSON.deserialize(json, EvolvablePerson.class); + + assertEquals(homer, homer2); + } + + static class EvolvablePerson implements Evolvable { + private Map unknownProperties = new LinkedHashMap<>(); + private String name; + private int age; + + public EvolvablePerson() { + } + + public EvolvablePerson(String name, int age) { + this.name = name; + this.age = age; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int getAge() { + return age; + } + + public void setAge(int age) { + this.age = age; + } + + @Override + public void addUnknownProperty(String propName, Object propValue) { + unknownProperties.put(propName, propValue); + } + + @Override + public Map unknownProperties() { + return unknownProperties; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + EvolvablePerson that = (EvolvablePerson) o; + return age == that.age && + Objects.equals(unknownProperties, that.unknownProperties) && + Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hash(unknownProperties, name, age); + } + + @Override + public String toString() { + return "EvolvablePerson{" + + "name='" + name + '\'' + + ", age=" + age + + ", unknownProperties=" + unknownProperties + + '}'; + } + } + + static class CtorEvolvablePerson extends EvolvablePerson { + private CtorEvolvablePerson() { + throw new RuntimeException("shouldn't be called"); + } + + @JsonCreator + public CtorEvolvablePerson(String name, int age) { + super(name, age); + } + } +}