spring-data-neo4j icon indicating copy to clipboard operation
spring-data-neo4j copied to clipboard

Investigate to convert non-domain-objects into JSON-like maps.

Open michael-simons opened this issue 4 years ago • 5 comments

In #2308 the question was raised whether non-domain objects can be serialized in some form as JSON-like map.

Several options could be possible:

  • Depending on Jackson / ObjectMapper and just creating a Map<String, Object> from non-domain objects, pass it to the driver and hope for the best
  • Or doing this but pipe every non-key through our converters, making sure the map contain only valid Value instances
  • Create ad-hoc entities via the Neo4jMappingContext and use the same supported format as we do for domain entities.

michael-simons avatar Jun 23 '21 09:06 michael-simons

I understand you have concerns about the solution 1. Would you mind explaining quickly what they are? I believe that's the approach that was taken in version 5? I have personally never experienced any issue with this type of serialization, and we use the version 5 very intensively. The drawback I see for you is the dependency on Jackson. But was there any other issue with this approach I don't see?

About proposition 3, correct me if I'm wrong, but would that require all the arbitrary parameters to have an attribute annotated with @Id? In which case, I think it wouldn't make sense. If not, then it would have the advantage to keep the same serialization format as with node entities with a little extra work. However, it's limited to flat objects. Deeply nested inputs wouldn't work well.

That being said, solution 2 seems to be the best overall, since it relies entirely on neo4j conversions only and not on Jackson. It would require to map the object to a MapValue recursively and use the conversions of neo4j. Might however require the most work. It would require basically to re-create Jackson (a much simpler version) with your own conversions, is that right?

pelletier197 avatar Jun 23 '21 10:06 pelletier197

Re 1

  • Date time values are serialized as strings, not as native types (the database supports them native)
  • No support for spatial

Re 3

  • No, we can create ad hoc entities.

Re 2 Yes, it would be indeed the most work intensive. One solution would be requiring Jackson, but doing custom conversions correctly (we had this topic over there https://github.com/neo4j/neo4j-ogm/issues/772 already)

michael-simons avatar Jun 23 '21 12:06 michael-simons

I believe solution 1 is not a good long term solution for you. I agree with that.

As for solution 3, I'm concerned it might not work well with nested input objects (Some queries in our projects have that).

So solution 2 seems to work best. I'm not sure, how it can be implemented with Jackson, but that's probably doable.

pelletier197 avatar Jun 23 '21 13:06 pelletier197

For whoever might be interested, since I believe it might take time to solve this issue depending on the solution taken, I implemented something to work this around:

My solution starts with an interface

interface Neo4jParameter

and for example, one object I needed to convert to a map was this:

data class DateRange(
    val after: Instant?,
    val before: Instant?
)

data class HostsFilters(
    val ids: List<String>?,
    val hostnames: List<String>?,
    val platforms: List<String>?,
    val osNames: List<String>?,
    val lastSeen: DateRange?, // <-- recursive mapping
    val hasFile: List<String>?
) : Neo4jParameter // <-- implement this interface

Then, I created this converter

class Neo4jParameterConverter(otherConversions: List<GenericConverter>) : GenericConverter {
    private val conversionService = DefaultConversionService()

    init {
        Neo4jConversions(otherConversions).registerConvertersIn(conversionService)
    }

    override fun getConvertibleTypes(): Set<GenericConverter.ConvertiblePair> {
        return setOf(
            GenericConverter.ConvertiblePair(Neo4jParameter::class.java, Value::class.java)
        )
    }

    override fun convert(source: Any?, sourceType: TypeDescriptor?, targetType: TypeDescriptor?): Any {
        return convertObject(source)
    }

    private fun convertObject(input: Any?): Value {
        return when (input) {
            null -> Values.NULL
            is Collection<*> -> convertCollection(input)
            is Array<*> -> convertCollection(input.toList())
            is Map<*, *> -> convertMap(input)
            else -> {
                if (conversionService.canConvert(input::class.java, Value::class.java)) {
                    conversionService.convert(input, Value::class.java)
                } else {
                    convertObjectToMapOfValues(input)
                }
            }
        }
    }

    private fun convertObjectToMapOfValues(input: Any?): Value {
        if (input == null) return Values.NULL

        val output = HashMap<String, Value>()

        ReflectionUtils.doWithFields(input::class.java) {
            ReflectionUtils.makeAccessible(it)
            val fieldValue = ReflectionUtils.getField(it, input)
            output[it.name] = convertObject(fieldValue)
        }

        return MapValue(output)
    }

    private fun convertCollection(input: Collection<*>): ListValue {
        return ListValue(*input.map { convertObject(it) }.toTypedArray())
    }

    private fun convertMap(input: Map<*, *>): MapValue {
        return MapValue(input.map { it.key.toString() to convertObject(it.value) }.toMap())
    }
}

then just pass this converter to your Neo4jConversions bean.

It's basically going to convert every Neo4jParameter to a Value recursively field for field, and it will even use Neo4j's conversion to map your parameters, so it will serialize your datetimes as a DateTimeValue and will convert your spatial fields as well.

This is a basic implementation of solution 2. The only disadvantage is the interface that you have to implement, which I find acceptable personally for a workaround.

pelletier197 avatar Jun 23 '21 21:06 pelletier197

That’s a great idea. I think it’s worth adding something like this to the FAQ. Thank you.

Sunny Pelletier @.***> schrieb am Mi. 23. Juni 2021 um 23:18:

For whoever might be interested, since I believe it might take time to solve this issue depending on the solution taken, I implemented something to work this around:

My solution starts with an interface

interface Neo4jParameter

and for example, one object I needed to convert to a map was this:

data class DateRange( val after: Instant?, val before: Instant? ) data class HostsFilters( val ids: List<String>?, val hostnames: List<String>?, val platforms: List<String>?, val osNames: List<String>?, val lastSeen: DateRange?, // <-- recursive mapping val hasFile: List<String>? ) : Neo4jParameter // <-- implement this interface

Then, I created this converter

class Neo4jParameterConverter(otherConversions: List<GenericConverter>) : GenericConverter { private val conversionService = DefaultConversionService()

init {
    Neo4jConversions(otherConversions).registerConvertersIn(conversionService)
}

override fun getConvertibleTypes(): Set<GenericConverter.ConvertiblePair> {
    return setOf(
        GenericConverter.ConvertiblePair(Neo4jParameter::class.java, Value::class.java)
    )
}

override fun convert(source: Any?, sourceType: TypeDescriptor?, targetType: TypeDescriptor?): Any {
    return convertObject(source)
}

private fun convertObject(input: Any?): Value {
    if (input == null) return Values.NULL
    return when (input) {
        null -> Values.NULL
        is Collection<*> -> convertCollection(input)
        is Array<*> -> convertCollection(input.toList())
        is Map<*, *> -> convertMap(input)
        else -> {
            if (conversionService.canConvert(input::class.java, Value::class.java)) {
                conversionService.convert(input, Value::class.java)
            } else {
                convertObjectToMapOfValues(input)
            }
        }
    }
}

private fun convertObjectToMapOfValues(input: Any?): Value {
    if (input == null) return Values.NULL

    val output = HashMap<String, Value>()

    ReflectionUtils.doWithFields(input::class.java) {
        ReflectionUtils.makeAccessible(it)
        val fieldValue = ReflectionUtils.getField(it, input)
        output[it.name] = convertObject(fieldValue)
    }

    return MapValue(output)
}

private fun convertCollection(input: Collection<*>): ListValue {
    return ListValue(*input.map { convertObject(it) }.toTypedArray())
}

private fun convertMap(input: Map<*, *>): MapValue {
    return MapValue(input.map { it.key.toString() to convertObject(it.value) }.toMap())
}

}

then just pass this converter to your Neo4jConversions bean.

It's basically going to convert every Neo4jParameter to a Value by using Jackson recursively, and it will even use Neo4j's conversion to map your parameters, so it will serialize your datetimes as a DateTimeValue and will convert your spatial fields as well.

This is a basic implementation of solution 2. The only disadvantage is the interface that you have to implement, which I find acceptable personally for a workaround.

— You are receiving this because you authored the thread. Reply to this email directly, view it on GitHub https://github.com/spring-projects/spring-data-neo4j/issues/2309#issuecomment-867166218, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAEAQL6647GHB6XMA357NJTTUJFT7ANCNFSM47FN6CLQ .

michael-simons avatar Jun 24 '21 05:06 michael-simons