Skip to content

Commit

Permalink
Merge pull request #849 from aSemy/fix/152-value-classes
Browse files Browse the repository at this point in the history
#152 support value classes
  • Loading branch information
Raibaz committed Jul 26, 2022
2 parents 4f2f26f + 8b8017c commit 02a6d45
Show file tree
Hide file tree
Showing 16 changed files with 735 additions and 173 deletions.
100 changes: 40 additions & 60 deletions agent/android/src/main/kotlin/io/mockk/ValueClassSupport.kt
Expand Up @@ -6,80 +6,60 @@ import kotlin.reflect.full.declaredMemberProperties
import kotlin.reflect.full.primaryConstructor
import kotlin.reflect.jvm.isAccessible

private val valueClassFieldCache = mutableMapOf<KClass<out Any>, KProperty1<out Any, *>>()
// TODO this class is copy-pasted and should be de-duplicated
// see https://github.com/mockk/mockk/issues/857

/**
* Get boxed value of any value class
* Underlying property value of a **`value class`** or self.
*
* @return boxed value of value class, if this is value class, else just itself
* The type of the return might also be a `value class`!
*/
fun <T : Any> T.boxedValue(): Any? {
if (!this::class.isValueClass()) return this

// get backing field
val backingField = this::class.valueField()

// get boxed value
val <T : Any> T.boxedValue: Any?
@Suppress("UNCHECKED_CAST")
return (backingField as KProperty1<T, *>).get(this)
}
get() = if (!this::class.isValue_safe) {
this
} else {
(this::class as KClass<T>).boxedProperty.get(this)
}

/**
* Get class of boxed value of any value class
* Underlying property class of a **`value class`** or self.
*
* @return class of boxed value, if this is value class, else just class of itself
* The returned class might also be a `value class`!
*/
fun <T : Any> T.boxedClass(): KClass<*> {
return this::class.boxedClass()
}
val KClass<*>.boxedClass: KClass<*>
get() = if (!this.isValue_safe) {
this
} else {
this.boxedProperty.returnType.classifier as KClass<*>
}

private val valueClassFieldCache = mutableMapOf<KClass<out Any>, KProperty1<out Any, *>>()

/**
* Get the KClass of boxed value if this is a value class.
* Underlying property of a **`value class`**.
*
* @return class of boxed value, if this is value class, else just class of itself
* The underlying property might also be a `value class`!
*/
fun KClass<*>.boxedClass(): KClass<*> {
if (!this.isValueClass()) return this

// get backing field
val backingField = this.valueField()

// get boxed value
return backingField.returnType.classifier as KClass<*>
}


private fun <T : Any> KClass<T>.valueField(): KProperty1<out T, *> {
@Suppress("UNCHECKED_CAST")
return valueClassFieldCache.getOrPut(this) {
require(isValue) { "$this is not a value class" }

// value classes always have a primary constructor...
val constructor = primaryConstructor!!
// ...and exactly one constructor parameter
val constructorParameter = constructor.parameters.first()
// ...with a backing field
val backingField = declaredMemberProperties
.first { it.name == constructorParameter.name }
.apply { isAccessible = true }

backingField
} as KProperty1<out T, *>
}

private fun <T : Any> KClass<T>.isValueClass() = try {
this.isValue
} catch (_: Throwable) {
false
}
private val <T : Any> KClass<T>.boxedProperty: KProperty1<T, *>
get() = if (!this.isValue_safe) {
throw UnsupportedOperationException("$this is not a value class")
} else {
// value classes always have exactly one property
@Suppress("UNCHECKED_CAST")
valueClassFieldCache.getOrPut(this) {
this.declaredMemberProperties.first().apply { isAccessible = true }
} as KProperty1<T, *>
}

/**
* POLYFILL for kotlin version < 1.5
* will be shadowed by implementation in kotlin SDK 1.5+
* Returns `true` if calling [KClass.isValue] is safe.
*
* @return true if this is an inline class, else false
* (In some instances [KClass.isValue] can throw an exception.)
*/
private val <T : Any> KClass<T>.isValue: Boolean
get() = !isData &&
primaryConstructor?.parameters?.size == 1 &&
java.declaredMethods.any { it.name == "box-impl" }
private val <T : Any> KClass<T>.isValue_safe: Boolean
get() = try {
this.isValue
} catch (_: UnsupportedOperationException) {
false
}
Expand Up @@ -81,7 +81,7 @@ internal class Advice(
superMethodCall,
arguments
)
?.boxedValue() // unbox value class objects
?.boxedValue // unbox value class objects
}
}

Expand Down
100 changes: 40 additions & 60 deletions agent/jvm/src/main/kotlin/io/mockk/ValueClassSupport.kt
Expand Up @@ -6,80 +6,60 @@ import kotlin.reflect.full.declaredMemberProperties
import kotlin.reflect.full.primaryConstructor
import kotlin.reflect.jvm.isAccessible

private val valueClassFieldCache = mutableMapOf<KClass<out Any>, KProperty1<out Any, *>>()
// TODO this class is copy-pasted and should be de-duplicated
// see https://github.com/mockk/mockk/issues/857

/**
* Get boxed value of any value class
* Underlying property value of a **`value class`** or self.
*
* @return boxed value of value class, if this is value class, else just itself
* The type of the return might also be a `value class`!
*/
fun <T : Any> T.boxedValue(): Any? {
if (!this::class.isValueClass()) return this

// get backing field
val backingField = this::class.valueField()

// get boxed value
val <T : Any> T.boxedValue: Any?
@Suppress("UNCHECKED_CAST")
return (backingField as KProperty1<T, *>).get(this)
}
get() = if (!this::class.isValue_safe) {
this
} else {
(this::class as KClass<T>).boxedProperty.get(this)
}

/**
* Get class of boxed value of any value class
* Underlying property class of a **`value class`** or self.
*
* @return class of boxed value, if this is value class, else just class of itself
* The returned class might also be a `value class`!
*/
fun <T : Any> T.boxedClass(): KClass<*> {
return this::class.boxedClass()
}
val KClass<*>.boxedClass: KClass<*>
get() = if (!this.isValue_safe) {
this
} else {
this.boxedProperty.returnType.classifier as KClass<*>
}

private val valueClassFieldCache = mutableMapOf<KClass<out Any>, KProperty1<out Any, *>>()

/**
* Get the KClass of boxed value if this is a value class.
* Underlying property of a **`value class`**.
*
* @return class of boxed value, if this is value class, else just class of itself
* The underlying property might also be a `value class`!
*/
fun KClass<*>.boxedClass(): KClass<*> {
if (!this.isValueClass()) return this

// get backing field
val backingField = this.valueField()

// get boxed value
return backingField.returnType.classifier as KClass<*>
}


private fun <T : Any> KClass<T>.valueField(): KProperty1<out T, *> {
@Suppress("UNCHECKED_CAST")
return valueClassFieldCache.getOrPut(this) {
require(isValue) { "$this is not a value class" }

// value classes always have a primary constructor...
val constructor = primaryConstructor!!
// ...and exactly one constructor parameter
val constructorParameter = constructor.parameters.first()
// ...with a backing field
val backingField = declaredMemberProperties
.first { it.name == constructorParameter.name }
.apply { isAccessible = true }

backingField
} as KProperty1<out T, *>
}

private fun <T : Any> KClass<T>.isValueClass() = try {
this.isValue
} catch (_: Throwable) {
false
}
private val <T : Any> KClass<T>.boxedProperty: KProperty1<T, *>
get() = if (!this.isValue_safe) {
throw UnsupportedOperationException("$this is not a value class")
} else {
// value classes always have exactly one property
@Suppress("UNCHECKED_CAST")
valueClassFieldCache.getOrPut(this) {
this.declaredMemberProperties.first().apply { isAccessible = true }
} as KProperty1<T, *>
}

/**
* POLYFILL for kotlin version < 1.5
* will be shadowed by implementation in kotlin SDK 1.5+
* Returns `true` if calling [KClass.isValue] is safe.
*
* @return true if this is an inline class, else false
* (In some instances [KClass.isValue] can throw an exception.)
*/
private val <T : Any> KClass<T>.isValue: Boolean
get() = !isData &&
primaryConstructor?.parameters?.size == 1 &&
java.declaredMethods.any { it.name == "box-impl" }
private val <T : Any> KClass<T>.isValue_safe: Boolean
get() = try {
this.isValue
} catch (_: UnsupportedOperationException) {
false
}
Expand Up @@ -19,7 +19,7 @@ internal class Interceptor(
method
)
return handler.invocation(self, method, callOriginalMethod, arguments)
?.boxedValue() // unbox value class objects
?.boxedValue // unbox value class objects
}

}
6 changes: 6 additions & 0 deletions build.gradle
@@ -1,3 +1,5 @@
import java.time.Duration

buildscript {
ext.kotlin_gradle_version = findProperty('kotlin.version')?.toString() ?: '1.6.0'
ext.android_gradle_version = '7.0.0'
Expand Down Expand Up @@ -44,4 +46,8 @@ subprojects { subProject ->
languageVersion = "1.5"
}
}

tasks.withType(Test).configureEach {
timeout.set(Duration.ofMinutes(5))
}
}
10 changes: 6 additions & 4 deletions dsl/common/src/main/kotlin/io/mockk/API.kt
Expand Up @@ -3557,11 +3557,13 @@ interface TypedMatcher {
val argumentType: KClass<*>

fun checkType(arg: Any?): Boolean {
if (argumentType.simpleName === null) {
return true
return when {
argumentType.simpleName === null -> true
else -> {
val unboxedClass = InternalPlatformDsl.unboxClass(argumentType)
return unboxedClass.isInstance(arg)
}
}

return argumentType.isInstance(arg)
}
}

Expand Down
23 changes: 22 additions & 1 deletion dsl/common/src/main/kotlin/io/mockk/InternalPlatformDsl.kt
@@ -1,6 +1,7 @@
package io.mockk

import kotlin.coroutines.Continuation
import kotlin.reflect.KClass

expect object InternalPlatformDsl {
fun identityHashCode(obj: Any): Int
Expand Down Expand Up @@ -35,6 +36,26 @@ expect object InternalPlatformDsl {
fun counter(): InternalCounter

fun <T> coroutineCall(lambda: suspend () -> T): CoroutineCall<T>

/**
* Get the [KClass] of the single value that a `value class` contains.
*
* The result might also be a value class! So check recursively, if necessary.
*
* @return [KClass] of boxed value, if this is `value class`, else [cls].
*/
fun unboxClass(cls: KClass<*>): KClass<*>

/**
* Normally this simply casts [arg] to `T`
*
* However, if `T` is a `value class` (of type [cls]) this will construct a new instance of the
* value class, and set [arg] as the value.
*/
fun <T : Any> boxCast(
cls: KClass<*>,
arg: Any,
): T
}

interface CoroutineCall<T> {
Expand All @@ -50,4 +71,4 @@ interface InternalCounter {
val value: Long

fun increment(): Long
}
}
6 changes: 2 additions & 4 deletions dsl/common/src/main/kotlin/io/mockk/Matchers.kt
Expand Up @@ -140,17 +140,16 @@ data class CaptureNullableMatcher<T : Any>(
*/
data class CapturingSlotMatcher<T : Any>(
val captureSlot: CapturingSlot<T>,
override val argumentType: KClass<*>
override val argumentType: KClass<*>,
) : Matcher<T>, CapturingMatcher, TypedMatcher, EquivalentMatcher {
override fun equivalent(): Matcher<Any> = ConstantMatcher<Any>(true)

@Suppress("UNCHECKED_CAST")
override fun capture(arg: Any?) {
if (arg == null) {
captureSlot.isNull = true
} else {
captureSlot.isNull = false
captureSlot.captured = arg as T
captureSlot.captured = InternalPlatformDsl.boxCast(argumentType, arg)
}
captureSlot.isCaptured = true
}
Expand Down Expand Up @@ -473,4 +472,3 @@ fun CompositeMatcher<*>.captureSubMatchers(arg: Any?) {
.forEach { it.capture(arg) }
}
}

0 comments on commit 02a6d45

Please sign in to comment.