Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ class KMapperFirMappingChecker(val collector: MessageCollector, private val sess
return resolvedTypeArgument?.resolveConstructorFields()
}

private fun ConeKotlinType.resolveConstructorFields(): List<Field> {
private fun ConeKotlinType.resolveConstructorFields(visited: MutableSet<ConeKotlinType> = mutableSetOf()): List<Field> {
if (!visited.add(this)) return emptyList()
val classSymbol = toRegularClassSymbol(session)
if (classSymbol?.moduleData is FirBinaryDependenciesModuleData) return emptyList()
val primaryConstructor = classSymbol?.constructors(session)?.firstOrNull()
Expand All @@ -171,7 +172,7 @@ class KMapperFirMappingChecker(val collector: MessageCollector, private val sess
name = parameter.name,
type = parameter.resolvedReturnType,
hasDefaultValue = parameter.hasDefaultValue,
fields = parameter.resolvedReturnType.resolveConstructorFields()
fields = parameter.resolvedReturnType.resolveConstructorFields(visited)
)
}.orEmpty()
}
Expand All @@ -181,7 +182,8 @@ class KMapperFirMappingChecker(val collector: MessageCollector, private val sess
return resolvedTypeArgument?.resolvePropertyFields()
}

private fun ConeKotlinType.resolvePropertyFields(): List<Field> {
private fun ConeKotlinType.resolvePropertyFields(visited: MutableSet<ConeKotlinType> = mutableSetOf()): List<Field> {
if (!visited.add(this)) return emptyList()
val classSymbol = toRegularClassSymbol(session)
return classSymbol?.declaredProperties(session)
.orEmpty()
Expand All @@ -190,7 +192,7 @@ class KMapperFirMappingChecker(val collector: MessageCollector, private val sess
name = property.name,
type = property.resolvedReturnType,
hasDefaultValue = property.resolvedDefaultValue != null,
fields = property.resolvedReturnType.resolvePropertyFields()
fields = property.resolvedReturnType.resolvePropertyFields(visited)
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,33 @@ class KMapperIrBuildMapperVisitor(
val fromTypeArgument = expression.typeArguments[1] ?: error("Could not resolve source type for mapper")

val remapper = object : IrElementTransformerVoid() {
// The mapper lambda has two parameters:
// 1. Extension receiver ($this$mapper): the TO type being constructed
// 2. Regular parameter (it): the FROM source instance
// When extracting value expressions from the lambda body, we need to remap
// both parameters. The FROM parameter (it) is replaced with the actual receiver.
// The TO parameter ($this$mapper) may be captured in nested lambda closures
// even if not explicitly used; we must also remap it to avoid dangling references
// after the lambda is removed from the IR tree.
val itParamSymbol = callArgument?.function?.parameters
?.firstOrNull { it.kind == IrParameterKind.Regular || it.kind == IrParameterKind.Context }
?.symbol
val thisParamSymbol = callArgument?.function?.parameters
?.firstOrNull { it.kind == IrParameterKind.ExtensionReceiver }
?.symbol

override fun visitGetValue(expression: IrGetValue): IrExpression {
val transformedGetValue = super.visitGetValue(expression)
val itParamSymbol = callArgument?.function?.parameters
?.firstOrNull { it.kind == IrParameterKind.Regular || it.kind == IrParameterKind.Context }
?.symbol
if (expression.symbol == itParamSymbol) {
return receiverArgument.deepCopyWithSymbols()
}
// The TO extension receiver ($this$mapper) may be captured by nested lambdas.
// Since the TO object is being constructed (not yet available), we replace
// references with the receiver argument. This is safe because the captured
// reference is never actually accessed at runtime — only present in the closure.
if (expression.symbol == thisParamSymbol) {
return receiverArgument.deepCopyWithSymbols()
}
return transformedGetValue
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,172 @@ class BasicMappingTest {
}
}

@Test
fun shouldCompile_valueClassAutoMapInDslBlock() {
IntegrationTest(options)
.file("App.kt") {
$$"""
|package sample
|
|import community.flock.kmapper.mapper
|
|@JvmInline
|value class ModelId(val value: String)
|@JvmInline
|value class TaskDesc(val value: String)
|
|data class Run(
| val id: String,
| val modelIdentifier: ModelId,
| val taskDescription: TaskDesc,
| val isViewed: Boolean,
|)
|
|data class RunDto(
| val id: String,
| val modelIdentifier: String,
| val taskDescription: String,
| val isViewed: Boolean,
|)
|
|fun Run.toDto(): RunDto = mapper {
| isViewed = it.isViewed
|}
|
|fun main() {
| val run = Run(id = "1", modelIdentifier = ModelId("gpt-4"), taskDescription = TaskDesc("do stuff"), isViewed = true)
| val dto = run.toDto()
| println(dto)
|}
|
""".trimMargin()
}
.compileSuccess { output ->
assertTrue(
output.contains("RunDto(id=1, modelIdentifier=gpt-4, taskDescription=do stuff, isViewed=true)"),
"Expected RunDto(id=1, modelIdentifier=gpt-4, taskDescription=do stuff, isViewed=true) in output but got: $output"
)
}
}

@Test
fun shouldCompile_valueClassAutoMapInDslBlockWithSerializable() {
IntegrationTest(
options.copy(
additionalPlugins = listOf("""kotlin("plugin.serialization") version "${options.kotlinVersion}""""),
additionalDependencies = listOf("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1"),
)
)
.file("App.kt") {
$$"""
|package sample
|
|import community.flock.kmapper.mapper
|import kotlinx.serialization.Serializable
|import kotlinx.serialization.SerialName
|
|@JvmInline
|value class ModelId(val value: String)
|@JvmInline
|value class TaskDesc(val value: String)
|
|data class Run(
| val id: String,
| val modelIdentifier: ModelId,
| val taskDescription: TaskDesc,
| val isViewed: Boolean,
|)
|
|@Serializable
|@SerialName("RunDto")
|data class RunDto(
| val id: String,
| val modelIdentifier: String,
| val taskDescription: String,
| val isViewed: Boolean,
|)
|
|fun Run.toDto(): RunDto = mapper {
| isViewed = it.isViewed
|}
|
|fun main() {
| val run = Run(id = "1", modelIdentifier = ModelId("gpt-4"), taskDescription = TaskDesc("do stuff"), isViewed = true)
| val dto = run.toDto()
| println(dto)
|}
|
""".trimMargin()
}
.compileSuccess { output ->
assertTrue(
output.contains("RunDto(id=1, modelIdentifier=gpt-4, taskDescription=do stuff, isViewed=true)"),
"Expected RunDto(id=1, modelIdentifier=gpt-4, taskDescription=do stuff, isViewed=true) in output but got: $output"
)
}
}

@Test
fun shouldCompile_selfReferencingType() {
IntegrationTest(options)
.file("App.kt") {
$$"""
|package sample
|
|import community.flock.kmapper.mapper
|
|data class Category(val name: String, val parent: Category?)
|data class CategoryDto(val name: String, val parent: Category?)
|
|fun main() {
| val category = Category("child", Category("root", null))
| val dto: CategoryDto = category.mapper()
| println(dto)
|}
|
""".trimMargin()
}
.compileSuccess { output ->
assertTrue(
output.contains("CategoryDto(name=child, parent=Category(name=root, parent=null))"),
"Expected CategoryDto(name=child, parent=Category(name=root, parent=null)) in output"
)
}
}

@Test
fun shouldCompile_nestedLambdaInMapperBlock() {
IntegrationTest(options)
.file("App.kt") {
$$"""
|package sample
|
|import community.flock.kmapper.mapper
|
|data class User(val firstName: String, val tags: List<String>)
|data class UserDto(val name: String, val tags: List<String>)
|
|fun User.toDto(): UserDto = mapper {
| name = it.firstName
| tags = it.tags.map { tag -> tag.uppercase() }
|}
|
|fun main() {
| val user = User("Alice", listOf("kotlin", "java"))
| val dto = user.toDto()
| println(dto)
|}
|
""".trimMargin()
}
.compileSuccess { output ->
assertTrue(
output.contains("UserDto(name=Alice, tags=[KOTLIN, JAVA])"),
"Expected UserDto(name=Alice, tags=[KOTLIN, JAVA]) in output"
)
}
}

@Test
fun shouldFail_missingParameterAge() {
IntegrationTest(options)
Expand Down
Loading