diff --git a/README.md b/README.md index 675d363..a4e505d 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.70.8" +val kommVersion = "0.71.1" depensencies { implementation("com.ucasoft.komm:komm-annotations:$kommVersion") @@ -100,7 +100,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.70.8" +val kommVersion = "0.71.1" kotlin { jvm { @@ -224,6 +224,30 @@ fun SourceObject.convertToDestination(): DestinationObject = DestinationObject( it.intToString = intToString.toString() } ``` +#### Nullable Context +Set `nullableContext = true` when a mapping context should be optional at the mapping function boundary. +KOMM generates a nullable context parameter with a default `null` value. +If a context-aware converter or resolver is used, the generated mapper checks that the context was provided before calling it. + +###### Classes declaration +```kotlin +@KOMMMap( + from = [SourceObject::class], + context = SourceMapContext::class, + config = MapConfiguration( + nullableContext = true + ) +) +data class DestinationObject( + val id: Int +) +``` +###### Generated extension function +```kotlin +fun SourceObject.toDestinationObject(kommContext: SourceMapContext? = null): DestinationObject = DestinationObject( + id = id +) +``` ### @MapFunction annotation Use `@MapFunction` when the automatic `toType()` cast should call a top-level extension function from another package. @@ -646,7 +670,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.70.8" +val kommVersion = "0.71.1" depensencies { implementation("com.ucasoft.komm:komm-annotations:$kommVersion") @@ -660,7 +684,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.70.8" +val kommVersion = "0.71.1" //... @@ -720,7 +744,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.70.8" +val kommVersion = "0.71.1" depensencies { implementation("com.ucasoft.komm:komm-annotations:$kommVersion") @@ -764,7 +788,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.70.8" +val kommVersion = "0.71.1" depensencies { implementation("com.ucasoft.komm:komm-annotations:$kommVersion") @@ -778,7 +802,7 @@ plugins { id("com.google.devtools.ksp") version "2.3.9" } -val kommVersion = "0.70.8" +val kommVersion = "0.71.1" //... diff --git a/build.gradle.kts b/build.gradle.kts index 9a17fed..874f74d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,7 +12,7 @@ tasks.wrapper { allprojects { group = "com.ucasoft.komm" - version = "0.70.8" + version = "0.71.1" repositories { mavenCentral() diff --git a/komm-annotations/src/main/kotlin/com/ucasoft/komm/annotations/MapConfiguration.kt b/komm-annotations/src/main/kotlin/com/ucasoft/komm/annotations/MapConfiguration.kt index 60e4aa1..b7834b3 100644 --- a/komm-annotations/src/main/kotlin/com/ucasoft/komm/annotations/MapConfiguration.kt +++ b/komm-annotations/src/main/kotlin/com/ucasoft/komm/annotations/MapConfiguration.kt @@ -4,5 +4,6 @@ annotation class MapConfiguration( val tryAutoCast: Boolean = true, val allowNotNullAssertion: Boolean = false, val mapDefaultAsFallback: Boolean = false, + val nullableContext: Boolean = false, val convertFunctionName: String = "" -) \ No newline at end of file +) 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 cd33d45..7d4622f 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 @@ -7,7 +7,6 @@ import com.google.devtools.ksp.symbol.KSType 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 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 d5ff153..a11025d 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 @@ -10,7 +10,6 @@ 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, @@ -69,10 +68,10 @@ class KOMMPropertyMapper( "$destination = $converter(this).convert(${getSourceAccessName(source)})" nullSubstituteResolver != null -> "$destination = ${ - getSourceWithCast(destination, source, config, function, useSafeAccess = true) + getSourceWithCast(destination, source, function, useSafeAccess = true) } ?: ${mapResolver(nullSubstituteResolver, mapTo)}" else -> - "$destination = ${getSourceWithCast(destination, source, config, function)}" + "$destination = ${getSourceWithCast(destination, source, function)}" } } @@ -93,8 +92,7 @@ class KOMMPropertyMapper( private fun isContext( declaration: KSClassDeclaration, - superClassName: String, - typeSubstitutions: Map = emptyMap() + superClassName: String ): Boolean { for (superTypeReference in declaration.superTypes) { val superType = superTypeReference.resolve() @@ -104,13 +102,7 @@ class KOMMPropertyMapper( 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)) { + if (isContext(superDeclaration, superClassName)) { return true } } @@ -118,8 +110,16 @@ class KOMMPropertyMapper( return false } - 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 getRequiredContextParameterName(): String { + val parameterName = contextParameterName + ?: throw KOMMException("${KOMMMap::class.simpleName}.context is required when using context-aware @${MapConvert::class.simpleName} or @${MapDefault::class.simpleName}.") + + return if (config.getConfigValue(MapConfiguration::nullableContext.name)) { + "$parameterName ?: throw IllegalArgumentException(\"KOMM context is required for context-aware mapping.\")" + } else { + parameterName + } + } private fun getSourceProperty(sourceName: String): EmbeddedSourceProperty? { sourceProperties[sourceName]?.let { return EmbeddedSourceProperty(null, it) } @@ -231,7 +231,6 @@ class KOMMPropertyMapper( private fun getSourceWithCast( destinationProperty: KSPropertyDeclaration, source: EmbeddedSourceProperty, - config: KSAnnotation, function: Pair?, useSafeAccess: Boolean = false ): String { @@ -243,16 +242,12 @@ class KOMMPropertyMapper( val sourceIsNullable = source.isNullable val effectiveSourceType = if (sourceIsNullable) propertyType.makeNullable() else propertyType - getAssignableSource( - source, - propertyName, - destinationType, - destinationIsNullable, - sourceIsNullable, - effectiveSourceType, - useSafeAccess - )?.let { - return it + if (destinationType.isAssignableFrom(effectiveSourceType)) { + return if (sourceIsNullable && destinationIsNullable) { + getSourceAccessName(source, useSafeAccess = true) + } else { + propertyName + } } if (!config.getConfigValue(MapConfiguration::tryAutoCast.name)) { @@ -272,41 +267,37 @@ class KOMMPropertyMapper( return if (useSafeAccess) propertyName else getSourceAccessName(source, assertNotNull = true) } - 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 "."}" + val receiverPrefix = getCastReceiverPrefix( + source, + propertyName, + sourceIsNullable, + destinationIsNullable, + useSafeAccess + ) return "$receiverPrefix$functionName()" } - private fun getAssignableSource( + private fun getCastReceiverPrefix( source: EmbeddedSourceProperty, propertyName: String, - destinationType: KSType, - destinationIsNullable: Boolean, sourceIsNullable: Boolean, - effectiveSourceType: KSType, + destinationIsNullable: Boolean, useSafeAccess: Boolean - ): String? { - if (!destinationType.isAssignableFrom(effectiveSourceType)) { - return null + ): String { + val shouldUseSafeCall = sourceIsNullable && (useSafeAccess || destinationIsNullable) + val sourceAccessName = when { + shouldUseSafeCall -> getSourceAccessName(source, useSafeAccess = true) + sourceIsNullable -> getSourceAccessName(source, assertNotNull = true) + else -> propertyName } - return if (sourceIsNullable && destinationIsNullable) { - getSourceAccessName(source, useSafeAccess = true) - } else { - propertyName - } + return "$sourceAccessName${if (shouldUseSafeCall) "?." else "."}" } private fun canMapNullableSource( 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 4f3a50b..2f59944 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 @@ -4,6 +4,7 @@ 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.ParameterSpec import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.asClassName import com.squareup.kotlinpoet.asTypeName @@ -89,11 +90,17 @@ class KOMMVisitor( ) : FunSpec { val convertFunctionName = config.getConfigValue(MapConfiguration::convertFunctionName.name) val fromSourceFunctionName = convertFunctionName.ifEmpty { "to${destination.toClassName().simpleName}" } + val nullableContext = config.getConfigValue(MapConfiguration::nullableContext.name) return FunSpec.builder(fromSourceFunctionName) .receiver(getSourceName(source)) .apply { if (context != null) { - addParameter("kommContext", context.toTypeName()) + val contextParameterBuilder = ParameterSpec + .builder("kommContext", context.toTypeName().copy(nullable = nullableContext)) + if (nullableContext) { + contextParameterBuilder.defaultValue("null") + } + addParameter(contextParameterBuilder.build()) } } .returns(destination.toClassName()) 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 index 2756cd3..605f4a2 100644 --- a/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/ContextTests.kt +++ b/komm-processor/src/test/kotlin/com/ucasoft/komm/processor/ContextTests.kt @@ -10,15 +10,55 @@ 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.annotations.MapConfiguration 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 java.lang.reflect.InvocationTargetException +import kotlin.test.assertFailsWith import kotlin.test.Test internal class ContextTests : SatelliteTests() { + @Test + fun nullableContextUsesDefaultNull() { + val sourceSpec = buildFileSpec("SourceObject", mapOf("name" to PropertySpecInit(STRING))) + 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 generated = generate( + sourceSpec, + contextSpec, + buildFileSpec( + destinationClassName.simpleName, + mapOf("name" to PropertySpecInit(STRING)), + listOf( + KOMMMap::class to mapOf( + "from = %L" to listOf("[${sourceClassName.simpleName}::class]"), + "context = %L" to listOf("${contextClassName.simpleName}::class"), + "config = %L" to listOf("${MapConfiguration::class.simpleName}(${MapConfiguration::nullableContext.name} = true)") + ) + ) + ) + ) + + generated.exitCode.shouldBe(KotlinCompilation.ExitCode.OK) + + val mappingClass = generated.classLoader.loadClass("$packageName.MappingExtensionsKt") + val mappingMethod = mappingClass.declaredMethods.first { it.name.endsWith("\$default") } + val sourceClass = generated.classLoader.loadClass(sourceClassName.canonicalName) + val sourceInstance = sourceClass.constructors.first().newInstance("Main") + val destinationInstance = mappingMethod.invoke(null, sourceInstance, null, 1, null) + + destinationInstance.shouldNotBeNull() + destinationInstance::class.shouldHaveMemberProperty("name") { + it.getter.call(destinationInstance).shouldBe("Main") + } + } + @Test fun mapContextConvert() { val sourceSpec = buildFileSpec("SourceObject", mapOf("id" to PropertySpecInit(INT))) @@ -81,6 +121,77 @@ internal class ContextTests : SatelliteTests() { } } + @Test + fun mapContextConvertWithNullableContext() { + 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"), + "config = %L" to listOf("${MapConfiguration::class.simpleName}(${MapConfiguration::nullableContext.name} = true)") + ) + ) + ) + ) + + generated.exitCode.shouldBe(KotlinCompilation.ExitCode.OK) + + val mappingClass = generated.classLoader.loadClass("$packageName.MappingExtensionsKt") + val mappingMethod = mappingClass.declaredMethods.first { !it.name.endsWith("\$default") } + 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") + } + + val exception = assertFailsWith { + mappingMethod.invoke(null, sourceInstance, null) + } + + exception.cause.shouldNotBeNull() + exception.cause!!::class.shouldBe(IllegalArgumentException::class) + exception.cause!!.message.shouldBe("KOMM context is required for context-aware mapping.") + } + @Test fun mapDefaultWithContextResolver() { val sourceSpec = buildFileSpec("SourceObject", mapOf("accountId" to PropertySpecInit(INT))) @@ -158,6 +269,92 @@ internal class ContextTests : SatelliteTests() { } } + @Test + fun mapDefaultWithContextResolverWithNullableContext() { + 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"), + "config = %L" to listOf("${MapConfiguration::class.simpleName}(${MapConfiguration::nullableContext.name} = true)") + ) + ) + ) + ) + + generated.exitCode.shouldBe(KotlinCompilation.ExitCode.OK) + + val mappingClass = generated.classLoader.loadClass("$packageName.MappingExtensionsKt") + val mappingMethod = mappingClass.declaredMethods.first { !it.name.endsWith("\$default") } + 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") + } + + val exception = assertFailsWith { + mappingMethod.invoke(null, sourceInstance, null) + } + + exception.cause.shouldNotBeNull() + exception.cause!!::class.shouldBe(IllegalArgumentException::class) + exception.cause!!.message.shouldBe("KOMM context is required for context-aware mapping.") + } + @Test fun mapContextConvertWithoutContextFails() { val sourceSpec = buildFileSpec("SourceObject", mapOf("id" to PropertySpecInit(INT))) 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 3930a63..7e09a33 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 = [], context = Unit::class, 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, nullableContext = 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 3371542..be845c2 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 = [], context = Unit::class, 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, nullableContext = false, convertFunctionName = "")) data class DestinationObject( val id: Int, val stringToInt: Int, @@ -38,7 +38,7 @@ class StringResolver(destination: DestinationObject?): KOMMResolver