diff --git a/bom/pom.xml b/bom/pom.xml index 8173f996..17ca8373 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -6,7 +6,7 @@ tech.ydb.yoj yoj-bom - 1.0.3-SNAPSHOT + 1.1.0-SNAPSHOT pom YOJ - Bill of Materials diff --git a/databind/pom.xml b/databind/pom.xml index 9a6ac47e..229c3ec4 100644 --- a/databind/pom.xml +++ b/databind/pom.xml @@ -10,7 +10,7 @@ tech.ydb.yoj yoj-parent - 1.0.3-SNAPSHOT + 1.1.0-SNAPSHOT ../pom.xml @@ -34,5 +34,59 @@ javax.annotation javax.annotation-api + + org.slf4j + slf4j-api + + + + + org.jetbrains.kotlin + kotlin-reflect + true + + + org.jetbrains.kotlin + kotlin-stdlib + true + + + + org.apache.logging.log4j + log4j-slf4j-impl + test + + + org.apache.logging.log4j + log4j-core + test + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + + + compileTests + process-test-sources + + test-compile + + + + src/test/kotlin + target/generated-sources/annotations + + 17 + + + + + + + + diff --git a/databind/src/main/java/tech/ydb/yoj/databind/schema/reflect/KotlinDataClassComponent.java b/databind/src/main/java/tech/ydb/yoj/databind/schema/reflect/KotlinDataClassComponent.java new file mode 100644 index 00000000..2e738d35 --- /dev/null +++ b/databind/src/main/java/tech/ydb/yoj/databind/schema/reflect/KotlinDataClassComponent.java @@ -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() + "]"; + } +} diff --git a/databind/src/main/java/tech/ydb/yoj/databind/schema/reflect/KotlinDataClassType.java b/databind/src/main/java/tech/ydb/yoj/databind/schema/reflect/KotlinDataClassType.java new file mode 100644 index 00000000..0ed2407b --- /dev/null +++ b/databind/src/main/java/tech/ydb/yoj/databind/schema/reflect/KotlinDataClassType.java @@ -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 implements ReflectType { + private final Class type; + private final Constructor constructor; + private final List fields; + + public KotlinDataClassType(Reflector reflector, Class 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 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 getConstructor() { + return constructor; + } + + @Override + public List getFields() { + return fields; + } + + @Override + public Class getRawType() { + return type; + } + + @Override + public String toString() { + return "KotlinDataClassType[" + type + "]"; + } +} diff --git a/databind/src/main/java/tech/ydb/yoj/databind/schema/reflect/KotlinDataClassTypeFactory.java b/databind/src/main/java/tech/ydb/yoj/databind/schema/reflect/KotlinDataClassTypeFactory.java new file mode 100644 index 00000000..c789b37f --- /dev/null +++ b/databind/src/main/java/tech/ydb/yoj/databind/schema/reflect/KotlinDataClassTypeFactory.java @@ -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 ReflectType create(Reflector reflector, Class 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; + } + } +} diff --git a/databind/src/main/java/tech/ydb/yoj/databind/schema/reflect/KotlinReflectionDetector.java b/databind/src/main/java/tech/ydb/yoj/databind/schema/reflect/KotlinReflectionDetector.java new file mode 100644 index 00000000..2159a50d --- /dev/null +++ b/databind/src/main/java/tech/ydb/yoj/databind/schema/reflect/KotlinReflectionDetector.java @@ -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; + } +} diff --git a/databind/src/main/java/tech/ydb/yoj/databind/schema/reflect/PojoType.java b/databind/src/main/java/tech/ydb/yoj/databind/schema/reflect/PojoType.java index 27ccce1a..ab173ef7 100644 --- a/databind/src/main/java/tech/ydb/yoj/databind/schema/reflect/PojoType.java +++ b/databind/src/main/java/tech/ydb/yoj/databind/schema/reflect/PojoType.java @@ -63,7 +63,7 @@ public PojoType(@NonNull Reflector reflector, @NonNull Class type) { .toList(); } else { this.fields = Stream.of(type.getDeclaredFields()) - .filter(tech.ydb.yoj.databind.schema.reflect.PojoType::isEntityField) + .filter(PojoType::isEntityField) .map(f -> new PojoField(reflector, f)) .toList(); } @@ -94,7 +94,7 @@ private static boolean isEntityField(Field f) { // FIXME: this is NOT the best way to find all-args ctor private static Constructor findAllArgsCtor(Class type) { long instanceFieldCount = Stream.of(type.getDeclaredFields()) - .filter(tech.ydb.yoj.databind.schema.reflect.PojoType::isEntityField) + .filter(PojoType::isEntityField) .count(); @SuppressWarnings("unchecked") Constructor ctor = (Constructor) Stream.of(type.getDeclaredConstructors()) diff --git a/databind/src/main/java/tech/ydb/yoj/databind/schema/reflect/StdReflector.java b/databind/src/main/java/tech/ydb/yoj/databind/schema/reflect/StdReflector.java index 6394765c..d2c89646 100644 --- a/databind/src/main/java/tech/ydb/yoj/databind/schema/reflect/StdReflector.java +++ b/databind/src/main/java/tech/ydb/yoj/databind/schema/reflect/StdReflector.java @@ -11,8 +11,8 @@ import static java.util.Comparator.comparing; /** - * Standard {@link Reflector} implementation, suitable for most usages. By default, reflecting record classes, POJOs and - * simple types such as {@code int} is supported. + * Standard {@link Reflector} implementation, suitable for most usages. By default, reflecting record classes, Kotlin + * data classes, POJOs and simple types such as {@code int} is supported. *

* You can override default {@link Reflector} by creating a custom {@link SchemaRegistry} with your own instance of * {@code StdReflector} with a different set of {@link TypeFactory type factories}, or a wholly different implementation @@ -26,6 +26,7 @@ public final class StdReflector implements Reflector { public static final Reflector instance = new StdReflector(List.of( RecordType.FACTORY, + KotlinDataClassTypeFactory.instance, PojoType.FACTORY, SimpleType.FACTORY )); diff --git a/databind/src/test/kotlin/tech/ydb/yoj/databind/schema/KotlinJvmRecordSchemaTest.kt b/databind/src/test/kotlin/tech/ydb/yoj/databind/schema/KotlinJvmRecordSchemaTest.kt new file mode 100644 index 00000000..f2864b1f --- /dev/null +++ b/databind/src/test/kotlin/tech/ydb/yoj/databind/schema/KotlinJvmRecordSchemaTest.kt @@ -0,0 +1,117 @@ +package tech.ydb.yoj.databind.schema + +import org.assertj.core.api.Assertions.assertThat +import org.junit.BeforeClass +import org.junit.Test + +class KotlinJvmRecordSchemaTest { + @Test + fun testRawPathField() { + val field = schema!!.getField("entity1") + + assertThat(field.rawPath).isEqualTo("entity1") + } + + @Test + fun testRawPathSubField() { + val field = schema!!.getField("entity1.entity2.entity3") + + assertThat(field.rawPath).isEqualTo("entity1.entity2.entity3") + } + + @Test + fun testRawPathLeafField() { + val field = schema!!.getField("entity1.entity2.entity3.value") + + assertThat(field.rawPath).isEqualTo("entity1.entity2.entity3.value") + } + + @Test + fun testRawSubPathSubField() { + val field = schema!!.getField("entity1.entity2.entity3") + + assertThat(field.getRawSubPath(1)).isEqualTo("entity2.entity3") + } + + @Test + fun testRawSubPathLeafFieldOnlyLead() { + val field = schema!!.getField("entity1.entity2.entity3.value") + + assertThat(field.getRawSubPath(3)).isEqualTo("value") + } + + @Test + fun testRawSubPathLeafFieldEqualNesting() { + val field = schema!!.getField("entity1.entity2.entity3.value") + + assertThat(field.getRawSubPath(4)).isEmpty() + } + + @Test + fun testRawSubPathLeafFieldExceedsNesting() { + val field = schema!!.getField("entity1.entity2.entity3.value") + + assertThat(field.getRawSubPath(10)).isEmpty() + } + + @Test + fun testIsFlatTrue() { + assertThat(schema!!.getField("flatEntity").isFlat).isTrue() + } + + @Test + fun testIdsFlatFalse() { + assertThat(schema!!.getField("twoFieldEntity").isFlat).isFalse() + } + + @Test + fun testIsFlatFalseForNotFlat() { + assertThat(schema!!.getField("notFlatEntity").isFlat).isFalse() + } + + private class TestSchema(entityType: Class) : Schema(entityType) + + @JvmRecord + private data class UberEntity( + val entity1: Entity1, + val flatEntity: FlatEntity, + val twoFieldEntity: TwoFieldEntity, + val notFlatEntity: NotFlatEntity, + ) + + @JvmRecord + private data class Entity1(val entity2: Entity2) + + @JvmRecord + private data class Entity2(val entity3: Entity3) + + @JvmRecord + private data class Entity3(val value: Int) + + @JvmRecord + private data class FlatEntity(val entity1: Entity1) + + @JvmRecord + private data class TwoFieldEntity( + val entity1: Entity1, + val boolValue: Boolean, + ) + + @JvmRecord + private data class NotFlatEntity( + val twoFieldEntity: TwoFieldEntity, + val otherTwoFieldEntity: TwoFieldEntity, + ) + + companion object { + private var schema: Schema? = null + + @JvmStatic + @BeforeClass + fun setUpClass() { + schema = TestSchema( + UberEntity::class.java + ) + } + } +} diff --git a/databind/src/test/kotlin/tech/ydb/yoj/databind/schema/KotlinSchemaTest.kt b/databind/src/test/kotlin/tech/ydb/yoj/databind/schema/KotlinSchemaTest.kt new file mode 100644 index 00000000..eb4c0ef7 --- /dev/null +++ b/databind/src/test/kotlin/tech/ydb/yoj/databind/schema/KotlinSchemaTest.kt @@ -0,0 +1,110 @@ +package tech.ydb.yoj.databind.schema + +import org.assertj.core.api.Assertions.assertThat +import org.junit.BeforeClass +import org.junit.Test + +class KotlinSchemaTest { + @Test + fun testRawPathField() { + val field = schema!!.getField("entity1") + + assertThat(field.rawPath).isEqualTo("entity1") + } + + @Test + fun testRawPathSubField() { + val field = schema!!.getField("entity1.entity2.entity3") + + assertThat(field.rawPath).isEqualTo("entity1.entity2.entity3") + } + + @Test + fun testRawPathLeafField() { + val field = schema!!.getField("entity1.entity2.entity3.value") + + assertThat(field.rawPath).isEqualTo("entity1.entity2.entity3.value") + } + + @Test + fun testRawSubPathSubField() { + val field = schema!!.getField("entity1.entity2.entity3") + + assertThat(field.getRawSubPath(1)).isEqualTo("entity2.entity3") + } + + @Test + fun testRawSubPathLeafFieldOnlyLead() { + val field = schema!!.getField("entity1.entity2.entity3.value") + + assertThat(field.getRawSubPath(3)).isEqualTo("value") + } + + @Test + fun testRawSubPathLeafFieldEqualNesting() { + val field = schema!!.getField("entity1.entity2.entity3.value") + + assertThat(field.getRawSubPath(4)).isEmpty() + } + + @Test + fun testRawSubPathLeafFieldExceedsNesting() { + val field = schema!!.getField("entity1.entity2.entity3.value") + + assertThat(field.getRawSubPath(10)).isEmpty() + } + + @Test + fun testIsFlatTrue() { + assertThat(schema!!.getField("flatEntity").isFlat).isTrue() + } + + @Test + fun testIdsFlatFalse() { + assertThat(schema!!.getField("twoFieldEntity").isFlat).isFalse() + } + + @Test + fun testIsFlatFalseForNotFlat() { + assertThat(schema!!.getField("notFlatEntity").isFlat).isFalse() + } + + private class TestSchema(entityType: Class) : Schema(entityType) + + private data class UberEntity( + val entity1: Entity1, + val flatEntity: FlatEntity, + val twoFieldEntity: TwoFieldEntity, + val notFlatEntity: NotFlatEntity, + ) + + private data class Entity1(val entity2: Entity2) + + private data class Entity2(val entity3: Entity3) + + private data class Entity3(val value: Int) + + private data class FlatEntity(val entity1: Entity1) + + private data class TwoFieldEntity( + val entity1: Entity1, + val boolValue: Boolean, + ) + + private data class NotFlatEntity( + val twoFieldEntity: TwoFieldEntity, + val otherTwoFieldEntity: TwoFieldEntity, + ) + + companion object { + private var schema: Schema? = null + + @JvmStatic + @BeforeClass + fun setUpClass() { + schema = TestSchema( + UberEntity::class.java + ) + } + } +} diff --git a/databind/src/test/resources/log4j2.yaml b/databind/src/test/resources/log4j2.yaml new file mode 100644 index 00000000..5dfc2a3f --- /dev/null +++ b/databind/src/test/resources/log4j2.yaml @@ -0,0 +1,15 @@ +Configuration: + appenders: + Console: + name: stdout + PatternLayout: + Pattern: "%d %-5level %-6X{tx} [%t] %c{1.}: %msg%n%throwable" + + Loggers: + Root: + level: info + AppenderRef: + ref: stdout + Logger: + - name: tech.ydb.yoj.databind + level: debug diff --git a/pom.xml b/pom.xml index bf880236..df497d7f 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ tech.ydb.yoj yoj-parent - 1.0.3-SNAPSHOT + 1.1.0-SNAPSHOT pom YDB ORM for Java (YOJ) @@ -132,6 +132,9 @@ 2.14.0 2.10.1 + + 1.9.22 + 4.13.2 2.2 @@ -369,6 +372,11 @@ none + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + @@ -631,6 +639,18 @@ gson ${gson-version} + + org.jetbrains.kotlin + kotlin-reflect + ${kotlin.version} + true + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + true + diff --git a/repository-inmemory/pom.xml b/repository-inmemory/pom.xml index fdd37874..82b37c27 100644 --- a/repository-inmemory/pom.xml +++ b/repository-inmemory/pom.xml @@ -10,7 +10,7 @@ tech.ydb.yoj yoj-parent - 1.0.3-SNAPSHOT + 1.1.0-SNAPSHOT ../pom.xml diff --git a/repository-test/pom.xml b/repository-test/pom.xml index 64fc1462..ae0af7ce 100644 --- a/repository-test/pom.xml +++ b/repository-test/pom.xml @@ -10,7 +10,7 @@ tech.ydb.yoj yoj-parent - 1.0.3-SNAPSHOT + 1.1.0-SNAPSHOT ../pom.xml diff --git a/repository-ydb-common/pom.xml b/repository-ydb-common/pom.xml index 6d70fb81..0f0c839f 100644 --- a/repository-ydb-common/pom.xml +++ b/repository-ydb-common/pom.xml @@ -10,7 +10,7 @@ tech.ydb.yoj yoj-parent - 1.0.3-SNAPSHOT + 1.1.0-SNAPSHOT ../pom.xml diff --git a/repository-ydb-v1/pom.xml b/repository-ydb-v1/pom.xml index c085a4cb..03b53b92 100644 --- a/repository-ydb-v1/pom.xml +++ b/repository-ydb-v1/pom.xml @@ -10,7 +10,7 @@ tech.ydb.yoj yoj-parent - 1.0.3-SNAPSHOT + 1.1.0-SNAPSHOT ../pom.xml diff --git a/repository-ydb-v2/pom.xml b/repository-ydb-v2/pom.xml index ffe46968..1a1e343c 100644 --- a/repository-ydb-v2/pom.xml +++ b/repository-ydb-v2/pom.xml @@ -10,7 +10,7 @@ tech.ydb.yoj yoj-parent - 1.0.3-SNAPSHOT + 1.1.0-SNAPSHOT ../pom.xml @@ -104,7 +104,6 @@ tech.ydb.yoj yoj-repository-test - 1.0.3-SNAPSHOT test diff --git a/repository/pom.xml b/repository/pom.xml index 31c37e96..f92470f2 100644 --- a/repository/pom.xml +++ b/repository/pom.xml @@ -10,7 +10,7 @@ tech.ydb.yoj yoj-parent - 1.0.3-SNAPSHOT + 1.1.0-SNAPSHOT ../pom.xml diff --git a/util/pom.xml b/util/pom.xml index 50792561..b845942c 100644 --- a/util/pom.xml +++ b/util/pom.xml @@ -10,7 +10,7 @@ tech.ydb.yoj yoj-parent - 1.0.3-SNAPSHOT + 1.1.0-SNAPSHOT ../pom.xml