blob: e892d42e651f9606ff2a9d8889975766ef0cd28d [file] [log] [blame]
package com.google.gerrit.entities.converter;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
import com.google.common.collect.ImmutableList;
import com.google.common.primitives.Primitives;
import com.google.common.reflect.ClassPath;
import com.google.common.reflect.ClassPath.ClassInfo;
import com.google.common.testing.ArbitraryInstances;
import com.google.gerrit.common.ConvertibleToProto;
import com.google.gerrit.common.Nullable;
import com.google.protobuf.Descriptors.FieldDescriptor;
import com.google.protobuf.Message;
import com.google.protobuf.MessageLite;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.annotation.Nonnull;
import org.apache.commons.lang3.builder.EqualsBuilder;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameter;
import org.junit.runners.Parameterized.Parameters;
@RunWith(Parameterized.class)
public class SafeProtoConverterTest {
@Parameter(0)
public SafeProtoConverter<Message, Object> converter;
@Parameter(1)
public String converterName;
@Parameters(name = "{1}")
public static ImmutableList<Object[]> listSafeConverters() throws Exception {
return ClassPath.from(ClassLoader.getSystemClassLoader()).getAllClasses().stream()
.filter(type -> type.getPackageName().contains("gerrit"))
.map(ClassInfo::load)
.filter(SafeProtoConverter.class::isAssignableFrom)
.filter(clz -> !SafeProtoConverter.class.equals(clz))
.filter(Class::isEnum)
.map(clz -> (SafeProtoConverter<Message, Object>) clz.getEnumConstants()[0])
.map(clz -> new Object[] {clz, clz.getClass().getSimpleName()})
.collect(toImmutableList());
}
/**
* For rising visibility, all Java Entity classes which have a {@link SafeProtoConverter}, must be
* annotated with {@link ConvertibleToProto}.
*/
@Test
public void isJavaClassMarkedAsConvertibleToProto() {
assertThat(converter.getEntityClass().getDeclaredAnnotation(ConvertibleToProto.class))
.isNotNull();
}
/**
* All {@link SafeProtoConverter} implementations must be enums with a single instance. Please
* prefer descriptive enum and instance names, such as {@code MyTypeConverter::MY_TYPE_CONVERTER}.
*/
@Test
public void isConverterAValidEnum() {
assertThat(converter.getClass().isEnum()).isTrue();
assertThat(converter.getClass().getEnumConstants().length).isEqualTo(1);
}
/**
* If this test fails, it's likely that you added a field to a Java class that has a {@link
* SafeProtoConverter} set, or that you have changed the default value for such a field. Please
* update the corresponding proto accordingly.
*/
@Test
public void javaDefaultsKeptOnDoubleConversion() {
Object orig;
try {
orig = buildObjectWithFullFieldsOrThrow(converter.getEntityClass());
} catch (Exception e) {
throw new IllegalStateException(
String.format(
"Failed to build object for type %s, this likely means the buildObjectWithFullFieldsOrThrow should be adapted.",
converter.getEntityClass().getName()),
e);
}
Object res = converter.fromProto(converter.toProto(converter.getEntityClass().cast(orig)));
assertThat(orig).isEqualTo(res);
// If this assertion fails, it's likely that you forgot to update the `equals` method to include
// your new field.
assertThat(EqualsBuilder.reflectionEquals(orig, res)).isTrue();
}
/**
* If this test fails, it's likely that you added a field to a proto that has a {@link
* SafeProtoConverter} set, or that you have changed the default value for such a field. Please
* update the corresponding Java class accordingly.
*/
@Test
public void protoDefaultsKeptOnDoubleConversion() {
Message defaultInstance = getProtoDefaultInstance(converter.getProtoClass());
Message preFilled = explicitlyFillProtoDefaults(defaultInstance);
Message resFromDefault =
converter.toProto(converter.fromProto(converter.getProtoClass().cast(preFilled)));
Message resFromPrefilled =
converter.toProto(converter.fromProto(converter.getProtoClass().cast(preFilled)));
assertThat(resFromDefault).isEqualTo(preFilled);
assertThat(resFromPrefilled).isEqualTo(preFilled);
}
@Nullable
private static Object buildObjectWithFullFieldsOrThrow(Class<?> clz) throws Exception {
if (clz == null) {
return null;
}
Object obj = construct(clz);
if (isSimple(clz)) {
return obj;
}
for (Field field : clz.getDeclaredFields()) {
if (Modifier.isStatic(field.getModifiers())) {
continue;
}
Class<?> parameterizedType = getParameterizedType(field);
if (!field.getType().isArray()
&& !Map.class.isAssignableFrom(field.getType())
&& !Collection.class.isAssignableFrom(field.getType())) {
if (!field.trySetAccessible()) {
return null;
}
field.set(obj, buildObjectWithFullFieldsOrThrow(field.getType()));
} else if (Collection.class.isAssignableFrom(field.getType()) && parameterizedType != null) {
field.set(obj, ImmutableList.of(buildObjectWithFullFieldsOrThrow(parameterizedType)));
}
}
return obj;
}
/**
* AutoValue annotations are not retained on runtime. We can only find out if a class is an
* AutoValue, by trying to load the expected AutoValue class.
*
* <p>For the class {@code package.Clz}, the AutoValue class name is {@code
* package.AutoValue_Clz}, for {@code package.Enclosing$Clz}, it is {@code
* package.AutoValue_Enclosing_Clz}
*/
static Optional<Class<?>> toRepresentingAutoValueClass(Class<?> clz) {
String origClzName = clz.getName();
String autoValueClzName =
origClzName.substring(0, origClzName.lastIndexOf("."))
+ ".AutoValue_"
+ origClzName.substring(origClzName.lastIndexOf(".") + 1);
autoValueClzName = autoValueClzName.replace('$', '_');
try {
return Optional.of(clz.getClassLoader().loadClass(autoValueClzName));
} catch (Exception e) {
return Optional.empty();
}
}
@Nullable
private static Class<?> getParameterizedType(Field field) {
if (!Collection.class.isAssignableFrom(field.getType())) {
return null;
}
Type genericType = field.getGenericType();
if (genericType instanceof ParameterizedType) {
return (Class<?>) ((ParameterizedType) genericType).getActualTypeArguments()[0];
}
return null;
}
@Nonnull
static Object construct(@Nonnull Class<?> clz) {
try {
Object arbitrary = ArbitraryInstances.get(clz);
if (arbitrary != null) {
return arbitrary;
}
Optional<Class<?>> optionalAutoValueRepresentation = toRepresentingAutoValueClass(clz);
if (optionalAutoValueRepresentation.isPresent()) {
return construct(optionalAutoValueRepresentation.get());
}
Constructor<?> constructor =
Arrays.stream(clz.getDeclaredConstructors())
// Filter out copy constructors
.filter(
c ->
c.getParameterCount() != 1 || !c.getParameterTypes()[0].isAssignableFrom(clz))
// Filter out private constructors which cannot be set accessible.
.filter(c -> c.canAccess(null) || c.trySetAccessible())
.min(Comparator.comparingInt(Constructor::getParameterCount))
.get();
List<Object> args = new ArrayList<>();
for (Class<?> f : constructor.getParameterTypes()) {
args.add(construct(f));
}
return constructor.newInstance(args.toArray());
} catch (Exception e) {
throw new IllegalStateException("Failed to construct class " + clz.getName(), e);
}
}
static boolean isSimple(Class<?> c) {
return c.isPrimitive()
|| c.isEnum()
|| Primitives.isWrapperType(c)
|| String.class.isAssignableFrom(c)
|| Timestamp.class.isAssignableFrom(c);
}
/**
* Returns the default instance for the given MessageLite class, if it has the {@code
* getDefaultInstance} static method.
*
* @param type the protobuf message class
* @throws IllegalArgumentException if the given class doesn't have the static {@code
* getDefaultInstance} method
*/
public static <T extends MessageLite> T getProtoDefaultInstance(Class<T> type) {
try {
return type.cast(type.getMethod("getDefaultInstance").invoke(null));
} catch (ReflectiveOperationException | ClassCastException e) {
throw new IllegalStateException("Cannot get default instance for " + type, e);
}
}
private static Message explicitlyFillProtoDefaults(Message defaultInstance) {
Message.Builder res = defaultInstance.toBuilder();
for (FieldDescriptor f : defaultInstance.getDescriptorForType().getFields()) {
if (f.getType().equals(FieldDescriptor.Type.MESSAGE)) {
res.setField(f, explicitlyFillProtoDefaults((Message) defaultInstance.getField(f)));
} else {
res.setField(f, defaultInstance.getField(f));
}
}
return res.build();
}
}