Search code examples
mongodbserializationspring-data-mongodbjava-time

How to save a java.time.Instant in mongodb and load the same value out without exception?


I would like a java.time.Instant to have the same value going in and out of mongo:

data class Person(@Id val name: String, val born: Instant)

@ExtendWith(SpringExtension::class)
@SpringBootTest
class MongoMappingTest(@Autowired private val mongoTemplate: MongoTemplate)
{
    @Test
    fun `Test custom mapping of the Instant data type`()
    {
        testFor(Instant.MAX)   // This throws java.lang.ArithmeticException
                               // Failed to convert from type [java.time.Instant] to type [java.util.Date]

        testFor(Instant.now()) // This fails with:
                               // Expected :Person(name=Jim, born=2021-04-07T16:56:10.838Z)
                               // Actual   :Person(name=Jim, born=2021-04-07T16:56:10.838228297Z)
    }

    fun testFor(instant: Instant)
    {
        val jim = Person("Jim", instant)
        mongoTemplate.save(jim)
        val jimOut = mongoTemplate.findById("Jim", Person::class.java)
        Assertions.assertThat(jim).isEqualTo(jimOut)
    }
}

The Instant first gets convereted to java.util.Date (no idea why this is happening), which in turn gets converted to the BSON Date data type. The latter has only milisecond precision, so we need to abandon it. I made my own representation and I am trying to substitute it (following the docs):

data class InstantRepresentation
(
    val seconds: Long, val nanoseconds: Int
)

class InstantWriteConverter: Converter<Instant, InstantRepresentation>
{
    override fun convert(source: Instant): InstantRepresentation
    {
        return InstantRepresentation(source.epochSecond, source.nano)
    }
}

class InstantReadConverter: Converter<InstantRepresentation, Instant>
{
    override fun convert(source: InstantRepresentation): Instant?
    {
        return Instant.ofEpochSecond (source.seconds, source.nanoseconds.toLong())
    }
}

@Configuration
class MongoConfig: AbstractMongoClientConfiguration()
{
    public override fun getDatabaseName(): String
    {
        return "db-test"
    }


    public override fun configureConverters(adapter: MongoConverterConfigurationAdapter)
    {
        adapter.registerConverter(InstantWriteConverter())
        adapter.registerConverter(InstantReadConverter())
    }
}

However, nothing has changed. My conversion code is not picked up. It seems that spring is still trying to convert my Instant into a java.util.Date.

I tried to annotate Person.born with @Field(targetType = FieldType.IMPLICIT) to tell the system that I want to store it as an object, but it didn't help.


Solution

  • This is the solution:

    val secondsFieldName = "seconds"
    val nanoSecondsFieldname = "nanoseconds"
    
    @WritingConverter
    class InstantWriteConverter: Converter<Instant, Document>
    {
        override fun convert(source: Instant): Document
        {
            return Document(mapOf(
                    secondsFieldName to source.epochSecond, 
                    nanoSecondsFieldname to source.nano.toLong()))
        }
    }
    
    @ReadingConverter
    class InstantReadConverter: Converter<Document, Instant>
    {
        override fun convert(source: Document): Instant
        {
            return Instant.ofEpochSecond (
                    source[secondsFieldName] as Long, 
                    source[nanoSecondsFieldname] as Long)
        }
    }
    
    @Configuration
    class MongoConfig
    {
        @Bean
        fun customConversions() = MongoCustomConversions(listOf(
                   InstantWriteConverter(), 
                   InstantReadConverter()))
    }
    

    Main takeaway:

    You cannot convert your type to whatever you like. org.bson.Document works. Some other classes work too, but it is not clear which ones or which ones you should use.