Skip to content

Commit

Permalink
Polyfill for deserialization (#84)
Browse files Browse the repository at this point in the history
  • Loading branch information
prdoyle authored Jan 17, 2024
2 parents b5494b6 + 8189594 commit 66f6aa1
Show file tree
Hide file tree
Showing 8 changed files with 272 additions and 178 deletions.
53 changes: 43 additions & 10 deletions bosk-core/src/main/java/io/vena/bosk/SerializationPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

/**
Expand Down Expand Up @@ -192,6 +195,7 @@ public final List<Object> parameterValueList(Class<?> nodeClass, Map<String, Obj
} else if (Phantom.class.equals(type)) {
parameterValues.add(Phantom.empty());
} else {
Object polyfillIfAny = infoFor(nodeClass).polyfills().get(name);
Path path = currentScope.get().path();
if ("id".equals(name) && !path.isEmpty()) {
// If the object is an entry in a Catalog or a key in a SideTable, we can determine its ID
Expand All @@ -206,6 +210,8 @@ public final List<Object> parameterValueList(Class<?> nodeClass, Map<String, Obj
} else {
throw new DeserializationException("Missing id field for object at " + path);
}
} else if (polyfillIfAny != null) {
parameterValues.add(polyfillIfAny);
} else {
throw new DeserializationException("Missing field \"" + name + "\" at " + path);
}
Expand Down Expand Up @@ -283,41 +289,68 @@ private static ParameterInfo computeInfoFor(Class<?> nodeClassArg) {
Set<String> selfParameters = new HashSet<>();
Set<String> enclosingParameters = new HashSet<>();
Map<String, DeserializationPath> deserializationPathParameters = new HashMap<>();
Map<String, Object> 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<String> selfParameters, Set<String> enclosingParameters, Map<String, DeserializationPath> deserializationPathParameters) {
private static void scanForInfo(AnnotatedElement thing, String name, Set<String> selfParameters, Set<String> enclosingParameters, Map<String, DeserializationPath> deserializationPathParameters, Map<String, Object> 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<String> annotatedParameters_Self, Set<String> annotatedParameters_Enclosing,
Map<String, DeserializationPath> annotatedParameters_DeserializationPath
) {
}
Set<String> annotatedParameters_Self,
Set<String> annotatedParameters_Enclosing,
Map<String, DeserializationPath> annotatedParameters_DeserializationPath,
Map<String, Object> polyfills
) { }

private static final Map<Class<?>, ParameterInfo> PARAMETER_INFO_MAP = new ConcurrentHashMap<>();

Expand Down
32 changes: 32 additions & 0 deletions bosk-core/src/main/java/io/vena/bosk/annotations/Polyfill.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* 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 <em>temporarily</em> 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();
}
35 changes: 35 additions & 0 deletions bosk-core/src/test/java/io/vena/bosk/SerializationPluginTest.java
Original file line number Diff line number Diff line change
@@ -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<Child> 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);
}
}
}
Loading

0 comments on commit 66f6aa1

Please sign in to comment.