From 4afdbe3037f9fa0d596335965f9f9dc60ec4157c Mon Sep 17 00:00:00 2001 From: Alejandro Serrano Date: Sun, 9 Oct 2022 21:10:25 +0200 Subject: [PATCH] Copy from parent interface (#59) --- README.md | 47 ++++++++++++++++ .../main/kotlin/at/kopyk/CopyFromParent.kt | 41 ++++++++++++++ .../main/kotlin/at/kopyk/KopyKatOptions.kt | 3 ++ .../main/kotlin/at/kopyk/KopyKatProcessor.kt | 20 +++++-- .../kotlin/at/kopyk/utils/TypeCategory.kt | 2 +- .../kotlin/at/kopyk/utils/TypeCompileScope.kt | 10 ++-- .../kotlin/at/kopyk/CopyFromParentTest.kt | 53 +++++++++++++++++++ 7 files changed, 167 insertions(+), 9 deletions(-) create mode 100644 kopykat-ksp/src/main/kotlin/at/kopyk/CopyFromParent.kt create mode 100644 kopykat-ksp/src/test/kotlin/at/kopyk/CopyFromParentTest.kt diff --git a/README.md b/README.md index 32a9174..129cfe0 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ * [Nested collections](#nested-collections) * [Mapping `copyMap`](#mapping-copymap) * [`copy` for sealed hierarchies](#copy-for-sealed-hierarchies) + * [`copy` from supertypes](#copy-from-supertypes) * [`copy` for type aliases](#copy-for-type-aliases) * [Using KopyKat in your project](#using-kopykat-in-your-project) * [Enable only for selected types](#enable-only-for-selected-types) @@ -25,6 +26,8 @@ val p1 = Person("Alex", 1) val p2 = p1.copy(age = p1.age + 1) // too many 'age'! ``` +
+ ## What can KopyKat do? This plug-in generates a couple of new methods that make working with immutable (read-only) types, like data classes and @@ -32,6 +35,8 @@ value classes, more convenient. ![IntelliJ showing the methods](https://github.com/kopykat-kt/kopykat/blob/main/intellij.png?raw=true) +
+ ### Mutable `copy` This new version of `copy` takes a *block* as a parameter. Within that block, mutability is simulated; the final @@ -113,6 +118,8 @@ val p6 = p1.copy { // mutates the job.teams collection in-place } ``` +
+ ### Mapping `copyMap` Instead of new *values*, `copyMap` takes as arguments the *transformations* that ought to be applied to each argument. @@ -150,6 +157,8 @@ val a = Age(39) val b = a.copyMap { it + 1 } ``` +
+ ### `copy` for sealed hierarchies KopyKat also works with sealed hierarchies. These are both sealed classes and sealed interfaces. It generates @@ -183,6 +192,40 @@ fun User.takeOver() = this.copy(name = "Me") > KopyKat only generates these if all the subclasses are data or value classes. We can't mutate object types without > breaking the world underneath them. And cause a lot of pain. +
+ +### `copy` from supertypes + +KopyKat generates "fake constructors" which consume a supertype of a data class, if that supertype defines all the +properties required by its primary constructor. This is useful when working with separate domain and data transfer +types. + +```kotlin +data class Person(val name: String, val age: Int) +@Serializable data class RemotePerson(val name: String, val age: Int) +``` + +In that case you can define a common interface which represents the data, + +```kotlin +interface PersonCommon { + val name: String + val age: Int +} + +data class Person(override val name: String, override val age: Int): PersonCommon +@Serializable data class RemotePerson(override val name: String, override val age: Int): PersonCommon +``` + +With those "fake constructors" you can move easily from one to the other representation. + +```kotlin +val p1 = Person("Alex", 1) +val p2 = RemotePerson(p1) +``` + +
+ ### `copy` for type aliases KopyKat can also generate the different `copy` methods for a type alias. @@ -200,6 +243,7 @@ The following must hold for the type alias to be processed: - It must be marked with the `@CopyExtensions` annotation, - It must refer to a data or value class, or a type hierarchy of those. +
## Using KopyKat in your project @@ -298,11 +342,14 @@ ksp { arg("mutableCopy", "true") arg("copyMap", "false") arg("hierarchyCopy", "true") + arg("superCopy", "true") } ``` By default, the three kinds of methods are generated. +
+ ## What about optics? Optics, like the ones provided by [Arrow](https://arrow-kt.io/docs/optics/), are a much more powerful abstraction. Apart diff --git a/kopykat-ksp/src/main/kotlin/at/kopyk/CopyFromParent.kt b/kopykat-ksp/src/main/kotlin/at/kopyk/CopyFromParent.kt new file mode 100644 index 0000000..aaab810 --- /dev/null +++ b/kopykat-ksp/src/main/kotlin/at/kopyk/CopyFromParent.kt @@ -0,0 +1,41 @@ +package at.kopyk + +import at.kopyk.poet.addReturn +import at.kopyk.poet.append +import at.kopyk.utils.ClassCompileScope +import at.kopyk.utils.addGeneratedMarker +import at.kopyk.utils.baseName +import at.kopyk.utils.getPrimaryConstructorProperties +import at.kopyk.utils.lang.mapRun +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.ksp.toTypeName + +internal val ClassCompileScope.copyFromParentKt: FileSpec + get() = buildFile(fileName = target.append("FromParent").reflectionName()) { + val parameterized = target.parameterized + addGeneratedMarker() + + parentTypes + .filter { it.containsAllPropertiesOf(classDeclaration) } + .forEach { parent -> + addInlinedFunction(name = target.simpleName, receives = null, returns = parameterized) { + addParameter( + name = "from", + type = parent.toTypeName(typeParameterResolver) + ) + properties + .mapRun { "$baseName = from.$baseName" } + .run { addReturn("${target.simpleName}(${joinToString()})") } + } + } + } + +internal fun KSType.containsAllPropertiesOf(child: KSClassDeclaration): Boolean = + child.getPrimaryConstructorProperties().all { childProperty -> + (this.declaration as? KSClassDeclaration)?.getAllProperties().orEmpty().any { parentProperty -> + childProperty.baseName == parentProperty.baseName && + childProperty.type.resolve().isAssignableFrom(parentProperty.type.resolve()) + } + } diff --git a/kopykat-ksp/src/main/kotlin/at/kopyk/KopyKatOptions.kt b/kopykat-ksp/src/main/kotlin/at/kopyk/KopyKatOptions.kt index 0c68a81..1634678 100644 --- a/kopykat-ksp/src/main/kotlin/at/kopyk/KopyKatOptions.kt +++ b/kopykat-ksp/src/main/kotlin/at/kopyk/KopyKatOptions.kt @@ -32,12 +32,14 @@ internal data class KopyKatOptions( val copyMap: Boolean, val mutableCopy: Boolean, val hierarchyCopy: Boolean, + val superCopy: Boolean, val generate: KopyKatGenerate ) { companion object { const val COPY_MAP = "copyMap" const val MUTABLE_COPY = "mutableCopy" const val HIERARCHY_COPY = "hierarchyCopy" + const val SUPER_COPY = "superCopy" const val GENERATE = "generate" fun fromKspOptions(logger: KSPLogger, options: Map) = @@ -45,6 +47,7 @@ internal data class KopyKatOptions( copyMap = options.parseBoolOrTrue(COPY_MAP), mutableCopy = options.parseBoolOrTrue(MUTABLE_COPY), hierarchyCopy = options.parseBoolOrTrue(HIERARCHY_COPY), + superCopy = options.parseBoolOrTrue(SUPER_COPY), generate = KopyKatGenerate.fromKspOptions(logger, options[GENERATE]) ) } diff --git a/kopykat-ksp/src/main/kotlin/at/kopyk/KopyKatProcessor.kt b/kopykat-ksp/src/main/kotlin/at/kopyk/KopyKatProcessor.kt index 187aad7..b6e4440 100644 --- a/kopykat-ksp/src/main/kotlin/at/kopyk/KopyKatProcessor.kt +++ b/kopykat-ksp/src/main/kotlin/at/kopyk/KopyKatProcessor.kt @@ -10,10 +10,12 @@ import at.kopyk.utils.TypeCategory.Known.Value import at.kopyk.utils.TypeCompileScope import at.kopyk.utils.allNestedDeclarations import at.kopyk.utils.hasGeneratedMarker +import at.kopyk.utils.isConstructable import at.kopyk.utils.lang.forEachRun import at.kopyk.utils.onKnownCategory import at.kopyk.utils.typeCategory import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.isAbstract import com.google.devtools.ksp.isAnnotationPresent import com.google.devtools.ksp.processing.CodeGenerator import com.google.devtools.ksp.processing.KSPLogger @@ -43,16 +45,26 @@ internal class KopyKatProcessor( .onEach { it.checkRedundantAnnotation() } .filter { it.shouldGenerate() && it.typeCategory is Known } + // add different copies to data and value classes classes .let { targets -> targets.map { ClassCompileScope(it, classes, logger) } } .forEachRun { process() } - + // add different copies to type aliases declarations .filterIsInstance() .onEach { it.checkKnown() } .filter { it.isAnnotationPresent(CopyExtensions::class) && it.typeCategory is Known } .let { targets -> targets.map { TypeAliasCompileScope(it, classes, logger) } } .forEachRun { process() } + // add copy from parent to all classes + declarations + .filterIsInstance() + .filter { !it.isAbstract() && it.isConstructable() } + .forEach { + with(ClassCompileScope(it, classes, logger)) { + if (options.superCopy) copyFromParentKt.writeTo(codegen) + } + } } } return emptyList() @@ -60,14 +72,14 @@ internal class KopyKatProcessor( private fun TypeCompileScope.process() { logger.logging("Processing $simpleName") - fun generate() { + fun mapAndMutable() { if (options.copyMap) copyMapFunctionKt.writeTo(codegen) if (options.mutableCopy) mutableCopyKt.writeTo(codegen) } onKnownCategory { category -> when (category) { - Data, Value -> generate() - Sealed -> if (options.hierarchyCopy) generate() + Data, Value -> mapAndMutable() + Sealed -> if (options.hierarchyCopy) mapAndMutable() } } } diff --git a/kopykat-ksp/src/main/kotlin/at/kopyk/utils/TypeCategory.kt b/kopykat-ksp/src/main/kotlin/at/kopyk/utils/TypeCategory.kt index fbc4511..b6f0afc 100644 --- a/kopykat-ksp/src/main/kotlin/at/kopyk/utils/TypeCategory.kt +++ b/kopykat-ksp/src/main/kotlin/at/kopyk/utils/TypeCategory.kt @@ -48,7 +48,7 @@ internal sealed interface TypeCategory { @JvmInline value class Unknown(val original: KSDeclaration) : TypeCategory } -private fun KSClassDeclaration.isConstructable() = primaryConstructor?.isPublic() == true +internal fun KSClassDeclaration.isConstructable() = primaryConstructor?.isPublic() == true private fun KSClassDeclaration.isDataClass() = isConstructable() && Modifier.DATA in modifiers diff --git a/kopykat-ksp/src/main/kotlin/at/kopyk/utils/TypeCompileScope.kt b/kopykat-ksp/src/main/kotlin/at/kopyk/utils/TypeCompileScope.kt index 13ff217..b880950 100644 --- a/kopykat-ksp/src/main/kotlin/at/kopyk/utils/TypeCompileScope.kt +++ b/kopykat-ksp/src/main/kotlin/at/kopyk/utils/TypeCompileScope.kt @@ -67,7 +67,7 @@ internal fun TypeParameterResolver.invariant() = object : TypeParameterResolver } internal class ClassCompileScope( - private val classDeclaration: KSClassDeclaration, + val classDeclaration: KSClassDeclaration, private val mutableCandidates: Sequence, override val logger: KSPLogger, ) : TypeCompileScope, KSClassDeclaration by classDeclaration { @@ -78,6 +78,8 @@ internal class ClassCompileScope( get() = classDeclaration.typeParameters.toTypeParameterResolver().invariant() override val target: ClassName = classDeclaration.className + val parentTypes: Sequence = + classDeclaration.superTypes.map { it.resolve() } override val sealedTypes: Sequence = classDeclaration.sealedTypes override val properties: Sequence = classDeclaration.getPrimaryConstructorProperties() @@ -141,13 +143,13 @@ internal class FileCompilerScope( fun addFunction( name: String, - receives: TypeName, + receives: TypeName?, returns: TypeName, block: FunSpec.Builder.() -> Unit = {}, ) { file.addFunction( FunSpec.builder(name).apply { - receiver(receives) + if (receives != null) receiver(receives) returns(returns) addTypeVariables(element.typeVariableNames.map { it.makeInvariant() }) }.apply(block).build() @@ -156,7 +158,7 @@ internal class FileCompilerScope( fun addInlinedFunction( name: String, - receives: TypeName, + receives: TypeName?, returns: TypeName, block: FunSpec.Builder.() -> Unit = {}, ) { diff --git a/kopykat-ksp/src/test/kotlin/at/kopyk/CopyFromParentTest.kt b/kopykat-ksp/src/test/kotlin/at/kopyk/CopyFromParentTest.kt new file mode 100644 index 0000000..7b620ae --- /dev/null +++ b/kopykat-ksp/src/test/kotlin/at/kopyk/CopyFromParentTest.kt @@ -0,0 +1,53 @@ +package at.kopyk + +import org.junit.jupiter.api.Test + +class CopyFromParentTest { + + @Test + fun `simple test`() { + """ + |interface Person { + | val name: String + | val age: Int + |} + |data class Person1(override val name: String, override val age: Int): Person + |data class Person2(override val name: String, override val age: Int): Person + | + |val p1 = Person1("Alex", 1) + |val p2 = Person2(p1) + |val r = p2.age + """.evals("r" to 1) + } + + @Test + fun `simple test, non-data class`() { + """ + |interface Person { + | val name: String + | val age: Int + |} + |class Person1(override val name: String, override val age: Int): Person + |data class Person2(override val name: String, override val age: Int): Person + | + |val p1 = Person1("Alex", 1) + |val p2 = Person2(p1) + |val r = p2.age + """.evals("r" to 1) + } + + @Test + fun `missing field should not create`() { + """ + |interface Person { + | val name: String + |} + |data class Person1(override val name: String, val age: Int): Person + |data class Person2(override val name: String, val age: Int): Person + | + |val p1 = Person1("Alex", 1) + |val p2 = Person2(p1) + |val r = p2.age + """.failsWith { it.contains("No value passed for parameter") } + } +}