Skip to content

Commit

Permalink
#7: Support Kotlin data classes in StdReflector (#8)
Browse files Browse the repository at this point in the history
Kotlin support is enabled by default if you have `kotlin-stdlib` *AND*
`kotlin-reflection` 1.9.x on your classpath.

Note that you can use Kotlin data classes exposed as Java records
(`@JvmRecord`) even if you have *NO* Kotlin dependencies on your
classpath.

Fixes: #7
  • Loading branch information
nvamelichev authored Jan 26, 2024
1 parent 98e97a8 commit 954ed1a
Show file tree
Hide file tree
Showing 19 changed files with 616 additions and 15 deletions.
2 changes: 1 addition & 1 deletion bom/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>tech.ydb.yoj</groupId>
<artifactId>yoj-bom</artifactId>
<version>1.0.3-SNAPSHOT</version>
<version>1.1.0-SNAPSHOT</version>
<packaging>pom</packaging>

<name>YOJ - Bill of Materials</name>
Expand Down
56 changes: 55 additions & 1 deletion databind/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<parent>
<groupId>tech.ydb.yoj</groupId>
<artifactId>yoj-parent</artifactId>
<version>1.0.3-SNAPSHOT</version>
<version>1.1.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>

Expand All @@ -34,5 +34,59 @@
<groupId>javax.annotation</groupId>
<artifactId>javax.annotation-api</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>

<!-- Optional (Kotlin support) -->
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-reflect</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<optional>true</optional>
</dependency>

<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j-impl</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<executions>
<execution>
<id>compileTests</id>
<phase>process-test-sources</phase>
<goals>
<goal>test-compile</goal>
</goals>
<configuration>
<sourceDirs>
<source>src/test/kotlin</source>
<source>target/generated-sources/annotations</source>
</sourceDirs>
<jvmTarget>17</jvmTarget>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>


</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package tech.ydb.yoj.databind.schema.reflect;

import kotlin.jvm.JvmClassMappingKt;
import kotlin.reflect.KCallable;
import kotlin.reflect.jvm.ReflectJvmMapping;
import tech.ydb.yoj.databind.FieldValueType;
import tech.ydb.yoj.databind.schema.Column;
import tech.ydb.yoj.databind.schema.FieldValueException;

import javax.annotation.Nullable;
import java.lang.reflect.Type;

/**
* Represents a Kotlin data class component for the purposes of YOJ data-binding.
*/
public final class KotlinDataClassComponent implements ReflectField {
private final KCallable<?> callable;

private final String name;
private final Type genericType;
private final Class<?> type;
private final FieldValueType valueType;
private final Column column;

private final ReflectType<?> reflectType;

public KotlinDataClassComponent(Reflector reflector, String name, KCallable<?> callable) {
this.callable = callable;

this.name = name;

var kReturnType = callable.getReturnType();
this.genericType = ReflectJvmMapping.getJavaType(kReturnType);
this.type = JvmClassMappingKt.getJavaClass(kReturnType);
this.column = type.getAnnotation(Column.class);
this.valueType = FieldValueType.forJavaType(genericType, column);
this.reflectType = reflector.reflectFieldType(genericType, valueType);
}

@Nullable
@Override
public Object getValue(Object containingObject) {
try {
return callable.call(containingObject);
} catch (Exception e) {
throw new FieldValueException(e, getName(), containingObject);
}
}

@Override
public String getName() {
return name;
}

@Override
public Type getGenericType() {
return genericType;
}

@Override
public Class<?> getType() {
return type;
}

@Override
public FieldValueType getValueType() {
return valueType;
}

@Override
public Column getColumn() {
return column;
}

@Override
public ReflectType<?> getReflectType() {
return reflectType;
}

@Override
public String toString() {
return "KotlinDataClassComponent[val " + name + ": " + genericType.getTypeName() + "]";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package tech.ydb.yoj.databind.schema.reflect;

import com.google.common.base.Preconditions;
import kotlin.jvm.JvmClassMappingKt;
import kotlin.reflect.KCallable;
import kotlin.reflect.KMutableProperty;
import kotlin.reflect.KParameter;
import kotlin.reflect.full.KClasses;
import kotlin.reflect.jvm.ReflectJvmMapping;

import java.lang.reflect.Constructor;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

import static java.util.stream.Collectors.toMap;

/**
* Represents a Kotlin data class for the purposes of YOJ data-binding.
*/
public final class KotlinDataClassType<T> implements ReflectType<T> {
private final Class<T> type;
private final Constructor<T> constructor;
private final List<ReflectField> fields;

public KotlinDataClassType(Reflector reflector, Class<T> type) {
this.type = type;

var kClass = JvmClassMappingKt.getKotlinClass(type);
var kClassName = kClass.getQualifiedName();
Preconditions.checkArgument(kClass.isData(),
"'%s' is not a data class",
kClassName);

var primaryKtConstructor = KClasses.getPrimaryConstructor(kClass);
Preconditions.checkArgument(primaryKtConstructor != null,
"'%s' has no primary constructor",
kClassName);

var primaryJavaConstructor = ReflectJvmMapping.getJavaConstructor(primaryKtConstructor);
Preconditions.checkArgument(primaryJavaConstructor != null,
"Could not get Java Constructor<%s> from KFunction: %s",
kClassName, primaryKtConstructor);
this.constructor = primaryJavaConstructor;
this.constructor.setAccessible(true);

var functions = KClasses.getDeclaredMemberFunctions(kClass).stream()
.filter(c -> KotlinDataClassTypeFactory.isComponentMethodName(c.getName())
&& c.getParameters().size() == 1
&& Objects.equals(kClass, c.getParameters().get(0).getType().getClassifier()))
.collect(toMap(KCallable::getName, m -> m));

var mutableProperties = KClasses.getDeclaredMemberProperties(kClass).stream()
.filter(p -> p instanceof KMutableProperty)
.collect(toMap(KCallable::getName, m -> m));

List<ReflectField> fields = new ArrayList<>();
int n = 1;
for (KParameter param : primaryKtConstructor.getParameters()) {
var paramName = param.getName();

Preconditions.checkArgument(!mutableProperties.containsKey(paramName),
"Mutable constructor arguments are not allowed in '%s', but got: '%s'",
kClassName, paramName);

var callable = functions.get("component" + n);
Preconditions.checkState(callable != null,
"Could not find component%s() in '%s'",
n, kClassName);
fields.add(new KotlinDataClassComponent(reflector, paramName, callable));
n++;
}
this.fields = List.copyOf(fields);
}

@Override
public Constructor<T> getConstructor() {
return constructor;
}

@Override
public List<ReflectField> getFields() {
return fields;
}

@Override
public Class<T> getRawType() {
return type;
}

@Override
public String toString() {
return "KotlinDataClassType[" + type + "]";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package tech.ydb.yoj.databind.schema.reflect;

import kotlin.Metadata;
import tech.ydb.yoj.databind.FieldValueType;
import tech.ydb.yoj.databind.schema.reflect.StdReflector.TypeFactory;

import java.lang.annotation.Annotation;
import java.lang.reflect.Method;

import static java.util.Arrays.stream;

public final class KotlinDataClassTypeFactory implements TypeFactory {
public static final TypeFactory instance = new KotlinDataClassTypeFactory();

private static final int KIND_CLASS = 1;

private KotlinDataClassTypeFactory() {
}

@Override
public int priority() {
return 300;
}

@Override
public boolean matches(Class<?> rawType, FieldValueType fvt) {
return KotlinReflectionDetector.kotlinAvailable
&& stream(rawType.getDeclaredAnnotations()).anyMatch(this::isKotlinClassMetadata)
&& stream(rawType.getDeclaredMethods()).anyMatch(this::isComponentGetter);
}

private boolean isKotlinClassMetadata(Annotation ann) {
return Metadata.class.equals(ann.annotationType()) && ((Metadata) ann).k() == KIND_CLASS;
}

private boolean isComponentGetter(Method m) {
return m.getParameterCount() == 0 && isComponentMethodName(m.getName());
}

@Override
public <R> ReflectType<R> create(Reflector reflector, Class<R> rawType, FieldValueType fvt) {
return new KotlinDataClassType<>(reflector, rawType);
}

static boolean isComponentMethodName(String name) {
if (!name.startsWith("component")) {
return false;
}

try {
int n = Integer.parseInt(name.substring("component".length()), 10);
return n >= 1;
} catch (NumberFormatException e) {
return false;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package tech.ydb.yoj.databind.schema.reflect;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

final class KotlinReflectionDetector {
private static final Logger log = LoggerFactory.getLogger(KotlinReflectionDetector.class);

private KotlinReflectionDetector() {
}

public static final boolean kotlinAvailable = detectKotlinReflection();

private static boolean detectKotlinReflection() {
var cl = classLoader();

try {
Class.forName("kotlin.Metadata", false, cl);
} catch (ClassNotFoundException e) {
return false;
}

try {
Class.forName("kotlin.reflect.full.KClasses", false, cl);
return true;
} catch (ClassNotFoundException e) {
log.warn("YOJ has detected Kotlin but not kotlin-reflect. Kotlin data classes won't work as Entities.", e);
return false;
}
}

private static ClassLoader classLoader() {
ClassLoader cl = null;
try {
cl = Thread.currentThread().getContextClassLoader();
} catch (Exception ignore) {
}
if (cl == null) {
cl = KotlinDataClassType.class.getClassLoader();
if (cl == null) {
try {
cl = ClassLoader.getSystemClassLoader();
} catch (Exception ignore) {
}
}
}
return cl;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public PojoType(@NonNull Reflector reflector, @NonNull Class<T> type) {
.toList();
} else {
this.fields = Stream.of(type.getDeclaredFields())
.filter(tech.ydb.yoj.databind.schema.reflect.PojoType::isEntityField)
.filter(PojoType::isEntityField)
.<ReflectField>map(f -> new PojoField(reflector, f))
.toList();
}
Expand Down Expand Up @@ -94,7 +94,7 @@ private static boolean isEntityField(Field f) {
// FIXME: this is NOT the best way to find all-args ctor
private static <T> Constructor<T> findAllArgsCtor(Class<T> type) {
long instanceFieldCount = Stream.of(type.getDeclaredFields())
.filter(tech.ydb.yoj.databind.schema.reflect.PojoType::isEntityField)
.filter(PojoType::isEntityField)
.count();

@SuppressWarnings("unchecked") Constructor<T> ctor = (Constructor<T>) Stream.of(type.getDeclaredConstructors())
Expand Down
Loading

0 comments on commit 954ed1a

Please sign in to comment.