Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: value class support #633

Merged
merged 9 commits into from Jun 17, 2021
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions agent/jvm/build.gradle
Expand Up @@ -16,6 +16,8 @@ dependencies {

api "net.bytebuddy:byte-buddy:$byte_buddy_version"
api "net.bytebuddy:byte-buddy-agent:$byte_buddy_version"

implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
}

task copyMockKDispatcher(type: Copy) {
Expand Down
Expand Up @@ -18,6 +18,7 @@ internal class Interceptor(
method
)
return handler.invocation(self, method, callOriginalMethod, arguments)
?.boxedValue() // unbox value class objects
}

}
@@ -0,0 +1,76 @@
package io.mockk.proxy.jvm.advice

import kotlin.reflect.KClass
import kotlin.reflect.KProperty1
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, *>>()

/**
* Get boxed value of any value class
*
* @return boxed value of value class, if this is value class, else just itself
*/
fun <T : Any> T.boxedValue(): Any? {
if (!this::class.isValueClass()) return this

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

// get boxed value
@Suppress("UNCHECKED_CAST")
return (backingField as KProperty1<T, *>).get(this)
}

/**
* Get class of boxed value of any value class
*
* @return class of boxed value, if this is value class, else just class of itself
*/
fun <T : Any> T.boxedClass(): KClass<*> {
qoomon marked this conversation as resolved.
Show resolved Hide resolved
if (!this::class.isValueClass()) return this::class

// get backing field
val backingField = this::class.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, *>
}

fun <T : Any> KClass<T>.isValueClass() = try {
qoomon marked this conversation as resolved.
Show resolved Hide resolved
this.isValue
} catch (ex: UnsupportedOperationException) {
false
}

/**
* POLYFILL for kotlin version < 1.5
* will be shadowed by implementation in kotlin SDK 1.5+
*
* @return true if this is an inline class, else false
*/
private val <T : Any> KClass<T>.isValue: Boolean
get() = !isData &&
primaryConstructor?.parameters?.size == 1 &&
java.declaredMethods.any { it.name == "box-impl" }
3 changes: 1 addition & 2 deletions build.gradle
Expand Up @@ -36,11 +36,10 @@ subprojects { subProject ->
jcenter()
}


tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
kotlinOptions {
jvmTarget = "1.8"
languageVersion = "1.3"
languageVersion = kotlin_version.replaceAll( /^([^.]+\.[^.]+).*/, '$1' )
useIR = findProperty("kotlin.ir.enabled")?.toBoolean() == true
}
}
Expand Down
7 changes: 0 additions & 7 deletions client-tests/jvm/build.gradle
Expand Up @@ -17,13 +17,6 @@ apply plugin: 'kotlin'
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8

compileTestKotlin {
qoomon marked this conversation as resolved.
Show resolved Hide resolved
kotlinOptions {
apiVersion = '1.3'
languageVersion = '1.3'
}
}

configurations.all {
resolutionStrategy.cacheChangingModulesFor 0, 'seconds'
resolutionStrategy.cacheDynamicVersionsFor 0, 'seconds'
Expand Down
10 changes: 0 additions & 10 deletions dsl/common/build.gradle
Expand Up @@ -10,13 +10,3 @@ apply from: "${gradles}/upload.gradle"
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8

compileKotlinCommon {
kotlinOptions {
apiVersion = '1.3'
languageVersion = '1.3'
}
}

jar {
}

1 change: 1 addition & 0 deletions gradle.properties
Expand Up @@ -3,3 +3,4 @@ org.gradle.configureondemand=false
org.gradle.jvmargs=-XX:MaxMetaspaceSize=768m
localrepo=build/mockk-repo
# localrepo=/Users/raibaz/.m2/repository
kotlin.version=1.5.10
2 changes: 1 addition & 1 deletion gradle/jacoco.gradle
@@ -1,7 +1,7 @@
apply plugin: 'jacoco'

jacoco {
toolVersion = "0.8.6"
toolVersion = "0.8.7"
}

afterEvaluate {
Expand Down
7 changes: 0 additions & 7 deletions mockk/common/build.gradle
Expand Up @@ -13,10 +13,3 @@ targetCompatibility = JavaVersion.VERSION_1_8
dependencies {
api project(":mockk-dsl")
}

compileKotlinCommon {
kotlinOptions {
apiVersion = '1.3'
languageVersion = '1.3'
}
}
Expand Up @@ -144,7 +144,7 @@ abstract class RecordingState(recorder: CommonCallRecorder) : CallRecordingState
.flatMap { it.args }
.filterNotNull()
.map(this::typeEstimation)
.max() ?: 1
.maxOrNull() ?: 1

val varargArguments = builder().signedCalls
.mapNotNull {
Expand All @@ -154,7 +154,7 @@ abstract class RecordingState(recorder: CommonCallRecorder) : CallRecordingState
null
}
}.map(this::varArgTypeEstimation)
.max() ?: 1
.maxOrNull() ?: 1

return max(regularArguments, varargArguments)
}
Expand Down
Expand Up @@ -26,7 +26,7 @@ object VerificationHelpers {

fun stackTrace(prefix: Int, stackTrace: List<StackElement>): String {
fun columnSize(block: StackElement.() -> String) =
stackTrace.map(block).map { it.length }.max() ?: 0
stackTrace.map(block).map { it.length }.maxOrNull() ?: 0

fun StackElement.fileLine() =
"($fileName:$line)${if (nativeMethod) "N" else ""}"
Expand Down
57 changes: 57 additions & 0 deletions mockk/common/src/test/kotlin/io/mockk/it/ValueClassTest.kt
@@ -0,0 +1,57 @@
package io.mockk.it

import io.mockk.*
import kotlin.jvm.JvmInline
import kotlin.test.Test
import kotlin.test.assertEquals

class ValueClassTest {

private val mock = mockk<DummyService>()

@Test
fun `value class object as return value`() {
qoomon marked this conversation as resolved.
Show resolved Hide resolved
every { mock.requestValue() } returns DummyValue(42)

assertEquals(DummyValue(42), mock.requestValue())

verify { mock.requestValue() }
}

@Test
fun `value class object as function argument and return value`() {
every { mock.processValue(DummyValue(1)) } returns DummyValue(42)

assertEquals(DummyValue(42), mock.processValue(DummyValue(1)))

verify { mock.processValue(DummyValue(1)) }
}

@Test
fun `value class object as function argument and answer value`() {
every { mock.processValue(DummyValue(1)) } answers { DummyValue(42) }

assertEquals(DummyValue(42), mock.processValue(DummyValue(1)))

verify { mock.processValue(DummyValue(1)) }
}

@Test
fun `any value class matcher as function argument and value class object as return value`() {
every { mock.processValue(any()) } returns DummyValue(42)

assertEquals(DummyValue(42), mock.processValue(DummyValue(1)))

verify { mock.processValue(DummyValue(1)) }
}
}

@JvmInline
private value class DummyValue(val value: Int)

private class DummyService {

fun requestValue() = DummyValue(0)

fun processValue(value: DummyValue) = DummyValue(0)
}
11 changes: 7 additions & 4 deletions mockk/jvm/src/main/kotlin/io/mockk/impl/InternalPlatform.kt
Expand Up @@ -6,6 +6,8 @@ import io.mockk.StackElement
import io.mockk.impl.platform.CommonIdentityHashMapOf
import io.mockk.impl.platform.CommonRef
import io.mockk.impl.platform.JvmWeakConcurrentMap
import io.mockk.proxy.jvm.advice.boxedClass
import io.mockk.proxy.jvm.advice.boxedValue
import java.lang.ref.WeakReference
import java.lang.reflect.Modifier
import java.util.*
Expand Down Expand Up @@ -58,10 +60,11 @@ actual object InternalPlatform {
actual fun <K, V> synchronizedMutableMap(): MutableMap<K, V> = Collections.synchronizedMap(hashMapOf())

actual fun packRef(arg: Any?): Any? {
return if (arg == null || isPassedByValue(arg::class))
arg
else
ref(arg)
return when {
arg == null -> null
isPassedByValue(arg.boxedClass()) -> arg.boxedValue()
else -> ref(arg)
}
}

actual fun prettifyRecordingException(ex: Throwable): Throwable {
Expand Down