diff --git a/README.md b/README.md index be7163a..1923d77 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ The **Kotlin Object Multiplatform Mapper** provides you a possibility to generat * [@MapName](#mapname-annotation) * [@MapEmbedded](#mapembedded-annotation) * [@MapConverter](#use-converter) + * [Context](#use-context) * [@MapDefault](#use-resolver) * [@NullSubstitute](#use-nullsubstitute) * [Allow Not-Null Assertion](#mapping-configuration-1) @@ -86,7 +87,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.61.6" +val kommVersion = "0.70.6" depensencies { implementation("com.ucasoft.komm:komm-annotations:$kommVersion") @@ -99,7 +100,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.61.6" +val kommVersion = "0.70.6" kotlin { jvm { @@ -397,6 +398,85 @@ fun SourceObject.toDestinationObject(): DestinationObject = DestinationObject( it.otherCost = CostConverter(this).convert(cost) } ``` +`@MapConvert` can also use a context-aware converter when the mapping has `KOMMMap.context`. + +```kotlin +data class AccountMapContext( + val banks: Map +) + +class BankConverter( + source: FullAccount, + context: AccountMapContext +) : KOMMContextConverter(source, context) { + + override fun convert(sourceMember: Long?): Bank? = + sourceMember?.let(context.banks::get) +} +``` +#### Classes declaration +```kotlin +@KOMMMap(from = [FullAccount::class], context = AccountMapContext::class) +data class Account( + //... + @MapConvert(BankConverter::class, "bankId") + val bank: Bank? +) +``` +#### Generated extension function +```kotlin +fun FullAccount.toAccount(kommContext: AccountMapContext): Account = Account( + //... + bank = BankConverter(this, kommContext).convert(bankId) +) +``` + +### Use Context +Use mapping context when destination members depend on data that is not part of the source object, such as lookup tables produced by other flows. + +#### Context declaration +```kotlin +data class TransactionMapContext( + val accounts: Map, + val accountCurrencies: Map, + val categories: Map +) +``` +#### Context resolver declaration +```kotlin +class FallbackAccountResolver( + destination: Transaction?, + context: TransactionMapContext +) : KOMMContextResolver(destination, context) { + + override fun resolve(): Account? { + return context.accounts.values.firstOrNull() + } +} +``` +#### Classes declaration +```kotlin +@KOMMMap(from = [DbTransaction::class], context = TransactionMapContext::class) +data class Transaction( + //... + @MapDefault(FallbackAccountResolver::class) + val expenseAccount: Account? +) +``` +#### Generated extension function +```kotlin +fun DbTransaction.toTransaction(kommContext: TransactionMapContext): Transaction = Transaction( + //... + expenseAccount = FallbackAccountResolver(null, kommContext).resolve() +) +``` +The context is a snapshot. Combine reactive inputs before mapping, then build a fresh context whenever any dependency emits. +```kotlin +combine(transactions, accountCurrencies, categories, accounts) { items, currencies, cats, accs -> + val context = TransactionMapContext(accs, currencies, cats) + items.map { it.toTransaction(context) } +} +``` ### Use Resolver #### Resolver declaration ```kotlin @@ -566,7 +646,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.61.6" +val kommVersion = "0.70.6" depensencies { implementation("com.ucasoft.komm:komm-annotations:$kommVersion") @@ -580,7 +660,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.61.6" +val kommVersion = "0.70.6" //... @@ -640,7 +720,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.61.6" +val kommVersion = "0.70.6" depensencies { implementation("com.ucasoft.komm:komm-annotations:$kommVersion") @@ -684,7 +764,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.61.6" +val kommVersion = "0.70.6" depensencies { implementation("com.ucasoft.komm:komm-annotations:$kommVersion") @@ -698,7 +778,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.61.6" +val kommVersion = "0.70.6" //... diff --git a/build.gradle.kts b/build.gradle.kts index ea952c0..2baf052 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,7 +12,7 @@ tasks.wrapper { allprojects { group = "com.ucasoft.komm" - version = "0.61.6" + version = "0.70.6" repositories { mavenCentral() diff --git a/komm-annotations/src/main/kotlin/com/ucasoft/komm/abstractions/KOMMContextConverter.kt b/komm-annotations/src/main/kotlin/com/ucasoft/komm/abstractions/KOMMContextConverter.kt new file mode 100644 index 0000000..2341d38 --- /dev/null +++ b/komm-annotations/src/main/kotlin/com/ucasoft/komm/abstractions/KOMMContextConverter.kt @@ -0,0 +1,6 @@ +package com.ucasoft.komm.abstractions + +abstract class KOMMContextConverter( + source: S, + protected val context: C +) : KOMMConverter(source) diff --git a/komm-annotations/src/main/kotlin/com/ucasoft/komm/abstractions/KOMMContextResolver.kt b/komm-annotations/src/main/kotlin/com/ucasoft/komm/abstractions/KOMMContextResolver.kt new file mode 100644 index 0000000..9273347 --- /dev/null +++ b/komm-annotations/src/main/kotlin/com/ucasoft/komm/abstractions/KOMMContextResolver.kt @@ -0,0 +1,6 @@ +package com.ucasoft.komm.abstractions + +abstract class KOMMContextResolver( + destination: D?, + protected val context: C +): KOMMResolver(destination) \ No newline at end of file diff --git a/komm-annotations/src/main/kotlin/com/ucasoft/komm/annotations/KOMMMap.kt b/komm-annotations/src/main/kotlin/com/ucasoft/komm/annotations/KOMMMap.kt index b5efb07..0bdaf93 100755 --- a/komm-annotations/src/main/kotlin/com/ucasoft/komm/annotations/KOMMMap.kt +++ b/komm-annotations/src/main/kotlin/com/ucasoft/komm/annotations/KOMMMap.kt @@ -5,4 +5,9 @@ import kotlin.reflect.KClass @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.SOURCE) @Repeatable -annotation class KOMMMap(val from: Array> = [], val to: Array> = [], val config: MapConfiguration = MapConfiguration()) +annotation class KOMMMap( + val from: Array> = [], + val to: Array> = [], + val context: KClass<*> = Unit::class, + val config: MapConfiguration = MapConfiguration() +) 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 e07532a..a7199b9 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 @@ -4,13 +4,14 @@ import com.google.devtools.ksp.symbol.KSAnnotation import com.google.devtools.ksp.symbol.KSClassDeclaration import com.google.devtools.ksp.symbol.KSPropertyDeclaration import com.google.devtools.ksp.symbol.KSType -import com.google.devtools.ksp.symbol.KSTypeArgument -import com.google.devtools.ksp.symbol.KSTypeParameter import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.ksp.toClassName +import com.ucasoft.komm.abstractions.KOMMContextConverter +import com.ucasoft.komm.abstractions.KOMMContextResolver import com.ucasoft.komm.abstractions.KOMMConverter import com.ucasoft.komm.annotations.* import com.ucasoft.komm.processor.exceptions.KOMMException +import com.ucasoft.komm.processor.extensions.resolveTypeArgument class KOMMAnnotationFinder(private val forClass: KSType) { @@ -55,7 +56,7 @@ class KOMMAnnotationFinder(private val forClass: KSType) { return packageName to name } - fun findSubstituteResolver(member: KSPropertyDeclaration): String? { + fun findSubstituteResolver(member: KSPropertyDeclaration): KSType? { val annotations = member.annotations.filter { it.shortName.asString() == NullSubstitute::class.simpleName } .associateWith(::associateWithFor) @@ -64,7 +65,7 @@ class KOMMAnnotationFinder(private val forClass: KSType) { if (annotation != null) { val resolverArgument = annotation.arguments.first { it.name?.asString() == NullSubstitute::default.name }.value as KSAnnotation - return resolverArgument.arguments.first { it.name?.asString() == MapDefault<*>::resolver.name }.value.toString() + return resolverArgument.arguments.first { it.name?.asString() == MapDefault<*>::resolver.name }.value as? KSType } return null @@ -75,20 +76,28 @@ class KOMMAnnotationFinder(private val forClass: KSType) { member: KSPropertyDeclaration, annotationName: String?, argumentName: String - ): String? { - val annotations = - member.annotations.filter { it.shortName.asString() == annotationName }.associateWith(::associateWithFor) - - val annotation = filterAnnotationsByClass(forClass, annotations, member) + ): KSType? { + val annotation = findAnnotation(forClass, member, annotationName) if (annotation != null) { val resolverArgument = annotation.arguments.first { it.name?.asString() == argumentName } - return resolverArgument.value.toString() + return resolverArgument.value as? KSType } return null } + private fun findAnnotation( + forClass: ClassName, + member: KSPropertyDeclaration, + annotationName: String? + ): KSAnnotation? { + val annotations = + member.annotations.filter { it.shortName.asString() == annotationName }.associateWith(::associateWithFor) + + return filterAnnotationsByClass(forClass, annotations, member) + } + fun getSuitedNamedAnnotations(member: KSPropertyDeclaration) = getSuitedNamedAnnotationsForClass(member).keys.toList() @@ -128,31 +137,42 @@ class KOMMAnnotationFinder(private val forClass: KSType) { ?: return emptyList() val converterDeclaration = converterArgument.declaration as? KSClassDeclaration ?: return emptyList() - return getKOMMConverterAssociations(converterDeclaration).map { it.toClassName() } + return getKOMMAssociations(converterDeclaration, KOMMConverter::class.qualifiedName!!, 0, 2) + .ifEmpty { getKOMMAssociations(converterDeclaration, KOMMContextConverter::class.qualifiedName!!, 0, 3) } + .map { it.toClassName() } } - private fun getKOMMConverterAssociations( + private fun getKOMMAssociations( declaration: KSClassDeclaration, + superClassName: String, + sourceIndex: Int, + destinationIndex: Int, typeSubstitutions: Map = emptyMap() ): List { for (superTypeReference in declaration.superTypes) { val superType = superTypeReference.resolve() val superDeclaration = superType.declaration as? KSClassDeclaration ?: continue - if (superDeclaration.qualifiedName?.asString() == KOMMConverter::class.qualifiedName) { + if (superDeclaration.qualifiedName?.asString() == superClassName) { return listOfNotNull( - resolveTypeArgument(superType.arguments.getOrNull(0), typeSubstitutions), - resolveTypeArgument(superType.arguments.getOrNull(2), typeSubstitutions) + superType.arguments.getOrNull(sourceIndex).resolveTypeArgument(typeSubstitutions), + superType.arguments.getOrNull(destinationIndex).resolveTypeArgument(typeSubstitutions) ) } val superSubstitutions = superDeclaration.typeParameters .zip(superType.arguments) .mapNotNull { (parameter, argument) -> - resolveTypeArgument(argument, typeSubstitutions)?.let { parameter.name.asString() to it } + argument.resolveTypeArgument(typeSubstitutions)?.let { parameter.name.asString() to it } } .toMap() - val associations = getKOMMConverterAssociations(superDeclaration, superSubstitutions) + val associations = getKOMMAssociations( + superDeclaration, + superClassName, + sourceIndex, + destinationIndex, + superSubstitutions + ) if (associations.isNotEmpty()) { return associations } @@ -161,12 +181,6 @@ class KOMMAnnotationFinder(private val forClass: KSType) { return emptyList() } - private fun resolveTypeArgument(argument: KSTypeArgument?, typeSubstitutions: Map): KSType? { - val type = argument?.type?.resolve() ?: return null - val typeParameter = type.declaration as? KSTypeParameter ?: return type - return typeSubstitutions[typeParameter.name.asString()] - } - private fun filterAnnotationsByClass( forClass: ClassName, annotationMap: Map>, 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 fcbe624..7a2faa3 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 @@ -2,12 +2,15 @@ package com.ucasoft.komm.processor import com.google.devtools.ksp.symbol.* import com.squareup.kotlinpoet.ksp.toTypeName +import com.ucasoft.komm.abstractions.KOMMContextConverter +import com.ucasoft.komm.abstractions.KOMMContextResolver import com.ucasoft.komm.annotations.* import com.ucasoft.komm.plugins.KOMMCastPlugin import com.ucasoft.komm.plugins.exceptions.KOMMPluginsException import com.ucasoft.komm.processor.exceptions.KOMMCastException import com.ucasoft.komm.processor.exceptions.KOMMException import com.ucasoft.komm.processor.extensions.getConfigValue +import com.ucasoft.komm.processor.extensions.resolveTypeArgument class KOMMPropertyMapper( source: KSType, @@ -15,7 +18,8 @@ class KOMMPropertyMapper( private val direction: KOMMVisitor.Direction, private val config: KSAnnotation, private val plugins: List, - private val imports: MutableMap> + private val imports: MutableMap>, + private val contextParameterName: String? ) { private val annotationFinder = KOMMAnnotationFinder( @@ -57,19 +61,65 @@ class KOMMPropertyMapper( annotationFinder.findSubstituteResolver(it) } } - return if (converter != null) { - "$destination = $converter(this).convert(${getSourceAccessName(source)})" - } else if (nullSubstituteResolver != null) { - "$destination = ${ - getSourceWithCast(destination, source, config, function, useSafeAccess = true) - } ?: ${mapResolver(nullSubstituteResolver, mapTo)}" + return when { + converter != null -> + if (converter.isContextConverter) + "$destination = $converter(this, ${getRequiredContextParameterName()}).convert(${getSourceAccessName(source)})" + else + "$destination = $converter(this).convert(${getSourceAccessName(source)})" + nullSubstituteResolver != null -> + "$destination = ${ + getSourceWithCast(destination, source, config, function, useSafeAccess = true) + } ?: ${mapResolver(nullSubstituteResolver, mapTo)}" + else -> + "$destination = ${getSourceWithCast(destination, source, config, function)}" + } + } + + private fun mapResolver(resolver: KSType, mapTo: KOMMVisitor.MapTo): String { + val destination = if (mapTo == KOMMVisitor.MapTo.Constructor) "null" else "it" + return if (resolver.isContextResolver) { + "$resolver($destination, ${getRequiredContextParameterName()}).resolve()" } else { - "$destination = ${getSourceWithCast(destination, source, config, function)}" + "$resolver($destination).resolve()" + } + } + + private val KSType?.isContextConverter + get() = (this?.declaration as? KSClassDeclaration)?.let { isContext(it, KOMMContextConverter::class.qualifiedName!!) } ?: false + + private val KSType?.isContextResolver + get() = (this?.declaration as? KSClassDeclaration)?.let { isContext(it, KOMMContextResolver::class.qualifiedName!!) } ?: false + + private fun isContext( + declaration: KSClassDeclaration, + superClassName: String, + typeSubstitutions: Map = emptyMap() + ): Boolean { + for (superTypeReference in declaration.superTypes) { + val superType = superTypeReference.resolve() + val superDeclaration = superType.declaration as? KSClassDeclaration ?: continue + + if (superDeclaration.qualifiedName?.asString() == superClassName) { + return true + } + + val superSubstitutions = superDeclaration.typeParameters + .zip(superType.arguments) + .mapNotNull { (parameter, argument) -> + argument.resolveTypeArgument(typeSubstitutions)?.let { parameter.name.asString() to it } + } + .toMap() + if (isContext(superDeclaration, superClassName, superSubstitutions)) { + return true + } } + + return false } - private fun mapResolver(resolver: String, mapTo: KOMMVisitor.MapTo) = - "$resolver(${if (mapTo == KOMMVisitor.MapTo.Constructor) "null" else "it"}).resolve()" + private fun getRequiredContextParameterName() = contextParameterName + ?: throw KOMMException("${KOMMMap::class.simpleName}.context is required when using context-aware @${MapConvert::class.simpleName} or @${MapDefault::class.simpleName}.") private fun getSourceProperty(sourceName: String): EmbeddedSourceProperty? { sourceProperties[sourceName]?.let { return EmbeddedSourceProperty(null, it) } @@ -111,7 +161,7 @@ class KOMMPropertyMapper( } private fun handleNoSourceProperty( - resolver: String?, + resolver: KSType?, destination: KSPropertyDeclaration, sourceName: String, mapTo: KOMMVisitor.MapTo 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 a3806df..4f3a50b 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 @@ -5,6 +5,7 @@ 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.asClassName import com.squareup.kotlinpoet.asTypeName import com.squareup.kotlinpoet.ksp.toClassName import com.squareup.kotlinpoet.ksp.toTypeName @@ -48,18 +49,22 @@ class KOMMVisitor( for (annotation in annotations) { val configArgument = annotation.arguments.first { it.name?.asString() == KOMMMap::config.name } val config = configArgument.value as KSAnnotation + val context = annotation.arguments + .first { it.name?.asString() == KOMMMap::context.name } + .value as? KSType + val effectiveContext = context?.takeUnless { it.toClassName() == Unit::class.asClassName() } val fromArgument = annotation.arguments.first { it.name?.asString() == KOMMMap::from.name }.value as ArrayList val toArgument = annotation.arguments.first { it.name?.asString() == KOMMMap::to.name }.value as ArrayList fromArgument.forEach { syncImports(classDeclaration.asStarProjectedType(), it, imports) - functions.add(buildFunction(classDeclaration.asStarProjectedType(), it, Direction.From, config)) + functions.add(buildFunction(classDeclaration.asStarProjectedType(), it, Direction.From, config, effectiveContext)) } toArgument.forEach { if (!it.isKotlinClass()) { throw KOMMException("The class ${it.toClassName().simpleName} is not a Kotlin class! Only Kotlin classes can be mapped via `to` parameter.") } syncImports(it, classDeclaration.asStarProjectedType(), imports) - functions.add(buildFunction(it, classDeclaration.asStarProjectedType(), Direction.To, config)) + functions.add(buildFunction(it, classDeclaration.asStarProjectedType(), Direction.To, config, effectiveContext)) } } } @@ -75,13 +80,24 @@ class KOMMVisitor( } } - private fun buildFunction(destination: KSType, source: KSType, direction: Direction, config: KSAnnotation) : FunSpec { + private fun buildFunction( + destination: KSType, + source: KSType, + direction: Direction, + config: KSAnnotation, + context: KSType? + ) : FunSpec { val convertFunctionName = config.getConfigValue(MapConfiguration::convertFunctionName.name) val fromSourceFunctionName = convertFunctionName.ifEmpty { "to${destination.toClassName().simpleName}" } return FunSpec.builder(fromSourceFunctionName) .receiver(getSourceName(source)) + .apply { + if (context != null) { + addParameter("kommContext", context.toTypeName()) + } + } .returns(destination.toClassName()) - .addCode(buildStatement(source, destination.declaration as KSClassDeclaration, direction, config)) + .addCode(buildStatement(source, destination.declaration as KSClassDeclaration, direction, config, context)) .build() } @@ -99,13 +115,27 @@ class KOMMVisitor( return source.toTypeName() } - private fun buildStatement(source: KSType, destination: KSClassDeclaration, direction: Direction, config: KSAnnotation): CodeBlock { + private fun buildStatement( + source: KSType, + destination: KSClassDeclaration, + direction: Direction, + config: KSAnnotation, + context: KSType? + ): 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, imports) + val propertyMapper = KOMMPropertyMapper( + source, + destination.asStarProjectedType(), + direction, + config, + castPlugins, + imports, + context?.let { "kommContext" } + ) val properties = destination.getAllProperties().groupBy { p -> destination.primaryConstructor?.parameters?.any { it.name == p.simpleName } } diff --git a/komm-processor/src/main/kotlin/com/ucasoft/komm/processor/extensions/KSTypeArgumentExtensions.kt b/komm-processor/src/main/kotlin/com/ucasoft/komm/processor/extensions/KSTypeArgumentExtensions.kt new file mode 100644 index 0000000..d982920 --- /dev/null +++ b/komm-processor/src/main/kotlin/com/ucasoft/komm/processor/extensions/KSTypeArgumentExtensions.kt @@ -0,0 +1,11 @@ +package com.ucasoft.komm.processor.extensions + +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.KSTypeArgument +import com.google.devtools.ksp.symbol.KSTypeParameter + +fun KSTypeArgument?.resolveTypeArgument(typeSubstitutions: Map): KSType? { + val type = this?.type?.resolve() ?: return null + val typeParameter = type.declaration as? KSTypeParameter ?: return type + return typeSubstitutions[typeParameter.name.asString()] +} diff --git a/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/ContextTests.kt b/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/ContextTests.kt new file mode 100644 index 0000000..2756cd3 --- /dev/null +++ b/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/ContextTests.kt @@ -0,0 +1,245 @@ +package com.ucasoft.komm.processor + +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.INT +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.STRING +import com.squareup.kotlinpoet.asTypeName +import com.tschuchort.compiletesting.KotlinCompilation +import com.ucasoft.komm.annotations.KOMMMap +import com.ucasoft.komm.annotations.MapDefault +import com.ucasoft.komm.annotations.MapConvert +import com.ucasoft.komm.processor.exceptions.KOMMException +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.reflection.shouldHaveMemberProperty +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import kotlin.test.Test + +internal class ContextTests : SatelliteTests() { + + @Test + fun mapContextConvert() { + val sourceSpec = buildFileSpec("SourceObject", mapOf("id" to PropertySpecInit(INT))) + val sourceClassName = ClassName(packageName, "SourceObject") + val contextSpec = buildFileSpec("TestContext", mapOf("prefix" to PropertySpecInit(STRING))) + val contextClassName = ClassName(packageName, "TestContext") + val destinationClassName = ClassName(packageName, "DestinationObject") + val converterSpec = buildContextConverter( + sourceClassName, + INT, + contextClassName, + destinationClassName, + STRING, + "return \"\${context.prefix}:\$sourceMember\"" + ) + val converterClassName = ClassName(packageName, converterSpec.typeSpecs.first().name!!) + val generated = generate( + sourceSpec, + contextSpec, + converterSpec, + buildFileSpec( + destinationClassName.simpleName, + mapOf( + "name" to PropertySpecInit( + STRING, + parametrizedAnnotations = listOf( + MapConvert::class.asTypeName().parameterizedBy( + sourceClassName, + destinationClassName, + converterClassName + ) to mapOf( + "name = %S" to listOf("id"), + "converter = %L" to listOf("${converterClassName.simpleName}::class") + ) + ) + ) + ), + listOf( + KOMMMap::class to mapOf( + "from = %L" to listOf("[${sourceClassName.simpleName}::class]"), + "context = %L" to listOf("${contextClassName.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(sourceClassName.canonicalName) + val contextClass = generated.classLoader.loadClass(contextClassName.canonicalName) + val sourceInstance = sourceClass.constructors.first().newInstance(42) + val contextInstance = contextClass.constructors.first().newInstance("account") + val destinationInstance = mappingMethod.invoke(null, sourceInstance, contextInstance) + + destinationInstance.shouldNotBeNull() + destinationInstance::class.shouldHaveMemberProperty("name") { + it.getter.call(destinationInstance).shouldBe("account:42") + } + } + + @Test + fun mapDefaultWithContextResolver() { + val sourceSpec = buildFileSpec("SourceObject", mapOf("accountId" to PropertySpecInit(INT))) + val sourceClassName = ClassName(packageName, "SourceObject") + val contextClassName = ClassName(packageName, "TestContext") + val mapType = ClassName("kotlin.collections", "Map").parameterizedBy(INT, STRING) + val contextSpec = com.squareup.kotlinpoet.FileSpec + .builder(packageName, "TestContext.kt") + .addType( + com.squareup.kotlinpoet.TypeSpec + .classBuilder(contextClassName.simpleName) + .primaryConstructor( + com.squareup.kotlinpoet.FunSpec + .constructorBuilder() + .addParameter("accounts", mapType) + .build() + ) + .addProperty( + PropertySpec + .builder("accounts", mapType) + .initializer("accounts") + .build() + ) + .build() + ) + .build() + val destinationClassName = ClassName(packageName, "DestinationObject") + val resolverSpec = buildContextResolver( + destinationClassName, + contextClassName, + STRING, + "return context.accounts[7] ?: \"\"" + ) + val resolverClassName = ClassName(packageName, resolverSpec.typeSpecs.first().name!!) + val generated = generate( + sourceSpec, + contextSpec, + resolverSpec, + buildFileSpec( + destinationClassName.simpleName, + mapOf( + "accountName" to PropertySpecInit( + STRING, + parametrizedAnnotations = listOf( + MapDefault::class.asTypeName().parameterizedBy( + resolverClassName + ) to mapOf( + "resolver = %L" to listOf("${resolverClassName.simpleName}::class") + ) + ) + ) + ), + listOf( + KOMMMap::class to mapOf( + "from = %L" to listOf("[${sourceClassName.simpleName}::class]"), + "context = %L" to listOf("${contextClassName.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(sourceClassName.canonicalName) + val contextClass = generated.classLoader.loadClass(contextClassName.canonicalName) + val sourceInstance = sourceClass.constructors.first().newInstance(7) + val contextInstance = contextClass.constructors.first().newInstance(mapOf(7 to "Cash")) + val destinationInstance = mappingMethod.invoke(null, sourceInstance, contextInstance) + + destinationInstance.shouldNotBeNull() + destinationInstance::class.shouldHaveMemberProperty("accountName") { + it.getter.call(destinationInstance).shouldBe("Cash") + } + } + + @Test + fun mapContextConvertWithoutContextFails() { + val sourceSpec = buildFileSpec("SourceObject", mapOf("id" to PropertySpecInit(INT))) + val sourceClassName = ClassName(packageName, "SourceObject") + val contextSpec = buildFileSpec("TestContext", mapOf("prefix" to PropertySpecInit(STRING))) + val contextClassName = ClassName(packageName, "TestContext") + val destinationClassName = ClassName(packageName, "DestinationObject") + val converterSpec = buildContextConverter( + sourceClassName, + INT, + contextClassName, + destinationClassName, + STRING, + "return \"\${context.prefix}:\$sourceMember\"" + ) + val converterClassName = ClassName(packageName, converterSpec.typeSpecs.first().name!!) + val generated = generate( + sourceSpec, + contextSpec, + converterSpec, + buildFileSpec( + destinationClassName.simpleName, + mapOf( + "name" to PropertySpecInit( + STRING, + parametrizedAnnotations = listOf( + MapConvert::class.asTypeName().parameterizedBy( + sourceClassName, + destinationClassName, + converterClassName + ) to mapOf( + "name = %S" to listOf("id"), + "converter = %L" to listOf("${converterClassName.simpleName}::class") + ) + ) + ) + ), + listOf(KOMMMap::class to mapOf("from = %L" to listOf("[${sourceClassName.simpleName}::class]"))) + ) + ) + + generated.exitCode.shouldBe(KotlinCompilation.ExitCode.INTERNAL_ERROR) + generated.messages.shouldContain("${KOMMException::class.simpleName}: ${KOMMMap::class.simpleName}.context is required when using context-aware @${MapConvert::class.simpleName} or @${MapDefault::class.simpleName}.") + } + + @Test + fun mapDefaultWithContextResolverWithoutContextFails() { + val sourceSpec = buildFileSpec("SourceObject", mapOf("accountId" to PropertySpecInit(INT))) + val sourceClassName = ClassName(packageName, "SourceObject") + val contextSpec = buildFileSpec("TestContext", mapOf("prefix" to PropertySpecInit(STRING))) + val contextClassName = ClassName(packageName, "TestContext") + val destinationClassName = ClassName(packageName, "DestinationObject") + val resolverSpec = buildContextResolver( + destinationClassName, + contextClassName, + STRING, + "return context.prefix" + ) + val resolverClassName = ClassName(packageName, resolverSpec.typeSpecs.first().name!!) + val generated = generate( + sourceSpec, + contextSpec, + resolverSpec, + buildFileSpec( + destinationClassName.simpleName, + mapOf( + "accountName" to PropertySpecInit( + STRING, + parametrizedAnnotations = listOf( + MapDefault::class.asTypeName().parameterizedBy( + resolverClassName + ) to mapOf( + "resolver = %L" to listOf("${resolverClassName.simpleName}::class") + ) + ) + ) + ), + listOf(KOMMMap::class to mapOf("from = %L" to listOf("[${sourceClassName.simpleName}::class]"))) + ) + ) + + generated.exitCode.shouldBe(KotlinCompilation.ExitCode.INTERNAL_ERROR) + generated.messages.shouldContain("${KOMMException::class.simpleName}: ${KOMMMap::class.simpleName}.context is required when using context-aware @${MapConvert::class.simpleName} or @${MapDefault::class.simpleName}.") + } +} diff --git a/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/SatelliteTests.kt b/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/SatelliteTests.kt index d540228..87dbfd3 100644 --- a/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/SatelliteTests.kt +++ b/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/SatelliteTests.kt @@ -2,6 +2,8 @@ package com.ucasoft.komm.processor import com.squareup.kotlinpoet.* import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.ucasoft.komm.abstractions.KOMMContextConverter +import com.ucasoft.komm.abstractions.KOMMContextResolver import com.ucasoft.komm.abstractions.KOMMConverter import com.ucasoft.komm.abstractions.KOMMResolver @@ -36,6 +38,92 @@ open class SatelliteTests : CompilationTests() { statement ) + protected fun buildContextConverter( + sourceType: ClassName, + srcType: ClassName, + contextType: ClassName, + destinationType: ClassName, + destType: ClassName, + statement: String + ) = + buildContextSatellite( + "TestContextConverter", + KOMMContextConverter::class.asTypeName().parameterizedBy( + sourceType, + srcType, + contextType, + destinationType, + destType + ), + "source", + sourceType, + contextType, + "convert", + srcType, + destType, + statement + ) + + protected fun buildContextResolver( + destinationType: ClassName, + contextType: ClassName, + destType: ClassName, + statement: String + ) = + buildContextSatellite( + "TestContextResolver", + KOMMContextResolver::class.asTypeName().parameterizedBy(contextType, destinationType, destType), + "destination", + destinationType.copy(true), + contextType, + "resolve", + null, + destType, + statement + ) + + protected fun buildContextSatellite( + className: String, + supperClass: ParameterizedTypeName, + firstParameterName: String, + firstParameterType: TypeName, + contextType: TypeName, + overrideFunctionName: String, + memberType: ClassName?, + returnType: TypeName, + statement: String + ) = FileSpec + .builder(packageName, "$className.kt") + .addType( + TypeSpec + .classBuilder(className) + .superclass(supperClass) + .addSuperclassConstructorParameter(firstParameterName) + .addSuperclassConstructorParameter("context") + .primaryConstructor( + FunSpec + .constructorBuilder() + .addParameter(firstParameterName, firstParameterType) + .addParameter("context", contextType) + .build() + ) + .addFunction( + FunSpec + .builder(overrideFunctionName) + .addModifiers(KModifier.OVERRIDE) + .apply { + if (memberType != null) { + addParameter("sourceMember", memberType) + } + } + .returns(returnType) + .addStatement(statement) + .build() + ) + .build() + ) + .build() + protected fun buildSatellite( className: String, supperClass: ParameterizedTypeName, @@ -75,4 +163,4 @@ open class SatelliteTests : CompilationTests() { .build() ) .build() -} \ No newline at end of file +} diff --git a/komm-sample/src/commonMain/kotlin/com/ucasoft/komm/sample/EmbeddedClass.kt b/komm-sample/src/commonMain/kotlin/com/ucasoft/komm/sample/EmbeddedClass.kt index 1c5ae35..3930a63 100644 --- a/komm-sample/src/commonMain/kotlin/com/ucasoft/komm/sample/EmbeddedClass.kt +++ b/komm-sample/src/commonMain/kotlin/com/ucasoft/komm/sample/EmbeddedClass.kt @@ -14,7 +14,7 @@ data class EmbeddedSourceObject( val description: String ) -@KOMMMap(from = [EmbeddedSourceObject::class], to = [], config = MapConfiguration(allowNotNullAssertion = false, tryAutoCast = true, mapDefaultAsFallback = false, convertFunctionName = "")) +@KOMMMap(from = [EmbeddedSourceObject::class], to = [], context = Unit::class, config = MapConfiguration(allowNotNullAssertion = false, tryAutoCast = true, mapDefaultAsFallback = false, convertFunctionName = "")) @MapEmbedded("details") data class EmbeddedDestinationObject( val id: Long, diff --git a/komm-sample/src/commonMain/kotlin/com/ucasoft/komm/sample/ExtendClass.kt b/komm-sample/src/commonMain/kotlin/com/ucasoft/komm/sample/ExtendClass.kt index 8784ca6..3371542 100755 --- a/komm-sample/src/commonMain/kotlin/com/ucasoft/komm/sample/ExtendClass.kt +++ b/komm-sample/src/commonMain/kotlin/com/ucasoft/komm/sample/ExtendClass.kt @@ -4,7 +4,7 @@ import com.ucasoft.komm.abstractions.KOMMConverter import com.ucasoft.komm.abstractions.KOMMResolver import com.ucasoft.komm.annotations.* -@KOMMMap(from = [SourceObject::class], to = [], config = MapConfiguration(allowNotNullAssertion = true, tryAutoCast = true, mapDefaultAsFallback = false, convertFunctionName = "")) +@KOMMMap(from = [SourceObject::class], to = [], context = Unit::class, config = MapConfiguration(allowNotNullAssertion = true, tryAutoCast = true, mapDefaultAsFallback = false, convertFunctionName = "")) data class DestinationObject( val id: Int, val stringToInt: Int, @@ -38,7 +38,7 @@ class StringResolver(destination: DestinationObject?): KOMMResolver