diff --git a/bosk-core/src/main/java/io/vena/bosk/SerializationPlugin.java b/bosk-core/src/main/java/io/vena/bosk/SerializationPlugin.java index d0f91f9f..e24e01b6 100644 --- a/bosk-core/src/main/java/io/vena/bosk/SerializationPlugin.java +++ b/bosk-core/src/main/java/io/vena/bosk/SerializationPlugin.java @@ -2,6 +2,7 @@ import io.vena.bosk.annotations.DeserializationPath; import io.vena.bosk.annotations.Enclosing; +import io.vena.bosk.annotations.Polyfill; import io.vena.bosk.annotations.Self; import io.vena.bosk.exceptions.DeserializationException; import io.vena.bosk.exceptions.InvalidTypeException; @@ -27,6 +28,8 @@ import static io.vena.bosk.ReferenceUtils.parameterType; import static io.vena.bosk.ReferenceUtils.rawClass; import static io.vena.bosk.ReferenceUtils.theOnlyConstructorFor; +import static java.lang.reflect.Modifier.isPrivate; +import static java.lang.reflect.Modifier.isStatic; import static java.util.Objects.requireNonNull; /** @@ -192,6 +195,7 @@ public final List parameterValueList(Class nodeClass, Map parameterValueList(Class nodeClass, Map nodeClassArg) { Set selfParameters = new HashSet<>(); Set enclosingParameters = new HashSet<>(); Map deserializationPathParameters = new HashMap<>(); + Map polyfills = new HashMap<>(); for (Parameter parameter: theOnlyConstructorFor(nodeClassArg).getParameters()) { scanForInfo(parameter, parameter.getName(), - selfParameters, enclosingParameters, deserializationPathParameters); + selfParameters, enclosingParameters, deserializationPathParameters, polyfills); } // Bosk generally ignores an object's fields, looking only at its // constructor arguments and its getters. However, we make an exception // for convenience: Bosk annotations that go on constructor parameters // can also go on fields with the same name. This accommodates systems - // like Lombok or Java 14's Records that derive constructors from fields. + // like Lombok that derive constructors from fields. for (Class c = nodeClassArg; c != Object.class; c = c.getSuperclass()) { - for (Field field: nodeClassArg.getDeclaredFields()) { + for (Field field: c.getDeclaredFields()) { scanForInfo(field, field.getName(), - selfParameters, enclosingParameters, deserializationPathParameters); + selfParameters, enclosingParameters, deserializationPathParameters, polyfills); } } - return new ParameterInfo(selfParameters, enclosingParameters, deserializationPathParameters); + return new ParameterInfo(selfParameters, enclosingParameters, deserializationPathParameters, polyfills); } - private static void scanForInfo(AnnotatedElement thing, String name, Set selfParameters, Set enclosingParameters, Map deserializationPathParameters) { + private static void scanForInfo(AnnotatedElement thing, String name, Set selfParameters, Set enclosingParameters, Map deserializationPathParameters, Map polyfills) { if (thing.isAnnotationPresent(Self.class)) { selfParameters.add(name); } else if (thing.isAnnotationPresent(Enclosing.class)) { enclosingParameters.add(name); } else if (thing.isAnnotationPresent(DeserializationPath.class)) { deserializationPathParameters.put(name, thing.getAnnotation(DeserializationPath.class)); + } else if (thing.isAnnotationPresent(Polyfill.class)) { + if (thing instanceof Field f && isStatic(f.getModifiers()) && !isPrivate(f.getModifiers())) { + f.setAccessible(true); + for (Polyfill polyfill : thing.getAnnotationsByType(Polyfill.class)) { + Object value; + try { + value = f.get(null); + } catch (IllegalAccessException e) { + throw new AssertionError("Field should not be inaccessible: " + f, e); + } + if (value == null) { + throw new NullPointerException("Polyfill value cannot be null: " + f); + } + for (String fieldName: polyfill.value()) { + Object previous = polyfills.put(fieldName, value); + if (previous != null) { + throw new IllegalStateException("Multiple polyfills for the same field \"" + fieldName + "\": " + f); + } + } + // TODO: Polyfills can't be used for implicit refs, Optionals, Phantoms + // Also can't be used for Entity.id + } + } else { + throw new IllegalStateException("@Polyfill annotation is only valid on non-private static fields; found on " + thing); + } } } private record ParameterInfo( - Set annotatedParameters_Self, Set annotatedParameters_Enclosing, - Map annotatedParameters_DeserializationPath - ) { - } + Set annotatedParameters_Self, + Set annotatedParameters_Enclosing, + Map annotatedParameters_DeserializationPath, + Map polyfills + ) { } private static final Map, ParameterInfo> PARAMETER_INFO_MAP = new ConcurrentHashMap<>(); diff --git a/bosk-core/src/main/java/io/vena/bosk/annotations/Polyfill.java b/bosk-core/src/main/java/io/vena/bosk/annotations/Polyfill.java new file mode 100644 index 00000000..203b314a --- /dev/null +++ b/bosk-core/src/main/java/io/vena/bosk/annotations/Polyfill.java @@ -0,0 +1,32 @@ +package io.vena.bosk.annotations; + +import io.vena.bosk.StateTreeNode; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import java.util.Optional; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * Marks a static final field in a {@link StateTreeNode} to indicate that it can be + * used as a default value for a given field, for backward compatibility with external + * systems that don't yet support the field. + * + *

+ * This is not meant to be used just to supply default values for optional fields; + * that should be achieved by declaring the field {@link Optional} + * and calling {@link Optional#orElse} when the field is used. + * Rather, this is meant to be used temporarily with newly added fields + * to support systems that are not yet aware of those fields. + * + * @author Patrick Doyle + */ +@Retention(RUNTIME) +@Target({ FIELD }) +public @interface Polyfill { + /** + * The names of the fields for which we're supplying a default value. + */ + String[] value(); +} diff --git a/bosk-core/src/test/java/io/vena/bosk/SerializationPluginTest.java b/bosk-core/src/test/java/io/vena/bosk/SerializationPluginTest.java new file mode 100644 index 00000000..e4a79f4a --- /dev/null +++ b/bosk-core/src/test/java/io/vena/bosk/SerializationPluginTest.java @@ -0,0 +1,35 @@ +package io.vena.bosk; + +import io.vena.bosk.annotations.Self; +import java.lang.reflect.Constructor; +import java.lang.reflect.Parameter; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.Test; + +import static io.vena.bosk.ReferenceUtils.theOnlyConstructorFor; +import static org.junit.jupiter.api.Assertions.assertTrue; + +// TODO: This should aim for full coverage of SerializationPlugin +class SerializationPluginTest { + + @Test + void inheritedFieldAttribute_works() { + Constructor childConstructor = theOnlyConstructorFor(Child.class); + Parameter selfParameter = childConstructor.getParameters()[0]; + assertTrue(SerializationPlugin.isSelfReference(Child.class, selfParameter)); + } + + @RequiredArgsConstructor + @Getter + static class Parent { + @Self final Parent self; + } + + @Getter + static class Child extends Parent { + public Child(Parent self) { + super(self); + } + } +} \ No newline at end of file diff --git a/bosk-jackson/src/test/java/io/vena/bosk/jackson/JacksonPluginTest.java b/bosk-jackson/src/test/java/io/vena/bosk/jackson/JacksonPluginTest.java index c90c7b0a..034bc53d 100644 --- a/bosk-jackson/src/test/java/io/vena/bosk/jackson/JacksonPluginTest.java +++ b/bosk-jackson/src/test/java/io/vena/bosk/jackson/JacksonPluginTest.java @@ -9,7 +9,6 @@ import io.vena.bosk.AbstractBoskTest; import io.vena.bosk.BindingEnvironment; import io.vena.bosk.Bosk; -import io.vena.bosk.Bosk.ReadContext; import io.vena.bosk.Catalog; import io.vena.bosk.CatalogReference; import io.vena.bosk.Identifier; @@ -20,12 +19,12 @@ import io.vena.bosk.Path; import io.vena.bosk.Reference; import io.vena.bosk.ReflectiveEntity; -import io.vena.bosk.SerializationPlugin.DeserializationScope; import io.vena.bosk.SideTable; import io.vena.bosk.StateTreeNode; import io.vena.bosk.TestEntityBuilder; import io.vena.bosk.annotations.DerivedRecord; import io.vena.bosk.annotations.DeserializationPath; +import io.vena.bosk.annotations.Polyfill; import io.vena.bosk.annotations.ReferencePath; import io.vena.bosk.exceptions.InvalidTypeException; import io.vena.bosk.exceptions.MalformedPathException; @@ -41,8 +40,6 @@ import java.util.function.Consumer; import java.util.stream.Stream; import lombok.Getter; -import lombok.RequiredArgsConstructor; -import lombok.Value; import lombok.experimental.FieldNameConstants; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -55,6 +52,7 @@ import static io.vena.bosk.AbstractBoskTest.TestEnum.OK; import static io.vena.bosk.ListingEntry.LISTING_ENTRY; import static java.util.Arrays.asList; +import static java.util.Collections.emptyMap; import static java.util.Collections.singletonList; import static java.util.Collections.singletonMap; import static java.util.stream.Collectors.toList; @@ -62,21 +60,22 @@ import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; class JacksonPluginTest extends AbstractBoskTest { private Bosk bosk; private TestEntityBuilder teb; private JacksonPlugin jacksonPlugin; private ObjectMapper boskMapper; - private CatalogReference entitiesRef; - private Reference parentRef; private Refs refs; public interface Refs { + @ReferencePath("/entities") CatalogReference entities(); @ReferencePath("/entities/-entity-") Reference entity(Identifier entity); - @ReferencePath("/entities/-entity-/implicitRefs") Reference implicitRefs(Identifier entity); + @ReferencePath("/entities/-entity-/children") CatalogReference children(Identifier entity); + @ReferencePath("/entities/parent/implicitRefs") Reference parentImplicitRefs(); + @ReferencePath("/entities/-entity-/implicitRefs") Reference implicitRefs(Identifier id); } /** @@ -88,8 +87,7 @@ public interface Refs { void setUpJackson() throws Exception { bosk = setUpBosk(Bosk::simpleDriver); teb = new TestEntityBuilder(bosk); - entitiesRef = bosk.rootReference().thenCatalog(TestEntity.class, Path.just(TestRoot.Fields.entities)); - parentRef = entitiesRef.then(Identifier.from("parent")); + refs = bosk.buildReferences(Refs.class); plainMapper = new ObjectMapper() .enable(INDENT_OUTPUT); @@ -98,7 +96,6 @@ void setUpJackson() throws Exception { boskMapper = new ObjectMapper() .registerModule(jacksonPlugin.moduleFor(bosk)) .enable(INDENT_OUTPUT); - refs = bosk.buildReferences(Refs.class); } @Test @@ -140,11 +137,11 @@ private static Arguments catalogCase(String ...ids) { @ParameterizedTest @MethodSource("listingArguments") void listing_works(List strings, List ids) { - Listing listing = Listing.of(entitiesRef, ids); + Listing listing = Listing.of(refs.entities(), ids); Map expected = new LinkedHashMap<>(); expected.put("ids", strings); - expected.put("domain", entitiesRef.pathString()); + expected.put("domain", refs.entities().pathString()); assertJacksonWorks(expected, listing, new TypeReference>() {}, Path.just("doesn't matter")); } @@ -169,14 +166,14 @@ void listingEntry_works() throws JsonProcessingException { @ParameterizedTest @MethodSource("sideTableArguments") void sideTable_works(List keys, Map valuesByString, Map valuesById) { - SideTable sideTable = SideTable.fromOrderedMap(entitiesRef, valuesById); + SideTable sideTable = SideTable.fromOrderedMap(refs.entities(), valuesById); List> expectedList = new ArrayList<>(); valuesByString.forEach((key, value) -> expectedList.add(singletonMap(key, value))); Map expected = new LinkedHashMap<>(); expected.put("valuesById", expectedList); - expected.put("domain", entitiesRef.pathString()); + expected.put("domain", refs.entities().pathString()); assertJacksonWorks( expected, @@ -210,21 +207,21 @@ static Arguments sideTableCase(Consumer> initializer) { } @Test - void phantom_isOmitted() throws InvalidTypeException, JsonProcessingException { + void phantom_isOmitted() throws JsonProcessingException { TestEntity entity = makeEntityWithOptionalString(Optional.empty()); String json = boskMapper.writeValueAsString(entity); assertThat(json, not(containsString(Phantoms.Fields.phantomString))); } @Test - void optional_isOmitted() throws InvalidTypeException, JsonProcessingException { + void optional_isOmitted() throws JsonProcessingException { TestEntity entity = makeEntityWithOptionalString(Optional.empty()); String json = boskMapper.writeValueAsString(entity); assertThat(json, not(containsString(Optionals.Fields.optionalString))); } @Test - void optional_isIncluded() throws InvalidTypeException, JsonProcessingException { + void optional_isIncluded() throws JsonProcessingException { String contents = "OPTIONAL STRING CONTENTS"; TestEntity entity = makeEntityWithOptionalString(Optional.of(contents)); String json = boskMapper.writeValueAsString(entity); @@ -253,7 +250,7 @@ void listValue_deserializationWorks(List list, JavaType type) throws JsonProc String json = plainMapper.writeValueAsString(list); Object actual = boskMapper.readerFor(type).readValue(json); assertEquals(expected, actual); - assertTrue(actual instanceof ListValue); + assertInstanceOf(ListValue.class, actual); } private static Stream listValueArguments() { @@ -294,11 +291,10 @@ void listValue_parameterizedElement_works() { Path.empty()); } - @Value - public static class NodeWithGenerics implements StateTreeNode { - ListValue listOfA; - ListValue listOfB; - } + public record NodeWithGenerics( + ListValue listOfA, + ListValue listOfB + ) implements StateTreeNode { } @ParameterizedTest @MethodSource("mapValueArguments") @@ -315,7 +311,7 @@ void mapValue_deserializationWorks(Map map, JavaType type) throws Json String json = plainMapper.writeValueAsString(map); Object actual = boskMapper.readerFor(type).readValue(json); assertEquals(expected, actual); - assertTrue(actual instanceof MapValue); + assertInstanceOf(MapValue.class, actual); } private static Stream mapValueArguments() { @@ -341,7 +337,7 @@ private static Map kv(String key, Object value) { } @Test - void implicitRefs_omitted() throws InvalidTypeException, JsonProcessingException { + void implicitRefs_omitted() throws JsonProcessingException { TestEntity entity = makeEntityWithOptionalString(Optional.empty()); String json = boskMapper.writeValueAsString(entity); assertThat(json, not(containsString(ImplicitRefs.Fields.reference))); @@ -349,7 +345,7 @@ void implicitRefs_omitted() throws InvalidTypeException, JsonProcessingException } @Test - void idsOmitted_filledInFromContext() throws InvalidTypeException { + void idsOmitted_filledInFromContext() { Identifier child1ID = Identifier.from("child1"); TestEntity expected = makeEntityWith( Optional.empty(), @@ -367,17 +363,16 @@ void idsOmitted_filledInFromContext() throws InvalidTypeException { } @Test - void derivedRecord_basic_works() throws InvalidTypeException, JsonProcessingException { - Reference iref = parentRef.then(ImplicitRefs.class, TestEntity.Fields.implicitRefs); + void derivedRecord_basic_works() throws JsonProcessingException { ImplicitRefs reflectiveEntity; - try (ReadContext context = bosk.readContext()) { - reflectiveEntity = iref.value(); + try (var __ = bosk.readContext()) { + reflectiveEntity = refs.parentImplicitRefs().value(); } String expectedJSON = boskMapper.writeValueAsString(new ExpectedBasic( - iref, + refs.parentImplicitRefs(), "stringValue", - iref, + refs.parentImplicitRefs(), "stringValue" )); String actualJSON = boskMapper.writeValueAsString(new ActualBasic( @@ -392,7 +387,7 @@ void derivedRecord_basic_works() throws InvalidTypeException, JsonProcessingExce assertEquals(expectedJSON, actualJSON); ActualBasic deserialized; - try (ReadContext context = bosk.readContext()) { + try (var __ = bosk.readContext()) { deserialized = boskMapper.readerFor(ActualBasic.class).readValue(expectedJSON); } @@ -402,40 +397,37 @@ void derivedRecord_basic_works() throws InvalidTypeException, JsonProcessingExce /** * Should be serialized the same as {@link ActualBasic}. */ - @RequiredArgsConstructor @Getter - public static class ExpectedBasic implements StateTreeNode { - final Reference entity; - final String nonEntity; - final Reference optionalEntity; - final String optionalNonEntity; - } + public record ExpectedBasic( + Reference entity, + String nonEntity, + Reference optionalEntity, + String optionalNonEntity + ) implements StateTreeNode { } - @RequiredArgsConstructor @Getter @DerivedRecord - public static class ActualBasic { - final ImplicitRefs entity; - final String nonEntity; - final Optional optionalEntity; - final Optional optionalNonEntity; - final Optional emptyEntity; - final Optional emptyNonEntity; - } + public record ActualBasic( + ImplicitRefs entity, + String nonEntity, + Optional optionalEntity, + Optional optionalNonEntity, + Optional emptyEntity, + Optional emptyNonEntity + ) { } @Test - void derivedRecord_list_works() throws InvalidTypeException, JsonProcessingException { - Reference iref = parentRef.then(ImplicitRefs.class, TestEntity.Fields.implicitRefs); + void derivedRecord_list_works() throws JsonProcessingException { ImplicitRefs reflectiveEntity; - try (ReadContext context = bosk.readContext()) { - reflectiveEntity = iref.value(); + try (var __ = bosk.readContext()) { + reflectiveEntity = refs.parentImplicitRefs().value(); } - String expectedJSON = boskMapper.writeValueAsString(singletonList(iref.path().urlEncoded())); + String expectedJSON = boskMapper.writeValueAsString(singletonList(refs.parentImplicitRefs().path().urlEncoded())); String actualJSON = boskMapper.writeValueAsString(new ActualList(reflectiveEntity)); assertEquals(expectedJSON, actualJSON); ActualList deserialized; - try (ReadContext context = bosk.readContext()) { + try (var __ = bosk.readContext()) { deserialized = boskMapper.readerFor(ActualList.class).readValue(expectedJSON); } @@ -453,14 +445,13 @@ protected ActualList(ReflectiveEntity... entries) { @Test void deserializationPath_works() throws InvalidTypeException { - Reference anyImplicitRefs = bosk.rootReference().then(ImplicitRefs.class, Path.of(TestRoot.Fields.entities, "-entity-", TestEntity.Fields.implicitRefs)); - Reference ref1 = anyImplicitRefs.boundTo(Identifier.from("123")); + Reference ref1 = refs.implicitRefs(Identifier.from("123")); ImplicitRefs firstObject = new ImplicitRefs( Identifier.from("firstObject"), ref1, ref1.enclosingReference(TestEntity.class), ref1, ref1.enclosingReference(TestEntity.class) ); - Reference ref2 = anyImplicitRefs.boundTo(Identifier.from("456")); + Reference ref2 = refs.implicitRefs(Identifier.from("456")); ImplicitRefs secondObject = new ImplicitRefs( Identifier.from("secondObject"), ref2, ref2.enclosingReference(TestEntity.class), @@ -477,23 +468,19 @@ void deserializationPath_works() throws InvalidTypeException { .bind("entity1", Identifier.from("123")) .bind("entity2", Identifier.from("456")) .build(); - try (DeserializationScope scope = jacksonPlugin.overlayScope(env)) { + try (var __ = jacksonPlugin.overlayScope(env)) { assertJacksonWorks(plainObject, boskObject, new TypeReference() {}, Path.empty()); } } - @Value @FieldNameConstants - public static class DeserializationPathContainer implements StateTreeNode { - @DeserializationPath("/entities/-entity1-/implicitRefs") - ImplicitRefs firstField; - - @DeserializationPath("/entities/-entity2-/implicitRefs") - ImplicitRefs secondField; - } + public record DeserializationPathContainer( + @DeserializationPath("/entities/-entity1-/implicitRefs") ImplicitRefs firstField, + @DeserializationPath("/entities/-entity2-/implicitRefs") ImplicitRefs secondField + ) implements StateTreeNode { } @Test - void deserializationPathMissingID_filledInFromContext() throws InvalidTypeException, JsonProcessingException { + void deserializationPathMissingID_filledInFromContext() { DeserializationPathMissingID expected = new DeserializationPathMissingID( makeEntityWithOptionalString(Optional.empty())); @@ -506,32 +493,29 @@ void deserializationPathMissingID_filledInFromContext() throws InvalidTypeExcept BindingEnvironment env = BindingEnvironment.empty().builder() .bind("entity", expected.entity.id()) .build(); - try (DeserializationScope scope = jacksonPlugin.overlayScope(env)) { + try (var __ = jacksonPlugin.overlayScope(env)) { Object actual = boskObjectFor(plainObject, new TypeReference() {}, Path.empty()); assertEquals(expected, actual, "Object should deserialize without \"id\" field"); } } - @Value @FieldNameConstants - public static class DeserializationPathMissingID implements StateTreeNode { - @DeserializationPath("/entities/-entity-") - TestEntity entity; - } + public record DeserializationPathMissingID( + @DeserializationPath("/entities/-entity-") TestEntity entity + ) implements StateTreeNode { } - private TestEntity makeEntityWith(Optional optionalString, Catalog children) throws InvalidTypeException { - CatalogReference catalogRef = entitiesRef; + private TestEntity makeEntityWith(Optional optionalString, Catalog children) { Identifier entityID = Identifier.unique("testOptional"); - Reference entityRef = catalogRef.then(entityID); - CatalogReference childrenRef = entityRef.thenCatalog(TestChild.class, TestEntity.Fields.children); - Reference implicitRefsRef = entityRef.then(ImplicitRefs.class, "implicitRefs"); + Reference entityRef = refs.entity(entityID); + CatalogReference childrenRef = refs.children(entityID); + Reference implicitRefsRef = refs.implicitRefs(entityID); return new TestEntity(entityID, entityID.toString(), OK, children, Listing.empty(childrenRef), SideTable.empty(childrenRef), Phantoms.empty(Identifier.unique("phantoms")), new Optionals(Identifier.unique("optionals"), optionalString, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()), new ImplicitRefs(Identifier.unique("implicitRefs"), implicitRefsRef, entityRef, implicitRefsRef, entityRef)); } - private TestEntity makeEntityWithOptionalString(Optional optionalString) throws InvalidTypeException { + private TestEntity makeEntityWithOptionalString(Optional optionalString) { return makeEntityWith(optionalString, Catalog.empty()); } @@ -580,12 +564,12 @@ private List plainListFor(Object boskObject) { } } - private Object boskObjectFor(Map plainObject, TypeReference boskObjectTypeRef, Path path) { + private T boskObjectFor(Map plainObject, TypeReference boskObjectTypeRef, Path path) { try { JavaType boskJavaType = TypeFactory.defaultInstance().constructType(boskObjectTypeRef); JavaType mapJavaType = TypeFactory.defaultInstance().constructParametricType(Map.class, String.class, Object.class); String json = plainMapper.writerFor(mapJavaType).writeValueAsString(plainObject); - try (DeserializationScope scope = jacksonPlugin.newDeserializationScope(path)) { + try (var __ = jacksonPlugin.newDeserializationScope(path)) { return boskMapper.readerFor(boskJavaType).readValue(json); } } catch (JsonProcessingException e) { @@ -598,7 +582,7 @@ private Object boskListFor(List plainList, JavaType boskListType, Path path) JavaType boskJavaType = TypeFactory.defaultInstance().constructType(boskListType); JavaType listJavaType = TypeFactory.defaultInstance().constructParametricType(List.class, Object.class); String json = plainMapper.writerFor(listJavaType).writeValueAsString(plainList); - try (DeserializationScope scope = jacksonPlugin.newDeserializationScope(path)) { + try (var __ = jacksonPlugin.newDeserializationScope(path)) { return boskMapper.readerFor(boskJavaType).readValue(json); } } catch (JsonProcessingException e) { @@ -606,6 +590,21 @@ private Object boskListFor(List plainList, JavaType boskListType, Path path) } } + @Test + void polyfill_works() { + HasPolyfill deserialized = boskObjectFor(emptyMap(), new TypeReference<>(){}, Path.empty()); + assertEquals(HasPolyfill.DEFAULT_STRING_FIELD_VALUE, deserialized.stringField1()); + assertEquals(HasPolyfill.DEFAULT_STRING_FIELD_VALUE, deserialized.stringField2()); + } + + public record HasPolyfill( + String stringField1, + String stringField2 + ) implements StateTreeNode { + @Polyfill({"stringField1","stringField2"}) + public static final String DEFAULT_STRING_FIELD_VALUE = "defaultValue"; + } + // Sad paths @Test @@ -697,11 +696,9 @@ void deserializationPath_wrongType_throws() { }); } - @Value - public static class WrongType implements StateTreeNode { - @DeserializationPath("/entities/123/string") - ImplicitRefs notAString; - } + public record WrongType( + @DeserializationPath("/entities/123/string") ImplicitRefs notAString + ) implements StateTreeNode { } @Test void deserializationPath_parameterUnbound_throws() { @@ -710,11 +707,9 @@ void deserializationPath_parameterUnbound_throws() { }); } - @Value - public static class EntityParameter implements StateTreeNode { - @DeserializationPath("/entities/-entity-") - ImplicitRefs field; - } + public record EntityParameter( + @DeserializationPath("/entities/-entity-") ImplicitRefs field + ) implements StateTreeNode { } @Test void deserializationPath_malformedPath() { @@ -723,11 +718,9 @@ void deserializationPath_malformedPath() { }); } - @Value - public static class MalformedPath implements StateTreeNode { - @DeserializationPath("/malformed////path") - ImplicitRefs field; - } + public record MalformedPath( + @DeserializationPath("/malformed////path") ImplicitRefs field + ) implements StateTreeNode { } @Test void deserializationPath_nonexistentPath_throws() { @@ -736,9 +729,8 @@ void deserializationPath_nonexistentPath_throws() { }); } - @Value - public static class NonexistentPath implements StateTreeNode { - @DeserializationPath("/nonexistent/path") - ImplicitRefs field; - } + public record NonexistentPath( + @DeserializationPath("/nonexistent/path") ImplicitRefs field + ) implements StateTreeNode { } + } diff --git a/bosk-mongo/src/test/java/io/vena/bosk/drivers/mongo/AbstractMongoDriverTest.java b/bosk-mongo/src/test/java/io/vena/bosk/drivers/mongo/AbstractMongoDriverTest.java index d975af82..c28c968c 100644 --- a/bosk-mongo/src/test/java/io/vena/bosk/drivers/mongo/AbstractMongoDriverTest.java +++ b/bosk-mongo/src/test/java/io/vena/bosk/drivers/mongo/AbstractMongoDriverTest.java @@ -154,17 +154,10 @@ protected DriverFactory createDriverFactory() { } public interface Refs { - @ReferencePath("/catalog") - CatalogReference catalog(); - - @ReferencePath("/listing") - ListingReference listing(); - - @ReferencePath("/listing/-entity-") - Reference listingEntry(Identifier entity); - - @ReferencePath("/catalog/-child-/catalog") - CatalogReference childCatalog(Identifier child); + @ReferencePath("/catalog") CatalogReference catalog(); + @ReferencePath("/listing") ListingReference listing(); + @ReferencePath("/listing/-entity-") Reference listingEntry(Identifier entity); + @ReferencePath("/catalog/-child-/catalog") CatalogReference childCatalog(Identifier child); } private static final AtomicBoolean ALREADY_WARNED = new AtomicBoolean(false); diff --git a/bosk-mongo/src/test/java/io/vena/bosk/drivers/mongo/BsonPluginTest.java b/bosk-mongo/src/test/java/io/vena/bosk/drivers/mongo/BsonPluginTest.java index d731a11c..5f969030 100644 --- a/bosk-mongo/src/test/java/io/vena/bosk/drivers/mongo/BsonPluginTest.java +++ b/bosk-mongo/src/test/java/io/vena/bosk/drivers/mongo/BsonPluginTest.java @@ -1,7 +1,6 @@ package io.vena.bosk.drivers.mongo; import io.vena.bosk.Bosk; -import io.vena.bosk.Bosk.ReadContext; import io.vena.bosk.Catalog; import io.vena.bosk.CatalogReference; import io.vena.bosk.Entity; @@ -10,8 +9,6 @@ import io.vena.bosk.SideTable; import io.vena.bosk.StateTreeNode; import io.vena.bosk.exceptions.InvalidTypeException; -import lombok.EqualsAndHashCode; -import lombok.Value; import lombok.experimental.FieldNameConstants; import org.bson.BsonDocument; import org.bson.BsonDocumentReader; @@ -34,7 +31,7 @@ void sideTableOfSideTables() { Bosk bosk = new Bosk("Test bosk", Root.class, this::defaultRoot, Bosk::simpleDriver); CodecRegistry registry = CodecRegistries.fromProviders(bp.codecProviderFor(bosk), new ValueCodecProvider()); Codec codec = registry.get(Root.class); - try (ReadContext context = bosk.readContext()) { + try (var __ = bosk.readContext()) { BsonDocument document = new BsonDocument(); Root original = bosk.rootReference().value(); codec.encode(new BsonDocumentWriter(document), original, EncoderContext.builder().build()); @@ -48,17 +45,14 @@ private Root defaultRoot(Bosk bosk) throws InvalidTypeException { return new Root(Catalog.empty(), SideTable.empty(catalogRef)); } - @Value @FieldNameConstants - @EqualsAndHashCode(callSuper = false) - public static class Root implements StateTreeNode { - Catalog items; - SideTable> nestedSideTable; - } + @FieldNameConstants + public record Root( + Catalog items, + SideTable> nestedSideTable + ) implements StateTreeNode { } - @Value @FieldNameConstants - @EqualsAndHashCode(callSuper = false) - public static class Item implements Entity { - Identifier id; - } + public record Item( + Identifier id + ) implements Entity { } } diff --git a/bosk-mongo/src/test/java/io/vena/bosk/drivers/mongo/MongoDriverSpecialTest.java b/bosk-mongo/src/test/java/io/vena/bosk/drivers/mongo/MongoDriverSpecialTest.java index 178e0815..a670e728 100644 --- a/bosk-mongo/src/test/java/io/vena/bosk/drivers/mongo/MongoDriverSpecialTest.java +++ b/bosk-mongo/src/test/java/io/vena/bosk/drivers/mongo/MongoDriverSpecialTest.java @@ -13,6 +13,7 @@ import io.vena.bosk.ListingReference; import io.vena.bosk.Reference; import io.vena.bosk.SideTable; +import io.vena.bosk.annotations.Polyfill; import io.vena.bosk.drivers.BufferingDriver; import io.vena.bosk.drivers.mongo.Formatter.DocumentFields; import io.vena.bosk.drivers.mongo.TestParameters.EventTiming; @@ -90,7 +91,7 @@ void warmStart_stateMatches() throws InvalidTypeException, InterruptedException, throw new AssertionError("Default root function should not be called"); }, driverFactory); - try (@SuppressWarnings("unused") Bosk.ReadContext context = latecomerBosk.readContext()) { + try (var __ = latecomerBosk.readContext()) { TestEntity actual = latecomerBosk.rootReference().value(); assertEquals(expected, actual); } @@ -135,7 +136,7 @@ public void submitReplacement(Reference target, T newValue) { } } - try (@SuppressWarnings("unused") Bosk.ReadContext context = bosk.readContext()) { + try (var __ = bosk.readContext()) { TestEntity expected = initialRoot(bosk); TestEntity actual = bosk.rootReference().value(); assertEquals(expected, actual, "MongoDriver should not have called downstream.flush() yet"); @@ -143,7 +144,7 @@ public void submitReplacement(Reference target, T newValue) { bosk.driver().flush(); - try (@SuppressWarnings("unused") Bosk.ReadContext context = bosk.readContext()) { + try (var __ = bosk.readContext()) { TestEntity expected = initialRoot(bosk).withListing(Listing.of(catalogRef, entity123)); TestEntity actual = bosk.rootReference().value(); assertEquals(expected, actual, "MongoDriver.flush() should reliably update the bosk"); @@ -171,7 +172,7 @@ void listing_stateMatches() throws InvalidTypeException, InterruptedException, I // Check the contents driver.flush(); - try (@SuppressWarnings("unused") Bosk.ReadContext readContext = bosk.readContext()) { + try (var __ = bosk.readContext()) { Listing actual = listingRef.value(); Listing expected = Listing.of(catalogRef, entity124, entity123); assertEquals(expected, actual); @@ -182,7 +183,7 @@ void listing_stateMatches() throws InvalidTypeException, InterruptedException, I // Check the contents driver.flush(); - try (@SuppressWarnings("unused") Bosk.ReadContext readContext = bosk.readContext()) { + try (var __ = bosk.readContext()) { Listing actual = listingRef.value(); Listing expected = Listing.of(catalogRef, entity124); assertEquals(expected, actual); @@ -221,14 +222,14 @@ void networkOutage_boskRecovers() throws InvalidTypeException, InterruptedExcept driver.flush(); TestEntity actual; - try (@SuppressWarnings("unused") Bosk.ReadContext readContext = bosk.readContext()) { + try (var __ = bosk.readContext()) { actual = bosk.rootReference().value(); } assertEquals(expected, actual); latecomerBosk.driver().flush(); TestEntity latecomerActual; - try (@SuppressWarnings("unused") Bosk.ReadContext readContext = latecomerBosk.readContext()) { + try (var __ = latecomerBosk.readContext()) { latecomerActual = latecomerBosk.rootReference().value(); } assertEquals(expected, latecomerActual); @@ -279,7 +280,7 @@ void hookRegisteredDuringNetworkOutage_works() throws InvalidTypeException, Inte .withListing(Listing.of(refs.catalog(), entity123, entity124)); TestEntity actual; - try (@SuppressWarnings("unused") Bosk.ReadContext readContext = bosk.readContext()) { + try (var __ = bosk.readContext()) { actual = bosk.rootReference().value(); } assertEquals(expected, actual); @@ -301,7 +302,7 @@ void initialStateHasNonexistentFields_ignored() throws InvalidTypeException { OldEntity expected = OldEntity.withString(rootID.toString(), prevBosk); OldEntity actual; - try (@SuppressWarnings("unused") Bosk.ReadContext readContext = prevBosk.readContext()) { + try (var __ = prevBosk.readContext()) { actual = prevBosk.rootReference().value(); } assertEquals(expected, actual); @@ -328,7 +329,7 @@ void updateHasNonexistentFields_ignored() throws InvalidTypeException, IOExcepti OldEntity expected = OldEntity.withString("replacementString", prevBosk); OldEntity actual; - try (@SuppressWarnings("unused") Bosk.ReadContext readContext = prevBosk.readContext()) { + try (var __ = prevBosk.readContext()) { actual = prevBosk.rootReference().value(); } @@ -356,7 +357,7 @@ void updateNonexistentField_ignored() throws InvalidTypeException, IOException, OldEntity expected = OldEntity.withString(rootID.toString(), prevBosk); // unchanged OldEntity actual; - try (@SuppressWarnings("unused") Bosk.ReadContext readContext = prevBosk.readContext()) { + try (var __ = prevBosk.readContext()) { actual = prevBosk.rootReference().value(); } @@ -382,7 +383,7 @@ void deleteNonexistentField_ignored() throws InvalidTypeException, IOException, OldEntity expected = OldEntity.withString(rootID.toString(), prevBosk); // unchanged OldEntity actual; - try (@SuppressWarnings("unused") Bosk.ReadContext readContext = prevBosk.readContext()) { + try (var __ = prevBosk.readContext()) { actual = prevBosk.rootReference().value(); } @@ -455,9 +456,16 @@ void refurbish_createsField() throws IOException, InterruptedException { createDriverFactory() ); + LOGGER.debug("Ensure polyfill returns the right value on read"); + TestValues polyfill; + try (var __ = upgradeableBosk.readContext()) { + polyfill = upgradeableBosk.rootReference().value().values(); + } + assertEquals(TestValues.blank(), polyfill); + LOGGER.debug("Check state before"); Optional before; - try (@SuppressWarnings("unused") Bosk.ReadContext readContext = originalBosk.readContext()) { + try (var __ = originalBosk.readContext()) { before = originalBosk.rootReference().value().values(); } assertEquals(Optional.empty(), before); // Not there yet @@ -468,7 +476,7 @@ void refurbish_createsField() throws IOException, InterruptedException { LOGGER.debug("Check state after"); Optional after; - try (@SuppressWarnings("unused") Bosk.ReadContext readContext = originalBosk.readContext()) { + try (var __ = originalBosk.readContext()) { after = originalBosk.rootReference().value().values(); } assertEquals(Optional.of(TestValues.blank()), after); // Now it's there @@ -613,7 +621,7 @@ public static OldEntity withString(String value, Bosk bosk) throws In /** * A version of {@link TestEntity} where the {@link Optional} {@link TestEntity#values()} - * field has a default (and some other fields have been deleted). + * field has a polyfill (and some other fields have been deleted). */ public record UpgradeableEntity( Identifier id, @@ -621,12 +629,10 @@ public record UpgradeableEntity( Catalog catalog, Listing listing, SideTable sideTable, - Optional values + TestValues values ) implements Entity { - @Override - public Optional values() { - return Optional.of(values.orElse(TestValues.blank())); - } + @Polyfill("values") + static final TestValues DEFAULT_VALUES = TestValues.blank(); } private static final Logger LOGGER = LoggerFactory.getLogger(MongoDriverSpecialTest.class); diff --git a/docs/USERS.md b/docs/USERS.md index 5fde7c4d..3dff48fb 100644 --- a/docs/USERS.md +++ b/docs/USERS.md @@ -688,37 +688,46 @@ and that describe where the document fits within the overall BSON structure. ##### Schema evolution: how to add a new field In general, bosk does not support `null` field values. -This means if you add a new field to your state tree node classes, they become incompatible with the existing database contents (which do not have that field). +If you add a new field to your state tree node classes, they become incompatible with the existing database contents (which do not have that field). +This means that new fields must, at least initially, support being absent. -This means that new fields must, at least initially, be declared as `Optional`. -If your application code is ok with the field being `Optional`, and can cope with that field's absence, you can stop here. -Otherwise, you must add your field in multiple steps. - -In the first step, you declare the new constructor argument to be `Optional` and supply a default value to make it behave as though the field were present in the database: +The first step is to use the `@Polyfill` annotation to indicate a default value: ``` java -ExampleNode(Optional newField) { - if (newField.isPresent()) { - this.newField = newField; - } else { - this.newField = Optional.of(ExampleValue.DEFAULT_VALUE); - } +record ExampleNode(ExampleValue newField) { + @Polyfill("newField") + static final ExampleValue NEW_FIELD_DEFAULT = ExampleValue.DEFAULT_VALUE; } ``` -This way, any updates written to MongoDB will include the new field, so the state will be gradually upgraded to include the new field. +This will allow operations that deserialize `ExampleNode` objects (from JSON, from databases, etc.) +to tolerate the absence of `newField` temporarily by providing the given default value. +With the `@Polyfill` in place, any updates written to MongoDB will include the new field, +so the database state will be gradually upgraded to include the new field. Because `MongoDriver` ignores any fields in the database it doesn't recognize, -this new version of the code can coexist with older versions that don't know about this field. +this new version of the code can coexist with older versions that don't know about the new field. The second step is to ensure that any older versions of the server are shut down. This will prevent _new_ objects from being created without the new field. -The third step is to call `MongoDriver.refurbish()`. +The third step is to change external systems so they always supply the new field; +for `MongoDriver`, this is accomplished by calling `MongoDriver.refurbish()`. This method rewrites the entire bosk state in the new format, which has the effect of adding the new field to all existing objects. -Finally, you can make your new field non-optional. -For example, you can change it from `Optional` to just `ExampleValue`, -secure in the knowledge that there are no objects in the database that don't have this field. +Finally, you can remove the `@Polyfill` field, +secure in the knowledge that there are no objects in the database that don't have the new field. + +Note that `@Polyfill` is not meant as a general way to supply default values for optional fields, +but rather to allow rollout of new required fields with no downtime. +For optional fields, just use `Optional`. + +Also note that `@Polyfill` does not yet provide a perfect illusion that the field exists; +specifically, updates _inside_ nonexistent state tree nodes will still be ignored, +even if they have a polyfill. +That is, if you provide a polyfill for a node at `/a/b`, but that node does not actually exist in the database, +then a read from `/a/b` will return the polyfill node, +but a write to `/a/b/c` will be ignored, which could be confusing. +We hope to overcome this shortcoming in the near future. #### Compliance rules