spring-projects / spring-data-neo4j

Provide support to increase developer productivity in Java when using Neo4j. Uses familiar Spring concepts such as a template classes for core API usage and lightweight repository style data access.
http://spring.io/projects/spring-data-neo4j
Apache License 2.0
820 stars 620 forks source link

Generic `Neo4jPersistentPropertyConverter<T>` implementation sometimes doesn't function. #2924

Closed lancylot2004 closed 2 weeks ago

lancylot2004 commented 1 month ago

Given the following data structures,

@Node
data class ClassA(
    @Id 
    val id: String,
    @ConvertWith(converter = ClassBConverter::class)
    val other: List<ClassB> = listOf(),
)

@Serializable
data class ClassB(
    val data: Int,
    val otherData: String,
)

The following Neo4jPersistentPropertyConverter does not work:

// Same issue with class
object ClassBConverter : SerializableListConverter<ClassB>(ClassB::class)

abstract class SerializableListConverter<T : Any>(
    private val clazz: KClass<T>,
) : Neo4jPersistentPropertyConverter<List<T>> {
    @OptIn(InternalSerializationApi::class)
    override fun write(source: List<T>?): Value =
        source?.let {
            ListValue(*source.map { StringValue(Json.encodeToString(clazz.serializer(), it)) }.toTypedArray())
        } ?: Values.NULL

    @OptIn(InternalSerializationApi::class)
    override fun read(source: Value): List<T>? =
        when (source) {
            is NullValue -> null
            is ListValue -> source.asList().map { Json.decodeFromString(clazz.serializer(), source.asString()) }
            else -> throw IllegalArgumentException("Expected a ListValue but got ${source::class.simpleName}")
        }
}

While the following, which is almost exactly the same as the implementations in SerializableListConverter above (except clazz and clazz.serializer() are no longer required), works

class ClassBConverter : Neo4jPersistentPropertyConverter<List<ClassB>> {
    override fun write(source: List<ClassB>?): Value = writeList(source)

    override fun read(source: Value): List<ClassB>? = readList(source)
}

inline fun <reified T : Any> writeList(source: List<T>?): Value = source?.let {
    ListValue(*source.map { StringValue(Json.encodeToString(it)) }.toTypedArray())
} ?: Values.NULL

inline fun <reified T : Any> readList(source: Value): List<T>? = when (source) {
    is NullValue -> null
    is ListValue -> source.asList().map {
        it as? String
            ?: throw TypeCastException("Expected String in ListValue but got ${it::class.simpleName}")
        Json.decodeFromString(it)
    }

    else -> throw IllegalArgumentException("Expected a ListValue but got ${source::class.simpleName}")
}

Using the first method which does not work, when trying to access any data in the related node, I get

Caused by: java.lang.IllegalStateException: Duplicate key T (attempted merging values java.util.List<? extends T> and class dev.active.data.classes.event.RuleSection)

Which has a stack trace

org.springframework.data.mapping.MappingException: Error mapping Record<{__sn__: node<13>, __sr__: [relationship<6917530127152709645>, relationship<6917530127152709646>, relationship<6917530127152709648>, relationship<6917530127152709649>], __srn__: [node<2>]}>
    at org.springframework.data.neo4j.core.mapping.DefaultNeo4jEntityConverter.read(DefaultNeo4jEntityConverter.java:123) ~[spring-data-neo4j-7.3.1.jar:7.3.1]
    at org.springframework.data.neo4j.core.mapping.DefaultNeo4jEntityConverter.read(DefaultNeo4jEntityConverter.java:71) ~[spring-data-neo4j-7.3.1.jar:7.3.1]
    at org.springframework.data.neo4j.core.mapping.Schema.lambda$getRequiredMappingFunctionFor$0(Schema.java:96) ~[spring-data-neo4j-7.3.1.jar:7.3.1]
    at org.springframework.data.neo4j.core.PreparedQuery$AggregatingMappingFunction.apply(PreparedQuery.java:246) ~[spring-data-neo4j-7.3.1.jar:7.3.1]
    at org.springframework.data.neo4j.core.PreparedQuery$AggregatingMappingFunction.apply(PreparedQuery.java:158) ~[spring-data-neo4j-7.3.1.jar:7.3.1]
    at org.springframework.data.neo4j.core.DefaultNeo4jClient$DefaultRecordFetchSpec.lambda$partialMappingFunction$0(DefaultNeo4jClient.java:490) ~[spring-data-neo4j-7.3.1.jar:7.3.1]
    at java.base/java.util.stream.ReferencePipeline$3$1.accept(Unknown Source) ~[na:na]
    at java.base/java.util.Iterator.forEachRemaining(Unknown Source) ~[na:na]
    at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Unknown Source) ~[na:na]
    at java.base/java.util.stream.AbstractPipeline.copyInto(Unknown Source) ~[na:na]
    at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(Unknown Source) ~[na:na]
    at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(Unknown Source) ~[na:na]
    at java.base/java.util.stream.AbstractPipeline.evaluate(Unknown Source) ~[na:na]
    at java.base/java.util.stream.ReferencePipeline.collect(Unknown Source) ~[na:na]
    at org.springframework.data.neo4j.core.DefaultNeo4jClient$DefaultRecordFetchSpec.all(DefaultNeo4jClient.java:475) ~[spring-data-neo4j-7.3.1.jar:7.3.1]
    at java.base/java.util.Optional.map(Unknown Source) ~[na:na]
    at org.springframework.data.neo4j.core.Neo4jTemplate$DefaultExecutableQuery.lambda$getResults$1(Neo4jTemplate.java:1240) ~[spring-data-neo4j-7.3.1.jar:7.3.1]
    at org.springframework.transaction.support.TransactionTemplate.execute(TransactionTemplate.java:140) ~[spring-tx-6.1.10.jar:6.1.10]
    at org.springframework.data.neo4j.core.Neo4jTemplate$DefaultExecutableQuery.getResults(Neo4jTemplate.java:1239) ~[spring-data-neo4j-7.3.1.jar:7.3.1]
    at org.springframework.data.neo4j.core.Neo4jTemplate.lambda$doFindAll$1(Neo4jTemplate.java:234) ~[spring-data-neo4j-7.3.1.jar:7.3.1]
    at org.springframework.transaction.support.TransactionTemplate.execute(TransactionTemplate.java:140) ~[spring-tx-6.1.10.jar:6.1.10]
    at org.springframework.data.neo4j.core.Neo4jTemplate.doFindAll(Neo4jTemplate.java:231) ~[spring-data-neo4j-7.3.1.jar:7.3.1]
    at org.springframework.data.neo4j.core.Neo4jTemplate.findAll(Neo4jTemplate.java:226) ~[spring-data-neo4j-7.3.1.jar:7.3.1]
    at org.springframework.data.neo4j.repository.support.SimpleNeo4jRepository.findAll(SimpleNeo4jRepository.java:83) ~[spring-data-neo4j-7.3.1.jar:7.3.1]
    at org.springframework.data.neo4j.repository.support.SimpleNeo4jRepository.findAll(SimpleNeo4jRepository.java:50) ~[spring-data-neo4j-7.3.1.jar:7.3.1]
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(Unknown Source) ~[na:na]
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source) ~[na:na]
    at java.base/java.lang.reflect.Method.invoke(Unknown Source) ~[na:na]
    at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:354) ~[spring-aop-6.1.10.jar:6.1.10]
    at org.springframework.data.repository.core.support.RepositoryMethodInvoker$RepositoryFragmentMethodInvoker.lambda$new$0(RepositoryMethodInvoker.java:277) ~[spring-data-commons-3.3.1.jar:3.3.1]
    at org.springframework.data.repository.core.support.RepositoryMethodInvoker.doInvoke(RepositoryMethodInvoker.java:170) ~[spring-data-commons-3.3.1.jar:3.3.1]
    at org.springframework.data.repository.core.support.RepositoryMethodInvoker.invoke(RepositoryMethodInvoker.java:158) ~[spring-data-commons-3.3.1.jar:3.3.1]
    at org.springframework.data.repository.core.support.RepositoryComposition$RepositoryFragments.invoke(RepositoryComposition.java:516) ~[spring-data-commons-3.3.1.jar:3.3.1]
    at org.springframework.data.repository.core.support.RepositoryComposition.invoke(RepositoryComposition.java:285) ~[spring-data-commons-3.3.1.jar:3.3.1]
    at org.springframework.data.repository.core.support.RepositoryFactorySupport$ImplementationMethodExecutionInterceptor.invoke(RepositoryFactorySupport.java:628) ~[spring-data-commons-3.3.1.jar:3.3.1]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.1.10.jar:6.1.10]
    at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.doInvoke(QueryExecutorMethodInterceptor.java:168) ~[spring-data-commons-3.3.1.jar:3.3.1]
    at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.invoke(QueryExecutorMethodInterceptor.java:143) ~[spring-data-commons-3.3.1.jar:3.3.1]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.1.10.jar:6.1.10]
    at org.springframework.data.projection.DefaultMethodInvokingMethodInterceptor.invoke(DefaultMethodInvokingMethodInterceptor.java:70) ~[spring-data-commons-3.3.1.jar:3.3.1]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.1.10.jar:6.1.10]
    at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:123) ~[spring-tx-6.1.10.jar:6.1.10]
    at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:392) ~[spring-tx-6.1.10.jar:6.1.10]
    at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) ~[spring-tx-6.1.10.jar:6.1.10]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.1.10.jar:6.1.10]
    at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:138) ~[spring-tx-6.1.10.jar:6.1.10]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.1.10.jar:6.1.10]
    at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97) ~[spring-aop-6.1.10.jar:6.1.10]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.1.10.jar:6.1.10]
    at org.springframework.data.repository.core.support.MethodInvocationValidator.invoke(MethodInvocationValidator.java:95) ~[spring-data-commons-3.3.1.jar:3.3.1]
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.1.10.jar:6.1.10]
    at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:223) ~[spring-aop-6.1.10.jar:6.1.10]
    at jdk.proxy2/jdk.proxy2.$Proxy96.findAll(Unknown Source) ~[na:na]
michael-simons commented 1 month ago

HI, I fail to understand what the Kotlin based SerializableListConverter should do. I don't see any obvious issues in the domain model. Care to explain?

lancylot2004 commented 1 month ago

Sorry for the late reply!

SerializableListConverter is intended to be a Neo4jPersistentPropertyConverter for a List of any arbitrary type which is Serializable.

meistermeier commented 1 month ago

Some questions for clarification: Is the exception Caused by: java.lang.IllegalStateException: Duplicate key T (attempted merging values java.util.List<? extends T> and class dev.active.data.classes.event.RuleSection) the one that you get as the root cause for the stacktrace The RuleSection seems to be your productive version of ClassB, right? Could you point out where the problematic line is in your converter (line (number))? What confuses me the most is that the exception reads like the converter wants to put something into a map, that I cannot see.

spring-projects-issues commented 3 weeks ago

If you would like us to look at this issue, please provide the requested information. If the information is not provided within the next 7 days this issue will be closed.

spring-projects-issues commented 2 weeks ago

Closing due to lack of requested feedback. If you would like us to look at this issue, please provide the requested information and we will re-open the issue.