1. Surgery on JOOQ generated POJO
1.1. Introduction to the POJO and the Strategy of the Surgery
We have set jooq to generate pojo file with withJpaAnnotation.
A typical example of a JOOQ generated pojo:
Copy 1 @Suppress ( "UNCHECKED_CAST" )
2 @Entity
3 @Table (
4 name = "Order" ,
5 schema = "public" ,
6 indexes = [
7 Index ( name = "Order_id_userEmail_idx" , columnList = "id ASC, userEmail ASC" )
8 ]
9 )
10 data class Order (
11 @get:Id
12 @get:Column ( name = "id" )
13 var id : UUID ? = null ,
14 @get:Column ( name = "error" )
15 var error : String ? = null ,
16 @get:Column ( name = "status" )
17 var status : Status ? = null ,
18 @get:Column ( name = "succeededAt" )
19 var succeededat : Double ? = null ,
20 @get:Column ( name = "failedAt" )
21 var failedat : Double ? = null ,
22 @get:Column ( name = "userEmail" , nullable = false )
23 var useremail : String ,
24 @get:Column ( name = "orderType" )
25 var ordertype : Ordertype ? = null ,
26 @get:Column ( name = "createdAt" )
27 var createdat : Double ? = null ,
28 @get:Column ( name = "createdAtHK" )
29 var createdathk : String ? = null
30 ) : Serializable {
31 .. .
32 }
Show More (32 lines)
What we will be automating:
1.2. Sample Result After Surgery
Copy 1 import com . billie . db . enums . Ordertype
2 import com . billie . db . enums . Status
3 import org . hibernate . annotations . DynamicInsert
4 import org . hibernate . dialect . PostgreSQLEnumJdbcType
5 import org . hibernate . annotations . JdbcType
6 import jakarta . persistence . Column
7 import jakarta . persistence . Entity
8 import jakarta . persistence . Enumerated
9 import jakarta . persistence . EnumType
10 import jakarta . persistence . Id
11 import jakarta . persistence . Table
12 import java . util . UUID
13
14 @Entity
15 @DynamicInsert
16 @Table (
17 name = "Order" ,
18 schema = "public"
19 )
20 class OrderEntity_ (
21 @Id
22 @Column ( name = "id" )
23 var id : UUID ? = null ,
24 @Column ( name = "error" )
25 var error : String ? = null ,
26 @Column ( name = "status" )
27 @Enumerated ( EnumType . STRING )
28 @JdbcType ( PostgreSQLEnumJdbcType :: class )
29 var status : Status ? = null ,
30 @Column ( name = "succeededAt" )
31 var succeededat : Double ? = null ,
32 @Column ( name = "failedAt" )
33 var failedat : Double ? = null ,
34 @Column ( name = "userEmail" , nullable = false )
35 var useremail : String ,
36 @Column ( name = "orderType" )
37 @Enumerated ( EnumType . STRING )
38 @JdbcType ( PostgreSQLEnumJdbcType :: class )
39 var ordertype : Ordertype ? = null ,
40 @Column ( name = "createdAt" )
41 var createdat : Double ? = null ,
42 @Column ( name = "createdAtHK" )
43 var createdathk : String ? = null
44 )
Show More (44 lines)
2. Execution of the Surgery via Customized Gradle Task in build.gradle.kts
2.1. The Original Configuration of JOOQ Generation Task
Copy 1 tasks . create ( "generate" ) {
2 val pojoDir = File ( " $ projectDir /src/main/kotlin/com/billie/db/tables/pojos" )
3 val preEntityDir = File ( " $ projectDir /src/main/kotlin/com/billie/db/tables/preentities" )
4
5 if ( pojoDir . exists ( ) ) {
6 pojoDir . deleteRecursively ( )
7 }
8 if ( preEntityDir . exists ( ) ) {
9 preEntityDir . deleteRecursively ( )
10 }
11
12 GenerationTool . generate (
13 Configuration ( )
14 . withJdbc (
15 Jdbc ( )
16 . withDriver ( "org.postgresql.Driver" )
17 . withUrl ( "xxx" )
18 . withUser ( "james.lee" )
19 . withPassword ( "xxx" )
20 )
21 . withGenerator (
22 Generator ( )
23 . withName ( "org.jooq.codegen.KotlinGenerator" )
24 . withDatabase (
25 Database ( )
26 . withInputSchema ( "public" )
27 . withExcludes ( "pgp_armor_headers" )
28 )
29 . withGenerate (
30 Generate ( )
31 . withPojos ( true )
32 . withDaos ( true )
33 . withSpringAnnotations ( true )
34 . withJpaAnnotations ( true )
35 . withKotlinNotNullPojoAttributes ( true )
36 . withKotlinDefaultedNullablePojoAttributes ( true )
37 )
38 . withTarget (
39 Target ( )
40 . withPackageName ( " $ srcPackage .db" )
41 . withDirectory ( " $ projectDir /src/main/kotlin" )
42 )
43 )
44 )
45 adjustJooqFilesForJPA ( pojoDir = pojoDir ,
46 preEntityDir = preEntityDir )
47 }
Show More (47 lines)
The adjustJooqFilesForJPA will
Copy the pojos to another folder
Modify every file in that new folder
Here we copy all pojos/XXX.kt into preentities/XXXEntity_.kt.
We then do the text manipulation in adjustJooqFilesForJPA to simplify the jooq's @Entity classes
We later copy the whole definition by creating our AbstractAggregateRoot where
Copy 1 fun getEnumList ( ) : Sequence < String > {
2 val enumDir = File ( " $ projectDir /src/main/kotlin/com/billie/db/enums" )
3
4 val enumNameList = enumDir . walkTopDown ( )
5 . filter { it . isFile && it . extension == "kt" }
6 . map { file -> file . name . replace ( ".kt" , "" ) }
7 return enumNameList
8 }
9
10 fun adjustJooqFilesForJPA ( pojoDir : File , preEntityDir : File ) {
11 if ( pojoDir . exists ( ) ) {
12 val enumNameList = getEnumList ( )
13 preEntityDir . deleteRecursively ( )
14 pojoDir . copyRecursively ( preEntityDir )
15
16 preEntityDir . walkTopDown ( ) . filter { it . isFile && it . extension == "kt" } . forEach { file ->
17 val content = file . readText ( )
18
19 val modifiedContent = content
20 . replace ( "package com.billie.db.tables.pojos" , "package com.billie.db.tables.preentities" )
21 . replace ( Regex ( "^(data )?class " , RegexOption . MULTILINE ) ,
22 "open class " )
23 . replace ( Regex ( """open class (\w+)\(""" ) ,
24 """class $1Entity_\(""" )
25 . replace ( Regex ( """(@get:.+\n\s*)var""" , RegexOption . MULTILINE ) ,
26 "$1 var" )
27 . replace ( "import jakarta.persistence.GeneratedValue" ,
28 "" )
29 . replace ( "import jakarta.persistence.GenerationType" ,
30 "" )
31 . replace ( Regex ( """indexes\s+=.*?\]""" , RegexOption . DOT_MATCHES_ALL ) , "" )
32 . replace ( Regex ( """import jakarta.persistence.Entity""" . trimIndent ( ) ) ,
33 """
34 import jakarta.persistence.Entity
35 import jakarta.persistence.MappedSuperclass
36 import jakarta.persistence.Enumerated
37 import jakarta.persistence.EnumType
38 import jakarta.persistence.Convert
39 import jakarta.persistence.GeneratedValue
40 import jakarta.persistence.GenerationType
41 import org.hibernate.annotations.DynamicInsert
42 import org.hibernate.dialect.PostgreSQLEnumJdbcType
43 import org.hibernate.annotations.JdbcType
44 """ . trimIndent ( ) . trimMargin ( ) )
45 . replace ( Regex ( """: Serializable""" , RegexOption . DOT_MATCHES_ALL ) ,
46 "" )
47 . replace ( Regex ( """@get:""" , RegexOption . MULTILINE ) ,
48 "@" )
49 . replace ( Regex ( """@Column\(name = "id"\).*?open var id: UUID\? = null""" , RegexOption . DOT_MATCHES_ALL ) ,
50 """
51 |@Column(name = "id")
52 | @GeneratedValue(generator = "ulid_as_uuid")
53 | var id: UUID? = null
54 """ . trimMargin ( )
55 )
56 . replace ( "@Entity" , """
57 @Entity
58 @DynamicInsert
59 """ . trimIndent ( ) )
60 . replace ( "@Generate()" , "" )
61 . replace ( "var id: Int? = null" ,
62 """
63 @Generate()
64 | var id: Int? = null
65 """ . trimIndent ( ) . trimMargin ( ) )
66 . replace ( Regex ( """\{.*\}""" , RegexOption . DOT_MATCHES_ALL ) , "" )
67 . split ( "\n" )
68 . map { line ->
69 val isEnumDeclared = enumNameList . any { cls ->
70 val enumFound = line . indexOf ( ": $ cls " ) > - 1
71 enumFound
72 }
73 if ( isEnumDeclared ) {
74 """
75 | <<enum_annotations>>
76 $ line
77 """ . trimIndent ( ) . trimMargin ( )
78 } else {
79 line
80 }
81 } . joinToString ( "\n" )
82 . replace ( "<<enum_annotations>>" ,
83 "@Enumerated(EnumType.STRING)\n @JdbcType(PostgreSQLEnumJdbcType::class)" )
84
85 if ( content != modifiedContent ) {
86 file . writeText ( modifiedContent )
87 val newFilepath = " ${ file . parent } / ${ file . nameWithoutExtension } Entity_.kt"
88 file . renameTo ( File ( newFilepath ) )
89 }
90 }
91 } else {
92 println ( "Source POJO folder does not exist" )
93 }
94 }
Show More (94 lines)
3. How to Create AbstractAggregateRoot?
3.1. OrderEntity extending AbstractAggregateRoot<OrderEntity>
Let's consider the following Order aggregate:
Now we copy the definition of simplified @Entity classes and create a domain object:
Copy 1 @Entity
2 @DynamicInsert
3 @Table ( name = "Order" , schema = "public" )
4 class OrderEntity (
5 @Id
6 @Column ( name = "id" )
7 @GeneratedValue ( generator = "ulid_as_uuid" )
8 var id : UUID ? = null ,
9 @Column ( name = "error" )
10 var error : String ? = null ,
11 @Column ( name = "status" )
12 @Enumerated ( EnumType . STRING )
13 @JdbcType ( PostgreSQLEnumJdbcType :: class )
14 var status : Status ? = null ,
15 @Column ( name = "succeededAt" )
16 var succeededat : Double ? = null ,
17 @Column ( name = "failedAt" )
18 var failedat : Double ? = null ,
19 @Column ( name = "userEmail" , nullable = false )
20 var useremail : String ,
21 @Column ( name = "orderType" )
22 @Enumerated ( EnumType . STRING )
23 @JdbcType ( PostgreSQLEnumJdbcType :: class )
24 var ordertype : Ordertype ? = null ,
25 @Column ( name = "createdAt" )
26 var createdat : Double ? = null ,
27 @Column ( name = "createdAtHK" )
28 var createdathk : String ? = null ,
29 ) : AbstractAggregateRoot < OrderEntity > ( ) {
30
31 @OneToOne ( mappedBy = "orderEntity" , fetch = FetchType . LAZY , cascade = [ CascadeType . PERSIST ] )
32 @JsonManagedReference
33 var orderStripe : OrderStripeEntity ? = null
34
35 @OneToOne ( mappedBy = "orderEntity" , fetch = FetchType . LAZY , cascade = [ CascadeType . PERSIST ] )
36 @JsonManagedReference
37 var orderMobile : OrderMobileEntity ? = null
38
39 fun updateStripeOrder ( stripeOrder : OrderStripe ) {
40 registerEvent ( StripeOrderUpdatedEvent ( stripeOrder ) )
41 }
42
43 fun updateOrderDetailSubscriptionId ( subscriptionId : String ) {
44 registerEvent ( SubscriptionCreatedEvent ( orderId = this . id !! , subscriptionId = subscriptionId ) )
45 }
46
47 fun updateOrderSucceededInfo ( ) {
48 this . status = Status . SUCCEEDED
49 this . succeededat = DateTime ( ) . millis . toDouble ( )
50 registerEvent (
51 OrderSucceededEvent ( orderId = this . id !! ,
52 succeededAt = this . succeededat !! )
53 )
54 }
55
56 fun createCheckoutSession (
57 customerStripeId : String ,
58 productName : String ,
59 numOfPersons : Int ,
60 targetPriceId : String ,
61 ) {
62 val orderId = this . id !!
63 registerEvent ( CreateStripeSessionCommand ( orderId ,
64 customerStripeId ,
65 productName ,
66 numOfPersons ,
67 targetPriceId ) )
68 }
69
70 fun appleOrderSucceeded ( ) {
71 val orderId = this . id !!
72 registerEvent ( AppleOrderSucceededEvent ( orderId ) )
73 }
74 }
Show More (74 lines)
3.2. OrderMobileEntity (Anemic Model)
Copy 1 @Entity
2 @DynamicInsert
3 @Table ( name = "Order_Mobile" , schema = "public" )
4 class OrderMobileEntity (
5 @Id
6 @Column ( name = "id" )
7 var id : UUID ? = null ,
8 @Column ( name = "orderId" , nullable = false )
9 var orderid : UUID ,
10 @Column ( name = "period" , nullable = false )
11 @Enumerated ( EnumType . STRING )
12 @JdbcType ( PostgreSQLEnumJdbcType :: class )
13 var period : Period ,
14 @Column ( name = "platform" , nullable = false )
15 @Enumerated ( EnumType . STRING )
16 @JdbcType ( PostgreSQLEnumJdbcType :: class )
17 var platform : Platform ,
18 @Column ( name = "originalAppUserId" , nullable = false )
19 var originalappuserid : String ,
20 @Column ( name = "userEmail" , nullable = false )
21 var useremail : String ,
22 ) {
23 @OneToOne ( fetch = FetchType . LAZY )
24 @JoinColumn ( name = "orderId" , referencedColumnName = "id" , insertable = false , updatable = false )
25 @JsonBackReference
26 var orderEntity : OrderEntity ? = null
27 }
3.3. OrderStripeEntity (Anemic Model)
Copy 1 @Entity
2 @DynamicInsert
3 @Table ( name = "Order_Stripe" , schema = "public" )
4 class OrderStripeEntity (
5 @Id
6 @Column ( name = "id" )
7 var id : UUID ? = null ,
8 @Column ( name = "stripeSessionId" )
9 var stripesessionid : String ? = null ,
10 @Column ( name = "actionType" , nullable = false )
11 @Enumerated ( EnumType . STRING )
12 @JdbcType ( PostgreSQLEnumJdbcType :: class )
13 var actiontype : Actiontype ,
14 @Column ( name = "numOfPersons" , nullable = false )
15 var numofpersons : Int ,
16 @Column ( name = "subscriptionId" )
17 var subscriptionid : String ? = null ,
18 @Column ( name = "actionTargetSeatId" )
19 var actiontargetseatid : Int ? = null ,
20 @Column ( name = "quota_SeatId" )
21 var quotaSeatid : Int ? = null ,
22 @Column ( name = "orderId" , nullable = false )
23 var orderid : UUID ,
24 ) {
25 @OneToOne ( fetch = FetchType . LAZY )
26 @JoinColumn ( name = "orderId" , referencedColumnName = "id" , insertable = false , updatable = false )
27 @JsonBackReference
28 private val orderEntity : OrderEntity ? = null
29 }
3.4. Finally, Avoid Back Reference that Causes Infinite Loop in Data Serialization
In short
inside of aggregate root we annotate subaggregate/subdomain object by @JsonManagedReference.
inside of subdomain object we add @JsonBackReference to the backward reference.
Copy 1 import com . fasterxml . jackson . annotation . JsonManagedReference
2 import com . fasterxml . jackson . annotation . JsonBackReference
3
4 class Parent {
5 @OneToMany ( mappedBy = "parent" )
6 @JsonManagedReference
7 val children : List < Child > = mutableListOf ( )
8 }
9
10 class Child {
11 @ManyToOne // so is @OneToOne
12 @JsonBackReference
13 lateinit var parent : Parent
14 }
4.1. The naming convention of findByXXX
For a complete of convention please visit the
Let's look at our entity class:
Copy 1 @Entity
2 @DynamicInsert
3 @Table ( name = "Quota_FreeQuotaRecord" , schema = "public" )
4 class QuotaFreeEntity (
5 @Id
6 @Column ( name = "id" )
7 @Generate ( )
8 var id : Int ? = null ,
9 @Column ( name = "userEmail" , nullable = false )
10 var useremail : String ,
11 @Column ( name = "audioUsed" , nullable = false )
12 var audioused : Double ,
13 @Column ( name = "summaryUsed" , nullable = false )
14 var summaryused : Int ,
15 ) : AbstractAggregateRoot < QuotaFreeEntity > ( ) {
16 fun notifyFreeQuotaCreated ( ) {
17 val event = FreeQuotaCreatedEvent ( this . useremail )
18 registerEvent ( event )
19 }
20
21 fun increaseSummaryCount ( ) {
22 this . summaryused = ( this . summaryused ?: 0 ) + 1
23 registerEvent ( FreeQuotaSummaryCountedEvent ( this . id !! ) )
24 }
25 }
Note that our member name is useremail, we capitalize the first letter to get:
Copy 1 @Repository
2 interface FreeQuotaJpaRepository : JpaRepository < QuotaFreeEntity , Int > {
3 fun findByUseremail ( email : String ) : QuotaFreeEntity ?
4 }
the custom repository method depends on the member name of our entity class but not on the actual column name .
4.2. When Tables and Columns are not Named in Snake Case
Since we use camel case in table and column name instead of lower-letter snake case which jpa recognizes by default, in every query we have to enclose every single occurence of table and column name by two double quote "'s.
We archive this by setting custom naming strategy for jpa:
Copy 1 package com . billie . payment . config . jooq
2
3 import org . hibernate . boot . model . naming . Identifier
4 import org . hibernate . boot . model . naming . PhysicalNamingStrategy
5 import org . hibernate . engine . jdbc . env . spi . JdbcEnvironment
6
7 class QuotedIdentifiersNamingStrategy : PhysicalNamingStrategy {
8
9 override fun toPhysicalCatalogName ( name : Identifier ? , context : JdbcEnvironment ? ) : Identifier ? {
10 return name ? . let { addQuotes ( it ) }
11 }
12
13 override fun toPhysicalSchemaName ( name : Identifier ? , context : JdbcEnvironment ? ) : Identifier ? {
14 return name ? . let { addQuotes ( it ) }
15 }
16
17 override fun toPhysicalTableName ( name : Identifier ? , context : JdbcEnvironment ? ) : Identifier ? {
18 return name ? . let { addQuotes ( it ) }
19 }
20
21 override fun toPhysicalSequenceName ( name : Identifier ? , context : JdbcEnvironment ? ) : Identifier ? {
22 return name ? . let { addQuotes ( it ) }
23 }
24
25 override fun toPhysicalColumnName ( name : Identifier ? , context : JdbcEnvironment ? ) : Identifier ? {
26 return name ? . let { addQuotes ( it ) }
27 }
28
29 private fun addQuotes ( id : Identifier ) : Identifier {
30 return Identifier . quote ( id )
31 }
32 }
Show More (32 lines)
Next we instruct hibernate to enclose all names by double quote:
Copy 1 spring :
2 jpa :
3 hibernate :
4 naming :
5 physical-strategy : com.billie.payment.config.jooq.QuotedIdentifiersNamingStrategy
6 properties :
7 hibernate :
8 globally_quoted_identifiers : true
9 type :
10 EnumType : STRING
4.3.1. Set the stage in our test:
Copy 1 @SpringBootTest
2 class RepositoryTest {
3
4 @Autowired
5 private lateinit var orderMobileJpaRepository : OrderMobileJpaRepository
6
7 @Autowired
8 private lateinit var orderJpaRepository : OrderJpaRepository
9
10 init {
11 System . setProperty ( "spring.profiles.active" , "uat,james_db_and_james_stripe" )
12 }
4.3.2. Test if we are abole to persist an entity:
Copy 14 @Test
15 fun `repository save` ( ) {
16 val orderEntity = OrderEntity ( useremail = "james.lee@wonderbricks.com" )
17 orderEntity . ordertype = Ordertype . MOBILE
18 orderJpaRepository . save ( orderEntity )
19 val orderMobileEntity = OrderMobileEntity ( orderid = orderEntity . id !! ,
20 period = Period . MONTHLY ,
21 platform = Platform . IOS ,
22 originalappuserid = "123" ,
23 useremail = orderEntity . useremail )
24 orderMobileJpaRepository . save ( orderMobileEntity )
25 }
4.3.3. Test if the "back-reference" works, and test if the domain event can be caught by @EventListener.
Copy 39 @Test
40 fun `repository get` ( ) {
41 val orderEntity = orderJpaRepository . findByIdOrNull ( UUID . fromString ( "77d8fd43-b780-4116-9b8e-dc4d032a3754" ) )
42 val orderMobileEntity = orderEntity ? . orderMobile
43 val theParentEntity = orderMobileEntity ? . orderEntity // this is the same as orderEntity in the first line
44 theParentEntity ? . updateOrderSucceededInfo ( )
45 orderJpaRepository . save ( theParentEntity !! ) // successfully dispatch an event and we get the event from event handler
46
47 println ( orderMobileEntity )
48 println ( theParentEntity )
49 }
50 }
4.4. Example of Adding Domain Behaviour and How it Actually Works with Our Controller
Copy 1 data class SeatSummaryCountedEvent (
2 val entity : QuotaSeatCounterEntity ,
3 )
4
5 @Entity
6 @DynamicInsert
7 @Table ( name = "Quota_UsageCounter" , schema = "public" )
8 class QuotaSeatCounterEntity (
9 override var seatid : Int ,
10 override var audioused : Double ,
11 override var duedate : Double ,
12 override var startdate : Double ,
13 ) : IDomainModel , QuotaUsagecounterPreEntity (
14 seatid = seatid ,
15 audioused = audioused ,
16 duedate = duedate ,
17 startdate = startdate
18 ) {
19
20 @Transient
21 override var domainEvents : MutableList < Any > ? = null
22
23 @ManyToOne ( fetch = FetchType . LAZY )
24 @JoinColumn ( name = "seatId" , referencedColumnName = "id" , insertable = false , updatable = false )
25 @JsonBackReference
26 var seat : QuotaSeatEntity ? = null
27
28 fun increaseSummaryCount ( ) {
29 this . summarygenerated = ( this . summarygenerated ?: 0 ) + 1
30 val event = SeatSummaryCountedEvent ( this )
31 registerEvent ( event )
32 }
33 }
Show More (33 lines)
4.4.2. The Controller Method
The highlighted demonstrates the state change can be managemented by the entity itself.
In the past without ORM we have to handle state change in eventListener. With ORM we can now arrange all the in-memory change, and let jpa figure out and persist the changes by repo.save(entity).
By repo.save(), jpa will look at the member annotated by @DomainEvents, then dispatch each event synchronously via ApplicationEventPublisher.
Copy 1 @PostMapping ( "/increase-summary-count" )
2 fun increaseSummaryCount ( @RequestBody reduceQuotaDto : IncreaseSummaryCountRequest ) : Response . Success < IncreaseSummaryCountResponse > {
3 val user = UserContext . instance . getUser ( )
4 val ( counterId ) = reduceQuotaDto
5 if ( counterId == null ) {
6 val freeQuota = freeQuotaJpaRepository . findByUseremail ( user . email ) ?: throw Exception ( "free quota has not created" )
7 freeQuota . increaseSummaryCount ( )
8 freeQuotaJpaRepository . save ( freeQuota )
9 }
10 val counter = seatCounterJpaRepository . findByIdOrNull ( counterId ) ?: throw Exception ( "counter not found" )
11 counter . increaseSummaryCount ( )
12 seatCounterJpaRepository . save ( counter )
13 return Response . Success ( result = IncreaseSummaryCountResponse ( counterId ) )
14 }
Therefore when we execute repo.save() method, we are actually doing:
To play safe just list out all the packages where our entity class live in.
Copy 1 @EntityScan ( basePackages = [
2 "com.billie.db" ,
3 "com.billie.payment"
4 ] )
5 class PaymentApplication {
6 .. .
7 }