Skip to content

Commit

Permalink
Copy from parent interface (#59)
Browse files Browse the repository at this point in the history
  • Loading branch information
serras committed Oct 9, 2022
1 parent e299c86 commit 4afdbe3
Show file tree
Hide file tree
Showing 7 changed files with 167 additions and 9 deletions.
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -25,13 +26,17 @@ val p1 = Person("Alex", 1)
val p2 = p1.copy(age = p1.age + 1) // too many 'age'!
```

<hr style="border-bottom: 3px dashed #b5e853;">

## 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
value classes, more convenient.

![IntelliJ showing the methods](https://github.com/kopykat-kt/kopykat/blob/main/intellij.png?raw=true)

<hr>

### Mutable `copy`

This new version of `copy` takes a *block* as a parameter. Within that block, mutability is simulated; the final
Expand Down Expand Up @@ -113,6 +118,8 @@ val p6 = p1.copy { // mutates the job.teams collection in-place
}
```

<hr>

### Mapping `copyMap`

Instead of new *values*, `copyMap` takes as arguments the *transformations* that ought to be applied to each argument.
Expand Down Expand Up @@ -150,6 +157,8 @@ val a = Age(39)
val b = a.copyMap { it + 1 }
```

<hr>

### `copy` for sealed hierarchies

KopyKat also works with sealed hierarchies. These are both sealed classes and sealed interfaces. It generates
Expand Down Expand Up @@ -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.
<hr>

### `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)
```

<hr>

### `copy` for type aliases

KopyKat can also generate the different `copy` methods for a type alias.
Expand All @@ -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.

<hr style="border-bottom: 3px dashed #b5e853;">

## Using KopyKat in your project

Expand Down Expand Up @@ -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.

<hr style="border-bottom: 3px dashed #b5e853;">

## What about optics?

Optics, like the ones provided by [Arrow](https://arrow-kt.io/docs/optics/), are a much more powerful abstraction. Apart
Expand Down
41 changes: 41 additions & 0 deletions kopykat-ksp/src/main/kotlin/at/kopyk/CopyFromParent.kt
Original file line number Diff line number Diff line change
@@ -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())
}
}
3 changes: 3 additions & 0 deletions kopykat-ksp/src/main/kotlin/at/kopyk/KopyKatOptions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,22 @@ 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<String, String>) =
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])
)
}
Expand Down
20 changes: 16 additions & 4 deletions kopykat-ksp/src/main/kotlin/at/kopyk/KopyKatProcessor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -43,31 +45,41 @@ 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<KSTypeAlias>()
.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<KSClassDeclaration>()
.filter { !it.isAbstract() && it.isConstructable() }
.forEach {
with(ClassCompileScope(it, classes, logger)) {
if (options.superCopy) copyFromParentKt.writeTo(codegen)
}
}
}
}
return emptyList()
}

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()
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion kopykat-ksp/src/main/kotlin/at/kopyk/utils/TypeCategory.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 6 additions & 4 deletions kopykat-ksp/src/main/kotlin/at/kopyk/utils/TypeCompileScope.kt
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ internal fun TypeParameterResolver.invariant() = object : TypeParameterResolver
}

internal class ClassCompileScope(
private val classDeclaration: KSClassDeclaration,
val classDeclaration: KSClassDeclaration,
private val mutableCandidates: Sequence<KSDeclaration>,
override val logger: KSPLogger,
) : TypeCompileScope, KSClassDeclaration by classDeclaration {
Expand All @@ -78,6 +78,8 @@ internal class ClassCompileScope(
get() = classDeclaration.typeParameters.toTypeParameterResolver().invariant()

override val target: ClassName = classDeclaration.className
val parentTypes: Sequence<KSType> =
classDeclaration.superTypes.map { it.resolve() }
override val sealedTypes: Sequence<KSClassDeclaration> = classDeclaration.sealedTypes
override val properties: Sequence<KSPropertyDeclaration> = classDeclaration.getPrimaryConstructorProperties()

Expand Down Expand Up @@ -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()
Expand All @@ -156,7 +158,7 @@ internal class FileCompilerScope(

fun addInlinedFunction(
name: String,
receives: TypeName,
receives: TypeName?,
returns: TypeName,
block: FunSpec.Builder.() -> Unit = {},
) {
Expand Down
53 changes: 53 additions & 0 deletions kopykat-ksp/src/test/kotlin/at/kopyk/CopyFromParentTest.kt
Original file line number Diff line number Diff line change
@@ -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") }
}
}

0 comments on commit 4afdbe3

Please sign in to comment.