Kotlin with JPA: default constructor hell

2019-01-16 13:59发布

问题:

As JPA requires, @Entity classes should have a default (non-arg) constructor to instantiate the objects when retrieving them from the database.

In Kotlin, properties are very convenient to declare within the primary constructor, as in the following example:

class Person(val name: String, val age: Int) { /* ... */ }

But when the non-arg constructor is declared as a secondary one it requires values for the primary constructor to be passed, so some valid values are needed for them, like here:

@Entity
class Person(val name: String, val age: Int) {
    private constructor(): this("", 0)
}

In case when the properties have some more complex type than just String and Int and they're non-nullable, it looks totally bad to provide the values for them, especially when there's much code in primary constructor and init blocks and when the parameters are actively used -- when they're to be reassigned through reflection most of the code is going to be executed again.

Moreover, val-properties cannot be reassigned after the constructor executes, so immutability is also lost.

So the question is: how can Kotlin code be adapted to work with JPA without code duplication, choosing "magic" initial values and loss of immutability?

P.S. Is it true that Hibernate aside of JPA can construct objects with no default constructor?

回答1:

As of Kotlin 1.0.6, the kotlin-noarg compiler plugin generates synthetic default construtors for classes that have been annotated with selected annotations.

If you use gradle, applying the kotlin-jpa plugin is enough to generate default constructors for classes annotated with @Entity:

buildscript {
    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-noarg:$kotlin_version"
    }
}

apply plugin: "kotlin-jpa"

For Maven:

<plugin>
    <artifactId>kotlin-maven-plugin</artifactId>
    <groupId>org.jetbrains.kotlin</groupId>
    <version>${kotlin.version}</version>

    <configuration>
        <compilerPlugins>
            <plugin>jpa</plugin>
        </compilerPlugins>
    </configuration>

    <dependencies>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-maven-noarg</artifactId>
            <version>${kotlin.version}</version>
        </dependency>
    </dependencies>
</plugin>


回答2:

just provide default values for all arguments, Kotlin will make default constructor for you.

@Entity
data class Person(val name: String="", val age: Int=0)

see the NOTE box below the following section:

https://kotlinlang.org/docs/reference/classes.html#secondary-constructors



回答3:

@D3xter has a good answer for one model, the other is a newer feature in Kotlin called lateinit:

class Entity() {
    constructor(name: String, age: Date): this() {
        this.name = name
        this.birthdate = age
    }

    lateinit var name: String
    lateinit var birthdate: Date
}

You would use this when you are sure something will fill in the values at construction time or very soon after (and before first use of the instance).

You will note I changed age to birthdate because you cannot use primitive values with lateinit and they also for the moment must be var (restriction might be released in the future).

So not a perfect answer for immutability, same problem as the other answer in that regard. The solution for that is plugins to libraries that can handle understanding the Kotlin constructor and mapping properties to constructor parameters, instead of requiring a default constructor. The Kotlin module for Jackson does this, so it is clearly possible.

See also: https://stackoverflow.com/a/34624907/3679676 for exploration of similar options.



回答4:

@Entity data class Person(/*@Id @GeneratedValue var id: Long? = null,*/
                          var name: String? = null,
                          var age: Int? = null)

Initial values are requires if you want reuse constructor for different fields, kotlin doesn't allowed nulls. So whenever you planning omit field, use this form in constructor: var field: Type? = defaultValue

jpa required no argument constructor:

val entity = Person() // Person(name=null, age=null)

there is no code duplication. If you need construct entity and only setup age, use this form:

val entity = Person(age = 33) // Person(name=null, age=33)

there is no magic (just read documentation)



回答5:

There is no way to keep immutability like this. Vals MUST be initialized when constructing the instance.

One way to do it without immutability is:

class Entity() {
    public constructor(name: String, age: Int): this() {        
        this.name = name
        this.age = age
    }

    public var name: String by Delegates.notNull()

    public var age: Int by Delegates.notNull()
}


回答6:

I have been working with Kotlin + JPA for quite a while and I have created my own idea how to write Entity classes.

I just slightly extend your initial idea. As you said we can create private argumentless constructor and provide default values for primitives, but when we try need to use another classes it gets a little messy. My idea is to create static STUB object for entity class that you currently writes e.g:

@Entity
data class TestEntity(
    val name: String,
    @Id @GeneratedValue val id: Int? = null
) {
    private constructor() : this("")

    companion object {
        val STUB = TestEntity()
    }
}

and when I have entity class that is related to TestEntity I can easily use stub I just have created. For example:

@Entity
data class RelatedEntity(
        val testEntity: TestEntity,
        @Id @GeneratedValue val id: Long? = null
) {
    private constructor() : this(TestEntity.STUB)

    companion object {
        val STUB = RelatedEntity()
    }
}

Of course this solution is not perfect. You still need to create some boilerplate code that should not be required. Also there is one case that cannot be solved nicely with stubbing - parent-child relation within one entity class - like this:

@Entity
data class TestEntity(
        val testEntity: TestEntity,
        @Id @GeneratedValue val id: Long? = null
) {
    private constructor() : this(STUB)

    companion object {
        val STUB = TestEntity()
    }
}

This code will produce NullPointerException due to chicken-egg issue - we need STUB to create STUB. Unfortunately we need to make this field nullable (or some similar solution) to make code works.

Also in my opinion having Id as last field (and nullable) is quite optimal. We shouldn't assign it by hand and let database do it for us.

I'm not saying that this is perfect solution, but I think that it leverages entity code readability and Kotlin features (e.g. null safety). I just hope future releases of JPA and/or Kotlin will make our code even more simpler and nicer.



回答7:

Similar to @pawelbial I've used companion object to create a default instance, however instead of defining a secondary constructor, just use default constructor args like @iolo. This saves you having to define multiple constructors and keeps the code simpler (although granted, defining "STUB" companion objects isn't exactly keeping it simple)

@Entity
data class TestEntity(
    val name: String = "",
    @Id @GeneratedValue val id: Int? = null
) {

    companion object {
        val STUB = TestEntity()
    }
}

And then for classes which relate to TestEntity

@Entity
data class RelatedEntity(
    val testEntity: TestEntity = TestEntity:STUB,
    @Id @GeneratedValue val id: Int? = null
)

As @pawelbial has mentioned, this won't work where the TestEntity class "has a" TestEntity class since STUB won't have been initialised when the constructor is run.



回答8:

I'm a nub myself but seems you have to explicit initializer and fallback to null value like this

@Entity
class Person(val name: String? = null, val age: Int? = null)


回答9:

These Gradle build lines helped me:
https://plugins.gradle.org/plugin/org.jetbrains.kotlin.plugin.jpa/1.1.50.
At least, it builds in IntelliJ. It's failing on the command line at the moment.

And I have a

class LtreeType : UserType

and

    @Column(name = "path", nullable = false, columnDefinition = "ltree")
    @Type(type = "com.tgt.unitplanning.data.LtreeType")
    var path: String

var path: LtreeType did not work.