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

In Kotlin, @DocumentReference(lazy = true) proxies are not automatically unwrapped #4483

Closed
epernice opened this issue Aug 23, 2023 · 4 comments
Labels
status: waiting-for-triage An issue we've not yet triaged

Comments

@epernice
Copy link

epernice commented Aug 23, 2023

When using @DocumentReference(lazy = true) in Kotlin, proxies do not automatically unwrap. This makes lazy loaded documents difficult to work with. For example, if you return a document over a REST endpoint, serializing to JSON, the referenced document properties are all serialized as null. If you want to work with a lazy loaded document in Java code, you must first cast it as a LazyLoadedProxy and access .target to manually obtain the unwrapped object.

Here is a minimal setup that reproduces the problem in a Spring Boot application:

build.gradle.kts

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
	id("org.springframework.boot") version "3.1.2"
	id("io.spring.dependency-management") version "1.1.2"
	kotlin("jvm") version "1.8.22"
	kotlin("plugin.spring") version "1.8.22"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"

java {
	sourceCompatibility = JavaVersion.VERSION_17
}

repositories {
	mavenCentral()
}

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-data-mongodb")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	testImplementation("org.springframework.boot:spring-boot-starter-test")
}

tasks.withType<KotlinCompile> {
	kotlinOptions {
		freeCompilerArgs += "-Xjsr305=strict"
		jvmTarget = "17"
	}
}

tasks.withType<Test> {
	useJUnitPlatform()
}

Person.kt

@Document("person")
open class Person {
    var id: ObjectId? = null
    var name: String? = null

    @DocumentReference(lazy = true)
    var address: Address? = null
}

Address.kt

@Document("address")
open class Address {
    var id: ObjectId? = null
    var city: String? = null
}

PersonRepository.kt

@Repository
interface PersonRepository : MongoRepository<Person, ObjectId>{
    fun findByName(name: String): Person
}

DemoApplicationTests.kt

@SpringBootTest
class DemoApplicationTests {

	@Autowired
	lateinit var personRepository: PersonRepository

	@Test
	fun lazyLoadedAddress() {
		val person = personRepository.findByName("John Doe")

		// Does not work
		println("Directly accessing lazy loaded document:")
		println(person.address!!.city)

		// Works
		println("Manually unwrapping LazyLoadingProxy:")
		val address = (person.address as LazyLoadingProxy?)!!.target as Address
		println(address.city)

		/**
		 * OUPTUT:
		 *
		 * Directly accessing lazy loaded document:
		 * null
		 * Manually unwrapping LazyLoadingProxy:
		 * San Francisco
		 */
	}
}
@mp911de
Copy link
Member

mp911de commented Aug 23, 2023

I'm not quite sure there is something we can do here. When Kotlin defaults to field access, then there isn't something we can do.

@epernice
Copy link
Author

epernice commented Aug 23, 2023

Are you suspecting that in this case the Kotlin compiler is optimizing out the property accessors and doing direct field access? I don't think that's the case. I can add this Java test to my Kotlin project, use the getters generated by Kotlin, and it still produces the same result as the Kotlin code above:

DemoApplicationTests.java

@SpringBootTest
class DemoApplicationTests {
    @Autowired
    PersonRepository personRepository;

    @Test
    void lazyLoadedAddress() {
        Person person = personRepository.findByName("John Doe");

        // Does not work
        System.out.println("Directly accessing lazy loaded document:");
        System.out.println(person.getAddress().getCity());

        // Works
        System.out.println("Manually unwrapping LazyLoadingProxy:");
        Address address = (Address) ((LazyLoadingProxy) person.getAddress()).getTarget();
        System.out.println(address.getCity());

        /**
         * OUPTUT:
         *
         * Directly accessing lazy loaded document:
         * null
         * Manually unwrapping LazyLoadingProxy:
         * San Francisco
         */
    }
}

Also, I haven't had any issues using JPA lazy loading with Kotlin, so I wouldn't think that there is a fundamental limitation.

In case it helps, here is the relevant bit of bytecode for Person.kt. It is generating getters and setters as shown:

// access flags 0x2
private Lcom/example/demo/Address; address
@Lorg/springframework/data/mongodb/core/mapping/DocumentReference;(lazy=true)
@Lorg/jetbrains/annotations/Nullable;() // invisible

// access flags 0x11
public final getAddress()Lcom/example/demo/Address;
@Lorg/jetbrains/annotations/Nullable;() // invisible
L0
LINENUMBER 13 L0
ALOAD 0
GETFIELD com/example/demo/Person.address : Lcom/example/demo/Address;
ARETURN
L1
LOCALVARIABLE this Lcom/example/demo/Person; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1

// access flags 0x11
public final setAddress(Lcom/example/demo/Address;)V
// annotable parameter count: 1 (visible)
// annotable parameter count: 1 (invisible)
@Lorg/jetbrains/annotations/Nullable;() // invisible, parameter 0
L0
LINENUMBER 13 L0
ALOAD 0
ALOAD 1
PUTFIELD com/example/demo/Person.address : Lcom/example/demo/Address;
RETURN
L1
LOCALVARIABLE this Lcom/example/demo/Person; L0 L1 0
LOCALVARIABLE <set-?> Lcom/example/demo/Address; L0 L1 1
MAXSTACK = 2
MAXLOCALS = 2

And here is the relevant bytecode from DemoApplicationTests.kt. As you can see, it is using getAddress

L1
LINENUMBER 19 L1
LDC "Directly accessing lazy loaded document:"
ASTORE 2
L2
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 2
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V
L3
L4
LINENUMBER 20 L4
ALOAD 1
INVOKEVIRTUAL com/example/demo/Person.getAddress ()Lcom/example/demo/Address;
DUP
INVOKESTATIC kotlin/jvm/internal/Intrinsics.checkNotNull (Ljava/lang/Object;)V
INVOKEVIRTUAL com/example/demo/Address.getCity ()Ljava/lang/String;
ASTORE 2
L5
GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
ALOAD 2
INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/Object;)V

@epernice
Copy link
Author

epernice commented Aug 23, 2023

I found the problem by comparing the Kotlin bytecode with the Java bytecode. Kotlin generates setters and getters as final. I assume this is breaking the proxy mechanism. To use lazy loading proxies, the Kotlin properties in the referenced document need to be declared as open. This resolves the issue:

@Document("address")
open class Address {
    open var id: ObjectId? = null // Needs to be open
    open var city: String? = null // Needs to be open
}

This issue can be closed.

@epernice
Copy link
Author

This can be solved more generally with the kotlin-allopen plugin:

build.gradle.kts

plugins {
	// ...
	kotlin("plugin.allopen") version "1.8.22"
}

// ...

allOpen {
	// Document members need to be open to support @DocumentReference(lazy = true)
	// See https://github.com/spring-projects/spring-data-mongodb/issues/4483
	annotation("org.springframework.data.mongodb.core.mapping.Document")
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
status: waiting-for-triage An issue we've not yet triaged
Projects
None yet
Development

No branches or pull requests

3 participants