From a20ef7b5bac16d169b9d19091be3320ab27b2491 Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Fri, 9 Feb 2024 22:06:57 -0500 Subject: [PATCH 1/2] feat: $V for inlined values in generated code If you have an instance you want to put inside the generated code, and you trust it, you can use the new "$V" argument spec to tell JavaPoet to generate a Supplier lambda to construct the object and immediately call it. --- README.md | 137 ++++ .../java/com/squareup/javapoet/CodeBlock.java | 17 + .../com/squareup/javapoet/CodeWriter.java | 5 + .../com/squareup/javapoet/NameAllocator.java | 4 +- .../com/squareup/javapoet/ObjectInliner.java | 659 ++++++++++++++++++ .../com/squareup/javapoet/TypeInliner.java | 6 + .../com/squareup/javapoet/CodeBlockTest.java | 8 + .../squareup/javapoet/ObjectInlinerTest.java | 632 +++++++++++++++++ 8 files changed, 1466 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/squareup/javapoet/ObjectInliner.java create mode 100644 src/main/java/com/squareup/javapoet/TypeInliner.java create mode 100644 src/test/java/com/squareup/javapoet/ObjectInlinerTest.java diff --git a/README.md b/README.md index 0cfbddc01..77fad6547 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,143 @@ public final class HelloWorld { } ``` +### $V for inlined values + +Sometimes you have an object that you want to reconstruct in generated code. +But that object has over 20 fields, and each of those fields has over 20 fields. +To simplify this reconstruction process, you can use **`$V`** to emit an **inlined** value: + +```java +import com.squareup.javapoet.ObjectInliner; + +private MethodSpec computeSomething(String name, MyComplexConfig config) { + ObjectInliner inliner = new ObjectInliner() + .trustExactTypes(MyComplexConfig.class); + return MethodSpec.methodBuilder(name) + .returns(int.class) + .addStatement("return MyComplexCalculation.calculate($V);", inliner.inlined(config)) + .build(); +} +``` + +Inlined values are constructed inside a generated `java.util.function.Supplier` lambda that constructs +the value from its fields. +For the above example, the generated code might look like this: +```java +int name() { + return MyComplexCalculation.calculate( + ((MyComplexConfig)((java.util.function.Supplier)(() -> { + MyComplexConfig $$javapoet$MyComplexConfig = new MyComplexConfig(); + $$javapoet$MyComplexConfig.setParameter1(1); + $$javapoet$MyComplexConfig.setParameter2("abc"); + // ... + return $$javapoet$MyComplexConfig; + })).get())); +} +``` +The generated code will invoke getters and setters on the passed instance, +so make sure you **trust** any values you inline with **`$V`**. +By default, only `java.lang.Object` is trusted. +You can trust additional object types by calling `trustExactTypes` and +`trustTypesAssignableTo`: + +```java +import com.squareup.javapoet.ObjectInliner; + +private MethodSpec computeSomething(String name, MyComplexConfig config) { + ObjectInliner inliner = new ObjectInliner() + .trustTypesAssignableTo(List.class) + .trustExactTypes(MyComplexConfig.class); + //... +} +``` +If you trust everything, you can call `trustEverything`. Although please don't do this with user controlled instances. + +```java +import com.squareup.javapoet.ObjectInliner; + +private MethodSpec computeSomething(String name, MyComplexConfig config) { + ObjectInliner inliner = new ObjectInliner() + .trustEverything(); + //... +} +``` + +If you don't pass an instance of `ObjectInliner.Inlined`, it will be +inlined by `ObjectInliner.getDefault()`. + +```java +import com.squareup.javapoet.ObjectInliner; + +private MethodSpec computeSomething(String name, MyComplexConfig config) { + ObjectInliner.getDefault() + .trustEverything(); + return MethodSpec.methodBuilder(name) + .returns(int.class) + // config will be inlined by ObjectInliner.getDefault() + .addStatement("return MyComplexCalculation.calculate($V);", config) + .build(); +} +``` + +By default, the following objects can be inlined if trusted: + +- Primitives and their boxed types (trusted by default) +- String (trusted by default) +- `java.lang.Class` (trusted by default) +- Instances of `java.lang.Enum` (trusted by default) +- Arrays of inlinable trusted objects (trusted by default) +- Lists, Sets, and Maps of inlineable trusted objects (not trusted by default) +- Objects that have public setters for all non-public fields (not trusted by default) +- Records of inlineable trusted objects (not trusted by default) + +You can register custom inliners for types not covered by the above using `TypeInliner`: + +```java +import com.squareup.javapoet.CodeBlock; +import com.squareup.javapoet.TypeInliner; +import com.squareup.javapoet.ObjectInliner; + +import java.time.Duration; + +private MethodSpec computeSomething(String name, MyComplexConfig config) { + ObjectInliner.getDefault() + .trustEverything() + .addTypeInliner(new TypeInliner() { + @Override + public boolean canInline(Class type) { + return type.equals(Duration.class); + } + + @Override + public String inline(ObjectInliner inliner, Object instance) { + Duration duration = (Duration) instance; + return CodeBlock.of("$T.ofNanos($V);", Duration.class, duration.toNanos()).toString(); + } + }); + return MethodSpec.methodBuilder(name) + .returns(int.class) + .addStatement("return MyComplexCalculation.calculate($V);", Duration.ofSeconds(1L)) + .build(); +} +``` + +If the default name prefix `$$javapoet$` conflicts with any variables in your generated code, +you can specify a different one using `useNamePrefix`: + +```java +import com.squareup.javapoet.ObjectInliner; + +private MethodSpec computeSomething(String name, MyComplexConfig config) { + ObjectInliner.getDefault() + .useNamePrefix("myPrefix"); + return MethodSpec.methodBuilder(name) + .returns(int.class) + .addStatement("return MyComplexCalculation.calculate($V);", config) + .build(); +} +``` + #### Import static JavaPoet supports `import static`. It does it via explicitly collecting type member names. Let's diff --git a/src/main/java/com/squareup/javapoet/CodeBlock.java b/src/main/java/com/squareup/javapoet/CodeBlock.java index 02542f58b..fb1373e57 100644 --- a/src/main/java/com/squareup/javapoet/CodeBlock.java +++ b/src/main/java/com/squareup/javapoet/CodeBlock.java @@ -51,6 +51,13 @@ *
  • {@code $T} emits a type reference. Types will be imported if possible. Arguments * for types may be {@linkplain Class classes}, {@linkplain javax.lang.model.type.TypeMirror ,* type mirrors}, and {@linkplain javax.lang.model.element.Element elements}. + *
  • {@code $V} emits an inlined-value. The inlined-value can be a primitive, + * {@linkplain Enum an enum value}, {@link Class a class constant}, an instance of a class + * with setters for all non-public instance fields, and arrays, collections, and maps + * containing inlinable values. An inlined-value can be assigned to a variable, passed + * as a parameter to a method, and generally can be used as an object with all its methods + * available for use. The inlined-value will be a raw type; cast to a generic type if + * needed. *
  • {@code $$} emits a dollar sign. *
  • {@code $W} emits a space or a newline, depending on its position on the line. This prefers * to wrap lines before 100 columns. @@ -329,6 +336,9 @@ private void addArgument(String format, char c, Object arg) { case 'T': this.args.add(argToType(arg)); break; + case 'V': + this.args.add(argToInlinedValue(arg)); + break; default: throw new IllegalArgumentException( String.format("invalid format string: '%s'", format)); @@ -360,6 +370,13 @@ private TypeName argToType(Object o) { throw new IllegalArgumentException("expected type but was " + o); } + private ObjectInliner.Inlined argToInlinedValue(Object o) { + if (o instanceof ObjectInliner.Inlined) { + return (ObjectInliner.Inlined) o; + } + return ObjectInliner.getDefault().inlined(o); + } + /** * @param controlFlow the control flow construct and its code, such as "if (foo == 5)". * Shouldn't contain braces or newline characters. diff --git a/src/main/java/com/squareup/javapoet/CodeWriter.java b/src/main/java/com/squareup/javapoet/CodeWriter.java index da6d3c8e5..6d1ce50e8 100644 --- a/src/main/java/com/squareup/javapoet/CodeWriter.java +++ b/src/main/java/com/squareup/javapoet/CodeWriter.java @@ -268,6 +268,11 @@ public CodeWriter emit(CodeBlock codeBlock, boolean ensureTrailingNewline) throw typeName.emit(this); break; + case "$V": + ObjectInliner.Inlined inlined = (ObjectInliner.Inlined) codeBlock.args.get(a++); + inlined.emit(this); + break; + case "$$": emitAndIndent("$"); break; diff --git a/src/main/java/com/squareup/javapoet/NameAllocator.java b/src/main/java/com/squareup/javapoet/NameAllocator.java index 8269664f4..970c71ac9 100644 --- a/src/main/java/com/squareup/javapoet/NameAllocator.java +++ b/src/main/java/com/squareup/javapoet/NameAllocator.java @@ -86,8 +86,8 @@ public NameAllocator() { this(new LinkedHashSet<>(), new LinkedHashMap<>()); } - private NameAllocator(LinkedHashSet allocatedNames, - LinkedHashMap tagToName) { + NameAllocator(Set allocatedNames, + Map tagToName) { this.allocatedNames = allocatedNames; this.tagToName = tagToName; } diff --git a/src/main/java/com/squareup/javapoet/ObjectInliner.java b/src/main/java/com/squareup/javapoet/ObjectInliner.java new file mode 100644 index 000000000..209995c53 --- /dev/null +++ b/src/main/java/com/squareup/javapoet/ObjectInliner.java @@ -0,0 +1,659 @@ +package com.squareup.javapoet; + +import java.io.IOException; +import java.lang.reflect.Array; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Supplier; + +public class ObjectInliner { + private static final String DEFAULT_NAME_PREFIX = "$$javapoet$"; + private static final ObjectInliner DEFAULT = new ObjectInliner(); + // We need to use object identity as a key in the map + private final IdentityHashMap pojoNameMap = new IdentityHashMap<>(); + private final Set possibleCircularRecordReferenceSet = Collections + .newSetFromMap(new IdentityHashMap<>()); + private final List typeInliners = new ArrayList<>(); + + // You probably should not be using this class + // if you did not construct the instance yourself. + /** + * This is the Set of all exact types we trust, + * to prevent malicious classes with malicious setters + * from being generated. + */ + private final Set> trustedExactTypes = new HashSet<>(); + + /** + * This is the Set of all assignable types we trust, + * which allows anything that can be assigned to them to + * be generated. Be careful with these; there can be a + * malicious subclass you do not know about. + */ + private final Set> trustedAssignableTypes = new HashSet<>(); + + private int inlineLevel = 0; + private NameAllocator nameAllocator; + private String suggestedNamePrefix; + + public ObjectInliner() { + this.suggestedNamePrefix = DEFAULT_NAME_PREFIX; + } + + public static ObjectInliner getDefault() { + return DEFAULT; + } + + public ObjectInliner useNamePrefix(String namePrefix) { + suggestedNamePrefix = namePrefix; + return this; + } + + public ObjectInliner addTypeInliner(TypeInliner typeInliner) { + typeInliners.add(typeInliner); + return this; + } + + /** + * Trust everything assignable to the given type. Do not call this method + * if users can create subclasses of that type, since this will allow + * the user to execute arbitrary code in the generated class. + */ + public ObjectInliner trustTypesAssignableTo(Class assignableType) { + trustedAssignableTypes.add(assignableType); + return this; + } + + /** + * Trust the exact types reachable from the given type. + * These are: + *
      + *
    • The type of its fields. + *
    • The type of any fields inherited from a superclass. + *
    • The type and its supertypes. + *
    + */ + public ObjectInliner trustExactTypes(Class exactType) { + if (trustedExactTypes.contains(exactType)) { + return this; + } + trustedExactTypes.add(exactType); + for (Field field : exactType.getDeclaredFields()) { + trustExactTypes(field.getType()); + } + if (exactType.getSuperclass() != null) { + trustExactTypes(exactType.getSuperclass()); + } + return this; + } + + /** + * Trust everything. Do not call this method if you are passing in + * user-generated instances of arbitrary types, since this will allow + * the user to execute arbitrary code in the generated class. + */ + public ObjectInliner trustEverything() { + return trustTypesAssignableTo(Object.class); + } + + public static class Inlined { + private final ObjectInliner inliner; + private final Object value; + + public Inlined(ObjectInliner inliner, Object value) { + this.inliner = inliner; + this.value = value; + } + + public ObjectInliner getInliner() { + return inliner; + } + + public Object getValue() { + return value; + } + + void emit(CodeWriter codeWriter) throws IOException { + inliner.emit(codeWriter, value); + } + + @Override + public boolean equals(Object object) { + if (this == object) + return true; + if (object == null || getClass() != object.getClass()) + return false; + Inlined inlined = (Inlined) object; + return Objects.equals(inliner, inlined.inliner) + && Objects.equals(value, inlined.value); + } + + @Override + public int hashCode() { + return Objects.hash(inliner, value); + } + } + + public Inlined inlined(Object object) { + return new Inlined(this, object); + } + + void emit(CodeWriter builder, Object object) throws IOException { + if (object == null) { + builder.emit("null"); + return; + } + + // Inline tracks how many nested inline calls there are. + // The one at the top level is responsible for creating the + // name allocator; inner inline calls must use the same allocator + // to provide name clashes. + if (inlineLevel == 0) { + nameAllocator = new NameAllocator(new LinkedHashSet<>(), + pojoNameMap); + } + inlineLevel++; + + // Does a neat trick, so we can get a code block in a fragment + // It defines an inline supplier and immediately calls it. + try { + builder.emit("(($T)(($T)(()->{", getSerializedType(object.getClass()), Supplier.class); + builder.emit("\nreturn $L;\n})).get())", getInlinedPojo(builder, object)); + } finally { + inlineLevel--; + // There are no inner inline calls, so we can clear the name allocator + // (since the names are scoped inside the supplier lambda block, which is now + // closed). + if (inlineLevel == 0) { + pojoNameMap.clear(); + nameAllocator = null; + } + } + } + + /** + * Serializes a Pojo to code that uses its no-args constructor + * and setters to create the object. + * + * @param pojo The object to be serialized. + * @return A string that can be used in a {@link CodeBlock.Builder} to access the object + */ + String getInlinedPojo(CodeWriter builder, Object pojo) throws IOException { + // First, check for primitives + if (pojo == null) { + return "null"; + } + if (pojo instanceof Boolean) { + return pojo.toString(); + } + if (pojo instanceof Byte) { + // Cast to byte + return "((byte) " + pojo + ")"; + } + if (pojo instanceof Character) { + return "'\\u" + Integer.toHexString(((char) pojo) | 0x10000) + .substring(1) + "'"; + } + if (pojo instanceof Short) { + // Cast to short + return "((short) " + pojo + ")"; + } + if (pojo instanceof Integer) { + return pojo.toString(); + } + if (pojo instanceof Long) { + // Add long suffix to number string + return pojo + "L"; + } + if (pojo instanceof Float) { + // Add float suffix to number string + return pojo + "f"; + } + if (pojo instanceof Double) { + // Add double suffix to number string + return pojo + "d"; + } + + // Check for builtin classes + if (pojo instanceof String) { + return CodeBlock.builder().add("$S", pojo).build().toString(); + } + if (pojo instanceof Class) { + Class value = (Class) pojo; + if (!Modifier.isPublic(value.getModifiers())) { + throw new IllegalArgumentException("Cannot serialize (" + value + + ") because it is not a public class."); + } + return value.getCanonicalName() + ".class"; + } + if (pojo.getClass().isEnum()) { + // Use field access to read the enum + Class enumClass = pojo.getClass(); + Enum pojoEnum = (Enum) pojo; + if (!Modifier.isPublic(enumClass.getModifiers())) { + // Use name() since toString() can be malicious + throw new IllegalArgumentException( + "Cannot serialize (" + pojoEnum.name() + + ") because its type (" + enumClass + + ") is not a public class."); + } + + return enumClass.getCanonicalName() + "." + pojoEnum.name(); + } + + // We need to use a custom in-liner, which will potentially + // call methods on a user-supplied instances. Make sure we trust + // the type before continuing + if (!isTrustedType(pojo.getClass())) { + throw new IllegalArgumentException("Cannot serialize instance of (" + + pojo.getClass() + ") because it is not an instance of a trusted type."); + } + + // Check if any registered TypeInliner matches the class + Class type = pojo.getClass(); + for (TypeInliner typeInliner : typeInliners) { + if (typeInliner.canInline(type)) { + return typeInliner.inline(this, pojo); + } + } + + return getInlinedComplexPojo(builder, pojo); + } + + private boolean isTrustedType(Class query) { + if (query.isArray()) { + return query.getComponentType().isPrimitive() + || isTrustedType(query.getComponentType()); + } + for (Class trustedAssignableType : trustedAssignableTypes) { + if (trustedAssignableType.isAssignableFrom(query)) { + return true; + } + } + for (Class trustedExactType : trustedExactTypes) { + if (trustedExactType.equals(query)) { + return true; + } + } + return false; + } + + /** + * Return a string that can be used in a {@link CodeBlock.Builder} to + * access a complex object. + * + * @param pojo The object to be accessed + * @return A string that can be used in a {@link CodeBlock.Builder} to + * access the object. + */ + private String getPojoValue(Object pojo) { + return nameAllocator.get(pojo); + } + + private static boolean isRecord(Object object) { + Class superClass = object.getClass().getSuperclass(); + return superClass != null && superClass.getName() + .equals("java.lang.Record"); + } + + /** + * Serializes collections and complex POJOs to code. + */ + private String getInlinedComplexPojo(CodeWriter builder, Object pojo) throws IOException { + if (possibleCircularRecordReferenceSet.contains(pojo)) { + // Records do not have a no-args constructor, so we cannot safely + // serialize self-references in records + // as we cannot do a map lookup before the record is created. + throw new IllegalArgumentException( + "Cannot serialize record of type (" + pojo.getClass() + + ") because it is a record containing a circular reference."); + } + + // If we already serialized the object, we should just return + // the code string + if (pojoNameMap.containsKey(pojo)) { + return getPojoValue(pojo); + } + if (isRecord(pojo)) { + // Records must set all fields at initialization time, + // so we delay the declaration of its variable + return getInlinedRecord(builder, pojo); + } + // Object is not serialized yet + // Create a new variable to store its value when setting its fields + String newIdentifier = nameAllocator.newName(suggestedNamePrefix + + getSerializedType(pojo.getClass()).getSimpleName(), pojo); + + // First, check if it is a collection type + if (pojo.getClass().isArray()) { + return getInlinedArray(builder, newIdentifier, pojo); + } + if (pojo instanceof List) { + return getInlinedList(builder, newIdentifier, (List) pojo); + } + if (pojo instanceof Set) { + return getInlinedSet(builder, newIdentifier, (Set) pojo); + } + if (pojo instanceof Map) { + return getInlinedMap(builder, newIdentifier, (Map) pojo); + } + + if (!Modifier.isPublic(pojo.getClass().getModifiers())) { + throw new IllegalArgumentException("Cannot serialize type (" + pojo.getClass() + + ") because it is not public."); + } + builder.emit("\n$T $N;", pojo.getClass(), newIdentifier); + try { + Constructor constructor = pojo.getClass().getConstructor(); + if (!Modifier.isPublic(constructor.getModifiers())) { + throw new IllegalArgumentException("Cannot serialize type (" + pojo.getClass() + + ") because its no-args constructor is not public."); + } + } catch (NoSuchMethodException e) { + throw new IllegalArgumentException("Cannot serialize type (" + pojo.getClass() + + ") because it does not have a public no-args constructor."); + } + builder.emit("\n$N = new $T();", newIdentifier, pojo.getClass()); + inlineFieldsOfPojo(builder, pojo.getClass(), newIdentifier, pojo); + return getPojoValue(pojo); + } + + private String getInlinedArray(CodeWriter builder, String newIdentifier, + Object array) throws IOException { + Class componentType = array.getClass().getComponentType(); + if (!Modifier.isPublic(componentType.getModifiers())) { + throw new IllegalArgumentException( + "Cannot serialize array of type (" + componentType + + ") because (" + componentType + ") is not public."); + } + builder.emit("\n$T $N;", array.getClass(), newIdentifier); + + // Get the length of the array + int length = Array.getLength(array); + + // Create a new array from the component type with the given length + builder.emit("\n$N = new $T[$L];", newIdentifier, + componentType, Integer.toString(length)); + for (int i = 0; i < length; i++) { + // Set the elements of the array + builder.emit("\n$N[$L] = $L;", + newIdentifier, + Integer.toString(i), + getInlinedPojo(builder, Array.get(array, i))); + } + return getPojoValue(array); + } + + private String getInlinedList(CodeWriter builder, String newIdentifier, + List list) throws IOException { + builder.emit("\n$T $N;", List.class, newIdentifier); + + // Create an ArrayList + builder.emit("\n$N = new $T($L);", newIdentifier, ArrayList.class, + Integer.toString(list.size())); + for (Object item : list) { + // Add each item of the list to the ArrayList + builder.emit("\n$N.add($L);", + newIdentifier, + getInlinedPojo(builder, item)); + } + return getPojoValue(list); + } + + private String getInlinedSet(CodeWriter builder, String newIdentifier, + Set set) throws IOException { + builder.emit("\n$T $N;", Set.class, newIdentifier); + + // Create a new HashSet + builder.emit("\n$N = new $T($L);", newIdentifier, LinkedHashSet.class, + Integer.toString(set.size())); + for (Object item : set) { + // Add each item of the set to the HashSet + builder.emit("\n$N.add($L);", + newIdentifier, + getInlinedPojo(builder, item)); + } + return getPojoValue(set); + } + + private String getInlinedMap(CodeWriter builder, String newIdentifier, + Map map) throws IOException { + builder.emit("\n$T $N;", Map.class, newIdentifier); + + // Create a HashMap + builder.emit("\n$N = new $T($L);", newIdentifier, LinkedHashMap.class, + Integer.toString(map.size())); + for (Map.Entry entry : map.entrySet()) { + // Put each entry of the map into the HashMap + builder.emit("\n$N.put($L, $L);", + newIdentifier, + getInlinedPojo(builder, entry.getKey()), + getInlinedPojo(builder, entry.getValue())); + } + return getPojoValue(map); + } + + // Workaround for Java 8 + private static final class RecordComponent { + private final Class type; + private final String name; + + private RecordComponent(Class type, String name) { + this.type = type; + this.name = name; + } + + public Class getType() { + return type; + } + + public String getName() { + return name; + } + + public Object getValue(Object record) { + try { + return record.getClass().getMethod(name).invoke(record); + } catch (InvocationTargetException | IllegalAccessException + | NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + } + + // Workaround for Java 8 + private static RecordComponent[] getRecordComponents(Class recordClass) { + try { + Object[] components = (Object[]) recordClass. + getMethod("getRecordComponents").invoke(recordClass); + RecordComponent[] out = new RecordComponent[components.length]; + for (int i = 0; i < components.length; i++) { + Object component = components[i]; + Class componentClass = component.getClass(); + Class type = (Class) componentClass + .getMethod("getType").invoke(component); + String name = (String) componentClass + .getMethod("getName").invoke(component); + out[i] = new RecordComponent(type, name); + } + return out; + } catch (InvocationTargetException | IllegalAccessException + | NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + private String getInlinedRecord(CodeWriter builder, Object record) + throws IOException { + possibleCircularRecordReferenceSet.add(record); + Class recordClass = record.getClass(); + if (!Modifier.isPublic(recordClass.getModifiers())) { + throw new IllegalArgumentException( + "Cannot serialize record type (" + recordClass + + ") because it is not public."); + } + + RecordComponent[] recordComponents = getRecordComponents(recordClass); + String[] componentAccessors = new String[recordComponents.length]; + for (int i = 0; i < recordComponents.length; i++) { + Object value; + Class serializedType = getSerializedType(recordComponents[i].getType()); + if (!recordComponents[i].getType().equals(serializedType)) { + throw new IllegalArgumentException( + "Cannot serialize type (" + recordClass + + ") as its component (" + recordComponents[i].getName() + + ") uses an implementation of a collection (" + + recordComponents[i].getType() + + ") instead of the interface type (" + + serializedType + ")."); + } + value = recordComponents[i].getValue(record); + try { + componentAccessors[i] = getInlinedPojo(builder, value); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Cannot serialize record type (" + + record.getClass() + ") because the type of its value (" + + value.getClass() + ") for its component (" + + recordComponents[i].getName() + ") is not serializable.", e); + } + } + // All components serialized, so no circular references + possibleCircularRecordReferenceSet.remove(record); + StringBuilder constructorArgs = new StringBuilder(); + for (String componentAccessor : componentAccessors) { + constructorArgs.append(componentAccessor).append(", "); + } + if (componentAccessors.length != 0) { + constructorArgs.delete(constructorArgs.length() - 2, + constructorArgs.length()); + } + String newIdentifier = nameAllocator.newName(suggestedNamePrefix + + recordClass.getSimpleName(), record); + builder.emit("\n$T $N = new $T($L);", recordClass, newIdentifier, + recordClass, constructorArgs.toString()); + return getPojoValue(record); + } + + static Class getSerializedType(Class query) { + if (List.class.isAssignableFrom(query)) { + return List.class; + } + if (Set.class.isAssignableFrom(query)) { + return Set.class; + } + if (Map.class.isAssignableFrom(query)) { + return Map.class; + } + return query; + } + + private static Method getSetterMethod(Class expectedArgumentType, Field field) { + Class declaringClass = field.getDeclaringClass(); + String fieldName = field.getName(); + + String methodName = "set" + Character.toUpperCase(fieldName.charAt(0)) + + fieldName.substring(1); + try { + return declaringClass.getMethod(methodName, expectedArgumentType); + } catch (NoSuchMethodException e) { + return null; + } + } + + /** + * Sets the fields of pojo declared in pojoClass and all its superclasses. + * + * @param pojoClass A class assignable to pojo containing some of its fields. + * @param identifier The name of the variable storing the serialized pojo. + * @param pojo The object being serialized. + */ + private void inlineFieldsOfPojo(CodeWriter builder, Class pojoClass, + String identifier, Object pojo) throws IOException { + if (pojoClass == Object.class) { + // We are the top-level, no more fields to set + return; + } + Field[] fields = pojoClass.getDeclaredFields(); + // Sort by name to guarantee a consistent ordering + Arrays.sort(fields, Comparator.comparing(Field::getName)); + for (Field field : fields) { + if (java.lang.reflect.Modifier.isStatic(field.getModifiers())) { + // We do not want to write static fields + continue; + } + if (Modifier.isPublic(field.getModifiers())) { + try { + builder.emit("\n$N.$N = $L;", identifier, + field.getName(), + getInlinedPojo(builder, field.get(pojo))); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + continue; + } + // Set the field accessible so we can read its value + field.setAccessible(true); + Class serializedType = getSerializedType(field.getType()); + Method setterMethod = getSetterMethod(serializedType, field); + // setterMethod guaranteed to be public + if (setterMethod == null) { + if (!field.getType().equals(serializedType)) { + throw new IllegalArgumentException( + "Cannot serialize type (" + pojoClass + + ") as its field (" + field.getName() + + ") uses an implementation of a collection (" + + field.getType() + + ") instead of the interface type (" + + serializedType + ")."); + } + throw new IllegalArgumentException( + "Cannot serialize type (" + pojoClass + + ") as it is missing a public setter method for field (" + + field.getName() + ") of type (" + field.getType() + ")."); + } + Object value; + try { + value = field.get(pojo); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + + try { + // Convert the field value to code, and call the setter + // corresponding to the field with the serialized field value. + builder.emit("\n$N.$N($L);", identifier, + setterMethod.getName(), + getInlinedPojo(builder, value)); + } catch (IllegalArgumentException e) { + // We trust pojo, but not necessary value + throw new IllegalArgumentException("Cannot serialize an instance of type (" + + pojo.getClass() + ") because the type of its value (" + + value.getClass() + + ") for its field (" + field.getName() + + ") is not serializable.", e); + } + } + try { + inlineFieldsOfPojo(builder, pojoClass.getSuperclass(), identifier, pojo); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Cannot serialize type (" + + pojoClass + ") because its superclass (" + + pojoClass.getSuperclass() + ") is not serializable.", e); + } + } +} diff --git a/src/main/java/com/squareup/javapoet/TypeInliner.java b/src/main/java/com/squareup/javapoet/TypeInliner.java new file mode 100644 index 000000000..e2a97cce5 --- /dev/null +++ b/src/main/java/com/squareup/javapoet/TypeInliner.java @@ -0,0 +1,6 @@ +package com.squareup.javapoet; + +public interface TypeInliner { + boolean canInline(Class type); + String inline(ObjectInliner inliner, Object instance); +} diff --git a/src/test/java/com/squareup/javapoet/CodeBlockTest.java b/src/test/java/com/squareup/javapoet/CodeBlockTest.java index 11b75fa4f..d7a369395 100644 --- a/src/test/java/com/squareup/javapoet/CodeBlockTest.java +++ b/src/test/java/com/squareup/javapoet/CodeBlockTest.java @@ -124,6 +124,14 @@ public final class CodeBlockTest { assertThat(block.toString()).isEqualTo("java.lang.String"); } + @Test public void inlinedValueFormatCanBeIndexed() { + CodeBlock block = CodeBlock.builder().add("$1V", "taco").build(); + assertThat(block.toString().replaceAll("\\s+", "")).isEqualTo( + ("((java.lang.String)((java.util.function.Supplier)(()->{\n" + + " return \"taco\";\n" + + " })).get())").replaceAll("\\s+", "")); + } + @Test public void simpleNamedArgument() { Map map = new LinkedHashMap<>(); map.put("text", "taco"); diff --git a/src/test/java/com/squareup/javapoet/ObjectInlinerTest.java b/src/test/java/com/squareup/javapoet/ObjectInlinerTest.java new file mode 100644 index 000000000..c2f76c37f --- /dev/null +++ b/src/test/java/com/squareup/javapoet/ObjectInlinerTest.java @@ -0,0 +1,632 @@ +package com.squareup.javapoet; + +import com.google.testing.compile.Compilation; +import com.google.testing.compile.CompilationSubject; +import org.junit.Assert; +import org.junit.Test; + +import javax.lang.model.element.Modifier; +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.testing.compile.Compiler.javac; + +public class ObjectInlinerTest { + public static class TrustedPojo { + public static final String TYPE = TrustedPojo.class.getCanonicalName(); + public String name; + private int value; + private TrustedPojo next; + + public TrustedPojo() { + } + + public TrustedPojo(String name, int value) { + this.name = name; + this.value = value; + } + + public int getValue() { + return value; + } + + public void setValue(int value) { + this.value = value; + } + + public TrustedPojo getNext() { + return next; + } + + public void setNext(TrustedPojo next) { + this.next = next; + } + + public TrustedPojo withNext(TrustedPojo next) { + this.next = next; + return this; + } + + @Override + public boolean equals(Object object) { + if (this == object) + return true; + if (object == null || getClass() != object.getClass()) + return false; + TrustedPojo that = (TrustedPojo) object; + return Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } + } + + private static String getInlineResult(Object object) { + return getInlineResult(object, (inliner) -> {}); + } + + private static String getInlineResult(Object object, + Consumer adapter) { + StringBuilder result = new StringBuilder(); + CodeWriter codeWriter = new CodeWriter(result); + ObjectInliner inliner = new ObjectInliner(); + adapter.accept(inliner); + try { + inliner.emit(codeWriter, object); + } catch (IOException e) { + throw new AssertionError("IOFailure", e); + } + return result.toString(); + } + + private String expectedResult(Class type, String lambdaBody) { + StringBuilder out = new StringBuilder(); + out.append("(("); + out.append(ObjectInliner.getSerializedType(type).getCanonicalName()); + out.append(")(("); + out.append(Supplier.class.getCanonicalName()); + out.append(")(()->{"); + out.append(lambdaBody); + out.append("})).get())"); + return out.toString().replaceAll("\\s+", ""); + } + + private String expectedInlinedTrustedPojo(TrustedPojo trustedPojo, String... identifiers) { + return expectedInlinedTrustedPojo(trustedPojo, Arrays.asList(identifiers)); + } + + private String expectedInlinedTrustedPojo(TrustedPojo trustedPojo, List identifiers) { + StringBuilder out = new StringBuilder() + .append(TrustedPojo.TYPE).append(identifiers.get(0)).append(";") + .append(identifiers.get(0)).append(" = new ") + .append(TrustedPojo.TYPE).append("();") + .append(identifiers.get(0)).append(".name = \"") + .append(trustedPojo.name).append("\";"); + + if (trustedPojo.next == null) { + out.append(identifiers.get(0)).append(".setNext(null);"); + } else if (trustedPojo.next == trustedPojo) { + out.append(identifiers.get(0)).append(".setNext(") + .append(identifiers.get(0)).append(");"); + } else { + out.append(expectedInlinedTrustedPojo(trustedPojo.next, + identifiers.subList(1, identifiers.size()))); + out.append(identifiers.get(0)).append(".setNext(") + .append(identifiers.get(1)).append(");"); + } + out.append(identifiers.get(0)).append(".setValue("). + append(trustedPojo.value).append(");"); + return out.toString(); + } + + private void assertCompiles(Class type, String value) { + JavaFile javaFile = JavaFile.builder("", TypeSpec + .classBuilder("TestClass") + .addField(ObjectInliner.getSerializedType(type), "field", Modifier.STATIC) + .addStaticBlock(CodeBlock.of("field = $L;", value)) + .build()).build(); + Compilation compilation = javac().compile(javaFile.toJavaFileObject()); + CompilationSubject.assertThat(compilation).succeeded(); + } + + private void assertResult(String expected, + Object object) { + assertResult(expected, object, inliner -> {}); + } + + private void assertResult(String expected, + Object object, + Consumer adapter) { + String result = getInlineResult(object, adapter); + assertThat(result.replaceAll("\\s+", "")).isEqualTo(expectedResult(object.getClass(), expected)); + assertCompiles(object.getClass(), result); + } + + private void assertThrows(String errorMessage, + Object object) { + assertThrows(errorMessage, object, inliner -> {}); + } + + private void assertThrows(String errorMessage, + Object object, + Consumer adapter) { + try { + getInlineResult(object, adapter); + Assert.fail("Expected an exception to be thrown."); + } catch (IllegalArgumentException e) { + assertThat(e).hasMessageThat().isEqualTo(errorMessage); + } + + } + + @Test + public void testInlineNull() { + assertThat(getInlineResult(null)).isEqualTo("null"); + } + + @Test + public void testInlineBool() { + assertResult("return true;", true); + } + + @Test + public void testInlineByte() { + assertResult("return ((byte) 1);", (byte) 1); + } + + @Test + public void testInlineChar() { + assertResult("return '\\u0061';", 'a'); + } + + @Test + public void testInlineShort() { + assertResult("return ((short) 1);", (short) 1); + } + + @Test + public void testInlineInt() { + assertResult("return 1;", 1); + } + + @Test + public void testInlineLong() { + assertResult("return 1L;", 1L); + } + + @Test + public void testInlineFloat() { + assertResult("return 1.0f;", 1.0f); + } + + @Test + public void testInlineDouble() { + assertResult("return 1.0d;", 1.0d); + } + + @Test + public void testInlineClass() { + assertResult("return " + List.class.getCanonicalName() + ".class;", List.class); + } + + public enum MyEnum { + VALUE + } + + @Test + public void testInlineEnum() { + assertResult("return " + MyEnum.class.getCanonicalName() + ".VALUE;", MyEnum.VALUE); + } + + @Test + public void testPrimitiveArrays() { + assertResult(new StringBuilder() + .append("int[] $$javapoet$int__;") + .append("$$javapoet$int__ = new int[3];") + .append("$$javapoet$int__[0] = 1;") + .append("$$javapoet$int__[1] = 2;") + .append("$$javapoet$int__[2] = 3;") + .append("return $$javapoet$int__;") + .toString(), new int[] {1, 2, 3}); + } + + @Test + public void testObjectArrays() { + TrustedPojo[] inlined = new TrustedPojo[] { + new TrustedPojo("a", 1), + new TrustedPojo("b", 2) + .withNext(new TrustedPojo("c", 3)) + }; + assertResult(new StringBuilder() + .append(TrustedPojo.TYPE).append("[] ") + .append("$$javapoet$TrustedPojo__;") + .append("$$javapoet$TrustedPojo__ = new ") + .append(TrustedPojo.TYPE).append("[2];") + .append(expectedInlinedTrustedPojo(inlined[0], "$$javapoet$TrustedPojo")) + .append("$$javapoet$TrustedPojo__[0] = $$javapoet$TrustedPojo;") + .append(expectedInlinedTrustedPojo(inlined[1], + "$$javapoet$TrustedPojo_", + "$$javapoet$TrustedPojo___")) + .append("$$javapoet$TrustedPojo__[1] = $$javapoet$TrustedPojo_;") + .append("return $$javapoet$TrustedPojo__;") + .toString(), + inlined, + inliner -> inliner.trustExactTypes(TrustedPojo.class)); + } + + @Test + public void testPrimitiveList() { + assertResult(new StringBuilder() + .append(List.class.getCanonicalName()).append(" $$javapoet$List;") + .append("$$javapoet$List = new ") + .append(ArrayList.class.getCanonicalName()).append("(3);") + .append("$$javapoet$List.add(1);") + .append("$$javapoet$List.add(2);") + .append("$$javapoet$List.add(3);") + .append("return $$javapoet$List;") + .toString(), Arrays.asList(1, 2, 3), + inliner -> inliner.trustTypesAssignableTo(List.class)); + } + + @Test + public void testObjectList() { + List inlined = Arrays.asList( + new TrustedPojo("a", 1), + new TrustedPojo("b", 2) + .withNext(new TrustedPojo("c", 3))); + assertResult(new StringBuilder() + .append(List.class.getCanonicalName()).append(" $$javapoet$List;") + .append("$$javapoet$List = new ") + .append(ArrayList.class.getCanonicalName()).append("(2);") + .append(expectedInlinedTrustedPojo(inlined.get(0), "$$javapoet$TrustedPojo")) + .append("$$javapoet$List.add($$javapoet$TrustedPojo);") + .append(expectedInlinedTrustedPojo(inlined.get(1), + "$$javapoet$TrustedPojo_", + "$$javapoet$TrustedPojo__")) + .append("$$javapoet$List.add($$javapoet$TrustedPojo_);") + .append("return $$javapoet$List;") + .toString(), + inlined, + inliner -> inliner.trustTypesAssignableTo(List.class) + .trustExactTypes(TrustedPojo.class)); + } + + @Test + public void testPrimitiveSet() { + assertResult(new StringBuilder() + .append(Set.class.getCanonicalName()).append(" $$javapoet$Set;") + .append("$$javapoet$Set = new ") + .append(LinkedHashSet.class.getCanonicalName()).append("(3);") + .append("$$javapoet$Set.add(1);") + .append("$$javapoet$Set.add(2);") + .append("$$javapoet$Set.add(3);") + .append("return $$javapoet$Set;") + .toString(), new LinkedHashSet<>(Arrays.asList(1, 2, 3)), + inliner -> inliner + .trustTypesAssignableTo(Set.class)); + } + + @Test + public void testObjectSet() { + Set inlined = new LinkedHashSet<>(Arrays.asList( + new TrustedPojo("a", 1), + new TrustedPojo("b", 2) + .withNext(new TrustedPojo("c", 3)))); + + Iterator iterator = inlined.iterator(); + assertResult(new StringBuilder() + .append(Set.class.getCanonicalName()).append(" $$javapoet$Set;") + .append("$$javapoet$Set = new ") + .append(LinkedHashSet.class.getCanonicalName()).append("(2);") + .append(expectedInlinedTrustedPojo(iterator.next(), "$$javapoet$TrustedPojo")) + .append("$$javapoet$Set.add($$javapoet$TrustedPojo);") + .append(expectedInlinedTrustedPojo(iterator.next(), + "$$javapoet$TrustedPojo_", + "$$javapoet$TrustedPojo__")) + .append("$$javapoet$Set.add($$javapoet$TrustedPojo_);") + .append("return $$javapoet$Set;") + .toString(), + inlined, + inliner -> inliner + .trustTypesAssignableTo(Set.class) + .trustExactTypes(TrustedPojo.class)); + } + + @Test + public void testPrimitiveMap() { + Map map = new LinkedHashMap<>(); + map.put("a", 1); + map.put("b", 2); + map.put("c", 3); + assertResult(new StringBuilder() + .append(Map.class.getCanonicalName()).append(" $$javapoet$Map;") + .append("$$javapoet$Map = new ") + .append(LinkedHashMap.class.getCanonicalName()).append("(3);") + .append("$$javapoet$Map.put(\"a\", 1);") + .append("$$javapoet$Map.put(\"b\", 2);") + .append("$$javapoet$Map.put(\"c\", 3);") + .append("return $$javapoet$Map;") + .toString(), map, + inliner -> inliner.trustTypesAssignableTo(Map.class)); + } + + @Test + public void testObjectValueMap() { + Map map = new LinkedHashMap<>(); + map.put("a", new TrustedPojo("a", 1)); + map.put("b", new TrustedPojo("b", 2).withNext(new TrustedPojo("c", 3))); + assertResult(new StringBuilder() + .append(Map.class.getCanonicalName()).append(" $$javapoet$Map;") + .append("$$javapoet$Map = new ") + .append(LinkedHashMap.class.getCanonicalName()).append("(2);") + .append(expectedInlinedTrustedPojo(map.get("a"), "$$javapoet$TrustedPojo")) + .append("$$javapoet$Map.put(\"a\", $$javapoet$TrustedPojo);") + .append(expectedInlinedTrustedPojo(map.get("b"), "$$javapoet$TrustedPojo_", "$$javapoet$TrustedPojo__")) + .append("$$javapoet$Map.put(\"b\", $$javapoet$TrustedPojo_);") + .append("return $$javapoet$Map;") + .toString(), map, + inliner -> inliner + .trustTypesAssignableTo(Map.class) + .trustExactTypes(TrustedPojo.class)); + } + + @Test + public void testObjectKeyMap() { + Map map = new LinkedHashMap<>(); + map.put(new TrustedPojo("a", 1), 1); + map.put(new TrustedPojo("b", 2).withNext(new TrustedPojo("c", 3)), 2); + Iterator iterator = map.keySet().iterator(); + assertResult(new StringBuilder() + .append(Map.class.getCanonicalName()).append(" $$javapoet$Map;") + .append("$$javapoet$Map = new ") + .append(LinkedHashMap.class.getCanonicalName()).append("(2);") + .append(expectedInlinedTrustedPojo(iterator.next(), "$$javapoet$TrustedPojo")) + .append("$$javapoet$Map.put($$javapoet$TrustedPojo, 1);") + .append(expectedInlinedTrustedPojo(iterator.next(), "$$javapoet$TrustedPojo_", "$$javapoet$TrustedPojo__")) + .append("$$javapoet$Map.put($$javapoet$TrustedPojo_, 2);") + .append("return $$javapoet$Map;") + .toString(), map, + inliner -> inliner + .trustTypesAssignableTo(Map.class) + .trustExactTypes(TrustedPojo.class)); + } + + @Test + public void testTrustedObject() { + TrustedPojo pojo = new TrustedPojo("a", 1) + .withNext(new TrustedPojo("b", 2)); + assertResult(new StringBuilder() + .append(expectedInlinedTrustedPojo(pojo, "$$javapoet$TrustedPojo", "$$javapoet$TrustedPojo_")) + .append("return $$javapoet$TrustedPojo;") + .toString(), pojo, inliner -> inliner.trustExactTypes(TrustedPojo.class)); + } + + @Test + public void testTrustedObjectWithSelfReference() { + TrustedPojo pojo = new TrustedPojo("a", 1); + pojo.setNext(pojo); + assertResult(new StringBuilder() + .append(expectedInlinedTrustedPojo(pojo, "$$javapoet$TrustedPojo")) + .append("return $$javapoet$TrustedPojo;") + .toString(), pojo, inliner -> inliner.trustExactTypes(TrustedPojo.class)); + } + + @Test + public void testUntrustedObjectThrows() { + TrustedPojo pojo = new TrustedPojo("a", 1); + pojo.setNext(pojo); + assertThrows("Cannot serialize instance of (" + TrustedPojo.class + + ") because it is not an instance of a trusted type.", pojo); + } + + @Test + public void testUntrustedCollectionThrows() { + assertThrows("Cannot serialize instance of (" + ArrayList.class + + ") because it is not an instance of a trusted type.", new ArrayList<>()); + assertThrows("Cannot serialize instance of (" + LinkedHashSet.class + + ") because it is not an instance of a trusted type.", new LinkedHashSet<>()); + assertThrows("Cannot serialize instance of (" + LinkedHashMap.class + + ") because it is not an instance of a trusted type.", new LinkedHashMap<>()); + } + + private static class PrivatePojo { + + } + + @Test + public void testPrivatePojoThrows() { + PrivatePojo pojo = new PrivatePojo(); + assertThrows("Cannot serialize type (" + PrivatePojo.class + + ") because it is not public.", pojo, + inliner -> inliner.trustExactTypes(PrivatePojo.class)); + } + + public static class MissingSetterPojo { + private String value; + + public MissingSetterPojo() { + } + + public MissingSetterPojo(String value) { + this.value = value; + } + } + + @Test + public void testMissingSetterPojoThrows() { + MissingSetterPojo pojo = new MissingSetterPojo(); + assertThrows("Cannot serialize type (" + MissingSetterPojo.class + + ") as it is missing a public setter method for field (value)" + + " of type (class java.lang.String).", pojo, + inliner -> inliner.trustExactTypes(MissingSetterPojo.class)); + } + + public static class PrivateSetterPojo { + private String value; + + public PrivateSetterPojo() { + } + + public PrivateSetterPojo(String value) { + this.value = value; + } + + private String getValue() { + return value; + } + + private void setValue(String value) { + this.value = value; + } + } + + @Test + public void testPrivateSetterPojoThrows() { + PrivateSetterPojo pojo = new PrivateSetterPojo(); + assertThrows("Cannot serialize type (" + PrivateSetterPojo.class + + ") as it is missing a public setter method for field (value)" + + " of type (class java.lang.String).", pojo, + inliner -> inliner.trustExactTypes(PrivateSetterPojo.class)); + } + + public static class MissingConstructorPojo { + public MissingConstructorPojo(String value) { + } + } + + @Test + public void testMissingConstructorPojoThrows() { + MissingConstructorPojo pojo = new MissingConstructorPojo("a"); + assertThrows("Cannot serialize type (" + MissingConstructorPojo.class + + ") because it does not have a public no-args constructor.", pojo, + inliner -> inliner.trustExactTypes(MissingConstructorPojo.class)); + } + + public static class PrivateConstructorPojo { + private PrivateConstructorPojo() { + } + } + + @Test + public void testPrivateConstructorPojoThrows() { + PrivateConstructorPojo pojo = new PrivateConstructorPojo(); + assertThrows("Cannot serialize type (" + PrivateConstructorPojo.class + + ") because it does not have a public no-args constructor.", pojo, + inliner -> inliner.trustExactTypes(PrivateConstructorPojo.class)); + } + + public static class RawTypePojo { + private final static String TYPE = RawTypePojo.class.getCanonicalName(); + public Object value; + + public RawTypePojo() { + } + + public RawTypePojo(Object value) { + this.value = value; + } + } + + @Test + public void testRawTypePojoWithTrustedValue() { + RawTypePojo pojo = new RawTypePojo("taco"); + assertResult(new StringBuilder() + .append(RawTypePojo.TYPE).append(" $$javapoet$RawTypePojo;") + .append(" $$javapoet$RawTypePojo = new ") + .append(RawTypePojo.TYPE).append("();") + .append("$$javapoet$RawTypePojo.value = \"taco\";") + .append("return $$javapoet$RawTypePojo;") + .toString(), + pojo, + inliner -> inliner.trustExactTypes(RawTypePojo.class)); + } + + @Test + public void testRawTypePojoWithUntrustedFieldThrows() { + RawTypePojo pojo = new RawTypePojo(new ArrayList<>()); + assertThrows("Cannot serialize instance of (" + ArrayList.class + + ") because it is not an instance of a trusted type.", + pojo, + inliner -> inliner.trustExactTypes(RawTypePojo.class)); + } + + private static class DurationInliner implements TypeInliner { + + @Override + public boolean canInline(Class type) { + return type.equals(Duration.class); + } + + @Override + public String inline(ObjectInliner inliner, Object instance) { + Duration duration = (Duration) instance; + return CodeBlock.of("$T.ofNanos($V)", Duration.class, + duration.toNanos()).toString(); + } + } + + @Test + public void testCustomInliner() { + Duration duration = Duration.ofSeconds(1L); + String inlinedNanos = CodeBlock.of("$V", duration.toNanos()).toString(); + assertResult(new StringBuilder("return ") + .append(Duration.class.getCanonicalName()) + .append(".ofNanos(") + .append(inlinedNanos) + .append(");") + .toString(), + duration, + inliner -> inliner + .trustExactTypes(Duration.class) + .addTypeInliner(new DurationInliner())); + } + + @Test + public void testCustomInlinerNotCalledOnUntrustedTypes() { + Duration duration = Duration.ofSeconds(1L); + assertThrows("Cannot serialize instance of (" + Duration.class + ") because it is not an instance of a trusted type.", + duration, + inliner -> inliner + .addTypeInliner(new DurationInliner())); + } + + @Test + public void testCustomInlinerIgnoresUnknownTypes() { + TrustedPojo pojo = new TrustedPojo("a", 1); + assertResult(new StringBuilder() + .append(expectedInlinedTrustedPojo(pojo, "$$javapoet$TrustedPojo")) + .append("return $$javapoet$TrustedPojo;") + .toString(), + pojo, + inliner -> inliner + .trustExactTypes(TrustedPojo.class) + .addTypeInliner(new DurationInliner())); + } + + @Test + public void testUseCustomPrefix() { + TrustedPojo pojo = new TrustedPojo("a", 1); + assertResult(new StringBuilder() + .append(expectedInlinedTrustedPojo(pojo, "test$TrustedPojo")) + .append("return test$TrustedPojo;") + .toString(), + pojo, + inliner -> inliner + .useNamePrefix("test$") + .trustExactTypes(TrustedPojo.class)); + } +} From 4eb0de13d0442ea33e81fa342b8ececab91d5d9c Mon Sep 17 00:00:00 2001 From: Christopher Chianelli Date: Mon, 12 Feb 2024 23:49:03 -0500 Subject: [PATCH 2/2] chore: allow multithreaded use of $V Previously, if multiple threads were inlining using the same ObjectInliner, the internal state of the inliner would be corrupted. This is fixed by creating a new ObjectEmitter whenever a value is being inlined; the ObjectEmitters created from an ObjectInliner are completely independent and thus cannot be corrupted by multithreaded use. Also undos the unneccessary NameAllocator change, update the API for TypeInliner, and adds more tests for custom inlining. --- README.md | 9 +- .../com/squareup/javapoet/NameAllocator.java | 4 +- .../com/squareup/javapoet/ObjectEmitter.java | 609 ++++++++++++++++++ .../com/squareup/javapoet/ObjectInliner.java | 569 +--------------- .../com/squareup/javapoet/TypeInliner.java | 47 +- .../squareup/javapoet/ObjectInlinerTest.java | 207 +++++- 6 files changed, 875 insertions(+), 570 deletions(-) create mode 100644 src/main/java/com/squareup/javapoet/ObjectEmitter.java diff --git a/README.md b/README.md index 77fad6547..f5036c2cf 100644 --- a/README.md +++ b/README.md @@ -452,6 +452,7 @@ You can register custom inliners for types not covered by the above using `TypeI ```java import com.squareup.javapoet.CodeBlock; +import com.squareup.javapoet.ObjectEmitter; import com.squareup.javapoet.TypeInliner; import com.squareup.javapoet.ObjectInliner; @@ -462,14 +463,14 @@ private MethodSpec computeSomething(String name, MyComplexConfig config) { .trustEverything() .addTypeInliner(new TypeInliner() { @Override - public boolean canInline(Class type) { - return type.equals(Duration.class); + public boolean canInline(Object object) { + return object instanceof Duration; } @Override - public String inline(ObjectInliner inliner, Object instance) { + public String inline(ObjectEmitter emitter, Object instance) { Duration duration = (Duration) instance; - return CodeBlock.of("$T.ofNanos($V);", Duration.class, duration.toNanos()).toString(); + return CodeBlock.of("$T.ofNanos($V);", Duration.class, emitter.inlined(duration.toNanos())).toString(); } }); return MethodSpec.methodBuilder(name) diff --git a/src/main/java/com/squareup/javapoet/NameAllocator.java b/src/main/java/com/squareup/javapoet/NameAllocator.java index 970c71ac9..8269664f4 100644 --- a/src/main/java/com/squareup/javapoet/NameAllocator.java +++ b/src/main/java/com/squareup/javapoet/NameAllocator.java @@ -86,8 +86,8 @@ public NameAllocator() { this(new LinkedHashSet<>(), new LinkedHashMap<>()); } - NameAllocator(Set allocatedNames, - Map tagToName) { + private NameAllocator(LinkedHashSet allocatedNames, + LinkedHashMap tagToName) { this.allocatedNames = allocatedNames; this.tagToName = tagToName; } diff --git a/src/main/java/com/squareup/javapoet/ObjectEmitter.java b/src/main/java/com/squareup/javapoet/ObjectEmitter.java new file mode 100644 index 000000000..9949d2173 --- /dev/null +++ b/src/main/java/com/squareup/javapoet/ObjectEmitter.java @@ -0,0 +1,609 @@ +package com.squareup.javapoet; + +import java.io.IOException; +import java.lang.reflect.Array; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; + +public class ObjectEmitter { + private final IdentityHashMap objectIdentifierMap; + private final Set possibleCircularReferenceSet; + private final List typeInliners; + + /** + * This is the Set of all exact types we trust, + * to prevent malicious classes with malicious setters + * from being generated. + */ + private final Set> trustedExactTypes; + + /** + * This is the Set of all assignable types we trust, + * which allows anything that can be assigned to them to + * be generated. Be careful with these; there can be a + * malicious subclass you do not know about. + */ + private final Set> trustedAssignableTypes; + + private final Function emitterFactory; + private final NameAllocator nameAllocator; + private final String namePrefix; + private final CodeWriter codeWriter; + + ObjectEmitter(ObjectInliner objectInliner, CodeWriter codeWriter) { + this.typeInliners = new ArrayList<>(objectInliner.getTypeInliners()); + this.trustedAssignableTypes = new HashSet<>(objectInliner.getTrustedAssignableTypes()); + this.trustedExactTypes = new HashSet<>(objectInliner.getTrustedExactTypes()); + this.namePrefix = objectInliner.getNamePrefix(); + this.objectIdentifierMap = new IdentityHashMap<>(); + this.possibleCircularReferenceSet = Collections.newSetFromMap(new IdentityHashMap<>()); + this.nameAllocator = new NameAllocator(); + this.codeWriter = codeWriter; + this.emitterFactory = newCodeWriter -> new ObjectEmitter(this, newCodeWriter); + } + + private ObjectEmitter(ObjectEmitter objectEmitter, CodeWriter codeWriter) { + this.typeInliners = objectEmitter.typeInliners; + this.trustedAssignableTypes = objectEmitter.trustedAssignableTypes; + this.trustedExactTypes = objectEmitter.trustedExactTypes; + this.namePrefix = objectEmitter.namePrefix; + // Use a copy of the existing name map so sibling emitters cannot + // see variables defined in this emitter (since they cannot access them) + this.objectIdentifierMap = new IdentityHashMap<>(objectEmitter.objectIdentifierMap); + this.nameAllocator = objectEmitter.nameAllocator.clone(); + + // Use the same possibleCircularReferenceSet, so we can detect circular references + // caused by nested TypeInliners + this.possibleCircularReferenceSet = objectEmitter.possibleCircularReferenceSet; + this.codeWriter = codeWriter; + this.emitterFactory = newCodeWriter -> new ObjectEmitter(this, newCodeWriter); + } + + public ObjectInliner.Inlined inlined(Object value) { + return new ObjectInliner.Inlined(emitterFactory, value); + } + + public void emit(String s) throws IOException { + codeWriter.emit(s); + } + + public void emit(String format, Object... args) throws IOException { + codeWriter.emit(format, args); + } + + public String newName(String suggestedSuffix) { + return nameAllocator.newName(namePrefix + suggestedSuffix); + } + + public String newName(String suggestedSuffix, Object tag) { + return nameAllocator.newName(namePrefix + suggestedSuffix, tag); + } + + /** + * Reserves a {@link String} that can be used as an identifier + * for an object. + */ + private String reserveObjectIdentifier(Object object, Class expressionType) { + String reservedIdentifier = nameAllocator + .newName(namePrefix + expressionType.getSimpleName()); + objectIdentifierMap.put(object, reservedIdentifier); + return reservedIdentifier; + } + + /** + * Return a string that can be used in a {@link CodeWriter} to + * access a complex object. + * + * @param object The object to be accessed + * @return A string that can be used by a {@link CodeWriter} to + * access the object. + */ + private String getObjectIdentifier(Object object) { + return objectIdentifierMap.get(object); + } + + public String getName(Object tag) { + return nameAllocator.get(tag); + } + + void emit(Object object) throws IOException { + if (object == null) { + codeWriter.emit("null"); + return; + } + + // Does a neat trick, so we can get a code block in a fragment + // It defines an inline supplier and immediately calls it. + codeWriter.emit("(($T)(($T)(()->{", getSerializedType(object.getClass()), Supplier.class); + codeWriter.emit("\nreturn $L;\n})).get())", getInlinedObject(object)); + } + + /** + * Serializes a Pojo to code that uses its no-args constructor + * and setters to create the object. + * + * @param object The object to be serialized. + * @return A string that can be used by a {@link CodeWriter} to access the object + */ + private String getInlinedObject(Object object) throws IOException { + // Some inliners cannot inline circular references, so bail out if we detect an + // unsupported circular reference + if (possibleCircularReferenceSet.contains(object)) { + throw new IllegalArgumentException( + "Cannot serialize an object of type (" + object.getClass().getCanonicalName() + + ") because it contains a circular reference."); + } + // If we already serialized the object, we should just return + // its identifier + if (objectIdentifierMap.containsKey(object)) { + return getObjectIdentifier(object); + } + // First, check for primitives + if (object == null) { + return "null"; + } + if (object instanceof Boolean) { + return object.toString(); + } + if (object instanceof Byte) { + // Cast to byte + return "((byte) " + object + ")"; + } + if (object instanceof Character) { + // A char is 16-bits, so its max value is 0xFFFF. + // So if we get the hex string of (value | 0x10000), + // we get a five-digit hex string 0x1abcd where 1 + // is the known first digit and abcd are the hex + // digits for the character (with "a" being the most significant bit). + // Any 16-bit Java character can be accessed using the expression + // '\uABCD' where ABCD are the hex digits for the Java character. + return "'\\u" + Integer.toHexString(((char) object) | 0x10000) + .substring(1) + "'"; + } + if (object instanceof Short) { + // Cast to short + return "((short) " + object + ")"; + } + if (object instanceof Integer) { + return object.toString(); + } + if (object instanceof Long) { + // Add long suffix to number string + return object + "L"; + } + if (object instanceof Float) { + // Add float suffix to number string + return object + "f"; + } + if (object instanceof Double) { + // Add double suffix to number string + return object + "d"; + } + + // Check for builtin classes + if (object instanceof String) { + return CodeBlock.builder().add("$S", object).build().toString(); + } + if (object instanceof Class) { + Class value = (Class) object; + if (!Modifier.isPublic(value.getModifiers())) { + throw new IllegalArgumentException("Cannot serialize (" + value + + ") because it is not a public class."); + } + return value.getCanonicalName() + ".class"; + } + if (object.getClass().isEnum()) { + // Use field access to read the enum + Class enumClass = object.getClass(); + Enum objectEnum = (Enum) object; + if (!Modifier.isPublic(enumClass.getModifiers())) { + // Use name() since toString() can be malicious + throw new IllegalArgumentException( + "Cannot serialize (" + objectEnum.name() + + ") because its type (" + enumClass + + ") is not a public class."); + } + + return enumClass.getCanonicalName() + "." + objectEnum.name(); + } + + // We need to use a custom in-liner, which will potentially + // call methods on a user-supplied instances. Make sure we trust + // the type before continuing + if (!isTrustedType(object.getClass())) { + throw new IllegalArgumentException("Cannot serialize instance of (" + + object.getClass().getCanonicalName() + + ") because it is not an instance of a trusted type."); + } + // Check if any registered TypeInliner matches the class + for (TypeInliner typeInliner : typeInliners) { + if (typeInliner.canInline(object)) { + Class expressionType = typeInliner.getInlinedType(object); + String identifier = reserveObjectIdentifier(object, expressionType); + possibleCircularReferenceSet.add(object); + String out = typeInliner.inline(this, object); + possibleCircularReferenceSet.remove(object); + emit("\n$T $N = $L;", expressionType, + identifier, + out); + return identifier; + } + } + + return getInlinedComplexObject(object); + } + + private boolean isTrustedType(Class query) { + if (query.isArray()) { + return query.getComponentType().isPrimitive() + || isTrustedType(query.getComponentType()); + } + for (Class trustedAssignableType : trustedAssignableTypes) { + if (trustedAssignableType.isAssignableFrom(query)) { + return true; + } + } + for (Class trustedExactType : trustedExactTypes) { + if (trustedExactType.equals(query)) { + return true; + } + } + return false; + } + + private static boolean isRecord(Object object) { + Class superClass = object.getClass().getSuperclass(); + return superClass != null && superClass.getName() + .equals("java.lang.Record"); + } + + /** + * Serializes collections and complex POJOs to code. + */ + private String getInlinedComplexObject(Object object) throws IOException { + if (isRecord(object)) { + // Records must set all fields at initialization time, + // so we delay the declaration of its variable + return getInlinedRecord(object); + } + // Object is not serialized yet + // Create a new variable to store its value when setting its fields + String newIdentifier = reserveObjectIdentifier(object, + getSerializedType(object.getClass())); + + // First, check if it is a collection type + if (object.getClass().isArray()) { + return getInlinedArray(newIdentifier, object); + } + if (object instanceof List) { + return getInlinedList(newIdentifier, (List) object); + } + if (object instanceof Set) { + return getInlinedSet(newIdentifier, (Set) object); + } + if (object instanceof Map) { + return getInlinedMap(newIdentifier, (Map) object); + } + + if (!Modifier.isPublic(object.getClass().getModifiers())) { + throw new IllegalArgumentException("Cannot serialize type (" + + object.getClass().getCanonicalName() + + ") because it is not public."); + } + codeWriter.emit("\n$T $N;", object.getClass(), newIdentifier); + try { + Constructor constructor = object.getClass().getConstructor(); + if (!Modifier.isPublic(constructor.getModifiers())) { + throw new IllegalArgumentException("Cannot serialize type (" + + object.getClass().getCanonicalName() + + ") because its no-args constructor is not public."); + } + } catch (NoSuchMethodException e) { + throw new IllegalArgumentException("Cannot serialize type (" + + object.getClass().getCanonicalName() + + ") because it does not have a public no-args constructor."); + } + codeWriter.emit("\n$N = new $T();", newIdentifier, object.getClass()); + inlineFieldsOfPojo(object.getClass(), newIdentifier, object); + return getObjectIdentifier(object); + } + + private String getInlinedArray(String newIdentifier, Object array) throws IOException { + Class componentType = array.getClass().getComponentType(); + if (!Modifier.isPublic(componentType.getModifiers())) { + throw new IllegalArgumentException( + "Cannot serialize array of type (" + componentType.getCanonicalName() + + ") because (" + componentType.getCanonicalName() + + ") is not public."); + } + codeWriter.emit("\n$T $N;", array.getClass(), newIdentifier); + + // Get the length of the array + int length = Array.getLength(array); + + // Create a new array from the component type with the given length + codeWriter.emit("\n$N = new $T[$L];", newIdentifier, + componentType, Integer.toString(length)); + for (int i = 0; i < length; i++) { + // Set the elements of the array + codeWriter.emit("\n$N[$L] = $L;", + newIdentifier, + Integer.toString(i), + getInlinedObject(Array.get(array, i))); + } + return getObjectIdentifier(array); + } + + private String getInlinedList(String newIdentifier, List list) throws IOException { + codeWriter.emit("\n$T $N;", List.class, newIdentifier); + + // Create an ArrayList + codeWriter.emit("\n$N = new $T($L);", newIdentifier, ArrayList.class, + Integer.toString(list.size())); + for (Object item : list) { + // Add each item of the list to the ArrayList + codeWriter.emit("\n$N.add($L);", + newIdentifier, + getInlinedObject(item)); + } + return getObjectIdentifier(list); + } + + private String getInlinedSet(String newIdentifier, Set set) throws IOException { + codeWriter.emit("\n$T $N;", Set.class, newIdentifier); + + // Create a new HashSet + codeWriter.emit("\n$N = new $T($L);", newIdentifier, LinkedHashSet.class, + Integer.toString(set.size())); + for (Object item : set) { + // Add each item of the set to the HashSet + codeWriter.emit("\n$N.add($L);", + newIdentifier, + getInlinedObject(item)); + } + return getObjectIdentifier(set); + } + + private String getInlinedMap(String newIdentifier, + Map map) throws IOException { + codeWriter.emit("\n$T $N;", Map.class, newIdentifier); + + // Create a HashMap + codeWriter.emit("\n$N = new $T($L);", newIdentifier, LinkedHashMap.class, + Integer.toString(map.size())); + for (Map.Entry entry : map.entrySet()) { + // Put each entry of the map into the HashMap + codeWriter.emit("\n$N.put($L, $L);", + newIdentifier, + getInlinedObject(entry.getKey()), + getInlinedObject(entry.getValue())); + } + return getObjectIdentifier(map); + } + + // Workaround for Java 8 + private static final class RecordComponent { + private final Class type; + private final String name; + + private RecordComponent(Class type, String name) { + this.type = type; + this.name = name; + } + + public Class getType() { + return type; + } + + public String getName() { + return name; + } + + public Object getValue(Object record) { + try { + return record.getClass().getMethod(name).invoke(record); + } catch (InvocationTargetException | IllegalAccessException + | NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + } + + // Workaround for Java 8 + private static RecordComponent[] getRecordComponents(Class recordClass) { + try { + Object[] components = (Object[]) recordClass. + getMethod("getRecordComponents").invoke(recordClass); + RecordComponent[] out = new RecordComponent[components.length]; + for (int i = 0; i < components.length; i++) { + Object component = components[i]; + Class componentClass = component.getClass(); + Class type = (Class) componentClass + .getMethod("getType").invoke(component); + String name = (String) componentClass + .getMethod("getName").invoke(component); + out[i] = new RecordComponent(type, name); + } + return out; + } catch (InvocationTargetException | IllegalAccessException + | NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + private String getInlinedRecord(Object record) + throws IOException { + possibleCircularReferenceSet.add(record); + Class recordClass = record.getClass(); + if (!Modifier.isPublic(recordClass.getModifiers())) { + throw new IllegalArgumentException( + "Cannot serialize record type (" + recordClass.getCanonicalName() + + ") because it is not public."); + } + + RecordComponent[] recordComponents = getRecordComponents(recordClass); + String[] componentAccessors = new String[recordComponents.length]; + for (int i = 0; i < recordComponents.length; i++) { + Object value; + Class serializedType = getSerializedType(recordComponents[i].getType()); + if (!recordComponents[i].getType().equals(serializedType)) { + throw new IllegalArgumentException( + "Cannot serialize type (" + recordClass + + ") as its component (" + recordComponents[i].getName() + + ") uses an implementation of a collection (" + + recordComponents[i].getType() + + ") instead of the interface type (" + + serializedType + ")."); + } + value = recordComponents[i].getValue(record); + try { + componentAccessors[i] = getInlinedObject(value); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Cannot serialize record type (" + + record.getClass().getCanonicalName() + ") because the type of its value (" + + value.getClass().getCanonicalName() + ") for its component (" + + recordComponents[i].getName() + ") is not serializable.", e); + } + } + // All components serialized, so no circular references + possibleCircularReferenceSet.remove(record); + StringBuilder constructorArgs = new StringBuilder(); + for (String componentAccessor : componentAccessors) { + constructorArgs.append(componentAccessor).append(", "); + } + if (componentAccessors.length != 0) { + constructorArgs.delete(constructorArgs.length() - 2, + constructorArgs.length()); + } + String newIdentifier = nameAllocator.newName(namePrefix + + recordClass.getSimpleName()); + objectIdentifierMap.put(record, newIdentifier); + codeWriter.emit("\n$T $N = new $T($L);", recordClass, newIdentifier, + recordClass, constructorArgs.toString()); + return getObjectIdentifier(record); + } + + static Class getSerializedType(Class query) { + if (List.class.isAssignableFrom(query)) { + return List.class; + } + if (Set.class.isAssignableFrom(query)) { + return Set.class; + } + if (Map.class.isAssignableFrom(query)) { + return Map.class; + } + return query; + } + + private static Method getSetterMethod(Class expectedArgumentType, Field field) { + Class declaringClass = field.getDeclaringClass(); + String fieldName = field.getName(); + + String methodName = "set" + Character.toUpperCase(fieldName.charAt(0)) + + fieldName.substring(1); + try { + return declaringClass.getMethod(methodName, expectedArgumentType); + } catch (NoSuchMethodException e) { + return null; + } + } + + /** + * Sets the fields of object declared in objectSuperClass and all its superclasses. + * + * @param objectSuperClass A class assignable to object containing some of its fields. + * @param identifier The name of the variable storing the serialized object. + * @param object The object being serialized. + */ + private void inlineFieldsOfPojo(Class objectSuperClass, String identifier, + Object object) throws IOException { + if (objectSuperClass == Object.class) { + // We are the top-level, no more fields to set + return; + } + Field[] fields = objectSuperClass.getDeclaredFields(); + // Sort by name to guarantee a consistent ordering + Arrays.sort(fields, Comparator.comparing(Field::getName)); + for (Field field : fields) { + if (Modifier.isStatic(field.getModifiers())) { + // We do not want to write static fields + continue; + } + if (Modifier.isPublic(field.getModifiers())) { + try { + codeWriter.emit("\n$N.$N = $L;", identifier, + field.getName(), + getInlinedObject(field.get(object))); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + continue; + } + // Set the field accessible so we can read its value + field.setAccessible(true); + Class serializedType = getSerializedType(field.getType()); + Method setterMethod = getSetterMethod(serializedType, field); + // setterMethod guaranteed to be public + if (setterMethod == null) { + if (!field.getType().equals(serializedType)) { + throw new IllegalArgumentException( + "Cannot serialize type (" + objectSuperClass + + ") as its field (" + field.getName() + + ") uses an implementation of a collection (" + + field.getType() + + ") instead of the interface type (" + + serializedType + ")."); + } + throw new IllegalArgumentException( + "Cannot serialize type (" + objectSuperClass + + ") as it is missing a public setter method for field (" + + field.getName() + ") of type (" + field.getType() + ")."); + } + Object value; + try { + value = field.get(object); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + + try { + // Convert the field value to code, and call the setter + // corresponding to the field with the serialized field value. + codeWriter.emit("\n$N.$N($L);", identifier, + setterMethod.getName(), + getInlinedObject(value)); + } catch (IllegalArgumentException e) { + // We trust object, but not necessary value + throw new IllegalArgumentException("Cannot serialize an instance of type (" + + object.getClass().getCanonicalName() + ") because the type of its value (" + + value.getClass().getCanonicalName() + + ") for its field (" + field.getName() + + ") is not serializable.", e); + } + } + try { + inlineFieldsOfPojo(objectSuperClass.getSuperclass(), identifier, object); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Cannot serialize type (" + + objectSuperClass + ") because its superclass (" + + objectSuperClass.getSuperclass() + ") is not serializable.", e); + } + } +} diff --git a/src/main/java/com/squareup/javapoet/ObjectInliner.java b/src/main/java/com/squareup/javapoet/ObjectInliner.java index 209995c53..f449b30b7 100644 --- a/src/main/java/com/squareup/javapoet/ObjectInliner.java +++ b/src/main/java/com/squareup/javapoet/ObjectInliner.java @@ -1,37 +1,19 @@ package com.squareup.javapoet; import java.io.IOException; -import java.lang.reflect.Array; -import java.lang.reflect.Constructor; import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Comparator; import java.util.HashSet; -import java.util.IdentityHashMap; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.Set; -import java.util.function.Supplier; +import java.util.function.Function; public class ObjectInliner { private static final String DEFAULT_NAME_PREFIX = "$$javapoet$"; private static final ObjectInliner DEFAULT = new ObjectInliner(); - // We need to use object identity as a key in the map - private final IdentityHashMap pojoNameMap = new IdentityHashMap<>(); - private final Set possibleCircularRecordReferenceSet = Collections - .newSetFromMap(new IdentityHashMap<>()); private final List typeInliners = new ArrayList<>(); - // You probably should not be using this class - // if you did not construct the instance yourself. /** * This is the Set of all exact types we trust, * to prevent malicious classes with malicious setters @@ -46,13 +28,12 @@ public class ObjectInliner { * malicious subclass you do not know about. */ private final Set> trustedAssignableTypes = new HashSet<>(); - - private int inlineLevel = 0; - private NameAllocator nameAllocator; - private String suggestedNamePrefix; + private final Function emitterFactory; + private String namePrefix; public ObjectInliner() { - this.suggestedNamePrefix = DEFAULT_NAME_PREFIX; + this.namePrefix = DEFAULT_NAME_PREFIX; + this.emitterFactory = codeWriter -> new ObjectEmitter(this, codeWriter); } public static ObjectInliner getDefault() { @@ -60,7 +41,7 @@ public static ObjectInliner getDefault() { } public ObjectInliner useNamePrefix(String namePrefix) { - suggestedNamePrefix = namePrefix; + this.namePrefix = namePrefix; return this; } @@ -111,25 +92,37 @@ public ObjectInliner trustEverything() { return trustTypesAssignableTo(Object.class); } + List getTypeInliners() { + return typeInliners; + } + + Set> getTrustedExactTypes() { + return trustedExactTypes; + } + + Set> getTrustedAssignableTypes() { + return trustedAssignableTypes; + } + + String getNamePrefix() { + return namePrefix; + } + public static class Inlined { - private final ObjectInliner inliner; + private final Function emitterFactory; private final Object value; - public Inlined(ObjectInliner inliner, Object value) { - this.inliner = inliner; + Inlined(Function emitterFactory, Object value) { + this.emitterFactory = emitterFactory; this.value = value; } - public ObjectInliner getInliner() { - return inliner; - } - public Object getValue() { return value; } void emit(CodeWriter codeWriter) throws IOException { - inliner.emit(codeWriter, value); + emitterFactory.apply(codeWriter).emit(value); } @Override @@ -139,521 +132,17 @@ public boolean equals(Object object) { if (object == null || getClass() != object.getClass()) return false; Inlined inlined = (Inlined) object; - return Objects.equals(inliner, inlined.inliner) + return Objects.equals(emitterFactory, inlined.emitterFactory) && Objects.equals(value, inlined.value); } @Override public int hashCode() { - return Objects.hash(inliner, value); + return Objects.hash(emitterFactory, value); } } public Inlined inlined(Object object) { - return new Inlined(this, object); - } - - void emit(CodeWriter builder, Object object) throws IOException { - if (object == null) { - builder.emit("null"); - return; - } - - // Inline tracks how many nested inline calls there are. - // The one at the top level is responsible for creating the - // name allocator; inner inline calls must use the same allocator - // to provide name clashes. - if (inlineLevel == 0) { - nameAllocator = new NameAllocator(new LinkedHashSet<>(), - pojoNameMap); - } - inlineLevel++; - - // Does a neat trick, so we can get a code block in a fragment - // It defines an inline supplier and immediately calls it. - try { - builder.emit("(($T)(($T)(()->{", getSerializedType(object.getClass()), Supplier.class); - builder.emit("\nreturn $L;\n})).get())", getInlinedPojo(builder, object)); - } finally { - inlineLevel--; - // There are no inner inline calls, so we can clear the name allocator - // (since the names are scoped inside the supplier lambda block, which is now - // closed). - if (inlineLevel == 0) { - pojoNameMap.clear(); - nameAllocator = null; - } - } - } - - /** - * Serializes a Pojo to code that uses its no-args constructor - * and setters to create the object. - * - * @param pojo The object to be serialized. - * @return A string that can be used in a {@link CodeBlock.Builder} to access the object - */ - String getInlinedPojo(CodeWriter builder, Object pojo) throws IOException { - // First, check for primitives - if (pojo == null) { - return "null"; - } - if (pojo instanceof Boolean) { - return pojo.toString(); - } - if (pojo instanceof Byte) { - // Cast to byte - return "((byte) " + pojo + ")"; - } - if (pojo instanceof Character) { - return "'\\u" + Integer.toHexString(((char) pojo) | 0x10000) - .substring(1) + "'"; - } - if (pojo instanceof Short) { - // Cast to short - return "((short) " + pojo + ")"; - } - if (pojo instanceof Integer) { - return pojo.toString(); - } - if (pojo instanceof Long) { - // Add long suffix to number string - return pojo + "L"; - } - if (pojo instanceof Float) { - // Add float suffix to number string - return pojo + "f"; - } - if (pojo instanceof Double) { - // Add double suffix to number string - return pojo + "d"; - } - - // Check for builtin classes - if (pojo instanceof String) { - return CodeBlock.builder().add("$S", pojo).build().toString(); - } - if (pojo instanceof Class) { - Class value = (Class) pojo; - if (!Modifier.isPublic(value.getModifiers())) { - throw new IllegalArgumentException("Cannot serialize (" + value - + ") because it is not a public class."); - } - return value.getCanonicalName() + ".class"; - } - if (pojo.getClass().isEnum()) { - // Use field access to read the enum - Class enumClass = pojo.getClass(); - Enum pojoEnum = (Enum) pojo; - if (!Modifier.isPublic(enumClass.getModifiers())) { - // Use name() since toString() can be malicious - throw new IllegalArgumentException( - "Cannot serialize (" + pojoEnum.name() - + ") because its type (" + enumClass - + ") is not a public class."); - } - - return enumClass.getCanonicalName() + "." + pojoEnum.name(); - } - - // We need to use a custom in-liner, which will potentially - // call methods on a user-supplied instances. Make sure we trust - // the type before continuing - if (!isTrustedType(pojo.getClass())) { - throw new IllegalArgumentException("Cannot serialize instance of (" - + pojo.getClass() + ") because it is not an instance of a trusted type."); - } - - // Check if any registered TypeInliner matches the class - Class type = pojo.getClass(); - for (TypeInliner typeInliner : typeInliners) { - if (typeInliner.canInline(type)) { - return typeInliner.inline(this, pojo); - } - } - - return getInlinedComplexPojo(builder, pojo); - } - - private boolean isTrustedType(Class query) { - if (query.isArray()) { - return query.getComponentType().isPrimitive() - || isTrustedType(query.getComponentType()); - } - for (Class trustedAssignableType : trustedAssignableTypes) { - if (trustedAssignableType.isAssignableFrom(query)) { - return true; - } - } - for (Class trustedExactType : trustedExactTypes) { - if (trustedExactType.equals(query)) { - return true; - } - } - return false; - } - - /** - * Return a string that can be used in a {@link CodeBlock.Builder} to - * access a complex object. - * - * @param pojo The object to be accessed - * @return A string that can be used in a {@link CodeBlock.Builder} to - * access the object. - */ - private String getPojoValue(Object pojo) { - return nameAllocator.get(pojo); - } - - private static boolean isRecord(Object object) { - Class superClass = object.getClass().getSuperclass(); - return superClass != null && superClass.getName() - .equals("java.lang.Record"); - } - - /** - * Serializes collections and complex POJOs to code. - */ - private String getInlinedComplexPojo(CodeWriter builder, Object pojo) throws IOException { - if (possibleCircularRecordReferenceSet.contains(pojo)) { - // Records do not have a no-args constructor, so we cannot safely - // serialize self-references in records - // as we cannot do a map lookup before the record is created. - throw new IllegalArgumentException( - "Cannot serialize record of type (" + pojo.getClass() - + ") because it is a record containing a circular reference."); - } - - // If we already serialized the object, we should just return - // the code string - if (pojoNameMap.containsKey(pojo)) { - return getPojoValue(pojo); - } - if (isRecord(pojo)) { - // Records must set all fields at initialization time, - // so we delay the declaration of its variable - return getInlinedRecord(builder, pojo); - } - // Object is not serialized yet - // Create a new variable to store its value when setting its fields - String newIdentifier = nameAllocator.newName(suggestedNamePrefix - + getSerializedType(pojo.getClass()).getSimpleName(), pojo); - - // First, check if it is a collection type - if (pojo.getClass().isArray()) { - return getInlinedArray(builder, newIdentifier, pojo); - } - if (pojo instanceof List) { - return getInlinedList(builder, newIdentifier, (List) pojo); - } - if (pojo instanceof Set) { - return getInlinedSet(builder, newIdentifier, (Set) pojo); - } - if (pojo instanceof Map) { - return getInlinedMap(builder, newIdentifier, (Map) pojo); - } - - if (!Modifier.isPublic(pojo.getClass().getModifiers())) { - throw new IllegalArgumentException("Cannot serialize type (" + pojo.getClass() - + ") because it is not public."); - } - builder.emit("\n$T $N;", pojo.getClass(), newIdentifier); - try { - Constructor constructor = pojo.getClass().getConstructor(); - if (!Modifier.isPublic(constructor.getModifiers())) { - throw new IllegalArgumentException("Cannot serialize type (" + pojo.getClass() - + ") because its no-args constructor is not public."); - } - } catch (NoSuchMethodException e) { - throw new IllegalArgumentException("Cannot serialize type (" + pojo.getClass() - + ") because it does not have a public no-args constructor."); - } - builder.emit("\n$N = new $T();", newIdentifier, pojo.getClass()); - inlineFieldsOfPojo(builder, pojo.getClass(), newIdentifier, pojo); - return getPojoValue(pojo); - } - - private String getInlinedArray(CodeWriter builder, String newIdentifier, - Object array) throws IOException { - Class componentType = array.getClass().getComponentType(); - if (!Modifier.isPublic(componentType.getModifiers())) { - throw new IllegalArgumentException( - "Cannot serialize array of type (" + componentType - + ") because (" + componentType + ") is not public."); - } - builder.emit("\n$T $N;", array.getClass(), newIdentifier); - - // Get the length of the array - int length = Array.getLength(array); - - // Create a new array from the component type with the given length - builder.emit("\n$N = new $T[$L];", newIdentifier, - componentType, Integer.toString(length)); - for (int i = 0; i < length; i++) { - // Set the elements of the array - builder.emit("\n$N[$L] = $L;", - newIdentifier, - Integer.toString(i), - getInlinedPojo(builder, Array.get(array, i))); - } - return getPojoValue(array); - } - - private String getInlinedList(CodeWriter builder, String newIdentifier, - List list) throws IOException { - builder.emit("\n$T $N;", List.class, newIdentifier); - - // Create an ArrayList - builder.emit("\n$N = new $T($L);", newIdentifier, ArrayList.class, - Integer.toString(list.size())); - for (Object item : list) { - // Add each item of the list to the ArrayList - builder.emit("\n$N.add($L);", - newIdentifier, - getInlinedPojo(builder, item)); - } - return getPojoValue(list); - } - - private String getInlinedSet(CodeWriter builder, String newIdentifier, - Set set) throws IOException { - builder.emit("\n$T $N;", Set.class, newIdentifier); - - // Create a new HashSet - builder.emit("\n$N = new $T($L);", newIdentifier, LinkedHashSet.class, - Integer.toString(set.size())); - for (Object item : set) { - // Add each item of the set to the HashSet - builder.emit("\n$N.add($L);", - newIdentifier, - getInlinedPojo(builder, item)); - } - return getPojoValue(set); - } - - private String getInlinedMap(CodeWriter builder, String newIdentifier, - Map map) throws IOException { - builder.emit("\n$T $N;", Map.class, newIdentifier); - - // Create a HashMap - builder.emit("\n$N = new $T($L);", newIdentifier, LinkedHashMap.class, - Integer.toString(map.size())); - for (Map.Entry entry : map.entrySet()) { - // Put each entry of the map into the HashMap - builder.emit("\n$N.put($L, $L);", - newIdentifier, - getInlinedPojo(builder, entry.getKey()), - getInlinedPojo(builder, entry.getValue())); - } - return getPojoValue(map); - } - - // Workaround for Java 8 - private static final class RecordComponent { - private final Class type; - private final String name; - - private RecordComponent(Class type, String name) { - this.type = type; - this.name = name; - } - - public Class getType() { - return type; - } - - public String getName() { - return name; - } - - public Object getValue(Object record) { - try { - return record.getClass().getMethod(name).invoke(record); - } catch (InvocationTargetException | IllegalAccessException - | NoSuchMethodException e) { - throw new RuntimeException(e); - } - } - } - - // Workaround for Java 8 - private static RecordComponent[] getRecordComponents(Class recordClass) { - try { - Object[] components = (Object[]) recordClass. - getMethod("getRecordComponents").invoke(recordClass); - RecordComponent[] out = new RecordComponent[components.length]; - for (int i = 0; i < components.length; i++) { - Object component = components[i]; - Class componentClass = component.getClass(); - Class type = (Class) componentClass - .getMethod("getType").invoke(component); - String name = (String) componentClass - .getMethod("getName").invoke(component); - out[i] = new RecordComponent(type, name); - } - return out; - } catch (InvocationTargetException | IllegalAccessException - | NoSuchMethodException e) { - throw new RuntimeException(e); - } - } - - private String getInlinedRecord(CodeWriter builder, Object record) - throws IOException { - possibleCircularRecordReferenceSet.add(record); - Class recordClass = record.getClass(); - if (!Modifier.isPublic(recordClass.getModifiers())) { - throw new IllegalArgumentException( - "Cannot serialize record type (" + recordClass - + ") because it is not public."); - } - - RecordComponent[] recordComponents = getRecordComponents(recordClass); - String[] componentAccessors = new String[recordComponents.length]; - for (int i = 0; i < recordComponents.length; i++) { - Object value; - Class serializedType = getSerializedType(recordComponents[i].getType()); - if (!recordComponents[i].getType().equals(serializedType)) { - throw new IllegalArgumentException( - "Cannot serialize type (" + recordClass - + ") as its component (" + recordComponents[i].getName() - + ") uses an implementation of a collection (" - + recordComponents[i].getType() - + ") instead of the interface type (" - + serializedType + ")."); - } - value = recordComponents[i].getValue(record); - try { - componentAccessors[i] = getInlinedPojo(builder, value); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Cannot serialize record type (" - + record.getClass() + ") because the type of its value (" - + value.getClass() + ") for its component (" - + recordComponents[i].getName() + ") is not serializable.", e); - } - } - // All components serialized, so no circular references - possibleCircularRecordReferenceSet.remove(record); - StringBuilder constructorArgs = new StringBuilder(); - for (String componentAccessor : componentAccessors) { - constructorArgs.append(componentAccessor).append(", "); - } - if (componentAccessors.length != 0) { - constructorArgs.delete(constructorArgs.length() - 2, - constructorArgs.length()); - } - String newIdentifier = nameAllocator.newName(suggestedNamePrefix - + recordClass.getSimpleName(), record); - builder.emit("\n$T $N = new $T($L);", recordClass, newIdentifier, - recordClass, constructorArgs.toString()); - return getPojoValue(record); - } - - static Class getSerializedType(Class query) { - if (List.class.isAssignableFrom(query)) { - return List.class; - } - if (Set.class.isAssignableFrom(query)) { - return Set.class; - } - if (Map.class.isAssignableFrom(query)) { - return Map.class; - } - return query; - } - - private static Method getSetterMethod(Class expectedArgumentType, Field field) { - Class declaringClass = field.getDeclaringClass(); - String fieldName = field.getName(); - - String methodName = "set" + Character.toUpperCase(fieldName.charAt(0)) - + fieldName.substring(1); - try { - return declaringClass.getMethod(methodName, expectedArgumentType); - } catch (NoSuchMethodException e) { - return null; - } - } - - /** - * Sets the fields of pojo declared in pojoClass and all its superclasses. - * - * @param pojoClass A class assignable to pojo containing some of its fields. - * @param identifier The name of the variable storing the serialized pojo. - * @param pojo The object being serialized. - */ - private void inlineFieldsOfPojo(CodeWriter builder, Class pojoClass, - String identifier, Object pojo) throws IOException { - if (pojoClass == Object.class) { - // We are the top-level, no more fields to set - return; - } - Field[] fields = pojoClass.getDeclaredFields(); - // Sort by name to guarantee a consistent ordering - Arrays.sort(fields, Comparator.comparing(Field::getName)); - for (Field field : fields) { - if (java.lang.reflect.Modifier.isStatic(field.getModifiers())) { - // We do not want to write static fields - continue; - } - if (Modifier.isPublic(field.getModifiers())) { - try { - builder.emit("\n$N.$N = $L;", identifier, - field.getName(), - getInlinedPojo(builder, field.get(pojo))); - } catch (IllegalAccessException e) { - throw new RuntimeException(e); - } - continue; - } - // Set the field accessible so we can read its value - field.setAccessible(true); - Class serializedType = getSerializedType(field.getType()); - Method setterMethod = getSetterMethod(serializedType, field); - // setterMethod guaranteed to be public - if (setterMethod == null) { - if (!field.getType().equals(serializedType)) { - throw new IllegalArgumentException( - "Cannot serialize type (" + pojoClass - + ") as its field (" + field.getName() - + ") uses an implementation of a collection (" - + field.getType() - + ") instead of the interface type (" - + serializedType + ")."); - } - throw new IllegalArgumentException( - "Cannot serialize type (" + pojoClass - + ") as it is missing a public setter method for field (" - + field.getName() + ") of type (" + field.getType() + ")."); - } - Object value; - try { - value = field.get(pojo); - } catch (IllegalAccessException e) { - throw new RuntimeException(e); - } - - try { - // Convert the field value to code, and call the setter - // corresponding to the field with the serialized field value. - builder.emit("\n$N.$N($L);", identifier, - setterMethod.getName(), - getInlinedPojo(builder, value)); - } catch (IllegalArgumentException e) { - // We trust pojo, but not necessary value - throw new IllegalArgumentException("Cannot serialize an instance of type (" - + pojo.getClass() + ") because the type of its value (" - + value.getClass() - + ") for its field (" + field.getName() - + ") is not serializable.", e); - } - } - try { - inlineFieldsOfPojo(builder, pojoClass.getSuperclass(), identifier, pojo); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Cannot serialize type (" - + pojoClass + ") because its superclass (" - + pojoClass.getSuperclass() + ") is not serializable.", e); - } + return new Inlined(emitterFactory, object); } } diff --git a/src/main/java/com/squareup/javapoet/TypeInliner.java b/src/main/java/com/squareup/javapoet/TypeInliner.java index e2a97cce5..5f0960c63 100644 --- a/src/main/java/com/squareup/javapoet/TypeInliner.java +++ b/src/main/java/com/squareup/javapoet/TypeInliner.java @@ -1,6 +1,49 @@ package com.squareup.javapoet; +import java.io.IOException; + +/** + * An interface that allows for additional classes to be inlined + * by the $V formatter. + */ public interface TypeInliner { - boolean canInline(Class type); - String inline(ObjectInliner inliner, Object instance); + /** + * Returns true if this {@link TypeInliner} can inline an {@link Object} of the + * given type. + * + * @param object The object to be inlined + * @return True if the {@link TypeInliner} can inline the given object, + * false otherwise. + */ + boolean canInline(Object object); + + /** + * Returns the type of the expression returned by {@link #inline(ObjectEmitter, Object)}. + * Defaults to the type of the passed instance. + * @param instance The object to be inlined. + * @return The type of the expression returned by {@link #inline(ObjectEmitter, Object)}. + */ + default Class getInlinedType(Object instance) { + return instance.getClass(); + } + + /** + * Inlines an {@link Object} that + * {@link #canInline(Object)} accepted. + * The argument for `$V` must be + * obtained by using the {@link ObjectEmitter#inlined(Object)} + * of the passed {@link ObjectEmitter}. + * Returns a {@link String} that represents a valid Java expression, + * that when evaluated, results in an {@link Object} equal to the + * passed {@link Object}. + * + * @param emitter An emitter that can be used to generate code. + * You can emit any valid Java statements. + * When {@link #inline(ObjectEmitter, Object)} + * returns, it is expected the emitted code is a set of complete Java statements. + * + * @param instance The object to inline. + * @return A Java expression that evaluates to the inlined {@link Object}. + */ + String inline(ObjectEmitter emitter, Object instance) throws IOException; } diff --git a/src/test/java/com/squareup/javapoet/ObjectInlinerTest.java b/src/test/java/com/squareup/javapoet/ObjectInlinerTest.java index c2f76c37f..c0eb7d1b1 100644 --- a/src/test/java/com/squareup/javapoet/ObjectInlinerTest.java +++ b/src/test/java/com/squareup/javapoet/ObjectInlinerTest.java @@ -86,7 +86,7 @@ private static String getInlineResult(Object object, ObjectInliner inliner = new ObjectInliner(); adapter.accept(inliner); try { - inliner.emit(codeWriter, object); + inliner.inlined(object).emit(codeWriter); } catch (IOException e) { throw new AssertionError("IOFailure", e); } @@ -96,7 +96,7 @@ private static String getInlineResult(Object object, private String expectedResult(Class type, String lambdaBody) { StringBuilder out = new StringBuilder(); out.append("(("); - out.append(ObjectInliner.getSerializedType(type).getCanonicalName()); + out.append(ObjectEmitter.getSerializedType(type).getCanonicalName()); out.append(")(("); out.append(Supplier.class.getCanonicalName()); out.append(")(()->{"); @@ -136,7 +136,7 @@ private String expectedInlinedTrustedPojo(TrustedPojo trustedPojo, List private void assertCompiles(Class type, String value) { JavaFile javaFile = JavaFile.builder("", TypeSpec .classBuilder("TestClass") - .addField(ObjectInliner.getSerializedType(type), "field", Modifier.STATIC) + .addField(ObjectEmitter.getSerializedType(type), "field", Modifier.STATIC) .addStaticBlock(CodeBlock.of("field = $L;", value)) .build()).build(); Compilation compilation = javac().compile(javaFile.toJavaFileObject()); @@ -429,17 +429,17 @@ public void testTrustedObjectWithSelfReference() { public void testUntrustedObjectThrows() { TrustedPojo pojo = new TrustedPojo("a", 1); pojo.setNext(pojo); - assertThrows("Cannot serialize instance of (" + TrustedPojo.class + + assertThrows("Cannot serialize instance of (" + TrustedPojo.class.getCanonicalName() + ") because it is not an instance of a trusted type.", pojo); } @Test public void testUntrustedCollectionThrows() { - assertThrows("Cannot serialize instance of (" + ArrayList.class + + assertThrows("Cannot serialize instance of (" + ArrayList.class.getCanonicalName() + ") because it is not an instance of a trusted type.", new ArrayList<>()); - assertThrows("Cannot serialize instance of (" + LinkedHashSet.class + + assertThrows("Cannot serialize instance of (" + LinkedHashSet.class.getCanonicalName() + ") because it is not an instance of a trusted type.", new LinkedHashSet<>()); - assertThrows("Cannot serialize instance of (" + LinkedHashMap.class + + assertThrows("Cannot serialize instance of (" + LinkedHashMap.class.getCanonicalName() + ") because it is not an instance of a trusted type.", new LinkedHashMap<>()); } @@ -450,7 +450,7 @@ private static class PrivatePojo { @Test public void testPrivatePojoThrows() { PrivatePojo pojo = new PrivatePojo(); - assertThrows("Cannot serialize type (" + PrivatePojo.class + + assertThrows("Cannot serialize type (" + PrivatePojo.class.getCanonicalName() + ") because it is not public.", pojo, inliner -> inliner.trustExactTypes(PrivatePojo.class)); } @@ -511,7 +511,7 @@ public MissingConstructorPojo(String value) { @Test public void testMissingConstructorPojoThrows() { MissingConstructorPojo pojo = new MissingConstructorPojo("a"); - assertThrows("Cannot serialize type (" + MissingConstructorPojo.class + + assertThrows("Cannot serialize type (" + MissingConstructorPojo.class.getCanonicalName() + ") because it does not have a public no-args constructor.", pojo, inliner -> inliner.trustExactTypes(MissingConstructorPojo.class)); } @@ -524,7 +524,7 @@ private PrivateConstructorPojo() { @Test public void testPrivateConstructorPojoThrows() { PrivateConstructorPojo pojo = new PrivateConstructorPojo(); - assertThrows("Cannot serialize type (" + PrivateConstructorPojo.class + + assertThrows("Cannot serialize type (" + PrivateConstructorPojo.class.getCanonicalName() + ") because it does not have a public no-args constructor.", pojo, inliner -> inliner.trustExactTypes(PrivateConstructorPojo.class)); } @@ -558,7 +558,7 @@ public void testRawTypePojoWithTrustedValue() { @Test public void testRawTypePojoWithUntrustedFieldThrows() { RawTypePojo pojo = new RawTypePojo(new ArrayList<>()); - assertThrows("Cannot serialize instance of (" + ArrayList.class + assertThrows("Cannot serialize instance of (" + ArrayList.class.getCanonicalName() + ") because it is not an instance of a trusted type.", pojo, inliner -> inliner.trustExactTypes(RawTypePojo.class)); @@ -567,15 +567,15 @@ public void testRawTypePojoWithUntrustedFieldThrows() { private static class DurationInliner implements TypeInliner { @Override - public boolean canInline(Class type) { - return type.equals(Duration.class); + public boolean canInline(Object object) { + return object instanceof Duration; } @Override - public String inline(ObjectInliner inliner, Object instance) { + public String inline(ObjectEmitter emitter, Object instance) { Duration duration = (Duration) instance; return CodeBlock.of("$T.ofNanos($V)", Duration.class, - duration.toNanos()).toString(); + emitter.inlined(duration.toNanos())).toString(); } } @@ -583,12 +583,19 @@ public String inline(ObjectInliner inliner, Object instance) { public void testCustomInliner() { Duration duration = Duration.ofSeconds(1L); String inlinedNanos = CodeBlock.of("$V", duration.toNanos()).toString(); - assertResult(new StringBuilder("return ") - .append(Duration.class.getCanonicalName()) - .append(".ofNanos(") - .append(inlinedNanos) - .append(");") - .toString(), + assertResult(new StringBuilder() + .append(Duration.class.getCanonicalName()) + .append(" ") + .append("$$javapoet$") + .append(Duration.class.getSimpleName()) + .append(" = ") + .append(Duration.class.getCanonicalName()) + .append(".ofNanos(") + .append(inlinedNanos) + .append(");") + .append("return $$javapoet$") + .append(Duration.class.getSimpleName()).append(";") + .toString(), duration, inliner -> inliner .trustExactTypes(Duration.class) @@ -598,7 +605,7 @@ public void testCustomInliner() { @Test public void testCustomInlinerNotCalledOnUntrustedTypes() { Duration duration = Duration.ofSeconds(1L); - assertThrows("Cannot serialize instance of (" + Duration.class + ") because it is not an instance of a trusted type.", + assertThrows("Cannot serialize instance of (" + Duration.class.getCanonicalName() + ") because it is not an instance of a trusted type.", duration, inliner -> inliner .addTypeInliner(new DurationInliner())); @@ -629,4 +636,160 @@ public void testUseCustomPrefix() { .useNamePrefix("test$") .trustExactTypes(TrustedPojo.class)); } + + private static class RecursiveInliner implements TypeInliner { + + @Override + public boolean canInline(Object object) { + return object instanceof TrustedPojo; + } + + @Override + public String inline(ObjectEmitter emitter, Object instance) throws IOException { + TrustedPojo pojo = (TrustedPojo) instance; + emitter.newName(pojo.name, pojo); + emitter.emit("$T $N = new $T();", TrustedPojo.class, emitter.getName(pojo), TrustedPojo.class); + emitter.emit("$N.name = $S + $V;", emitter.getName(pojo), "Taco ", emitter.inlined(pojo.name)); + emitter.emit("$N.setValue($V);", emitter.getName(pojo), emitter.inlined(pojo.value)); + emitter.emit("$N.setNext($V);", emitter.getName(pojo), emitter.inlined(pojo.next)); + return emitter.getName(pojo); + } + } + + private String expectedCustomInlinedTrustedPojo(TrustedPojo trustedPojo, String prefix, String suffix) { + StringBuilder out = new StringBuilder() + .append(TrustedPojo.TYPE).append(prefix).append(trustedPojo.name) + .append(" = new ") + .append(TrustedPojo.TYPE).append("();") + .append(prefix).append(trustedPojo.name).append(".name = \"") + .append("Taco\" + ").append(CodeBlock.of("$V", trustedPojo.name)).append(";") + .append(prefix).append(trustedPojo.name).append(".setValue(") + .append(CodeBlock.of("$V", trustedPojo.value)).append(");"); + + if (trustedPojo.next == null) { + out.append(prefix).append(trustedPojo.name).append(".setNext(null);"); + } else { + out.append(prefix).append(trustedPojo.name).append(".setNext(") + .append("((").append(TrustedPojo.class.getCanonicalName()).append(")((") + .append(Supplier.class.getCanonicalName()).append(")(()->{") + .append(expectedCustomInlinedTrustedPojo(trustedPojo.next, prefix, suffix + "_")) + .append("return ").append(prefix).append(TrustedPojo.class.getSimpleName()).append(suffix).append("_").append(";") + .append("})).get())") + .append(");"); + } + out.append(TrustedPojo.class.getCanonicalName()) + .append(prefix).append(TrustedPojo.class.getSimpleName()).append(suffix) + .append(" = ") + .append(prefix).append(trustedPojo.name) + .append(";"); + return out.toString(); + } + + @Test + public void testCustomInlineTrustedObject() { + TrustedPojo pojo = new TrustedPojo("a", 1) + .withNext(new TrustedPojo("b", 2)); + assertResult(new StringBuilder() + .append(expectedCustomInlinedTrustedPojo(pojo, "$$javapoet$", "")) + .append("return $$javapoet$").append(TrustedPojo.class.getSimpleName()).append(";") + .toString(), pojo, inliner -> inliner.trustExactTypes(TrustedPojo.class) + .addTypeInliner(new RecursiveInliner())); + } + + @Test + public void testCustomInlineTrustedObjectWithSelfReference() { + TrustedPojo pojo = new TrustedPojo("a", 1); + pojo.setNext(pojo); + assertThrows("Cannot serialize an object of type (" + + TrustedPojo.class.getCanonicalName() + ") because it contains a circular reference.", pojo, + inliner -> inliner.trustExactTypes(TrustedPojo.class) + .addTypeInliner(new RecursiveInliner())); + } + + public static class PairPojo { + private PairPojo left; + private PairPojo right; + + public PairPojo(PairPojo left, PairPojo right) { + this.left = left; + this.right = right; + } + + public PairPojo getLeft() { + return left; + } + + public PairPojo getRight() { + return right; + } + } + + private static class PairPojoInliner implements TypeInliner { + + @Override + public boolean canInline(Object object) { + return object instanceof PairPojo; + } + + @Override + public String inline(ObjectEmitter emitter, Object instance) { + PairPojo pair = (PairPojo) instance; + return CodeBlock.of("new $T($V, $V)", + PairPojo.class, + emitter.inlined(pair.getLeft()), + emitter.inlined(pair.getRight())).toString(); + } + } + + private String expectedCustomInlinedPairPojo(PairPojo pairPojo, String prefix, String suffix) { + StringBuilder out = new StringBuilder() + .append(PairPojo.class.getCanonicalName()) + .append(" ") + .append(prefix).append(PairPojo.class.getSimpleName()).append(suffix) + .append(" = new ") + .append(PairPojo.class.getCanonicalName()) + .append("("); + + if (pairPojo.left == null) { + out.append("null"); + } else { + out.append("((").append(PairPojo.class.getCanonicalName()).append(")((") + .append(Supplier.class.getCanonicalName()).append(")(()->{"); + out.append(expectedCustomInlinedPairPojo(pairPojo.left, prefix, suffix + "_")) + .append("return ").append(prefix).append(PairPojo.class.getSimpleName()).append(suffix).append("_"); + out.append(";})).get())"); + } + + out.append(", "); + + if (pairPojo.right == null) { + out.append("null"); + } else { + out.append("((").append(PairPojo.class.getCanonicalName()).append(")((") + .append(Supplier.class.getCanonicalName()).append(")(()->{"); + out.append(expectedCustomInlinedPairPojo(pairPojo.right, prefix, suffix + "_")) + .append("return ").append(prefix).append(PairPojo.class.getSimpleName()).append(suffix).append("_"); + out.append(";})).get())"); + } + + out.append(");"); + + + return out.toString(); + } + + @Test + public void testCustomInlineTrustedObjectWithSiblingReference() { + PairPojo common = new PairPojo(null, null); + PairPojo root = new PairPojo(common, common); + // Make sure the sibling emitters do not try to share common, since + // they cannot access each other's variables + assertResult(new StringBuilder() + .append(expectedCustomInlinedPairPojo(root, "$$javapoet$", "")) + .append("return $$javapoet$").append(PairPojo.class.getSimpleName()).append(";") + .toString(), + root, + inliner -> inliner.trustExactTypes(PairPojo.class) + .addTypeInliner(new PairPojoInliner())); + } }