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

Viewed 993

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.

2 Answers

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.

Trying to save a person born in year 1 000 000 000 makes no sense. Don’t do that. Have your constructor validate the birth date and reject one that is in the future and one that is more than 20 000 000 years ago. No human was born 20 million years ago.

For the precision: Converting to java.util.Date is probably a leftover from way back. If the database itself has higher precision than milliseconds, this should be considered a bug. Can you get a newer version of the JDBC driver? In the meantime a hack of a workaround would be to truncate the value in Java before saving it:

   born = born.truncatedTo(ChronoUnit.MILLIS);
Related