Skip to content

Commit

Permalink
@VariantCaseMap
Browse files Browse the repository at this point in the history
  • Loading branch information
prdoyle committed Jul 21, 2024
1 parent a08e1bd commit 021ecbb
Show file tree
Hide file tree
Showing 15 changed files with 559 additions and 58 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package works.bosk.annotations;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Retention(RUNTIME)
@Target({ FIELD }) // TODO: Also METHOD
public @interface VariantCaseMap {
}
56 changes: 48 additions & 8 deletions bosk-core/src/main/java/works/bosk/SerializationPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Field;
import java.lang.reflect.Parameter;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
Expand All @@ -14,14 +15,17 @@
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import lombok.EqualsAndHashCode;
import lombok.Value;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import works.bosk.annotations.DeserializationPath;
import works.bosk.annotations.Enclosing;
import works.bosk.annotations.Polyfill;
import works.bosk.annotations.Self;
import works.bosk.annotations.VariantCaseMap;
import works.bosk.exceptions.DeserializationException;
import works.bosk.exceptions.InvalidTypeException;
import works.bosk.exceptions.MalformedPathException;
Expand Down Expand Up @@ -240,6 +244,15 @@ public static boolean hasDeserializationPath(Class<?> nodeClass, Parameter param
return infoFor(nodeClass).annotatedParameters_DeserializationPath().containsKey(parameter.getName());
}

@Nullable
public static MapValue<Type> getVariantCaseMapIfAny(Class<?> nodeClass) {
if (nodeClass.isInterface()) {
return infoFor(nodeClass).variantCaseMap();
} else {
return null;
}
}

public <R extends StateTreeNode> void initializeEnclosingPolyfills(Reference<?> target, BoskDriver<R> driver) {
if (!ANY_POLYFILLS.get()) {
return;
Expand Down Expand Up @@ -331,27 +344,34 @@ private static ParameterInfo computeInfoFor(Class<?> nodeClass) {
Set<String> enclosingParameters = new HashSet<>();
Map<String, DeserializationPath> deserializationPathParameters = new HashMap<>();
Map<String, Object> polyfills = new HashMap<>();
for (Parameter parameter: ReferenceUtils.theOnlyConstructorFor(nodeClass).getParameters()) {
scanForInfo(parameter, parameter.getName(),
selfParameters, enclosingParameters, deserializationPathParameters, polyfills);
AtomicReference<MapValue<Type>> variantCaseMap = new AtomicReference<>(null);

if (!nodeClass.isInterface()) { // Avoid for @VariantCaseMap classes
for (Parameter parameter: ReferenceUtils.theOnlyConstructorFor(nodeClass).getParameters()) {
scanForInfo(parameter, parameter.getName(),
selfParameters, enclosingParameters, deserializationPathParameters, polyfills, variantCaseMap);
}
}

// 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 that derive constructors from fields.
//
// It's also required to scan static fields for features like @VariantCaseMap.

for (Class<?> c = nodeClass; c != Object.class; c = c.getSuperclass()) {
for (Class<?> c = nodeClass; c != Object.class && c != null; c = c.getSuperclass()) {
for (Field field: c.getDeclaredFields()) {
scanForInfo(field, field.getName(),
selfParameters, enclosingParameters, deserializationPathParameters, polyfills);
selfParameters, enclosingParameters, deserializationPathParameters, polyfills, variantCaseMap);
}
}
return new ParameterInfo(selfParameters, enclosingParameters, deserializationPathParameters, polyfills);
return new ParameterInfo(selfParameters, enclosingParameters, deserializationPathParameters, polyfills, variantCaseMap.get());
}

private static void scanForInfo(AnnotatedElement thing, String name, Set<String> selfParameters, Set<String> enclosingParameters, Map<String, DeserializationPath> deserializationPathParameters, Map<String, Object> polyfills) {
@SuppressWarnings({"rawtypes","unchecked"})
private static void scanForInfo(AnnotatedElement thing, String name, Set<String> selfParameters, Set<String> enclosingParameters, Map<String, DeserializationPath> deserializationPathParameters, Map<String, Object> polyfills, AtomicReference<MapValue<Type>> variantCaseMap) {
if (thing.isAnnotationPresent(Self.class)) {
selfParameters.add(name);
} else if (thing.isAnnotationPresent(Enclosing.class)) {
Expand Down Expand Up @@ -384,14 +404,34 @@ private static void scanForInfo(AnnotatedElement thing, String name, Set<String>
} else {
throw new IllegalStateException("@Polyfill annotation is only valid on non-private static fields; found on " + thing);
}
} else if (thing.isAnnotationPresent(VariantCaseMap.class)) {
// TODO: Lots of code duplication with Polyfill
if (thing instanceof Field f && isStatic(f.getModifiers()) && !isPrivate(f.getModifiers())) {
f.setAccessible(true);
var annotations = thing.getAnnotationsByType(VariantCaseMap.class);
if (annotations.length >= 2) {
throw new IllegalStateException("Multiple variant case maps for the same class: " + f);
}
MapValue value;
try {
value = (MapValue) f.get(null);
} catch (IllegalAccessException e) {
throw new AssertionError("Field should not be inaccessible: " + f, e);
}
if (value == null) {
throw new NullPointerException("VariantCaseMap cannot be null: " + f);
}
variantCaseMap.set(value);
}
}
}

private record ParameterInfo(
Set<String> annotatedParameters_Self,
Set<String> annotatedParameters_Enclosing,
Map<String, DeserializationPath> annotatedParameters_DeserializationPath,
Map<String, Object> polyfills
Map<String, Object> polyfills,
@Nullable MapValue<Type> variantCaseMap
) { }

private static final Map<Class<?>, ParameterInfo> PARAMETER_INFO_MAP = new ConcurrentHashMap<>();
Expand Down
64 changes: 50 additions & 14 deletions bosk-core/src/main/java/works/bosk/TypeValidation.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
import java.lang.reflect.Parameter;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.Collection;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import works.bosk.annotations.DerivedRecord;
Expand All @@ -25,6 +27,7 @@
import static java.lang.reflect.Modifier.isStatic;
import static java.util.Arrays.asList;
import static java.util.Collections.newSetFromMap;
import static java.util.Objects.requireNonNull;
import static works.bosk.SerializationPlugin.hasDeserializationPath;
import static works.bosk.SerializationPlugin.isEnclosingReference;
import static works.bosk.SerializationPlugin.isSelfReference;
Expand Down Expand Up @@ -139,29 +142,53 @@ private static boolean isSimpleClass(Class<?> theClass) {
}

private static void validateStateTreeNodeClass(Class<?> nodeClass, Set<Type> alreadyValidated) throws InvalidTypeException {
var variantCaseMap = SerializationPlugin.getVariantCaseMapIfAny(nodeClass);
if (variantCaseMap == null) {
validateOrdinaryStateTreeNodeClass(nodeClass, alreadyValidated);
} else {
validateVariantNodeClass(nodeClass, variantCaseMap, alreadyValidated);
}
}

private static void validateOrdinaryStateTreeNodeClass(Class<?> nodeClass, Set<Type> alreadyValidated) throws InvalidTypeException {
Constructor<?>[] constructors = nodeClass.getConstructors();
if (constructors.length != 1) {
throw new InvalidTypeException(nodeClass.getSimpleName() + " must have one constructor; found " + constructors.length + " constructors");
}

// Every constructor parameter must have an appropriate getter and wither
for (Parameter p: constructors[0].getParameters()) {
validateConstructorParameter(nodeClass, p);
var typesToValidate = validateConstructorParameter(nodeClass, p);
validateGetter(nodeClass, p);

// Recurse to check that the field type itself is valid.
// For troubleshooting reasons, wrap any thrown exception so the
// user is able to follow the reference chain.
try {
validateType(p.getParameterizedType(), alreadyValidated);
} catch (InvalidTypeException e) {
throw new InvalidFieldTypeException(nodeClass, p.getName(), e.getMessage(), e);
for (Type type : typesToValidate) {// Recurse to check that the field type itself is valid.
// For troubleshooting reasons, wrap any thrown exception so the
// user is able to follow the reference chain.
try {
validateType(type, alreadyValidated);
} catch (InvalidTypeException e) {
throw new InvalidFieldTypeException(nodeClass, p.getName(), e.getMessage(), e);
}
}
}

validateFieldsAreFinal(nodeClass);
}

private static void validateVariantNodeClass(Class<?> nodeClass, Map<String, Type> variantCaseMap, Set<Type> alreadyValidated) throws InvalidTypeException {
if (!nodeClass.isInterface()) {
throw new InvalidTypeException("Variant node class " + nodeClass.getSimpleName() + " must be an interface");
}
if (!VariantNode.class.isAssignableFrom(nodeClass)) {
throw new InvalidTypeException("Variant node class " + nodeClass.getSimpleName() + " must implement " + VariantNode.class.getSimpleName());
}

for (Map.Entry<String, Type> entry : variantCaseMap.entrySet()) {
validateFieldName(nodeClass, requireNonNull(entry.getKey())); // TODO: this produces confusing exception messages
validateType(requireNonNull(entry.getValue()), alreadyValidated);
}
}

/**
* We really, really want our types to be immutable. There's no way to truly
* prevent people from putting mutable data in an object, but let's work hard to
Expand Down Expand Up @@ -192,13 +219,13 @@ private static void validateFieldsAreFinal(Class<?> nodeClass) throws InvalidFie
}
}

private static void validateConstructorParameter(Class<?> containingClass, Parameter parameter) throws InvalidFieldTypeException {
/**
* @return the set of types this <code>parameter</code> might use;
* usually, that's just the declared parameterized type of the parameter.
*/
private static Collection<Type> validateConstructorParameter(Class<?> containingClass, Parameter parameter) throws InvalidFieldTypeException {
String fieldName = parameter.getName();
for (int i = 0; i < fieldName.length(); i++) {
if (!isValidFieldNameChar(fieldName.codePointAt(i))) {
throw new InvalidFieldTypeException(containingClass, fieldName, "Only ASCII letters, numbers, and underscores are allowed in field names; illegal character '" + fieldName.charAt(i) + "' at offset " + i);
}
}
validateFieldName(containingClass, fieldName);
if (hasDeserializationPath(containingClass, parameter)) {
throw new InvalidFieldTypeException(containingClass, fieldName, "@" + DeserializationPath.class.getSimpleName() + " not valid inside the bosk");
} else if (isEnclosingReference(containingClass, parameter)) {
Expand All @@ -221,6 +248,15 @@ private static void validateConstructorParameter(Class<?> containingClass, Param
throw new InvalidFieldTypeException(containingClass, fieldName, "@" + Self.class.getSimpleName() + " reference to " + ReferenceUtils.rawClass(referencedType).getSimpleName() + " incompatible with containing class " + containingClass.getSimpleName());
}
}
return List.of(parameter.getParameterizedType());
}

private static void validateFieldName(Class<?> containingClass, String fieldName) throws InvalidFieldTypeException {
for (int i = 0; i < fieldName.length(); i++) {
if (!isValidFieldNameChar(fieldName.codePointAt(i))) {
throw new InvalidFieldTypeException(containingClass, fieldName, "Only ASCII letters, numbers, and underscores are allowed in field names; illegal character '" + fieldName.charAt(i) + "' at offset " + i);
}
}
}

/**
Expand Down
5 changes: 5 additions & 0 deletions bosk-core/src/main/java/works/bosk/VariantNode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package works.bosk;

public interface VariantNode extends StateTreeNode {
String tag(); // TODO: Identifier?
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,11 @@ protected static Object sideTableEntryOrThrow(SideTable<?,?> sideTable, Identifi
}
}

protected static Object instanceofOrNonexistent(Object object, Class<?> desiredClass, Reference<?> ref) throws NonexistentEntryException {
if (desiredClass.isInstance(object)) {
return object;
} else {
throw new NonexistentEntryException(ref.path());
}
}
}
Loading

0 comments on commit 021ecbb

Please sign in to comment.