Skip to content

Commit

Permalink
Merge pull request #36 from prdoyle/records-only
Browse files Browse the repository at this point in the history
Require state tree nodes to be records
  • Loading branch information
prdoyle authored Jan 10, 2025
2 parents fba3169 + ea7b764 commit 32c5a6a
Show file tree
Hide file tree
Showing 25 changed files with 383 additions and 725 deletions.
62 changes: 1 addition & 61 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,40 +29,6 @@ all we do is send updates to MongoDB, and maintain the in-memory replica by foll

## Getting Started

### Build settings

First, be sure you're compiling Java with the `-parameters` argument.

In Gradle:

```
dependencies {
compileJava {
options.compilerArgs << '-parameters'
}
compileTestJava {
options.compilerArgs << '-parameters'
}
}
```

In Maven:

```
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
</configuration>
</plugin>
```

### Standalone example

The [bosk-core](bosk-core) library is enough to create a `Bosk` object and start writing your application.

The library works particularly well with Java records.
Expand All @@ -78,17 +44,6 @@ public record ExampleState (
) implements StateTreeNode {}
```

You can also use classes, especially if you're using Lombok:

```
@Value
@Accessors(fluent = true)
public class ExampleState implements StateTreeNode {
// Add fields here as you need them
String name;
}
```

Now declare your singleton `Bosk` class to house and manage your application state:

```
Expand Down Expand Up @@ -198,7 +153,7 @@ To run this, you'll need a MongoDB replica set.
You can run a single-node replica set using the following `Dockerfile`:

```
FROM mongo:4.4
FROM mongo:7.0
RUN echo "rs.initiate()" > /docker-entrypoint-initdb.d/rs-initiate.js
CMD [ "mongod", "--replSet", "rsLonesome", "--port", "27017", "--bind_ip_all" ]
```
Expand All @@ -215,21 +170,6 @@ provide integrations with other technologies.

The subprojects are listed in [settings.gradle](settings.gradle), and each has its own `README.md` describing what it is.

### Compiler flags

Ensure `javac` is supplied the `-parameters` flag.

This is required because,
for each class you use to describe your Bosk state, the "system of record" for its structure is its constructor.
For example, you might define a class with a constructor like this:

```
public Member(Identifier id, String name) {...}
```

Based on this, Bosk now knows the names and types of all the "properties" of your object.
For this to work smoothly, the parameter names must be present in the compiled bytecode.

### Gradle setup

Each project has its own `build.gradle`.
Expand Down
2 changes: 1 addition & 1 deletion bosk-core/src/main/java/works/bosk/Bosk.java
Original file line number Diff line number Diff line change
Expand Up @@ -741,7 +741,7 @@ private <V> V refValueIfExists(Reference<V> containerRef, @Nullable R priorRoot)
// TODO: This would be less cumbersome if we could apply a Reference to an arbitrary root object.
// For now, References only apply to the current ReadContext, so we need a new ReadContext every time
// we want to change roots.
try (@SuppressWarnings("unused") ReadContext priorContext = new ReadContext(priorRoot)) {
try (var __ = new ReadContext(priorRoot)) {
return containerRef.valueIfExists();
}
}
Expand Down
23 changes: 20 additions & 3 deletions bosk-core/src/main/java/works/bosk/ReferenceUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.RecordComponent;
import java.lang.reflect.Type;
import java.lang.reflect.TypeVariable;
import java.util.Arrays;
Expand Down Expand Up @@ -275,8 +276,8 @@ public static Method getterMethod(Class<?> objectClass, String fieldName) throws

public static <T> Constructor<T> theOnlyConstructorFor(Class<T> nodeClass) {
List<Constructor<?>> constructors = Stream.of(nodeClass.getDeclaredConstructors())
.filter(ctor -> !ctor.isSynthetic())
.toList();
.filter(ctor -> !ctor.isSynthetic())
.toList();
if (constructors.isEmpty()) {
throw new IllegalArgumentException("No suitable constructor for " + nodeClass.getSimpleName());
} else if (constructors.size() >= 2) {
Expand All @@ -287,9 +288,25 @@ public static <T> Constructor<T> theOnlyConstructorFor(Class<T> nodeClass) {
return ReflectionHelpers.setAccessible(theConstructor);
}

/**
* @see Class#getRecordComponents()
*/
public static <T> Constructor<T> getCanonicalConstructor(Class<T> cls) {
assert Record.class.isAssignableFrom(cls): cls.getSimpleName() + " must be a record";
Class<?>[] paramTypes =
Arrays.stream(cls.getRecordComponents())
.map(RecordComponent::getType)
.toArray(Class<?>[]::new);
try {
return ReflectionHelpers.setAccessible(cls.getDeclaredConstructor(paramTypes));
} catch (NoSuchMethodException e) {
throw new AssertionError("Record class must have a canonical constructor; is " + cls.getSimpleName() + " a record class?", e);
}
}

public static Map<String, Method> gettersForConstructorParameters(Class<?> nodeClass) throws InvalidTypeException {
Iterable<String> names = Stream
.of(theOnlyConstructorFor(nodeClass).getParameters())
.of(getCanonicalConstructor(nodeClass).getParameters())
.map(Parameter::getName)
::iterator;
Map<String, Method> result = new LinkedHashMap<>();
Expand Down
19 changes: 6 additions & 13 deletions bosk-core/src/main/java/works/bosk/ReflectiveEntity.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,18 @@
* <p>
* Because the bosk system identifies an object by its location in the document
* tree, this means instances of this class have enough information to determine
* their identity, and so we provide {@link #equals(Object) equals} and {@link
* #hashCode() hashCode} implementations.
* their identity, and so we provide some recommended {@link #equals(Object) equals}
* and {@link #hashCode() hashCode} implementations.
*
* <p>
* <em>Performance note</em>: References aren't cheap to create.
*
* @author Patrick Doyle
*/
public abstract class ReflectiveEntity<T extends ReflectiveEntity<T>> implements Entity {
public abstract Reference<T> reference();
public interface ReflectiveEntity<T extends ReflectiveEntity<T>> extends Entity {
Reference<T> reference();

@Override
public int hashCode() {
return reference().hashCode();
}

@Override
public boolean equals(Object obj) {
default boolean reflectiveEntity_equals(Object obj) {
if (this == obj) {
return true;
} else if (obj instanceof ReflectiveEntity<?> r) {
Expand All @@ -33,8 +27,7 @@ public boolean equals(Object obj) {
}
}

@Override
public String toString() {
default String reflectiveEntity_toString() {
return getClass().getSimpleName() + "(" + reference() + ")";
}
}
43 changes: 22 additions & 21 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.RecordComponent;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.HashMap;
Expand Down Expand Up @@ -172,23 +173,23 @@ public void close() {
/**
* Turns <code>parameterValuesByName</code> into a list suitable for
* passing to a constructor, in the order indicated by
* <code>parametersByName</code>.
* <code>componentsByName</code>.
*
*
* @param parameterValuesByName values read from the input. <em>Modified by this method.</em>
* @param parametersByName ordered map of constructor {@link Parameter}s.
* @param componentsByName ordered map of {@link RecordComponent}s
* @return {@link List} of parameter values to pass to the constructor, in
* the same order as in <code>parametersByName</code>. Missing values are
* the same order as in <code>componentsByName</code>. Missing values are
* supplied where possible, such as <code>Optional.empty()</code> and
* {@link Enclosing} references.
*/
public final List<Object> parameterValueList(Class<?> nodeClass, Map<String, Object> parameterValuesByName, LinkedHashMap<String, Parameter> parametersByName, BoskInfo<?> boskInfo) {
public final List<Object> parameterValueList(Class<?> nodeClass, Map<String, Object> parameterValuesByName, LinkedHashMap<String, RecordComponent> componentsByName, BoskInfo<?> boskInfo) {
List<Object> parameterValues = new ArrayList<>();
for (Entry<String, Parameter> entry: parametersByName.entrySet()) {
for (Entry<String, RecordComponent> entry: componentsByName.entrySet()) {
String name = entry.getKey();
Parameter parameter = entry.getValue();
Class<?> type = parameter.getType();
Reference<?> implicitReference = findImplicitReferenceIfAny(nodeClass, parameter, boskInfo);
RecordComponent component = entry.getValue();
Class<?> type = component.getType();
Reference<?> implicitReference = findImplicitReferenceIfAny(nodeClass, component, boskInfo);

Object value = parameterValuesByName.remove(name);
if (value == null) {
Expand Down Expand Up @@ -234,16 +235,16 @@ public final List<Object> parameterValueList(Class<?> nodeClass, Map<String, Obj
return parameterValues;
}

public static boolean isSelfReference(Class<?> nodeClass, Parameter parameter) {
return infoFor(nodeClass).annotatedParameters_Self().contains(parameter.getName());
public static boolean isSelfReference(Class<?> nodeClass, RecordComponent component) {
return infoFor(nodeClass).annotatedParameters_Self().contains(component.getName());
}

public static boolean isEnclosingReference(Class<?> nodeClass, Parameter parameter) {
return infoFor(nodeClass).annotatedParameters_Enclosing().contains(parameter.getName());
public static boolean isEnclosingReference(Class<?> nodeClass, RecordComponent component) {
return infoFor(nodeClass).annotatedParameters_Enclosing().contains(component.getName());
}

public static boolean hasDeserializationPath(Class<?> nodeClass, Parameter parameter) {
return infoFor(nodeClass).annotatedParameters_DeserializationPath().containsKey(parameter.getName());
public static boolean hasDeserializationPath(Class<?> nodeClass, RecordComponent component) {
return infoFor(nodeClass).annotatedParameters_DeserializationPath().containsKey(component.getName());
}

/**
Expand Down Expand Up @@ -305,12 +306,12 @@ private <R extends StateTreeNode, T> void initializePolyfills(Reference<T> ref,
}
}

private Reference<?> findImplicitReferenceIfAny(Class<?> nodeClass, Parameter parameter, BoskInfo<?> boskInfo) {
private Reference<?> findImplicitReferenceIfAny(Class<?> nodeClass, RecordComponent parameter, BoskInfo<?> boskInfo) {
if (isSelfReference(nodeClass, parameter)) {
Class<?> targetClass = ReferenceUtils.rawClass(ReferenceUtils.parameterType(parameter.getParameterizedType(), Reference.class, 0));
Class<?> targetClass = ReferenceUtils.rawClass(ReferenceUtils.parameterType(parameter.getGenericType(), Reference.class, 0));
return selfReference(targetClass, boskInfo);
} else if (isEnclosingReference(nodeClass, parameter)) {
Class<?> targetClass = ReferenceUtils.rawClass(ReferenceUtils.parameterType(parameter.getParameterizedType(), Reference.class, 0));
Class<?> targetClass = ReferenceUtils.rawClass(ReferenceUtils.parameterType(parameter.getGenericType(), Reference.class, 0));
Reference<Object> selfRef = selfReference(Object.class, boskInfo);
try {
return selfRef.enclosingReference(targetClass);
Expand All @@ -335,11 +336,11 @@ private <T> Reference<T> selfReference(Class<T> targetClass, BoskInfo<?> boskInf
}

/**
* @return true if the given parameter is computed automatically during
* @return true if the given component is computed automatically during
* deserialization, and therefore does not appear in the serialized output.
*/
public static boolean isImplicitParameter(Class<?> nodeClass, Parameter parameter) {
String name = parameter.getName();
public static boolean isImplicitParameter(Class<?> nodeClass, RecordComponent component) {
String name = component.getName();
ParameterInfo info = infoFor(nodeClass);
return info.annotatedParameters_Self.contains(name)
|| info.annotatedParameters_Enclosing.contains(name);
Expand All @@ -358,7 +359,7 @@ private static ParameterInfo computeInfoFor(Class<?> nodeClass) {
AtomicReference<VariantCaseMapInfo> variantCaseMap = new AtomicReference<>(new NoVariantCaseMap(nodeClass));

if (!nodeClass.isInterface()) { // Avoid for @VariantCaseMap classes
for (Parameter parameter: ReferenceUtils.theOnlyConstructorFor(nodeClass).getParameters()) {
for (Parameter parameter: ReferenceUtils.getCanonicalConstructor(nodeClass).getParameters()) {
scanForInfo(parameter, parameter.getName(),
selfParameters, enclosingParameters, deserializationPathParameters, polyfills);
}
Expand Down
48 changes: 18 additions & 30 deletions bosk-core/src/main/java/works/bosk/TypeValidation.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
import java.lang.reflect.Modifier;
import java.lang.reflect.Parameter;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.RecordComponent;
import java.lang.reflect.Type;
import java.util.Collection;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -151,24 +151,17 @@ private static void validateStateTreeNodeClass(Class<?> nodeClass, Set<Type> alr
}

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");
if (!Record.class.isAssignableFrom(nodeClass)) {
throw new InvalidTypeException(nodeClass + " must be a record because it is a " + StateTreeNode.class.getSimpleName());
}

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

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);
}
for (var c: nodeClass.getRecordComponents()) {
// For troubleshooting reasons, wrap any thrown exception so the
// user is able to follow the reference chain.
try {
validateRecordComponent(nodeClass, c);
validateType(c.getGenericType(), alreadyValidated);
} catch (InvalidTypeException e) {
throw new InvalidFieldTypeException(nodeClass, c.getName(), e.getMessage(), e);
}
}

Expand Down Expand Up @@ -217,17 +210,13 @@ private static void validateFieldsAreFinal(Class<?> nodeClass) throws InvalidFie
}
}

/**
* @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();
private static void validateRecordComponent(Class<?> containingClass, RecordComponent component) throws InvalidFieldTypeException {
String fieldName = component.getName();
validateFieldName(containingClass, fieldName);
if (hasDeserializationPath(containingClass, parameter)) {
if (hasDeserializationPath(containingClass, component)) {
throw new InvalidFieldTypeException(containingClass, fieldName, "@" + DeserializationPath.class.getSimpleName() + " not valid inside the bosk");
} else if (isEnclosingReference(containingClass, parameter)) {
Type type = parameter.getParameterizedType();
} else if (isEnclosingReference(containingClass, component)) {
Type type = component.getGenericType();
if (!Reference.class.isAssignableFrom(rawClass(type))) {
throw new InvalidFieldTypeException(containingClass, fieldName, "@" + Enclosing.class.getSimpleName() + " applies only to Reference parameters");
}
Expand All @@ -236,8 +225,8 @@ private static Collection<Type> validateConstructorParameter(Class<?> containing
// Not certain this needs to be so strict
throw new InvalidFieldTypeException(containingClass, fieldName, "@" + Enclosing.class.getSimpleName() + " applies only to References to Entities");
}
} else if (isSelfReference(containingClass, parameter)) {
Type type = parameter.getParameterizedType();
} else if (isSelfReference(containingClass, component)) {
Type type = component.getGenericType();
if (!Reference.class.isAssignableFrom(rawClass(type))) {
throw new InvalidFieldTypeException(containingClass, fieldName, "@" + Self.class.getSimpleName() + " applies only to References");
}
Expand All @@ -246,7 +235,6 @@ private static Collection<Type> validateConstructorParameter(Class<?> containing
throw new InvalidFieldTypeException(containingClass, fieldName, "@" + Self.class.getSimpleName() + " reference to " + rawClass(referencedType).getSimpleName() + " incompatible with containing class " + containingClass.getSimpleName());
}
}
return List.of(parameter.getParameterizedType());
}

private static void validateFieldName(Class<?> containingClass, String fieldName) throws InvalidFieldTypeException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import works.bosk.Path;
import works.bosk.Phantom;
import works.bosk.Reference;
import works.bosk.ReferenceUtils;
import works.bosk.SerializationPlugin;
import works.bosk.SideTable;
import works.bosk.StateTreeNode;
Expand All @@ -52,7 +53,6 @@
import static works.bosk.ReferenceUtils.gettersForConstructorParameters;
import static works.bosk.ReferenceUtils.parameterType;
import static works.bosk.ReferenceUtils.rawClass;
import static works.bosk.ReferenceUtils.theOnlyConstructorFor;
import static works.bosk.bytecode.ClassBuilder.here;

/**
Expand Down Expand Up @@ -248,7 +248,7 @@ private Step newSegmentStep(Type currentType, String segment, int segmentNum, St
// InvalidTypeException here instead of adding the getter to the map. -pdoyle
getters.put(segment, getterMethod(currentClass, segment));

Step fieldStep = newFieldStep(segment, getters, theOnlyConstructorFor(currentClass));
Step fieldStep = newFieldStep(segment, getters, ReferenceUtils.getCanonicalConstructor(currentClass));
Class<?> fieldClass = rawClass(fieldStep.targetType());
Map<String, Type> typeMap = SerializationPlugin.getVariantCaseMapIfAny(fieldStep.targetClass());
if (typeMap != null) {
Expand Down
Loading

0 comments on commit 32c5a6a

Please sign in to comment.