diff --git a/README.md b/README.md index f493ea0..be7163a 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ The **Kotlin Object Multiplatform Mapper** provides you a possibility to generat * [Configuration](#mapping-configuration) * [Disable AutoCast](#disable-autocast) * [Change Convert Function Name](#change-convert-function-name) + * [@MapFunction](#mapfunction-annotation) * [@MapName](#mapname-annotation) * [@MapEmbedded](#mapembedded-annotation) * [@MapConverter](#use-converter) @@ -55,6 +56,7 @@ The **Kotlin Object Multiplatform Mapper** provides you a possibility to generat * Has next properties annotations: * Specify mapping from property with different name * Specify a converter to map data from source unusual way + * Specify a top-level extension function for property casting * Specify a resolver to map default values into properties * Specify null substitute to map nullable properties into not-nullable * Support extension via plugins @@ -84,7 +86,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.60.0" +val kommVersion = "0.61.6" depensencies { implementation("com.ucasoft.komm:komm-annotations:$kommVersion") @@ -97,7 +99,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.60.0" +val kommVersion = "0.61.6" kotlin { jvm { @@ -222,6 +224,42 @@ fun SourceObject.convertToDestination(): DestinationObject = DestinationObject( } ``` +### @MapFunction annotation +Use `@MapFunction` when the automatic `toType()` cast should call a top-level extension function from another package. +KOMM imports the function and keeps extension-call syntax in generated code. + +#### Function declaration +```kotlin +fun ByteArray.toImageBitmap(): ImageBitmap = //... +``` +#### Classes declaration +```kotlin +data class SourceObject( + val logo: ByteArray? +) + +@KOMMMap(from = [SourceObject::class]) +data class DestinationObject( + @MapFunction(packageName = "com.test.converters") + val logo: ImageBitmap? +) +``` +or specify the function name explicitly: +```kotlin +@MapFunction( + packageName = "com.test.converters", + name = "toImageBitmap" +) +``` +#### Generated extension function +```kotlin +import com.test.converters.toImageBitmap + +fun SourceObject.toDestinationObject(): DestinationObject = DestinationObject( + logo = logo?.toImageBitmap() +) +``` + ### @MapName annotation #### Classes declaration ```kotlin @@ -528,7 +566,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.60.0" +val kommVersion = "0.61.6" depensencies { implementation("com.ucasoft.komm:komm-annotations:$kommVersion") @@ -542,7 +580,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.60.0" +val kommVersion = "0.61.6" //... @@ -602,7 +640,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.60.0" +val kommVersion = "0.61.6" depensencies { implementation("com.ucasoft.komm:komm-annotations:$kommVersion") @@ -646,7 +684,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.60.0" +val kommVersion = "0.61.6" depensencies { implementation("com.ucasoft.komm:komm-annotations:$kommVersion") @@ -660,7 +698,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.60.0" +val kommVersion = "0.61.6" //... diff --git a/build.gradle.kts b/build.gradle.kts index 8c0f5ae..ea952c0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,7 +12,7 @@ tasks.wrapper { allprojects { group = "com.ucasoft.komm" - version = "0.60.0" + version = "0.61.6" repositories { mavenCentral() diff --git a/komm-annotations/src/main/kotlin/com/ucasoft/komm/annotations/MapFunction.kt b/komm-annotations/src/main/kotlin/com/ucasoft/komm/annotations/MapFunction.kt new file mode 100644 index 0000000..0b99980 --- /dev/null +++ b/komm-annotations/src/main/kotlin/com/ucasoft/komm/annotations/MapFunction.kt @@ -0,0 +1,12 @@ +package com.ucasoft.komm.annotations + +import kotlin.reflect.KClass + +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.SOURCE) +@Repeatable +annotation class MapFunction( + val packageName: String, + val name: String = "", + val `for`: Array> = [] +) diff --git a/komm-processor/src/main/kotlin/com/ucasoft/komm/processor/KOMMAnnotationFinder.kt b/komm-processor/src/main/kotlin/com/ucasoft/komm/processor/KOMMAnnotationFinder.kt index d3443ef..e07532a 100644 --- a/komm-processor/src/main/kotlin/com/ucasoft/komm/processor/KOMMAnnotationFinder.kt +++ b/komm-processor/src/main/kotlin/com/ucasoft/komm/processor/KOMMAnnotationFinder.kt @@ -36,6 +36,25 @@ class KOMMAnnotationFinder(private val forClass: KSType) { MapConvert<*, *, *>::converter.name ) + fun findFunction(member: KSPropertyDeclaration): Pair? { + val annotations = member.annotations.filter { it.shortName.asString() == MapFunction::class.simpleName } + .associateWith(::associateWithFor) + + val annotation = filterAnnotationsByClass(forClass.toClassName(), annotations, member) + ?: return null + + val packageName = annotation.arguments + .first { it.name?.asString() == MapFunction::packageName.name } + .value + .toString() + val name = annotation.arguments + .first { it.name?.asString() == MapFunction::name.name } + .value + .toString() + + return packageName to name + } + fun findSubstituteResolver(member: KSPropertyDeclaration): String? { val annotations = member.annotations.filter { it.shortName.asString() == NullSubstitute::class.simpleName } .associateWith(::associateWithFor) diff --git a/komm-processor/src/main/kotlin/com/ucasoft/komm/processor/KOMMPropertyMapper.kt b/komm-processor/src/main/kotlin/com/ucasoft/komm/processor/KOMMPropertyMapper.kt index 33cca73..fcbe624 100644 --- a/komm-processor/src/main/kotlin/com/ucasoft/komm/processor/KOMMPropertyMapper.kt +++ b/komm-processor/src/main/kotlin/com/ucasoft/komm/processor/KOMMPropertyMapper.kt @@ -14,7 +14,8 @@ class KOMMPropertyMapper( destination: KSType, private val direction: KOMMVisitor.Direction, private val config: KSAnnotation, - private val plugins: List + private val plugins: List, + private val imports: MutableMap> ) { private val annotationFinder = KOMMAnnotationFinder( @@ -44,6 +45,12 @@ class KOMMPropertyMapper( annotationFinder.findConverter(it) } } + val function = when (direction) { + KOMMVisitor.Direction.From -> annotationFinder.findFunction(destination) + KOMMVisitor.Direction.To -> (sourceProperty as? KSPropertyDeclaration)?.let { + annotationFinder.findFunction(it) + } + } val nullSubstituteResolver = when (direction) { KOMMVisitor.Direction.From -> annotationFinder.findSubstituteResolver(destination) KOMMVisitor.Direction.To -> (sourceProperty as? KSPropertyDeclaration)?.let { @@ -54,10 +61,10 @@ class KOMMPropertyMapper( "$destination = $converter(this).convert(${getSourceAccessName(source)})" } else if (nullSubstituteResolver != null) { "$destination = ${ - getSourceWithCast(destination, source, config, useSafeAccess = true) + getSourceWithCast(destination, source, config, function, useSafeAccess = true) } ?: ${mapResolver(nullSubstituteResolver, mapTo)}" } else { - "$destination = ${getSourceWithCast(destination, source, config)}" + "$destination = ${getSourceWithCast(destination, source, config, function)}" } } @@ -175,6 +182,7 @@ class KOMMPropertyMapper( destinationProperty: KSPropertyDeclaration, source: EmbeddedSourceProperty, config: KSAnnotation, + function: Pair?, useSafeAccess: Boolean = false ): String { val propertyName = getSourceAccessName(source, useSafeAccess) @@ -215,12 +223,21 @@ class KOMMPropertyMapper( return if (useSafeAccess) propertyName else getSourceAccessName(source, assertNotNull = true) } - return "${ - if (sourceIsNullable && !useSafeAccess) getSourceAccessName( - source, - assertNotNull = true - ) else propertyName - }${if (sourceIsNullable && useSafeAccess) "?" else ""}.to${destinationProperty.type}()" + val shouldUseSafeCall = sourceIsNullable && (useSafeAccess || destinationIsNullable) + val sourceAccessName = when { + shouldUseSafeCall -> getSourceAccessName(source, useSafeAccess = true) + sourceIsNullable -> getSourceAccessName(source, assertNotNull = true) + else -> propertyName + } + + val conversionFunctionName = "to${destinationType.declaration.simpleName.asString()}" + val functionName = function?.second?.ifEmpty { conversionFunctionName } ?: conversionFunctionName + if (function != null) { + imports[function.first] = imports[function.first].orEmpty() + functionName + } + val receiverPrefix = "$sourceAccessName${if (shouldUseSafeCall) "?." else "."}" + + return "$receiverPrefix$functionName()" } private fun getSourceWithPluginCast( diff --git a/komm-processor/src/main/kotlin/com/ucasoft/komm/processor/KOMMSymbolProcessor.kt b/komm-processor/src/main/kotlin/com/ucasoft/komm/processor/KOMMSymbolProcessor.kt index 30004f0..c06b114 100644 --- a/komm-processor/src/main/kotlin/com/ucasoft/komm/processor/KOMMSymbolProcessor.kt +++ b/komm-processor/src/main/kotlin/com/ucasoft/komm/processor/KOMMSymbolProcessor.kt @@ -23,8 +23,9 @@ class KOMMSymbolProcessor( val symbols = resolver.getSymbolsWithAnnotation(KOMMMap::class.qualifiedName!!).filterIsInstance() + .toList() - if (!symbols.iterator().hasNext()) { + if (symbols.isEmpty()) { return emptyList() } @@ -41,15 +42,15 @@ class KOMMSymbolProcessor( val file = FileSpec .builder(packageName.asString(), "MappingExtensions") .apply { - imports.forEach { this.addImport(it.key, it.value) } + imports.forEach { addImport(it.key, it.value) } functions.forEach { this.addFunction(it) } } .build() - file.writeTo(codeGenerator, false) + file.writeTo(codeGenerator, true, classDeclarations.mapNotNull { it.containingFile }) } - return symbols.filterNot { it.validate() }.toList() + return symbols.filterNot { it.validate() } } private fun loadPlugins(): Map, List>> { @@ -66,4 +67,4 @@ class KOMMSymbolProcessor( }.mapValues { it.value.map { it.second } } } } -} \ No newline at end of file +} diff --git a/komm-processor/src/main/kotlin/com/ucasoft/komm/processor/KOMMVisitor.kt b/komm-processor/src/main/kotlin/com/ucasoft/komm/processor/KOMMVisitor.kt index a3ffe79..a3806df 100755 --- a/komm-processor/src/main/kotlin/com/ucasoft/komm/processor/KOMMVisitor.kt +++ b/komm-processor/src/main/kotlin/com/ucasoft/komm/processor/KOMMVisitor.kt @@ -2,6 +2,7 @@ package com.ucasoft.komm.processor import com.google.devtools.ksp.isPrivate import com.google.devtools.ksp.symbol.* +import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.asTypeName @@ -64,10 +65,13 @@ class KOMMVisitor( } private fun syncImports(destination: KSType, source: KSType, imports: MutableMap>) { - val destinationPackageName = destination.toClassName().packageName - val sourcePackageName = source.toClassName().packageName - if (sourcePackageName != destinationPackageName) { - imports[sourcePackageName] = imports[sourcePackageName].orEmpty() + source.toClassName().simpleName + val destinationClassName = destination.toClassName() + val sourceClassName = source.toClassName() + if ( + sourceClassName.packageName != destinationClassName.packageName && + sourceClassName.simpleName != destinationClassName.simpleName + ) { + imports[sourceClassName.packageName] = imports[sourceClassName.packageName].orEmpty() + sourceClassName.simpleName } } @@ -77,7 +81,7 @@ class KOMMVisitor( return FunSpec.builder(fromSourceFunctionName) .receiver(getSourceName(source)) .returns(destination.toClassName()) - .addStatement(buildStatement(source, destination.declaration as KSClassDeclaration, direction, config)) + .addCode(buildStatement(source, destination.declaration as KSClassDeclaration, direction, config)) .build() } @@ -95,13 +99,13 @@ class KOMMVisitor( return source.toTypeName() } - private fun buildStatement(source: KSType, destination: KSClassDeclaration, direction: Direction, config: KSAnnotation): String { + private fun buildStatement(source: KSType, destination: KSClassDeclaration, direction: Direction, config: KSAnnotation): CodeBlock { val castPlugins = typePlugins.toMutableList().apply { addAll(plugins[KOMMCastPlugin::class] ?.map { it.getDeclaredConstructor().newInstance() } ?: emptyList()) }.filterIsInstance() - val propertyMapper = KOMMPropertyMapper(source, destination.asStarProjectedType(), direction, config, castPlugins) + val propertyMapper = KOMMPropertyMapper(source, destination.asStarProjectedType(), direction, config, castPlugins, imports) val properties = destination.getAllProperties().groupBy { p -> destination.primaryConstructor?.parameters?.any { it.name == p.simpleName } } @@ -116,8 +120,8 @@ class KOMMVisitor( propertyMapper.map(it, MapTo.Also) } - return buildString { - appendLine("return $destination(") + val statement = buildString { + appendLine("return %T(") constructorProperties?.forEach { appendLine("\t$it,") } deleteLast(2) if (noConstructorProperties.isNullOrEmpty()) { @@ -128,9 +132,11 @@ class KOMMVisitor( append("}") } } + + return CodeBlock.of(statement, destination.toClassName()) } private fun StringBuilder.deleteLast(length: Int) = this.delete(this.length - length, this.length - 1)!! private fun KSType.isKotlinClass() = declaration.origin == Origin.KOTLIN -} \ No newline at end of file +} diff --git a/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/CastTests.kt b/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/CastTests.kt index d9827ee..1515ea7 100755 --- a/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/CastTests.kt +++ b/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/CastTests.kt @@ -1,12 +1,17 @@ package com.ucasoft.komm.processor +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.INT import com.squareup.kotlinpoet.STRING +import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.asClassName import com.tschuchort.compiletesting.KotlinCompilation import com.ucasoft.komm.annotations.KOMMMap import com.ucasoft.komm.annotations.MapConfiguration import com.ucasoft.komm.annotations.MapConvert +import com.ucasoft.komm.annotations.MapFunction import com.ucasoft.komm.processor.exceptions.KOMMCastException import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.nulls.shouldNotBeNull @@ -128,6 +133,66 @@ internal class CastTests: CompilationTests() { } } + @Test + fun nullableSourceCastsToNullableDestinationWithSafeCall() { + val sourceObjectClassName = ClassName(packageName, "SourceObject") + val imageBitmapClassName = ClassName(packageName, "ImageBitmap") + val byteArrayClassName = ClassName("kotlin", "ByteArray") + val convertersPackageName = "$packageName.converters" + val propertyName = "logo" + val generated = generate( + FileSpec.builder(packageName, "ImageBitmap.kt") + .addType(TypeSpec.classBuilder(imageBitmapClassName).build()) + .build(), + FileSpec.builder(convertersPackageName, "ImageBitmapConverters.kt") + .addFunction( + FunSpec.builder("toImageBitmap") + .receiver(byteArrayClassName) + .returns(imageBitmapClassName) + .addStatement("return %T()", imageBitmapClassName) + .build() + ) + .build(), + buildFileSpec( + sourceObjectClassName.simpleName, + mapOf(propertyName to PropertySpecInit(byteArrayClassName, isNullable = true)) + ), + buildFileSpec( + "DestinationObject", + mapOf( + propertyName to PropertySpecInit( + imageBitmapClassName, + isNullable = true, + annotations = listOf( + MapFunction::class to mapOf( + "packageName = %S" to listOf(convertersPackageName) + ) + ) + ) + ), + listOf(KOMMMap::class to mapOf("from = %L" to listOf("[${sourceObjectClassName.simpleName}::class]"))) + ) + ) + + generated.exitCode.shouldBe(KotlinCompilation.ExitCode.OK) + + val mappingClass = generated.classLoader.loadClass("$packageName.MappingExtensionsKt") + val mappingMethod = mappingClass.declaredMethods.first() + val sourceClass = generated.classLoader.loadClass(sourceObjectClassName.canonicalName) + + val nullSourceInstance = sourceClass.constructors.first().newInstance(null) + val nullDestinationInstance = mappingMethod.invoke(null, nullSourceInstance) + nullDestinationInstance::class.shouldHaveMemberProperty(propertyName) { + it.getter.call(nullDestinationInstance).shouldBe(null) + } + + val sourceInstance = sourceClass.constructors.first().newInstance(byteArrayOf(1)) + val destinationInstance = mappingMethod.invoke(null, sourceInstance) + destinationInstance::class.shouldHaveMemberProperty(propertyName) { + it.getter.call(destinationInstance).shouldNotBeNull() + } + } + companion object { @JvmStatic @@ -146,4 +211,4 @@ internal class CastTests: CompilationTests() { ) ) } -} \ No newline at end of file +} diff --git a/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/CompilationTests.kt b/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/CompilationTests.kt index eb06fad..b30b4df 100644 --- a/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/CompilationTests.kt +++ b/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/CompilationTests.kt @@ -21,9 +21,10 @@ abstract class CompilationTests { className: String, constructorProperties: Map, classAnnotations: List, Map>>> = emptyList(), - properties: Map = emptyMap() + properties: Map = emptyMap(), + `package`: String = packageName ) = FileSpec - .builder(packageName, "$className.kt") + .builder(`package`, "$className.kt") .addImport("com.ucasoft.komm.annotations", "MapConfiguration", "MapDefault") .addImport("java.util", "Currency") .addType( @@ -100,7 +101,7 @@ abstract class CompilationTests { fun generate(vararg fileSpec: FileSpec) = KotlinCompilation().apply { inheritClassPath = true - sources = fileSpec.map { SourceFile.kotlin(it.name, it.toString()) } + sources = fileSpec.map { SourceFile.kotlin("${it.packageName.replace('.', '/')}/${it.name}", it.toString()) } workingDir = tempDir configureKsp { symbolProcessorProviders.add(KOMMProcessorProvider()) @@ -139,4 +140,4 @@ abstract class CompilationTests { val parametrizedAnnotations: List>>> = emptyList(), val parametrizedType: KClass<*>? = null ) -} \ No newline at end of file +} diff --git a/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/ProcessorTests.kt b/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/ProcessorTests.kt index 4a307b7..a3e4754 100755 --- a/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/ProcessorTests.kt +++ b/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/ProcessorTests.kt @@ -187,6 +187,34 @@ internal class ProcessorTests : CompilationTests() { } } + @Test + fun mapsClassesWithSameSimpleNameFromDifferentPackages() { + val sourcePackageName = "$packageName.db" + val sourceSpec = buildFileSpec("Account", mapOf("id" to PropertySpecInit(INT)), `package` = sourcePackageName) + val generated = generate( + sourceSpec, + buildFileSpec( + "Account", + mapOf("id" to PropertySpecInit(INT)), + listOf(KOMMMap::class to mapOf("from = %L" to listOf("[$sourcePackageName.Account::class]"))) + ) + ) + + generated.exitCode.shouldBe(KotlinCompilation.ExitCode.OK) + + val mappingClass = generated.classLoader.loadClass("$packageName.MappingExtensionsKt") + val mappingMethod = mappingClass.declaredMethods.first { it.name == "toAccount" } + val sourceClass = generated.classLoader.loadClass("$sourcePackageName.Account") + val destinationClass = generated.classLoader.loadClass("$packageName.Account") + val sourceInstance = sourceClass.constructors.first().newInstance(123) + val destinationInstance = mappingMethod.invoke(null, sourceInstance) + + destinationInstance::class.java.shouldBe(destinationClass) + destinationInstance::class.shouldHaveMemberProperty("id") { + it.getter.call(destinationInstance).shouldBe(123) + } + } + @Test fun toDestinationIsNotKotlinFails() { val generated = generate( @@ -221,4 +249,4 @@ internal class ProcessorTests : CompilationTests() { ) ) } -} \ No newline at end of file +}