├── .gitignore ├── src ├── test │ ├── resources │ │ └── container-license-acceptance.txt │ └── kotlin │ │ └── com │ │ └── github │ │ └── vokorm │ │ ├── UtilsTest.kt │ │ ├── ConditionBuilderTest.kt │ │ ├── ValidationTest.kt │ │ ├── ConnectionUtilsTest.kt │ │ ├── AbstractDatabaseTests.kt │ │ ├── Person.kt │ │ ├── H2DatabaseTest.kt │ │ ├── TestUtils.kt │ │ ├── MariadbDatabaseTest.kt │ │ ├── Databases.kt │ │ ├── MysqlDatabaseTest.kt │ │ ├── PosgresqlDatabaseTest.kt │ │ ├── CockroachDatabaseTest.kt │ │ ├── MssqlDatabaseTest.kt │ │ ├── AbstractFiltersTest.kt │ │ ├── AbstractDbMappingTest.kt │ │ └── AbstractDbDaoTests.kt └── main │ └── kotlin │ └── com │ └── github │ └── vokorm │ ├── VokOrm.kt │ ├── DB.kt │ ├── ConnectionUtils.kt │ ├── Utils.kt │ ├── ConditionBuilder.kt │ ├── Dao.kt │ └── KEntity.kt ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── .github └── workflows │ └── gradle.yml ├── LICENSE ├── gradlew.bat ├── gradlew └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | out 4 | build 5 | .gradle 6 | classes 7 | local.properties 8 | 9 | -------------------------------------------------------------------------------- /src/test/resources/container-license-acceptance.txt: -------------------------------------------------------------------------------- 1 | mcr.microsoft.com/mssql/server:2017-latest-ubuntu -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvysny/vok-orm/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/vokorm/VokOrm.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | /** 4 | * Global configuration for vok-orm. For the JDBC configuration please see 5 | * [com.gitlab.mvysny.jdbiorm.JdbiOrm]. 6 | * @author mavi 7 | */ 8 | public object VokOrm {} 9 | -------------------------------------------------------------------------------- /src/test/kotlin/com/github/vokorm/UtilsTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import org.junit.jupiter.api.Test 4 | import kotlin.test.expect 5 | 6 | class UtilsTest { 7 | @Test fun implements() { 8 | expect(true) { Person::class.java.implements(KEntity::class.java) } 9 | expect(false) { String::class.java.implements(KEntity::class.java) } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/gradle.yml: -------------------------------------------------------------------------------- 1 | name: Gradle 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest, windows-latest] 11 | java: [17, 21] 12 | 13 | runs-on: ${{ matrix.os }} 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up JDK ${{ matrix.java }} 18 | uses: actions/setup-java@v4 19 | with: 20 | java-version: ${{ matrix.java }} 21 | distribution: 'corretto' 22 | - name: Cache Gradle packages 23 | uses: actions/cache@v4 24 | with: 25 | path: | 26 | ~/.gradle/caches 27 | ~/.gradle/wrapper 28 | key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts', 'gradle/wrapper/gradle-wrapper.properties', 'gradle.properties') }} 29 | - name: Build with Gradle 30 | run: ./gradlew --stacktrace --info --no-daemon 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2018 Martin Vyšný 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/test/kotlin/com/github/vokorm/ConditionBuilderTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import org.junit.jupiter.api.Test 4 | import kotlin.test.expect 5 | 6 | class ConditionBuilderTest { 7 | @Test fun `smoke API tests`() { 8 | buildCondition { Person::name eq "foo" } 9 | buildCondition { !(Person::name eq "foo") } 10 | buildCondition { (Person::name eq "foo") and (Person::id gt 25)} 11 | buildCondition { (Person::name eq "foo") or (Person::id gt 25)} 12 | } 13 | @Test fun `produced condition`() { 14 | expect("Person.name = foo") { 15 | buildCondition { Person::name eq "foo" } .toString() 16 | } 17 | expect("NOT(Person.name = foo)") { 18 | buildCondition { !(Person::name eq "foo") } .toString() 19 | } 20 | expect("(Person.name = foo) AND (Person.id > 25)") { 21 | buildCondition { (Person::name eq "foo") and (Person::id gt 25) } .toString() 22 | } 23 | expect("(Person.name = foo) OR (Person.id > 25)") { 24 | buildCondition { (Person::name eq "foo") or (Person::id gt 25)} .toString() 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/vokorm/DB.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import com.gitlab.mvysny.jdbiorm.JdbiOrm.jdbi 4 | import org.jdbi.v3.core.Handle 5 | import java.sql.Connection 6 | 7 | /** 8 | * Provides access to a single JDBC connection and its [Handle], and several utility methods. 9 | * 10 | * The [db] function executes block in context of this class. 11 | * @property handle the reference to the JDBI's [Handle]. Typically you'd want to call [Handle.createQuery] 12 | * or [Handle.createUpdate] on the connection. 13 | * @property jdbcConnection the old-school, underlying JDBC connection. 14 | */ 15 | public class PersistenceContext(public val handle: Handle) { 16 | /** 17 | * The underlying JDBC connection. 18 | */ 19 | public val jdbcConnection: Connection get() = handle.connection 20 | } 21 | 22 | /** 23 | * Makes sure given block is executed in a DB transaction. When the block finishes normally, the transaction commits; 24 | * if the block throws any exception, the transaction is rolled back. 25 | * 26 | * Example of use: `db { con.query() }` 27 | * @param block the block to run in the transaction. Builder-style provides helpful methods and values, e.g. [PersistenceContext.handle] 28 | */ 29 | public fun db(block: PersistenceContext.()->R): R = jdbi().inTransaction { handle -> 30 | PersistenceContext(handle).block() 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/vokorm/ConnectionUtils.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import org.jdbi.v3.core.result.ResultIterator 4 | import org.jdbi.v3.core.statement.Query 5 | import java.sql.ResultSet 6 | import java.sql.ResultSetMetaData 7 | 8 | /** 9 | * Dumps the result of the query and returns it as a string formatted as follows: 10 | * ``` 11 | * id, name, age, dateOfBirth, created, modified, alive, maritalStatus 12 | * ------------------------------------------------------------------- 13 | * 1, Chuck Norris, 25, null, 2018-11-23 20:41:07.143, 2018-11-23 20:41:07.145, null, null 14 | * -------------------------------------------------------------------1 row(s) 15 | * ``` 16 | * @return a pretty-printed outcome of given select 17 | */ 18 | public fun Query.dump(): String { 19 | fun ResultSet.dumpCurrentRow(): String = (0 until metaData.columnCount).joinToString { "${getObject(it + 1)}" } 20 | 21 | val rows: ResultIterator = map { rs: ResultSet, _ -> rs.dumpCurrentRow() }.iterator() 22 | val metadata: ResultSetMetaData = rows.context.statement.metaData 23 | return buildString { 24 | 25 | // draw the header and the separator 26 | val header: String = (0 until metadata.columnCount).joinToString { metadata.getColumnName(it + 1) } 27 | appendLine(header) 28 | repeat(header.length) { append("-") } 29 | appendLine() 30 | 31 | // draw the table body 32 | var rowCount = 0 33 | rows.forEach { row: String -> rowCount++; appendLine(row) } 34 | 35 | // the bottom separator 36 | repeat(header.length) { append("-") } 37 | appendLine("$rowCount row(s)") 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/test/kotlin/com/github/vokorm/ValidationTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import com.gitlab.mvysny.jdbiorm.Entity 4 | import jakarta.validation.ValidationException 5 | import org.junit.jupiter.api.Test 6 | import org.junit.jupiter.api.assertThrows 7 | import kotlin.test.expect 8 | import kotlin.test.fail 9 | 10 | class ValidationTest : AbstractH2DatabaseTest() { 11 | // this is not really testing the database: we're testing Entity.validate(). 12 | // Therefore, it's enough to run this battery on H2 only. 13 | @Test fun `Validation on empty name fails`() { 14 | assertThrows { 15 | Person(name = "", age = 20).validate() 16 | } 17 | expect(false) { Person(name = "", age = 20).isValid } 18 | } 19 | @Test fun `Validation on non-empty name succeeds`() { 20 | Person(name = "Valid Name", age = 20).validate() 21 | expect(true) { Person(name = "Valid Name", age = 20).isValid } 22 | } 23 | @Test fun `save() fails when the bean is invalid`() { 24 | expectThrows("name: length must be between 1 and 2147483647") { 25 | Person(name = "", age = 20).save() 26 | } 27 | } 28 | @Test fun `validation is skipped when save(false) is called`() { 29 | data class ValidationAlwaysFails(private var id: Long?) : Entity { 30 | override fun setId(id: Long?) { this.id = id } 31 | override fun getId(): Long? = id 32 | override fun validate() = fail("Shouldn't be called") 33 | } 34 | db { ddl("create table ValidationAlwaysFails ( id bigint primary key auto_increment )") } 35 | ValidationAlwaysFails(null).save(false) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/test/kotlin/com/github/vokorm/ConnectionUtilsTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import org.junit.jupiter.api.Nested 4 | import org.junit.jupiter.api.Test 5 | import java.time.LocalDate 6 | import kotlin.test.expect 7 | 8 | class ConnectionUtilsTest : AbstractH2DatabaseTest() { 9 | @Nested inner class `one column` { 10 | @Test fun `empty dump`() { 11 | expect("ID\n--\n--0 row(s)\n") { db { handle.createQuery("select id from Test").dump() } } 12 | } 13 | @Test fun `two rows`() { 14 | Person(name = "Chuck", age = 25, dateOfBirth = LocalDate.of(2000, 1, 1)).save() 15 | Person(name = "Duke", age = 40, dateOfBirth = LocalDate.of(1999, 1, 1)).save() 16 | expect("NAME\n----\nChuck\nDuke\n----2 row(s)\n") { db { handle.createQuery("select name from Test").dump() } } 17 | } 18 | } 19 | 20 | @Nested inner class `multiple columns`() { 21 | @Test fun `empty dump`() { 22 | expect("""ID, NAME, AGE, DATEOFBIRTH, CREATED, MODIFIED, ALIVE, MARITALSTATUS 23 | ------------------------------------------------------------------- 24 | -------------------------------------------------------------------0 row(s) 25 | """) { db { handle.createQuery("select * from Test").dump() } } 26 | } 27 | @Test fun `two rows`() { 28 | Person(name = "Chuck", age = 25, dateOfBirth = LocalDate.of(2000, 1, 1)).save() 29 | Person(name = "Duke", age = 40, dateOfBirth = LocalDate.of(1999, 1, 1)).save() 30 | expect("""ID, NAME, AGE, DATEOFBIRTH, ALIVE, MARITALSTATUS 31 | ------------------------------------------------ 32 | 1, Chuck, 25, 2000-01-01, null, null 33 | 2, Duke, 40, 1999-01-01, null, null 34 | ------------------------------------------------2 row(s) 35 | """) { db { handle.createQuery("select id, name, age, dateofbirth, alive, maritalstatus from Test").dump() } } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/test/kotlin/com/github/vokorm/AbstractDatabaseTests.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import org.jdbi.v3.core.Handle 4 | import org.junit.jupiter.api.Nested 5 | import org.junit.jupiter.api.Test 6 | import org.junit.jupiter.api.assertThrows 7 | import java.io.IOException 8 | import kotlin.test.expect 9 | 10 | abstract class AbstractDatabaseTests(val info: DatabaseInfo) { 11 | @Nested inner class DbFunTests : AbstractDbFunTests() 12 | @Nested inner class MappingTests : AbstractDbMappingTests() 13 | @Nested inner class DbDaoTests : AbstractDbDaoTests() 14 | @Nested inner class FiltersTests : AbstractFiltersTest(info) 15 | } 16 | 17 | /** 18 | * Tests the `db{}` method whether it manages transactions properly. 19 | */ 20 | abstract class AbstractDbFunTests() { 21 | @Test fun verifyEntityManagerClosed() { 22 | val em: Handle = db { handle } 23 | expect(true) { em.connection.isClosed } 24 | } 25 | @Test fun exceptionRollsBack() { 26 | assertThrows { 27 | db { 28 | Person(name = "foo", age = 25).save() 29 | expect(listOf(25)) { db { Person.findAll().map { it.age } } } 30 | throw IOException("simulated") 31 | } 32 | } 33 | expect(listOf()) { db { Person.findAll() } } 34 | } 35 | @Test fun commitInNestedDbBlocks() { 36 | val person = db { 37 | db { 38 | db { 39 | Person(name = "foo", age = 25).apply { save() } 40 | } 41 | } 42 | } 43 | expect(listOf(person)) { db { Person.findAll() } } 44 | } 45 | @Test fun exceptionRollsBackInNestedDbBlocks() { 46 | assertThrows { 47 | db { 48 | db { 49 | db { 50 | Person(name = "foo", age = 25).save() 51 | throw IOException("simulated") 52 | } 53 | } 54 | } 55 | } 56 | expect(listOf()) { Person.findAll() } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/test/kotlin/com/github/vokorm/Person.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import com.gitlab.mvysny.jdbiorm.Dao 4 | import com.gitlab.mvysny.jdbiorm.Ignore 5 | import com.gitlab.mvysny.jdbiorm.Table 6 | import org.hibernate.validator.constraints.Length 7 | import org.jdbi.v3.core.mapper.reflect.ColumnName 8 | import java.time.Instant 9 | import java.time.LocalDate 10 | import java.util.* 11 | 12 | /** 13 | * A test table that tests the most basic cases. The ID is auto-generated by the database. 14 | */ 15 | @Table("Test") 16 | data class Person( 17 | override var id: Long? = null, 18 | @field:Length(min = 1) 19 | var name: String = "", 20 | var age: Int = -1, 21 | @Ignore var ignored: String? = null, 22 | @Transient var ignored2: Any? = null, 23 | var dateOfBirth: LocalDate? = null, 24 | var created: Date? = null, 25 | var modified: Instant? = null, 26 | // test of aliased field 27 | @field:ColumnName("alive") 28 | var isAlive25: Boolean? = null, 29 | var maritalStatus: MaritalStatus? = null 30 | 31 | ) : KEntity { 32 | override fun save(validate: Boolean) { 33 | if (id == null) { 34 | if (created == null) created = java.sql.Timestamp(System.currentTimeMillis()).withZeroMillis 35 | // otherwise we can't test 'search by `modified`' 36 | if (modified == null) modified = Instant.ofEpochMilli(1238123123L).withZeroNanos 37 | } 38 | super.save(validate) 39 | } 40 | 41 | // should not be persisted into the database since it's not backed by a field. 42 | fun getSomeComputedValue(): Int = age + 2 43 | 44 | // should not be persisted into the database since it's not backed by a field. 45 | val someOtherComputedValue: Int get() = age 46 | 47 | companion object : Dao(Person::class.java) { 48 | val IGNORE_THIS_FIELD: Int = 0 49 | val ID = tableProperty(Person::id) 50 | val NAME = tableProperty(Person::name) 51 | val AGE = tableProperty(Person::age) 52 | } 53 | } 54 | 55 | enum class MaritalStatus { 56 | Single, 57 | Married, 58 | Divorced, 59 | Widowed 60 | } 61 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/vokorm/Utils.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import com.gitlab.mvysny.jdbiorm.Dao 4 | import com.gitlab.mvysny.jdbiorm.OrderBy 5 | import com.gitlab.mvysny.jdbiorm.Property 6 | import com.gitlab.mvysny.jdbiorm.TableProperty 7 | import com.gitlab.mvysny.jdbiorm.quirks.DatabaseVariant 8 | import com.gitlab.mvysny.jdbiorm.quirks.Quirks 9 | import org.jdbi.v3.core.Handle 10 | import kotlin.reflect.KProperty1 11 | 12 | /** 13 | * Checks whether this class implements given interface [intf]. 14 | */ 15 | public fun Class<*>.implements(intf: Class<*>): Boolean { 16 | require(intf.isInterface) { "$intf is not an interface" } 17 | return intf.isAssignableFrom(this) 18 | } 19 | 20 | public val Handle.databaseVariant: DatabaseVariant get() = DatabaseVariant.from(this) 21 | public val Handle.quirks: Quirks get() = Quirks.from(this) 22 | 23 | /** 24 | * Converts Kotlin [KProperty1] to JDBI-ORM Expression ([TableProperty]). That allows you to construct JDBI-ORM Conditions easily: 25 | * ```kotlin 26 | * dao.findAll(Person::id.exp.eq(25)) 27 | * ``` 28 | * However, it's also possible to use [buildCondition] for a more Kotlin-like Condition construction. 29 | */ 30 | public inline val KProperty1.exp: TableProperty get() = toProperty(T::class.java) 31 | 32 | /** 33 | * Converts Kotlin [KProperty1] to JDBI-ORM Expression ([TableProperty]). 34 | */ 35 | public fun KProperty1.toProperty(receiverClass: Class): TableProperty = TableProperty.of(receiverClass, name) 36 | 37 | /** 38 | * Produces [OrderBy] suitable to be passed into [Dao.findAll] 39 | * ```kotlin 40 | * dao.findAll(Person::id.asc) 41 | * ``` 42 | */ 43 | public inline val KProperty1.asc: OrderBy get() = OrderBy(exp, OrderBy.Order.ASC) 44 | 45 | /** 46 | * Produces [OrderBy] suitable to be passed into [Dao.findAll] 47 | * ```kotlin 48 | * dao.findAll(Person::id.asc) 49 | * ``` 50 | */ 51 | public inline val KProperty1.desc: OrderBy get() = OrderBy(exp, OrderBy.Order.DESC) 52 | 53 | public val LongRange.length: Long get() = if (isEmpty()) 0 else endInclusive - start + 1 54 | public val IntRange.length: Int get() = if (isEmpty()) 0 else endInclusive - start + 1 55 | -------------------------------------------------------------------------------- /src/test/kotlin/com/github/vokorm/H2DatabaseTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import com.gitlab.mvysny.jdbiorm.JdbiOrm 4 | import com.gitlab.mvysny.jdbiorm.quirks.DatabaseVariant 5 | import org.junit.jupiter.api.* 6 | import kotlin.test.expect 7 | 8 | /** 9 | * Tests JDBI-ORM on H2. 10 | */ 11 | abstract class AbstractH2DatabaseTest { 12 | companion object { 13 | @BeforeAll 14 | @JvmStatic 15 | fun setupJdbi() { 16 | hikari { 17 | jdbcUrl = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1" 18 | username = "sa" 19 | password = "" 20 | } 21 | } 22 | 23 | @AfterAll 24 | @JvmStatic 25 | fun destroyJdbi() { 26 | JdbiOrm.destroy() 27 | } 28 | } 29 | 30 | @BeforeEach 31 | fun setupDatabase() { 32 | db { 33 | ddl("DROP ALL OBJECTS") 34 | ddl("""CREATE ALIAS IF NOT EXISTS FTL_INIT FOR "org.h2.fulltext.FullTextLucene.init";CALL FTL_INIT();""") 35 | ddl("""create table Test ( 36 | id bigint primary key auto_increment, 37 | name varchar not null, 38 | age integer not null, 39 | dateOfBirth date, 40 | created timestamp, 41 | modified timestamp, 42 | alive boolean, 43 | maritalStatus varchar 44 | )""") 45 | ddl("""create table EntityWithAliasedId(myid bigint primary key auto_increment, name varchar not null)""") 46 | ddl("""create table NaturalPerson(id varchar(10) primary key, name varchar(400) not null, bytes binary(16) not null)""") 47 | ddl("""create table LogRecord(id UUID primary key, text varchar(400) not null)""") 48 | ddl("""create table TypeMappingEntity(id bigint primary key auto_increment, enumTest ENUM('Single', 'Married', 'Divorced', 'Widowed'))""") 49 | ddl("""CALL FTL_CREATE_INDEX('PUBLIC', 'TEST', 'NAME');""") 50 | } 51 | } 52 | @AfterEach 53 | fun dropDatabase() { 54 | db { ddl("DROP ALL OBJECTS") } 55 | } 56 | 57 | @Test 58 | fun expectH2Variant() { 59 | expect(DatabaseVariant.H2) { db { DatabaseVariant.from(handle) } } 60 | } 61 | } 62 | 63 | class H2DatabaseTest : AbstractH2DatabaseTest() { 64 | @Nested 65 | inner class AllDatabaseTests : AbstractDatabaseTests(DatabaseInfo(DatabaseVariant.H2)) 66 | } 67 | -------------------------------------------------------------------------------- /src/test/kotlin/com/github/vokorm/TestUtils.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import com.fatboyindustrial.gsonjavatime.Converters 4 | import com.google.gson.Gson 5 | import com.google.gson.GsonBuilder 6 | import org.junit.jupiter.api.Assumptions 7 | import org.junit.jupiter.api.assertThrows 8 | import org.testcontainers.DockerClientFactory 9 | import java.io.ByteArrayOutputStream 10 | import java.io.ObjectInputStream 11 | import java.io.ObjectOutputStream 12 | import java.io.Serializable 13 | import java.sql.Timestamp 14 | import java.time.Instant 15 | import java.util.Date 16 | import kotlin.test.expect 17 | 18 | val isX86_64: Boolean get() = System.getProperty("os.arch") == "amd64" 19 | val isWindows: Boolean get() = System.getProperty("os.name").contains("Windows", ignoreCase = true) 20 | val h2only: Boolean get() = System.getProperty("h2only").toBoolean() 21 | 22 | val gson: Gson = GsonBuilder().registerJavaTimeAdapters().create() 23 | 24 | private fun GsonBuilder.registerJavaTimeAdapters(): GsonBuilder = apply { 25 | Converters.registerAll(this) 26 | } 27 | 28 | /** 29 | * Expects that [actual] list of objects matches [expected] list of objects. Fails otherwise. 30 | */ 31 | fun expectList(vararg expected: T, actual: ()->List) { 32 | expect(expected.toList(), actual) 33 | } 34 | 35 | inline fun expectThrows(msg: String, block: () -> Unit) { 36 | val ex = assertThrows(block) 37 | expect(true) { ex.message!!.contains(msg) } 38 | } 39 | 40 | /** 41 | * Clones this object by serialization and returns the deserialized clone. 42 | * @return the clone of this 43 | */ 44 | fun T.cloneBySerialization(): T = javaClass.cast(serializeToBytes().deserialize()) 45 | 46 | inline fun ByteArray.deserialize(): T? = T::class.java.cast( 47 | ObjectInputStream(inputStream()).readObject()) 48 | 49 | /** 50 | * Serializes the object to a byte array 51 | * @return the byte array containing this object serialized form. 52 | */ 53 | fun Serializable?.serializeToBytes(): ByteArray = ByteArrayOutputStream().also { ObjectOutputStream(it).writeObject(this) }.toByteArray() 54 | 55 | fun assumeDockerAvailable() { 56 | Assumptions.assumeTrue(DockerClientFactory.instance().isDockerAvailable(), "Docker not available") 57 | } 58 | 59 | // MSSQL nulls out millis for some reason when running on CI 60 | val Date.withZeroMillis: Date get() { 61 | val result = Timestamp((this as Timestamp).time / 1000 * 1000) 62 | result.nanos = 0 63 | return result 64 | } 65 | 66 | val Instant.withZeroNanos: Instant get() = Instant.ofEpochMilli(toEpochMilli()) 67 | val List.plusNull: List get() = toList() + listOf(null) 68 | -------------------------------------------------------------------------------- /src/test/kotlin/com/github/vokorm/MariadbDatabaseTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import com.gitlab.mvysny.jdbiorm.JdbiOrm 4 | import com.gitlab.mvysny.jdbiorm.quirks.DatabaseVariant 5 | import org.junit.jupiter.api.* 6 | import org.testcontainers.containers.MariaDBContainer 7 | import kotlin.test.expect 8 | 9 | class MariaDBDatabaseTest { 10 | companion object { 11 | private lateinit var container: MariaDBContainer<*> 12 | @BeforeAll @JvmStatic 13 | fun setup() { 14 | Assumptions.assumeTrue(!h2only) { "Only H2 tests are running now" } 15 | assumeDockerAvailable() 16 | 17 | container = MariaDBContainer("mariadb:${DatabaseVersions.mariadb}") 18 | container.start() 19 | 20 | hikari { 21 | minimumIdle = 0 22 | maximumPoolSize = 30 23 | jdbcUrl = container.jdbcUrl 24 | username = container.username 25 | password = container.password 26 | } 27 | db { 28 | ddl( 29 | """create table if not exists Test ( 30 | id bigint primary key auto_increment, 31 | name varchar(400) not null, 32 | age integer not null, 33 | dateOfBirth date, 34 | created timestamp(3) NULL, 35 | modified timestamp(3) NULL, 36 | alive boolean, 37 | maritalStatus varchar(200), 38 | FULLTEXT index (name) 39 | )""" 40 | ) 41 | ddl("""create table if not exists EntityWithAliasedId(myid bigint primary key auto_increment, name varchar(400) not null)""") 42 | ddl("""create table if not exists NaturalPerson(id varchar(10) primary key, name varchar(400) not null, bytes binary(16) not null)""") 43 | ddl("""create table if not exists LogRecord(id binary(16) primary key, text varchar(400) not null)""") 44 | ddl("""create table TypeMappingEntity(id bigint primary key auto_increment, enumTest ENUM('Single', 'Married', 'Divorced', 'Widowed'))""") 45 | } 46 | } 47 | 48 | @AfterAll @JvmStatic 49 | fun tearDown() { 50 | JdbiOrm.destroy() 51 | if (this::container.isInitialized) { 52 | container.stop() 53 | } 54 | } 55 | } 56 | 57 | @BeforeEach @AfterEach fun purgeDb() { clearDb() } 58 | 59 | @Test fun `expect MySQL variant`() { 60 | expect(DatabaseVariant.MySQLMariaDB) { db { DatabaseVariant.from(handle) } } 61 | } 62 | 63 | @Nested 64 | inner class AllDatabaseTests : AbstractDatabaseTests(DatabaseInfo(DatabaseVariant.MySQLMariaDB)) 65 | } 66 | -------------------------------------------------------------------------------- /src/test/kotlin/com/github/vokorm/Databases.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import com.gitlab.mvysny.jdbiorm.Dao 4 | import com.gitlab.mvysny.jdbiorm.JdbiOrm 5 | import com.gitlab.mvysny.jdbiorm.quirks.DatabaseVariant 6 | import com.zaxxer.hikari.HikariConfig 7 | import com.zaxxer.hikari.HikariDataSource 8 | import org.intellij.lang.annotations.Language 9 | import org.jdbi.v3.core.mapper.reflect.ColumnName 10 | import java.util.* 11 | 12 | object DatabaseVersions { 13 | val postgres = "16.3" // https://hub.docker.com/_/postgres/ 14 | val cockroach = "v23.2.6" // https://hub.docker.com/r/cockroachdb/cockroach 15 | val mysql = "9.0.0" // https://hub.docker.com/_/mysql/ 16 | val mssql = "2017-latest-ubuntu" 17 | val mariadb = "11.2.4" // https://hub.docker.com/_/mariadb 18 | } 19 | 20 | /** 21 | * Tests for https://github.com/mvysny/vok-orm/issues/7 22 | */ 23 | data class EntityWithAliasedId( 24 | @field:ColumnName("myid") 25 | override var id: Long? = null, 26 | var name: String = "" 27 | ) : KEntity { 28 | companion object : Dao(EntityWithAliasedId::class.java) 29 | } 30 | 31 | /** 32 | * A table demoing natural person with government-issued ID (birth number, social security number, etc). 33 | */ 34 | data class NaturalPerson(override var id: String? = null, var name: String = "", var bytes: ByteArray = byteArrayOf()) : KEntity { 35 | companion object : Dao(NaturalPerson::class.java) 36 | } 37 | 38 | interface UuidEntity : KEntity { 39 | override fun create(validate: Boolean) { 40 | id = UUID.randomUUID() 41 | super.create(validate) 42 | } 43 | } 44 | 45 | /** 46 | * Demoes app-generated UUID ids. Note how [create] is overridden to auto-generate the ID, so that [save] works properly. 47 | */ 48 | data class LogRecord(override var id: UUID? = null, var text: String = "") : UuidEntity { 49 | companion object : Dao(LogRecord::class.java) 50 | } 51 | 52 | /** 53 | * Tests all sorts of type mapping: 54 | * @property enumTest tests Java Enum mapping to native database enum mapping: https://github.com/mvysny/vok-orm/issues/12 55 | */ 56 | data class TypeMappingEntity(override var id: Long? = null, 57 | var enumTest: MaritalStatus? = null 58 | ) : KEntity { 59 | companion object : Dao(TypeMappingEntity::class.java) 60 | } 61 | 62 | fun hikari(block: HikariConfig.() -> Unit) { 63 | JdbiOrm.databaseVariant = null 64 | JdbiOrm.setDataSource(HikariDataSource(HikariConfig().apply(block))) 65 | } 66 | 67 | fun PersistenceContext.ddl(@Language("sql") sql: String) { 68 | handle.execute(sql) 69 | } 70 | 71 | fun clearDb() { 72 | Person.deleteAll() 73 | EntityWithAliasedId.deleteAll() 74 | NaturalPerson.deleteAll() 75 | LogRecord.deleteAll() 76 | TypeMappingEntity.deleteAll() 77 | } 78 | 79 | data class DatabaseInfo(val variant: DatabaseVariant, val supportsFullText: Boolean = true) 80 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | # https://repo1.maven.org/maven2/org/slf4j/slf4j-api/ 3 | slf4j = "2.0.17" 4 | lucene = "8.11.1" 5 | testcontainers = "1.20.4" # https://repo1.maven.org/maven2/org/testcontainers/postgresql/ 6 | 7 | [libraries] 8 | jdbiorm = "com.gitlab.mvysny.jdbiorm:jdbi-orm:2.10" 9 | slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } 10 | slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } 11 | jetbrains-annotations = "org.jetbrains:annotations:24.1.0" 12 | # https://repo1.maven.org/maven2/org/junit/jupiter/junit-jupiter-engine/ 13 | junit-jupiter-engine = "org.junit.jupiter:junit-jupiter-engine:5.11.0" 14 | jakarta-validation = "jakarta.validation:jakarta.validation-api:3.0.2" 15 | jakarta-el = "org.glassfish:jakarta.el:4.0.2" 16 | hibernate-validator = "org.hibernate.validator:hibernate-validator:8.0.1.Final" # check latest version at https://repo1.maven.org/maven2/org/hibernate/validator/hibernate-validator/ 17 | gson = "com.google.code.gson:gson:2.11.0" 18 | hikaricp = "com.zaxxer:HikariCP:5.1.0" 19 | gsonjavatime = "com.fatboyindustrial.gson-javatime-serialisers:gson-javatime-serialisers:1.1.2" # workaround for https://github.com/google/gson/issues/1059 20 | h2 = "com.h2database:h2:2.2.224" # https://repo1.maven.org/maven2/com/h2database/h2/ 21 | lucene-analyzers = { module = "org.apache.lucene:lucene-analyzers-common", version.ref = "lucene" } 22 | lucene-queryparser = { module = "org.apache.lucene:lucene-queryparser", version.ref = "lucene" } 23 | testcontainers-testcontainers = { module = "org.testcontainers:testcontainers", version.ref = "testcontainers" } 24 | testcontainers-postgresql = { module = "org.testcontainers:postgresql", version.ref = "testcontainers" } 25 | testcontainers-mysql = { module = "org.testcontainers:mysql", version.ref = "testcontainers" } 26 | testcontainers-mariadb = { module = "org.testcontainers:mariadb", version.ref = "testcontainers" } 27 | testcontainers-mssqlserver = { module = "org.testcontainers:mssqlserver", version.ref = "testcontainers" } 28 | testcontainers-cockroachdb = { module = "org.testcontainers:cockroachdb", version.ref = "testcontainers" } 29 | jdbc-postgresql = "org.postgresql:postgresql:42.7.3" # check newest at https://jdbc.postgresql.org/download/ 30 | jdbc-mysql = "com.mysql:mysql-connector-j:8.2.0" # https://dev.mysql.com/downloads/connector/j/ 31 | jdbc-mariadb = "org.mariadb.jdbc:mariadb-java-client:3.4.0" # https://mariadb.com/kb/en/about-mariadb-connector-j/ 32 | jdbc-mssql = "com.microsoft.sqlserver:mssql-jdbc:12.2.0.jre11" 33 | 34 | [bundles] 35 | lucene = ["lucene-analyzers", "lucene-queryparser"] 36 | testcontainers = ["testcontainers-testcontainers", "testcontainers-postgresql", "testcontainers-mysql", "testcontainers-mariadb", "testcontainers-mssqlserver", "testcontainers-cockroachdb"] 37 | jdbc = ["jdbc-postgresql", "jdbc-mysql", "jdbc-mariadb", "jdbc-mssql"] 38 | hibernate-validator = ["hibernate-validator", "jakarta-el"] # EL is required: http://hibernate.org/validator/documentation/getting-started/ 39 | gson = ["gson", "gsonjavatime"] 40 | -------------------------------------------------------------------------------- /src/test/kotlin/com/github/vokorm/MysqlDatabaseTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import com.gitlab.mvysny.jdbiorm.JdbiOrm 4 | import com.gitlab.mvysny.jdbiorm.quirks.DatabaseVariant 5 | import org.junit.jupiter.api.* 6 | import org.testcontainers.containers.MySQLContainer 7 | import kotlin.test.expect 8 | 9 | class MysqlDatabaseTest { 10 | companion object { 11 | private lateinit var container: MySQLContainer<*> 12 | 13 | @BeforeAll 14 | @JvmStatic 15 | fun runMysqlContainer() { 16 | Assumptions.assumeTrue(!h2only) { "Only H2 tests are running now" } 17 | assumeDockerAvailable() 18 | 19 | container = MySQLContainer("mysql:${DatabaseVersions.mysql}") 20 | // disable SSL, to avoid SSL-related exceptions on github actions: 21 | // javax.net.ssl.SSLHandshakeException: No appropriate protocol (protocol is disabled or cipher suites are inappropriate) 22 | container.withUrlParam("useSSL", "false") 23 | container.start() 24 | 25 | hikari { 26 | minimumIdle = 0 27 | maximumPoolSize = 30 28 | jdbcUrl = container.jdbcUrl 29 | username = container.username 30 | password = container.password 31 | } 32 | db { 33 | ddl("""create table if not exists Test ( 34 | id bigint primary key auto_increment, 35 | name varchar(400) not null, 36 | age integer not null, 37 | dateOfBirth date, 38 | created timestamp(3) NULL, 39 | modified timestamp(3) NULL, 40 | alive boolean, 41 | maritalStatus varchar(200), 42 | FULLTEXT index (name) 43 | )""") 44 | ddl("""create table if not exists EntityWithAliasedId(myid bigint primary key auto_increment, name varchar(400) not null)""") 45 | ddl("""create table if not exists NaturalPerson(id varchar(10) primary key, name varchar(400) not null, bytes binary(16) not null)""") 46 | ddl("""create table if not exists LogRecord(id binary(16) primary key, text varchar(400) not null)""") 47 | ddl("""create table TypeMappingEntity(id bigint primary key auto_increment, enumTest ENUM('Single', 'Married', 'Divorced', 'Widowed'))""") 48 | } 49 | } 50 | 51 | @AfterAll 52 | @JvmStatic 53 | fun tearDownMysql() { 54 | JdbiOrm.destroy() 55 | if (::container.isInitialized) { 56 | container.stop() 57 | } 58 | } 59 | } 60 | 61 | @BeforeEach @AfterEach fun purgeDb() { clearDb() } 62 | 63 | @Test fun `expect MySQL variant`() { 64 | expect(DatabaseVariant.MySQLMariaDB) { 65 | db { 66 | DatabaseVariant.from(handle) 67 | } 68 | } 69 | } 70 | 71 | @Nested 72 | inner class AllDatabaseTests : AbstractDatabaseTests(DatabaseInfo(DatabaseVariant.MySQLMariaDB)) 73 | } 74 | -------------------------------------------------------------------------------- /src/test/kotlin/com/github/vokorm/PosgresqlDatabaseTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import com.gitlab.mvysny.jdbiorm.JdbiOrm 4 | import com.gitlab.mvysny.jdbiorm.quirks.DatabaseVariant 5 | import org.junit.jupiter.api.* 6 | import org.testcontainers.containers.PostgreSQLContainer 7 | import kotlin.test.expect 8 | 9 | class PosgresqlDatabaseTest { 10 | companion object { 11 | private lateinit var container: PostgreSQLContainer<*> 12 | @BeforeAll 13 | @JvmStatic 14 | fun setup() { 15 | Assumptions.assumeTrue(!h2only) { "Only H2 tests are running now" } 16 | assumeDockerAvailable() 17 | 18 | container = 19 | PostgreSQLContainer("postgres:${DatabaseVersions.postgres}") // https://hub.docker.com/_/postgres/ 20 | container.start() 21 | 22 | hikari { 23 | minimumIdle = 0 24 | maximumPoolSize = 30 25 | // stringtype=unspecified : see https://github.com/mvysny/vok-orm/issues/12 for more details. 26 | jdbcUrl = 27 | container.jdbcUrl.removeSuffix("loggerLevel=OFF") + "stringtype=unspecified" 28 | username = container.username 29 | password = container.password 30 | } 31 | db { 32 | ddl("""create table if not exists Test ( 33 | id bigserial primary key, 34 | name varchar(400) not null, 35 | age integer not null, 36 | dateOfBirth date, 37 | created timestamp, 38 | modified timestamp, 39 | alive boolean, 40 | maritalStatus varchar(200) 41 | )""") 42 | ddl("""CREATE INDEX pgweb_idx ON Test USING GIN (to_tsvector('english', name));""") 43 | ddl("""create table if not exists EntityWithAliasedId(myid bigserial primary key, name varchar(400) not null)""") 44 | ddl("""create table if not exists NaturalPerson(id varchar(10) primary key, name varchar(400) not null, bytes bytea not null)""") 45 | ddl("""create table if not exists LogRecord(id UUID primary key, text varchar(400) not null)""") 46 | ddl("""CREATE TYPE marital_status AS ENUM ('Single', 'Married', 'Widowed', 'Divorced')""") 47 | ddl("""CREATE TABLE IF NOT EXISTS TypeMappingEntity(id bigserial primary key, enumTest marital_status)""") 48 | } 49 | } 50 | 51 | @AfterAll 52 | @JvmStatic 53 | fun teardown() { 54 | JdbiOrm.destroy() 55 | if (::container.isInitialized) { 56 | container.stop() 57 | } 58 | } 59 | } 60 | 61 | @BeforeEach @AfterEach fun purgeDb() { clearDb() } 62 | 63 | @Test fun `expect PostgreSQL variant`() { 64 | expect(DatabaseVariant.PostgreSQL) { 65 | db { 66 | DatabaseVariant.from(handle) 67 | } 68 | } 69 | } 70 | 71 | @Nested 72 | inner class AllDatabaseTests : AbstractDatabaseTests(DatabaseInfo(DatabaseVariant.PostgreSQL)) 73 | } 74 | -------------------------------------------------------------------------------- /src/test/kotlin/com/github/vokorm/CockroachDatabaseTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import com.gitlab.mvysny.jdbiorm.JdbiOrm 4 | import com.gitlab.mvysny.jdbiorm.quirks.DatabaseVariant 5 | import org.junit.jupiter.api.* 6 | import org.testcontainers.containers.CockroachContainer 7 | import kotlin.test.expect 8 | 9 | class CockroachDatabaseTest { 10 | companion object { 11 | private lateinit var container: CockroachContainer 12 | @BeforeAll 13 | @JvmStatic 14 | fun setup() { 15 | Assumptions.assumeTrue(!h2only) { "Only H2 tests are running now" } 16 | assumeDockerAvailable() 17 | 18 | container = 19 | CockroachContainer("cockroachdb/cockroach:${DatabaseVersions.cockroach}") 20 | container.start() 21 | 22 | hikari { 23 | minimumIdle = 0 24 | maximumPoolSize = 30 25 | jdbcUrl = container.jdbcUrl 26 | username = container.username 27 | password = container.password 28 | } 29 | db { 30 | ddl("""create table if not exists Test ( 31 | id bigserial primary key, 32 | name varchar(400) not null, 33 | age integer not null, 34 | dateOfBirth date, 35 | created timestamp, 36 | modified timestamp, 37 | alive boolean, 38 | maritalStatus varchar(200) 39 | )""") 40 | // full-text search not yet supported: https://github.com/cockroachdb/cockroach/issues/41288 41 | // ddl("""CREATE INDEX pgweb_idx ON Test USING GIN (to_tsvector('english', name));""") 42 | ddl("""create table if not exists EntityWithAliasedId(myid bigserial primary key, name varchar(400) not null)""") 43 | ddl("""create table if not exists NaturalPerson(id varchar(10) primary key, name varchar(400) not null, bytes bytea not null)""") 44 | ddl("""create table if not exists LogRecord(id UUID primary key, text varchar(400) not null)""") 45 | ddl("""CREATE TYPE marital_status AS ENUM ('Single', 'Married', 'Widowed', 'Divorced')""") 46 | ddl("""CREATE TABLE IF NOT EXISTS TypeMappingEntity(id bigserial primary key, enumTest marital_status)""") 47 | } 48 | } 49 | 50 | @AfterAll 51 | @JvmStatic 52 | fun tearDown() { 53 | JdbiOrm.destroy() 54 | if (::container.isInitialized) { 55 | container.stop() 56 | } 57 | } 58 | } 59 | 60 | @BeforeEach @AfterEach fun purgeDb() { clearDb() } 61 | 62 | @Test fun `expect PostgreSQL variant`() { 63 | expect(DatabaseVariant.PostgreSQL) { 64 | db { 65 | DatabaseVariant.from(handle) 66 | } 67 | } 68 | } 69 | // full-text search not yet supported: https://github.com/cockroachdb/cockroach/issues/41288 70 | @Nested 71 | inner class AllDatabaseTests : AbstractDatabaseTests(DatabaseInfo(DatabaseVariant.PostgreSQL, supportsFullText = false)) 72 | } 73 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if %ERRORLEVEL% equ 0 goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if %ERRORLEVEL% equ 0 goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | set EXIT_CODE=%ERRORLEVEL% 84 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 85 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 86 | exit /b %EXIT_CODE% 87 | 88 | :mainEnd 89 | if "%OS%"=="Windows_NT" endlocal 90 | 91 | :omega 92 | -------------------------------------------------------------------------------- /src/test/kotlin/com/github/vokorm/MssqlDatabaseTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import com.gitlab.mvysny.jdbiorm.JdbiOrm 4 | import com.gitlab.mvysny.jdbiorm.quirks.DatabaseVariant 5 | import org.junit.jupiter.api.* 6 | import org.testcontainers.containers.MSSQLServerContainer 7 | import kotlin.test.expect 8 | 9 | class MssqlDatabaseTest { 10 | companion object { 11 | private lateinit var container: MSSQLServerContainer<*> 12 | @BeforeAll @JvmStatic 13 | fun setup() { 14 | Assumptions.assumeTrue(!h2only) { "Only H2 tests are running now" } 15 | assumeDockerAvailable() 16 | Assumptions.assumeTrue(isX86_64) { "MSSQL is only available on amd64: https://hub.docker.com/_/microsoft-mssql-server/ " } 17 | Assumptions.assumeTrue(isWindows) { "MSSQL tests fail to run on GitHub+Linux; don't know why, don't care" } 18 | 19 | container = 20 | MSSQLServerContainer("mcr.microsoft.com/mssql/server:${DatabaseVersions.mssql}") 21 | container.start() 22 | hikari { 23 | minimumIdle = 0 24 | maximumPoolSize = 30 25 | jdbcUrl = container.jdbcUrl 26 | username = container.username 27 | password = container.password 28 | } 29 | db { 30 | // otherwise the CREATE FULLTEXT CATALOG would fail: Cannot use full-text search in master, tempdb, or model database. 31 | ddl("CREATE DATABASE foo") 32 | ddl("USE foo") 33 | ddl("CREATE FULLTEXT CATALOG AdvWksDocFTCat") 34 | 35 | ddl( 36 | """create table Test ( 37 | id bigint primary key IDENTITY(1,1) not null, 38 | name varchar(400) not null, 39 | age integer not null, 40 | dateOfBirth datetime, 41 | created datetime NULL, 42 | modified datetime NULL, 43 | alive bit, 44 | maritalStatus varchar(200) 45 | )""" 46 | ) 47 | // unfortunately the default Docker image doesn't support the FULLTEXT index: 48 | // https://stackoverflow.com/questions/60489784/installing-mssql-server-express-using-docker-with-full-text-search-support 49 | // just skip the tests for now 50 | /* 51 | ddl("CREATE UNIQUE INDEX ui_ukDoc ON Test(name);") 52 | ddl("""CREATE FULLTEXT INDEX ON Test 53 | ( 54 | Test --Full-text index column name 55 | TYPE COLUMN name --Name of column that contains file type information 56 | Language 2057 --2057 is the LCID for British English 57 | ) 58 | KEY INDEX ui_ukDoc ON AdvWksDocFTCat --Unique index 59 | WITH CHANGE_TRACKING AUTO --Population type; """) 60 | */ 61 | 62 | ddl("""create table EntityWithAliasedId(myid bigint primary key IDENTITY(1,1) not null, name varchar(400) not null)""") 63 | ddl("""create table NaturalPerson(id varchar(10) primary key not null, name varchar(400) not null, bytes binary(16) not null)""") 64 | ddl("""create table LogRecord(id uniqueidentifier primary key not null, text varchar(400) not null)""") 65 | ddl("""create table TypeMappingEntity(id bigint primary key IDENTITY(1,1) not null, enumTest varchar(10))""") 66 | } 67 | } 68 | 69 | @AfterAll @JvmStatic 70 | fun teardown() { 71 | JdbiOrm.destroy() 72 | if (this::container.isInitialized) { 73 | container.stop() 74 | } 75 | } 76 | } 77 | 78 | @BeforeEach @AfterEach fun purgeDb() { clearDb() } 79 | 80 | @Test fun `expect MSSQL variant`() { 81 | expect(DatabaseVariant.MSSQL) { 82 | db { 83 | DatabaseVariant.from(handle) 84 | } 85 | } 86 | } 87 | 88 | // unfortunately the default Docker image doesn't support the FULLTEXT index: 89 | // https://stackoverflow.com/questions/60489784/installing-mssql-server-express-using-docker-with-full-text-search-support 90 | @Nested 91 | inner class AllDatabaseTests : AbstractDatabaseTests(DatabaseInfo(DatabaseVariant.MSSQL, supportsFullText = false)) 92 | } 93 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/vokorm/ConditionBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import com.gitlab.mvysny.jdbiorm.condition.Condition 4 | import java.io.Serializable 5 | import kotlin.reflect.KProperty1 6 | import com.gitlab.mvysny.jdbiorm.condition.Expression 7 | import com.gitlab.mvysny.jdbiorm.condition.NativeSQL 8 | 9 | /** 10 | * Creates a [Condition] programmatically: `buildCondition { Person::age lt 25 }` 11 | */ 12 | public inline fun buildCondition(block: ConditionBuilder.() -> Condition): Condition = 13 | block(ConditionBuilder(T::class.java)) 14 | 15 | /** 16 | * Running block with this class as its receiver will allow you to write expressions like this: 17 | * `Person::age lt 25`. Ultimately, the [Condition] is built. 18 | * 19 | * Containing these functions in this class will prevent polluting of the KProperty1 interface and also makes it type-safe. 20 | * @param clazz builds the query for this class. 21 | */ 22 | public class ConditionBuilder(public val clazz: Class) { 23 | /** 24 | * Creates a condition where this property should be equal to [value]. Calls [Expression.eq]. 25 | */ 26 | public infix fun KProperty1.eq(value: R): Condition = toProperty(clazz).eq(value) 27 | 28 | /** 29 | * This property value must be less-than or equal to given [value]. Calls [Expression.le]. 30 | */ 31 | public infix fun KProperty1.le(value: R): Condition = toProperty(clazz).le(value) 32 | 33 | /** 34 | * This property value must be less-than given [value]. Calls [Expression.lt]. 35 | */ 36 | public infix fun KProperty1.lt(value: R): Condition = toProperty(clazz).lt(value) 37 | 38 | /** 39 | * This property value must be greater-than or equal to given [value]. Calls [Expression.ge]. 40 | */ 41 | public infix fun KProperty1.ge(value: R): Condition = toProperty(clazz).ge(value) 42 | 43 | /** 44 | * This property value must be greater-than given [value]. Calls [Expression.gt]. 45 | */ 46 | public infix fun KProperty1.gt(value: R): Condition = toProperty(clazz).gt(value) 47 | 48 | /** 49 | * This property value must not be equal to given [value]. Calls [Expression.ne]. 50 | */ 51 | public infix fun KProperty1.ne(value: R): Condition = toProperty(clazz).ne(value) 52 | 53 | /** 54 | * This property value must be one of the values 55 | * provided in the [value] collection. Calls [Expression.in]. 56 | */ 57 | public infix fun KProperty1.`in`(value: Collection): Condition = toProperty(clazz).`in`(value) 58 | 59 | /** 60 | * The LIKE operator. 61 | * @param pattern e.g. "%foo%" 62 | * @return the condition, not null. 63 | */ 64 | public infix fun KProperty1.like(pattern: String?): Condition = toProperty(clazz).like(pattern) 65 | 66 | /** 67 | * The ILIKE operator. 68 | * @param pattern e.g. "%foo%" 69 | * @return the condition, not null. 70 | */ 71 | public infix fun KProperty1.likeIgnoreCase(pattern: String?): Condition = toProperty(clazz).likeIgnoreCase(pattern) 72 | 73 | /** 74 | * Matches only values contained in given range. 75 | * @param range the range 76 | */ 77 | public infix fun > KProperty1.between(range: ClosedRange): Condition = 78 | toProperty(clazz).between(range.start, range.endInclusive) 79 | 80 | /** 81 | * Matches only when the property is null. 82 | */ 83 | public val KProperty1.isNull: Condition get() = toProperty(clazz).isNull 84 | 85 | /** 86 | * Matches only when the property is not null. 87 | */ 88 | public val KProperty1.isNotNull: Condition get() = toProperty(clazz).isNotNull 89 | 90 | /** 91 | * Matches only when the property is true. See [Expression.isTrue] for more details. 92 | */ 93 | public val KProperty1.isTrue: Condition get() = toProperty(clazz).isTrue 94 | 95 | /** 96 | * Matches only when the property is false. See [Expression.isFalse] for more details. 97 | */ 98 | public val KProperty1.isFalse: Condition get() = toProperty(clazz).isFalse 99 | 100 | /** 101 | * Allows for a native SQL query: `"age < :age_p"("age_p" to 60)` 102 | */ 103 | public operator fun String.invoke(vararg params: Pair): Condition = 104 | NativeSQL(this, mapOf(*params)) 105 | } 106 | 107 | public infix fun Condition.and(other: Condition): Condition = and(other) 108 | public infix fun Condition.or(other: Condition): Condition = or(other) 109 | -------------------------------------------------------------------------------- /src/test/kotlin/com/github/vokorm/AbstractFiltersTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import com.gitlab.mvysny.jdbiorm.quirks.DatabaseVariant 4 | import org.junit.jupiter.api.Assumptions 5 | import org.junit.jupiter.api.BeforeEach 6 | import org.junit.jupiter.api.Nested 7 | import org.junit.jupiter.api.Test 8 | 9 | abstract class AbstractFiltersTest(val info: DatabaseInfo) { 10 | @Test fun `api test`() { 11 | Person.findAll(Person::age.asc, Person::created.desc) 12 | Person.findAllBy(Person::age.asc, Person::created.desc, condition = Person::age.exp.eq(5)) 13 | } 14 | 15 | @Nested inner class `filter test` { 16 | @BeforeEach fun preCreateTestEntities() { 17 | // create a basic set of entities 18 | Person(name = "Moby", age = 25).create() 19 | Person(name = "Jerry", age = 26).create() 20 | Person(name = "Paul", age = 27).create() 21 | } 22 | 23 | @Test fun `eq filter test`() { 24 | expectList() { 25 | Person.findAllBy { Person::age eq 40 }.map { it.name } 26 | } 27 | expectList("Jerry") { 28 | Person.findAllBy { Person::age eq 26 }.map { it.name } 29 | } 30 | } 31 | 32 | @Test fun `ne filter test`() { 33 | expectList("Moby", "Jerry", "Paul") { 34 | Person.findAllBy { Person::age ne 40 }.map { it.name } 35 | } 36 | expectList("Jerry", "Paul") { 37 | Person.findAllBy { Person::age ne 25 }.map { it.name } 38 | } 39 | } 40 | 41 | @Test fun `le filter test`() { 42 | expectList("Moby", "Jerry", "Paul") { 43 | Person.findAllBy { Person::age le 40 }.map { it.name } 44 | } 45 | expectList("Moby", "Jerry") { 46 | Person.findAllBy { Person::age le 26 }.map { it.name } 47 | } 48 | } 49 | 50 | @Test fun `lt filter test`() { 51 | expectList("Moby", "Jerry", "Paul") { 52 | Person.findAllBy { Person::age lt 40 }.map { it.name } 53 | } 54 | expectList("Moby") { 55 | Person.findAllBy { Person::age lt 26 }.map { it.name } 56 | } 57 | } 58 | 59 | @Test fun `ge filter test`() { 60 | expectList() { 61 | Person.findAllBy { Person::age ge 40 }.map { it.name } 62 | } 63 | expectList("Jerry", "Paul") { 64 | Person.findAllBy { Person::age ge 26 }.map { it.name } 65 | } 66 | } 67 | 68 | @Test fun `gt filter test`() { 69 | expectList() { 70 | Person.findAllBy { Person::age gt 40 }.map { it.name } 71 | } 72 | expectList("Paul") { 73 | Person.findAllBy { Person::age gt 26 }.map { it.name } 74 | } 75 | } 76 | 77 | @Test fun `not filter test`() { 78 | expectList("Moby", "Paul") { 79 | Person.findAllBy { !(Person::age eq 26) }.map { it.name } 80 | } 81 | } 82 | 83 | @Test fun `in filter test`() { 84 | expectList("Moby", "Jerry") { 85 | Person.findAllBy { Person::age `in` listOf(25, 26, 28) }.map { it.name } 86 | } 87 | } 88 | } 89 | 90 | @Nested inner class `full-text search` { 91 | @BeforeEach fun assumeSupportsFullText() { 92 | Assumptions.assumeTrue( 93 | info.supportsFullText, 94 | "This database doesn't support full-text search, skipping tests" 95 | ) 96 | } 97 | @Test fun `smoke test`() { 98 | Person.findAllBy(Person::name.exp.fullTextMatches("")) 99 | Person.findAllBy(Person::name.exp.fullTextMatches("a")) 100 | Person.findAllBy(Person::name.exp.fullTextMatches("the")) 101 | Person.findAllBy(Person::name.exp.fullTextMatches("Moby")) 102 | } 103 | 104 | @Test fun `blank filter matches all records`() { 105 | val moby = Person(name = "Moby") 106 | moby.create() 107 | expectList(moby) { Person.findAllBy(Person::name.exp.fullTextMatches("")) } 108 | } 109 | 110 | @Test fun `various queries matching-not matching Moby`() { 111 | val moby = Person(name = "Moby") 112 | moby.create() 113 | expectList() { Person.findAllBy(Person::name.exp.fullTextMatches("foobar")) } 114 | expectList(moby) { Person.findAllBy(Person::name.exp.fullTextMatches("Moby")) } 115 | expectList() { Person.findAllBy(Person::name.exp.fullTextMatches("Jerry")) } 116 | expectList() { Person.findAllBy(Person::name.exp.fullTextMatches("Jerry Moby")) } 117 | } 118 | 119 | @Test fun `partial match`() { 120 | val moby = Person(name = "Moby") 121 | moby.create() 122 | expectList(moby) { Person.findAllBy(Person::name.exp.fullTextMatches("Mob")) } 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/vokorm/Dao.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import com.gitlab.mvysny.jdbiorm.DaoOfAny 4 | import com.gitlab.mvysny.jdbiorm.EntityMeta 5 | import com.gitlab.mvysny.jdbiorm.OrderBy 6 | import com.gitlab.mvysny.jdbiorm.Property 7 | import com.gitlab.mvysny.jdbiorm.TableProperty 8 | import com.gitlab.mvysny.jdbiorm.condition.Condition 9 | import kotlin.reflect.KProperty1 10 | 11 | internal val DaoOfAny.meta: EntityMeta get() = EntityMeta.of(entityClass) 12 | 13 | /** 14 | * Retrieves single entity matching given criteria [block]. Fails if there is no such entity, or if there are two or more entities matching the criteria. 15 | * 16 | * Example: 17 | * ``` 18 | * Person.getSingleBy { Person::name eq "Rubedo" } 19 | * ``` 20 | * 21 | * This function fails if there is no such entity or there are 2 or more. Use [findSingleBy] if you wish to return `null` in case that 22 | * the entity does not exist. 23 | * @throws IllegalArgumentException if there is no entity matching given criteria, or if there are two or more matching entities. 24 | */ 25 | public fun DaoOfAny.singleBy(block: ConditionBuilder.() -> Condition): T = 26 | singleBy(block(ConditionBuilder(entityClass))) 27 | 28 | /** 29 | * Retrieves specific entity matching given [filter]. Returns `null` if there is no such entity. 30 | * Fails if there are two or more entities matching the criteria. 31 | * 32 | * This function returns `null` if there is no such entity. Use [singleBy] if you wish an exception to be thrown in case that 33 | * the entity does not exist. 34 | * @throws IllegalArgumentException if there are two or more matching entities. 35 | */ 36 | public fun DaoOfAny.findSingleBy(block: ConditionBuilder.() -> Condition): T? = 37 | findSingleBy(block(ConditionBuilder(entityClass))) 38 | 39 | /** 40 | * Counts all rows in given table which matches given [block] clause. 41 | */ 42 | public fun DaoOfAny.count(block: ConditionBuilder.() -> Condition): Long = 43 | countBy(ConditionBuilder(entityClass).block()) 44 | 45 | /** 46 | * Allows you to delete rows by given where clause: 47 | * 48 | * ``` 49 | * Person.deleteBy { "name = :name"("name" to "Albedo") } // raw sql where clause with parameters, preferred 50 | * Person.deleteBy { Person::name eq "Rubedo" } // fancy type-safe criteria, useful when you need to construct queries programatically. 51 | * ``` 52 | * 53 | * If you want more complex stuff or even joins, fall back and just write SQL: 54 | * 55 | * ``` 56 | * db { con.createQuery("delete from Foo where name = :name").addParameter("name", name).executeUpdate() } 57 | * ``` 58 | */ 59 | public fun DaoOfAny.deleteBy(block: ConditionBuilder.() -> Condition) { 60 | deleteBy(ConditionBuilder(entityClass).block()) 61 | } 62 | 63 | /** 64 | * Allows you to find rows by given where clause, fetching given [range] of rows: 65 | * 66 | * ``` 67 | * Person.findAllBy { "name = :name"("name" to "Albedo") } // raw sql where clause with parameters, the preferred way 68 | * Person.findBy { Person::name eq "Rubedo" } // fancy type-safe criteria, useful when you need to construct queries programatically 69 | * ``` 70 | * 71 | * If you want more complex stuff or even joins, fall back and just write 72 | * SQL: 73 | * 74 | * ``` 75 | * db { con.createQuery("select * from Foo where name = :name").addParameter("name", name).executeAndFetch(Person::class.java) } 76 | * ``` 77 | * @param orderBy if not empty, this is passed in as the ORDER BY clause. 78 | * @param range use LIMIT+OFFSET to fetch given page of data. Defaults to all data. 79 | * @param block the filter to use. 80 | */ 81 | public fun DaoOfAny.findAllBy( 82 | vararg orderBy: OrderBy = arrayOf(), 83 | range: IntRange = IntRange(0, Int.MAX_VALUE), 84 | block: ConditionBuilder.() -> Condition 85 | ): List = findAllBy(orderBy = orderBy, range = range, condition = block(ConditionBuilder(entityClass))) 86 | 87 | /** 88 | * Finds all rows in given table. Fails if there is no table in the database with the 89 | * name of {@link EntityMeta#getDatabaseTableName()}. If both offset and limit 90 | * are specified, then the LIMIT and OFFSET sql paging is used. 91 | * @param orderBy if not empty, this is passed in as the ORDER BY clause. 92 | * @param range use LIMIT+OFFSET to fetch given page of data. Defaults to all data. 93 | */ 94 | public fun DaoOfAny.findAll( 95 | vararg orderBy: OrderBy = arrayOf(), 96 | range: IntRange = IntRange(0, Int.MAX_VALUE) 97 | ): List { 98 | val offset: Long? = if (range == IntRange(0, Int.MAX_VALUE)) null else range.start.toLong() 99 | val limit: Long? = if (range == IntRange(0, Int.MAX_VALUE)) null else range.length.toLong() 100 | return findAll(orderBy.toList(), offset, limit) 101 | } 102 | 103 | /** 104 | * Allows you to find rows by given [filter], fetching given [range] of rows: 105 | * 106 | * If you want more complex stuff or even joins, fall back and just write 107 | * SQL: 108 | * 109 | * ``` 110 | * db { con.createQuery("select * from Foo where name = :name").addParameter("name", name).executeAndFetch(Person::class.java) } 111 | * ``` 112 | * @param orderBy if not empty, this is passed in as the ORDER BY clause. 113 | * @param range use LIMIT+OFFSET to fetch given page of data. Defaults to all data. 114 | * @param filter the filter to use. 115 | */ 116 | public fun DaoOfAny.findAllBy( 117 | vararg orderBy: OrderBy = arrayOf(), 118 | range: IntRange = IntRange(0, Int.MAX_VALUE), 119 | condition: Condition 120 | ): List { 121 | val offset: Long? = 122 | if (range == IntRange(0, Int.MAX_VALUE)) null else range.start.toLong() 123 | val limit: Long? = 124 | if (range == IntRange(0, Int.MAX_VALUE)) null else range.length.toLong() 125 | return findAllBy(condition, orderBy.toList(), offset, limit) 126 | } 127 | 128 | /** 129 | * Checks whether there is any instance matching given [block]: 130 | * 131 | * ``` 132 | * Person.existsBy { "name = :name"("name" to "Albedo") } // raw sql where clause with parameters, preferred 133 | * Person.existsBy { Person::name eq "Rubedo" } // fancy type-safe criteria, useful when you need to construct queries programmatically. 134 | * ``` 135 | * 136 | * If you want more complex stuff or even joins, fall back and just write SQL: 137 | * 138 | * ``` 139 | * db { con.createQuery("select count(1) from Foo where name = :name").addParameter("name", name).executeScalar(Long::class.java) > 0 } 140 | * ``` 141 | * @param block the filter to use. 142 | */ 143 | public fun DaoOfAny.existsBy(block: ConditionBuilder.() -> Condition): Boolean = 144 | existsBy(block(ConditionBuilder(entityClass))) 145 | 146 | public inline fun DaoOfAny.tableProperty(propertyName: String): TableProperty = TableProperty.of(T::class.java, propertyName) 147 | public inline fun DaoOfAny.tableProperty(property: KProperty1): TableProperty = TableProperty.of(T::class.java, property.name) 148 | public inline fun DaoOfAny.tableProperty(propertyName: Property.Name): TableProperty = TableProperty.of(T::class.java, propertyName) 149 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/vokorm/KEntity.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import com.gitlab.mvysny.jdbiorm.Dao 4 | import com.gitlab.mvysny.jdbiorm.EntityMeta 5 | import com.gitlab.mvysny.jdbiorm.spi.AbstractEntity 6 | import jakarta.validation.ConstraintViolationException 7 | import org.jdbi.v3.core.mapper.reflect.ColumnName 8 | 9 | /** 10 | * Allows you to fetch rows of a database table, and adds useful utility methods [save] 11 | * and [delete]. 12 | * 13 | * Automatically will try to store/update/retrieve all non-transient fields declared by this class and all superclasses. 14 | * To exclude fields, either mark them `transient` or [org.jdbi.v3.core.annotation.Unmappable]. 15 | * 16 | * If your table has no primary key or there is other reason you don't want to use this interface, you can still use 17 | * the DAO methods (see [com.gitlab.mvysny.jdbiorm.DaoOfAny] for more details); you only lose the ability to [save], 18 | * [create] and [delete]. 19 | * 20 | * Annotate the class with [com.gitlab.mvysny.jdbiorm.Table] to set SQL table name. 21 | * 22 | * ### Mapping columns 23 | * Use the [ColumnName] annotation to change the name of the column. 24 | * Please make sure to attach the annotation the field, for example 25 | * `@field:ColumnName("DateTime")`. 26 | * 27 | * ### Auto-generated IDs vs pre-provided IDs 28 | * There are generally three cases for entity ID generation: 29 | * 30 | * * IDs generated by the database when the `INSERT` statement is executed 31 | * * Natural IDs, such as a NaturalPerson with ID pre-provided by the government (social security number etc). 32 | * * IDs created by the application, for example via [java.util.UUID.randomUUID] 33 | * 34 | * The [save] method is designed to work out-of-the-box only for the first case (IDs auto-generated by the database). In this 35 | * case, [save] emits `INSERT` when the ID is null, and `UPDATE` when the ID is not null. 36 | * 37 | * When the ID is pre-provided, you can only use [save] method to update a row in the database; using [save] to create a 38 | * row in the database will throw an exception. In order to create an 39 | * entity with a pre-provided ID, you need to use the [.create] method: 40 | * ``` 41 | * NaturalPerson("12345678", "Albedo").create() 42 | * ``` 43 | * 44 | * For entities with IDs created by the application you can make [save] work properly, by overriding the [create] method 45 | * as follows: 46 | * ``` 47 | * override fun create(validate: Boolean) { 48 | * id = UUID.randomUUID() 49 | * super.create(validate) 50 | * } 51 | * ``` 52 | * @param ID the type of the primary key. All finder methods will only accept this type of ids. 53 | * @author mavi 54 | */ 55 | public interface KEntity : AbstractEntity { 56 | /** 57 | * The ID primary key. 58 | * 59 | * You can use the [ColumnName] annotation to change 60 | * the actual db column name - please make sure to attach the annotation the field, for example 61 | * `@field:ColumnName("DateTime")`. 62 | */ 63 | public var id: ID? 64 | 65 | /** 66 | * Validates current entity. The Java JSR303 validation is performed by default: just add `jakarta.validation` 67 | * annotations to entity properties. 68 | * 69 | * Make sure to add the validation annotations to 70 | * fields otherwise they will be ignored. For example `@field:NotNull`. 71 | * 72 | * You can override this method to perform additional validations on the level of the entire entity. 73 | * 74 | * @throws ConstraintViolationException when validation fails. 75 | */ 76 | public fun validate() { 77 | EntityMeta.of>(javaClass).defaultValidate(this) 78 | } 79 | 80 | /** 81 | * Checks whether this entity is valid: calls [validate] and returns false if [ConstraintViolationException] is thrown. 82 | */ 83 | public val isValid: Boolean 84 | get() = try { 85 | validate() 86 | true 87 | } catch (ex: ConstraintViolationException) { 88 | false 89 | } 90 | 91 | /** 92 | * Deletes this entity from the database. Fails if [id] is null, 93 | * since it is expected that the entity is already in the database. 94 | */ 95 | public fun delete() { 96 | val id = checkNotNull(id) { "The id is null, the entity is not yet in the database" } 97 | Dao, ID>(javaClass).deleteById(id) 98 | } 99 | 100 | /** 101 | * Always issues the database `INSERT`, even if the [id] is not null. This is useful for two cases: 102 | * * When the entity has a natural ID, such as a NaturalPerson with ID pre-provided by the government (social security number etc), 103 | * * ID auto-generated by the application, e.g. UUID 104 | * 105 | * It is possible to use this function with entities with IDs auto-generated by the database, but it may be simpler to 106 | * simply use [save]. 107 | */ 108 | public fun create(validate: Boolean = true) { 109 | if (validate) { 110 | validate() 111 | } 112 | EntityMeta.of>(javaClass).defaultCreate(this) 113 | } 114 | 115 | /** 116 | * Creates a new row in a database (if [id] is null) or updates the row in a database (if [id] is not null). 117 | * When creating, this method simply calls the [create] method. 118 | * 119 | * It is expected that the database will generate an id for us (by sequences, 120 | * `auto_increment` or other means). That generated ID is then automatically stored into the [id] field. 121 | * 122 | * The bean is validated first, by calling [validate]. 123 | * You can bypass this by setting the `validate` parameter to false, but that's not 124 | * recommended. 125 | * 126 | * **WARNING**: if your entity has pre-provided (natural) IDs, you must not call 127 | * this method with the intent to insert the entity into the database - this method will always run UPDATE and then 128 | * fail (since nothing has been updated since the row is not in the database yet). 129 | * To force create the database row, call [create]. 130 | * 131 | * **INFO**: Entities with IDs created by the application can be made to work properly, by overriding [create] 132 | * and [create] method accordingly. See [com.gitlab.mvysny.jdbiorm.Entity] doc for more details. 133 | * 134 | * @throws IllegalStateException if the database didn't provide a new ID (upon new row creation), 135 | * or if there was no row (if [id] was not null). 136 | */ 137 | public fun save(validate: Boolean = true) { 138 | if (validate) { 139 | validate() 140 | } 141 | if (id == null) { 142 | create(false) // no need to validate again 143 | } else { 144 | EntityMeta.of>(javaClass).defaultSave(this) 145 | } 146 | } 147 | 148 | /** 149 | * Re-populates this entity with the up-to-date values from the database. 150 | * The [id] must not be null. 151 | * @throws IllegalStateException if the ID is null. 152 | */ 153 | public fun reload() { 154 | checkNotNull(id) { "Invalid state: id is null" } 155 | EntityMeta.of>(javaClass).defaultReload(this) 156 | } 157 | } -------------------------------------------------------------------------------- /src/test/kotlin/com/github/vokorm/AbstractDbMappingTest.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") 2 | 3 | package com.github.vokorm 4 | 5 | import org.jdbi.v3.core.mapper.reflect.FieldMapper 6 | import org.junit.jupiter.api.Nested 7 | import org.junit.jupiter.api.Test 8 | import java.lang.IllegalStateException 9 | import java.lang.Long 10 | import java.time.Instant 11 | import java.time.LocalDate 12 | import java.util.* 13 | import kotlin.test.expect 14 | 15 | class Foo(var maritalStatus: String? = null) 16 | 17 | abstract class AbstractDbMappingTests() { 18 | @Test fun FindAll() { 19 | expectList() { Person.findAll() } 20 | val p = Person(name = "Zaphod", age = 42, ignored2 = Object()) 21 | p.save() 22 | expect(true) { p.id != null } 23 | p.ignored2 = null 24 | expectList(p) { Person.findAll() } 25 | } 26 | @Nested inner class PersonTests { 27 | @Nested inner class SaveTests { 28 | @Test fun Save() { 29 | val p = Person(name = "Albedo", age = 130) 30 | p.save() 31 | expectList("Albedo") { Person.findAll().map { it.name } } 32 | p.name = "Rubedo" 33 | p.save() 34 | expectList("Rubedo") { Person.findAll().map { it.name } } 35 | Person(name = "Nigredo", age = 130).save() 36 | expectList("Rubedo", "Nigredo") { Person.findAll().map { it.name } } 37 | } 38 | @Test fun SaveEnum() { 39 | val p = Person(name = "Zaphod", age = 42, maritalStatus = MaritalStatus.Divorced) 40 | p.save() 41 | expectList("Divorced") { 42 | db { 43 | handle.createQuery("select maritalStatus from Test").map(FieldMapper.of(Foo::class.java)).list().map { it.maritalStatus } 44 | } 45 | } 46 | expect(p) { db { Person.findAll()[0] } } 47 | } 48 | @Test fun SaveLocalDate() { 49 | val p = Person(name = "Zaphod", age = 42, dateOfBirth = LocalDate.of(1990, 1, 14)) 50 | p.save() 51 | expect(LocalDate.of(1990, 1, 14)) { db { Person.findAll()[0].dateOfBirth!! } } 52 | } 53 | @Test fun `save date and instant`() { 54 | val p = Person(name = "Zaphod", age = 20, created = Date(1000), modified = Instant.ofEpochMilli(120398123)) 55 | p.save() 56 | expect(1000) { db { Person.findAll()[0].created!!.time } } 57 | expect(Instant.ofEpochMilli(120398123)) { db { Person.findAll()[0].modified!! } } 58 | } 59 | @Test fun `updating non-existing row fails`() { 60 | val p = Person(id = 15, name = "Zaphod", age = 20, created = Date(1000), modified = Instant.ofEpochMilli(120398123)) 61 | expectThrows("We expected to update only one row but we updated 0 - perhaps there is no row with id 15?") { 62 | p.save() 63 | } 64 | } 65 | } 66 | @Test fun delete() { 67 | val p = Person(name = "Albedo", age = 130) 68 | p.save() 69 | p.delete() 70 | expectList() { Person.findAll() } 71 | } 72 | @Test fun JsonSerializationIgnoresMeta() { 73 | expect("""{"name":"Zaphod","age":42}""") { gson.toJson(Person(name = "Zaphod", age = 42)) } 74 | } 75 | @Test fun Meta() { 76 | val meta = Person.meta 77 | expect("Test") { meta.databaseTableName } // since Person is annotated with @Entity("Test") 78 | expect("Test.id") { meta.idProperty[0].dbName.qualifiedName } 79 | expect(Person::class.java) { meta.entityClass } 80 | expect(Long::class.java) { meta.idProperty[0].valueType } 81 | expect( 82 | setOf( 83 | "id", 84 | "name", 85 | "age", 86 | "dateOfBirth", 87 | "created", 88 | "alive", 89 | "maritalStatus", 90 | "modified" 91 | ) 92 | ) { meta.persistedFieldDbNames.map { it.unqualifiedName } .toSet() } 93 | } 94 | } 95 | @Nested inner class EntityWithAliasedIdTests { 96 | @Test fun Save() { 97 | val p = EntityWithAliasedId(name = "Albedo") 98 | p.save() 99 | expectList("Albedo") { EntityWithAliasedId.findAll().map { it.name } } 100 | p.name = "Rubedo" 101 | p.save() 102 | expectList("Rubedo") { EntityWithAliasedId.findAll().map { it.name } } 103 | EntityWithAliasedId(name = "Nigredo").save() 104 | expectList("Rubedo", "Nigredo") { EntityWithAliasedId.findAll().map { it.name } } 105 | } 106 | @Test fun delete() { 107 | val p = EntityWithAliasedId(name = "Albedo") 108 | p.save() 109 | p.delete() 110 | expect(listOf()) { EntityWithAliasedId.findAll() } 111 | } 112 | @Test fun JsonSerializationIgnoresMeta() { 113 | expect("""{"name":"Zaphod"}""") { gson.toJson(EntityWithAliasedId(name = "Zaphod")) } 114 | } 115 | @Test fun Meta() { 116 | val meta = EntityWithAliasedId.meta 117 | expect("EntityWithAliasedId") { meta.databaseTableName } 118 | expect("myid") { meta.idProperty[0].dbName.unqualifiedName } 119 | expect(EntityWithAliasedId::class.java) { meta.entityClass } 120 | expect(Long::class.java) { meta.idProperty[0].valueType } 121 | expect(setOf("myid", "name")) { meta.persistedFieldDbNames.map { it.unqualifiedName } .toSet() } 122 | } 123 | } 124 | @Nested inner class NaturalPersonTests { 125 | @Test fun `save fails`() { 126 | val p = NaturalPerson(id = "12345678", name = "Albedo", bytes = byteArrayOf(5)) 127 | expectThrows("We expected to update only one row but we updated 0 - perhaps there is no row with id 12345678?") { 128 | p.save() 129 | } 130 | } 131 | @Test fun Save() { 132 | val p = NaturalPerson(id = "12345678", name = "Albedo", bytes = byteArrayOf(5)) 133 | p.create() 134 | expectList("Albedo") { NaturalPerson.findAll().map { it.name } } 135 | p.name = "Rubedo" 136 | p.save() 137 | expectList("Rubedo") { NaturalPerson.findAll().map { it.name } } 138 | NaturalPerson(id = "aaa", name = "Nigredo", bytes = byteArrayOf(5)).create() 139 | expectList("Rubedo", "Nigredo") { NaturalPerson.findAll().map { it.name } } 140 | } 141 | @Test fun delete() { 142 | val p = NaturalPerson(id = "foo", name = "Albedo", bytes = byteArrayOf(5)) 143 | p.create() 144 | p.delete() 145 | expectList() { NaturalPerson.findAll() } 146 | } 147 | } 148 | @Nested inner class LogRecordTests() { 149 | @Test fun `save succeeds since create() auto-generates ID`() { 150 | val p = LogRecord(text = "foo") 151 | p.save() 152 | expectList("foo") { LogRecord.findAll().map { it.text } } 153 | } 154 | @Test fun Save() { 155 | val p = LogRecord(text = "Albedo") 156 | p.save() 157 | expectList("Albedo") { LogRecord.findAll().map { it.text } } 158 | p.text = "Rubedo" 159 | p.save() 160 | expectList("Rubedo") { LogRecord.findAll().map { it.text } } 161 | LogRecord(text = "Nigredo").save() 162 | expect(setOf("Rubedo", "Nigredo")) { LogRecord.findAll().map { it.text } .toSet() } 163 | } 164 | @Test fun delete() { 165 | val p = LogRecord(text = "foo") 166 | p.save() 167 | p.delete() 168 | expectList() { LogRecord.findAll() } 169 | } 170 | } 171 | @Nested inner class TypeMapping { 172 | @Test fun `java enum to native db enum`() { 173 | for (it in MaritalStatus.entries.plusNull) { 174 | val id: kotlin.Long? = TypeMappingEntity(enumTest = it).run { save(); id } 175 | val loaded = TypeMappingEntity.findById(id!!)!! 176 | expect(it) { loaded.enumTest } 177 | } 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Stop when "xargs" is not available. 209 | if ! command -v xargs >/dev/null 2>&1 210 | then 211 | die "xargs is not available" 212 | fi 213 | 214 | # Use "xargs" to parse quoted args. 215 | # 216 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 217 | # 218 | # In Bash we could simply go: 219 | # 220 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 221 | # set -- "${ARGS[@]}" "$@" 222 | # 223 | # but POSIX shell has neither arrays nor command substitution, so instead we 224 | # post-process each arg (as a line of input to sed) to backslash-escape any 225 | # character that might be a shell metacharacter, then use eval to reverse 226 | # that process (while maintaining the separation between arguments), and wrap 227 | # the whole thing up as a single "set" statement. 228 | # 229 | # This will of course break if any of these variables contains a newline or 230 | # an unmatched quote. 231 | # 232 | 233 | eval "set -- $( 234 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 235 | xargs -n1 | 236 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 237 | tr '\n' ' ' 238 | )" '"$@"' 239 | 240 | exec "$JAVACMD" "$@" 241 | -------------------------------------------------------------------------------- /src/test/kotlin/com/github/vokorm/AbstractDbDaoTests.kt: -------------------------------------------------------------------------------- 1 | package com.github.vokorm 2 | 3 | import org.junit.jupiter.api.Nested 4 | import org.junit.jupiter.api.Test 5 | import java.time.Instant 6 | import java.time.LocalDate 7 | import java.util.* 8 | import kotlin.test.expect 9 | 10 | abstract class AbstractDbDaoTests { 11 | @Nested inner class PersonTests { 12 | @Test fun FindById() { 13 | expect(null) { Person.findById(25) } 14 | val p = Person(name = "Albedo", age = 121) 15 | p.save() 16 | expect(p) { Person.findById(p.id!!) } 17 | } 18 | @Test fun GetById() { 19 | val p = Person(name = "Albedo", age = 122) 20 | p.save() 21 | expect(p) { Person.getById(p.id!!) } 22 | } 23 | @Test fun `GetById fails if there is no such entity`() { 24 | expectThrows("There is no Person for id 25") { 25 | Person.getById(25L) 26 | } 27 | } 28 | @Nested inner class `singleBy() tests` { 29 | @Test fun `succeeds if there is exactly one matching entity`() { 30 | val p = Person(name = "Albedo", age = 123) 31 | p.save() 32 | expect(p) { Person.singleBy { Person::name eq "Albedo" } } 33 | } 34 | 35 | @Test fun `fails if there is no such entity`() { 36 | expectThrows("no row matching Person: '(Test.name) = ") { 37 | Person.singleBy { Person::name eq "Albedo" } 38 | } 39 | } 40 | 41 | @Test fun `fails if there are two matching entities`() { 42 | repeat(2) { Person(name = "Albedo", age = 124).save() } 43 | expectThrows("too many rows matching Person: '(Test.name) = ") { 44 | Person.singleBy { Person::name eq "Albedo" } 45 | } 46 | } 47 | 48 | @Test fun `fails if there are ten matching entities`() { 49 | repeat(10) { Person(name = "Albedo", age = 125).save() } 50 | expectThrows("too many rows matching Person: '(Test.name) = ") { 51 | Person.singleBy { Person::name eq "Albedo" } 52 | } 53 | } 54 | } 55 | @Nested inner class count { 56 | @Test fun `basic count`() { 57 | expect(0) { Person.count() } 58 | listOf("Albedo", "Nigredo", "Rubedo").forEach { Person(name = it, age = 126).save() } 59 | expect(3) { Person.count() } 60 | } 61 | @Test fun `count with filters`() { 62 | expect(0) { Person.count { Person::age gt 6 } } 63 | listOf("Albedo", "Nigredo", "Rubedo").forEach { Person(name = it, age = it.length).save() } 64 | expect(1) { Person.count { Person::age gt 6 } } 65 | } 66 | } 67 | @Test fun DeleteAll() { 68 | listOf("Albedo", "Nigredo", "Rubedo").forEach { Person(name = it, age = 127).save() } 69 | expect(3) { Person.count() } 70 | Person.deleteAll() 71 | expect(0) { Person.count() } 72 | } 73 | @Nested inner class DeleteById { 74 | @Test fun simple() { 75 | listOf("Albedo", "Nigredo", "Rubedo").forEach { Person(name = it, age = 128).save() } 76 | expect(3) { Person.count() } 77 | Person.deleteById(Person.findAll().first { it.name == "Albedo" }.id!!) 78 | expect(listOf("Nigredo", "Rubedo")) { Person.findAll().map { it.name } } 79 | } 80 | @Test fun DoesNothingOnUnknownId() { 81 | db { com.github.vokorm.Person.deleteById(25L) } 82 | expect(listOf()) { Person.findAll() } 83 | } 84 | } 85 | @Test fun DeleteBy() { 86 | listOf("Albedo", "Nigredo", "Rubedo").forEach { Person(name = it, age = 129).save() } 87 | Person.deleteBy { "name = :name"("name" to "Albedo") } // raw sql where 88 | expect(listOf("Nigredo", "Rubedo")) { Person.findAll().map { it.name } } 89 | Person.deleteBy { Person::name eq "Rubedo" } // fancy type-safe criteria 90 | expect(listOf("Nigredo")) { Person.findAll().map { it.name } } 91 | } 92 | @Nested inner class `findOneBy() tests` { 93 | @Test fun `succeeds if there is exactly one matching entity`() { 94 | val p = Person(name = "Albedo", age = 130) 95 | p.save() 96 | expect(p) { Person.findSingleBy { Person::name eq "Albedo" } } 97 | } 98 | 99 | @Test fun `returns null if there is no such entity`() { 100 | expect(null) { Person.findSingleBy { Person::name eq "Albedo" } } 101 | } 102 | 103 | @Test fun `fails if there are two matching entities`() { 104 | repeat(2) { Person(name = "Albedo", age = 131).save() } 105 | expectThrows("too many rows matching Person: '(Test.name) = ") { 106 | Person.findSingleBy { Person::name eq "Albedo" } 107 | } 108 | } 109 | 110 | @Test fun `fails if there are ten matching entities`() { 111 | repeat(10) { Person(name = "Albedo", age = 132).save() } 112 | expectThrows("too many rows matching Person: '(Test.name) = ") { 113 | Person.findSingleBy { Person::name eq "Albedo" } 114 | } 115 | } 116 | 117 | @Test fun `test filter by date`() { 118 | val p = Person(name = "Albedo", age = 133, dateOfBirth = LocalDate.of(1980, 2, 2)) 119 | p.save() 120 | expect(p) { 121 | Person.findSingleBy {Person::dateOfBirth eq LocalDate.of(1980, 2, 2) } 122 | } 123 | // here I don't care about whether it selects something or not, I'm only testing the database compatibility 124 | Person.findSingleBy { Person::dateOfBirth eq Instant.now() } 125 | Person.findSingleBy { Person::dateOfBirth eq Date() } 126 | } 127 | } 128 | @Nested inner class exists { 129 | @Test fun `returns false on empty table`() { 130 | expect(false) { Person.existsAny() } 131 | expect(false) { Person.existsById(25) } 132 | expect(false) { Person.existsBy { Person::age le 26 } } 133 | } 134 | @Test fun `returns true on matching entity`() { 135 | val p = Person(name = "Albedo", age = 134) 136 | p.save() 137 | expect(true) { Person.existsAny() } 138 | expect(true) { Person.existsById(p.id!!) } 139 | expect(true) { Person.existsBy { Person::age ge 26 } } 140 | } 141 | @Test fun `returns false on non-matching entity`() { 142 | val p = Person(name = "Albedo", age = 135) 143 | p.save() 144 | expect(true) { Person.existsAny() } 145 | expect(false) { Person.existsById(p.id!! + 1) } 146 | expect(false) { Person.existsBy { Person::age le 26 } } 147 | } 148 | } 149 | @Test fun `findAll sorting`() { 150 | Person.findAll(Person::id.asc) 151 | Person.findAll(Person::id.desc) 152 | } 153 | } 154 | 155 | // quick tests which test that DAO methods generally work with entities with aliased ID columns 156 | @Nested inner class EntityWithAliasedIdTests { 157 | @Test fun FindById() { 158 | expect(null) { EntityWithAliasedId.findById(25) } 159 | val p = EntityWithAliasedId(name = "Albedo") 160 | p.save() 161 | expect(p) { EntityWithAliasedId.findById(p.id!!) } 162 | } 163 | @Test fun GetById() { 164 | val p = EntityWithAliasedId(name = "Albedo") 165 | p.save() 166 | expect(p) { EntityWithAliasedId.getById(p.id!!) } 167 | } 168 | @Nested inner class `singleBy() tests`() { 169 | @Test fun `succeeds if there is exactly one matching entity`() { 170 | val p = EntityWithAliasedId(name = "Albedo") 171 | p.save() 172 | expect(p) { EntityWithAliasedId.singleBy { EntityWithAliasedId::name eq "Albedo" } } 173 | } 174 | } 175 | @Nested inner class count { 176 | @Test fun `basic count`() { 177 | expect(0) { EntityWithAliasedId.count() } 178 | listOf("Albedo", "Nigredo", "Rubedo").forEach { EntityWithAliasedId(name = it).save() } 179 | expect(3) { EntityWithAliasedId.count() } 180 | } 181 | @Test fun `count with filters`() { 182 | expect(0) { EntityWithAliasedId.count() } 183 | listOf("Albedo", "Nigredo", "Rubedo").forEach { EntityWithAliasedId(name = it).save() } 184 | expect(1) { EntityWithAliasedId.count { EntityWithAliasedId::name eq "Albedo" } } 185 | val id = EntityWithAliasedId.findAll().first { it.name == "Albedo" }.id!! 186 | } 187 | } 188 | @Test fun DeleteAll() { 189 | listOf("Albedo", "Nigredo", "Rubedo").forEach { EntityWithAliasedId(name = it).save() } 190 | expect(3) { EntityWithAliasedId.count() } 191 | EntityWithAliasedId.deleteAll() 192 | expect(0) { EntityWithAliasedId.count() } 193 | } 194 | @Test fun DeleteById() { 195 | listOf("Albedo", "Nigredo", "Rubedo").forEach { EntityWithAliasedId(name = it).save() } 196 | expect(3) { EntityWithAliasedId.count() } 197 | EntityWithAliasedId.deleteById(EntityWithAliasedId.findAll().first { it.name == "Albedo" }.id!!) 198 | expect(listOf("Nigredo", "Rubedo")) { EntityWithAliasedId.findAll().map { it.name } } 199 | } 200 | @Test fun DeleteByIdDoesNothingOnUnknownId() { 201 | db { EntityWithAliasedId.deleteById(25L) } 202 | expect(listOf()) { EntityWithAliasedId.findAll() } 203 | } 204 | @Test fun DeleteBy() { 205 | listOf("Albedo", "Nigredo", "Rubedo").forEach { EntityWithAliasedId(name = it).save() } 206 | EntityWithAliasedId.deleteBy { "name = :name"("name" to "Albedo") } // raw sql where 207 | expect(listOf("Nigredo", "Rubedo")) { EntityWithAliasedId.findAll().map { it.name } } 208 | } 209 | @Nested inner class `findOneBy() tests` { 210 | @Test fun `succeeds if there is exactly one matching entity`() { 211 | val p = EntityWithAliasedId(name = "Albedo") 212 | p.save() 213 | expect(p) { EntityWithAliasedId.findSingleBy { EntityWithAliasedId::name eq "Albedo" } } 214 | } 215 | } 216 | @Nested inner class exists { 217 | @Test fun `returns false on empty table`() { 218 | expect(false) { EntityWithAliasedId.existsAny() } 219 | expect(false) { EntityWithAliasedId.existsById(25) } 220 | expect(false) { EntityWithAliasedId.existsBy { EntityWithAliasedId::name le "a" } } 221 | } 222 | @Test fun `returns true on matching entity`() { 223 | val p = EntityWithAliasedId(name = "Albedo") 224 | p.save() 225 | expect(true) { EntityWithAliasedId.existsAny() } 226 | expect(true) { EntityWithAliasedId.existsById(p.id!!) } 227 | expect(true) { EntityWithAliasedId.existsBy { EntityWithAliasedId::name eq "Albedo" } } 228 | } 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Join the chat at https://gitter.im/vaadin/vaadin-on-kotlin](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/vaadin/vaadin-on-kotlin?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 2 | [![GitHub tag](https://img.shields.io/github/tag/mvysny/vok-orm.svg)](https://github.com/mvysny/vok-orm/tags) 3 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.github.mvysny.vokorm/vok-orm/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.github.mvysny.vokorm/vok-orm) 4 | 5 | # Vaadin-On-Kotlin database mapping library 6 | 7 | > DEPRECATED. This library is now deprecated and no futher maintenance will be performed. 8 | > We recommend to use [Ktorm](https://www.ktorm.org/) which is much more mature, maintained and documented. 9 | > For Vaadin bindings see [ktorm-vaadin](https://github.com/mvysny/ktorm-vaadin). 10 | 11 | `vok-orm` allows you to load the data from database rows into objects (POJOs) 12 | and write the data back into the database. No JPA dirty tricks are used: no runtime enhancements, no lazy loading, no `DetachedExceptions`, no change tracking 13 | behind the scenes - everything happens explicitly. No compiler 14 | plugin is needed - `vok-orm` uses Kotlin language features to add a standard 15 | set of finders to your entities. You can add any number of business logic methods as 16 | you like to your entities; the database transaction is easy to launch simply by calling the 17 | global `db {}` function. 18 | 19 | No dependency injection framework is required - the library works in all 20 | sorts of environments. 21 | 22 | > vok-orm uses the [jdbi-orm](https://gitlab.com/mvysny/jdbi-orm) and [JDBI](http://jdbi.org/) under the belt, 23 | and introduces first-class Kotlin support on top of those frameworks. 24 | 25 | ## Supported Databases 26 | 27 | vok-orm currently supports MySQL, MariaDB, PostgreSQL, H2, Microsoft SQL and CockroachDB. 28 | Other databases are untested - they might or might not work. 29 | 30 | ## Usage 31 | 32 | Just add the following lines to your Gradle script, to include this library in your project: 33 | ```groovy 34 | repositories { 35 | mavenCentral() 36 | } 37 | dependencies { 38 | compile("com.github.mvysny.vokorm:vok-orm:x.y") 39 | } 40 | ``` 41 | 42 | > Note: obtain the newest version from the tag name at the top of the page 43 | 44 | Maven: (it's very simple since vok-orm is in Maven Central): 45 | 46 | ```xml 47 | 48 | 49 | 50 | com.github.mvysny.vokorm 51 | vok-orm 52 | x.y 53 | 54 | 55 | 56 | ``` 57 | 58 | See the [vok-orm-playground](https://gitlab.com/mvysny/vok-orm-playground) for a very simple example project 59 | using `vok-orm`. 60 | 61 | Compatibility Chart: 62 | 63 | | vok-orm version | validation API | Min. Java version | 64 | |-----------------|--------------------|-------------------| 65 | | 1.x | javax.validation | 8+ | 66 | | 2.x | jakarta.validation | 11+ | 67 | 68 | ## Usage Examples 69 | 70 | Say that we have a table containing a list of beverage categories, such as Cider or Beer. The H2 DDL for such table is simple: 71 | 72 | ```sql92 73 | create TABLE CATEGORY ( 74 | id bigint auto_increment PRIMARY KEY, 75 | name varchar(200) NOT NULL 76 | ); 77 | create UNIQUE INDEX idx_category_name ON CATEGORY(name); 78 | ``` 79 | 80 | > **Note:** We expect that the programmer wants to write the DDL scripts herself, to make full 81 | use of the DDL capabilities of the underlying database. 82 | We will therefore not hide the DDL behind some type-safe generator API. 83 | 84 | Such entity can be mapped to a data class as follows: 85 | ```kotlin 86 | data class Category(override var id: Long? = null, var name: String = "") : KEntity 87 | ``` 88 | (the `id` is nullable since its value is initially `null` until the category is 89 | actually created and the id is assigned by the database). 90 | 91 | The `Category` class is just a simple data class: there are no hidden private fields added by 92 | runtime enhancements, no hidden lazy loading - everything is pre-fetched upfront. Because of that, 93 | the class can be passed around the application freely as a DTO (data transfer object), 94 | without the fear of failing with 95 | `DetachedException` when accessing properties. Since `Entity` is `Serializable`, you can 96 | also store the entity into a session. 97 | 98 | > The Category class (or any entity class for that matter) must have all fields 99 | pre-initialized, so that Kotlin creates a zero-arg constructor. 100 | Zero-arg constructor is mandated by JDBI, in order for JDBI to be able to construct 101 | instances of entity class for every row returned. 102 | 103 | By implementing the `KEntity` interface, we are telling vok-orm that the primary key is of type `Long`; 104 | this will be important later on when using Dao. 105 | The [KEntity](src/main/kotlin/com/github/vokorm/KEntity.kt) interface brings in three useful methods: 106 | 107 | * `save()` which either creates a new row by generating the INSERT statement 108 | (if the ID is null), or updates the row by generating the UPDATE statement (if the ID is not null) 109 | * `create()` for special cases when the ID is pre-known (social security number) 110 | and `save()` wouldn't work. More info in the 'Pre-known IDs' chapter. 111 | * `delete()` which deletes the row identified by the `id` primary key from the database. 112 | * `validate()` validates the bean. By default all `javax.validation` annotations 113 | are validated; you can override this method to provide further bean-level validations. 114 | Please read the 'Validation' chapter below, for further details. 115 | 116 | > There are two interfaces you can use: Entity and KEntity. Both work the same way, however 117 | Entity is tailored towards Java developers and is not as pleasant to use with Kotlin as KEntity is. 118 | 119 | The INSERT/UPDATE statement is automatically constructed by the `save()` method, 120 | simply by enumerating all non-transient and non-ignored properties of 121 | the bean using reflection and fetching their values. See the [KEntity](src/main/kotlin/com/github/vokorm/KEntity.kt) 122 | sources for more details. 123 | You can annotate the `Category` class with the `@Table(dbname = "Categories")` annotation, to specify a different table name. 124 | 125 | The category can now be created easily: 126 | 127 | ```kotlin 128 | Category(name = "Beer").save() 129 | ``` 130 | 131 | But how do we specify the target database where to store the category in? 132 | 133 | ### Connecting to a database 134 | 135 | As a bare minimum, you need to specify the JDBC URL and a couple of config parameters as follows: 136 | ```kotlin 137 | JdbiOrm.setDataSource(HikariDataSource(HikariConfig())) 138 | ``` 139 | 140 | to the `JdbiOrm.setDataSource()` first. It's a [Hikari-CP](https://brettwooldridge.github.io/HikariCP/) 141 | configuration file which contains lots of other options as well. 142 | It comes pre-initialized with sensible default settings. 143 | 144 | > Hikari-CP is a JDBC connection pool which manages a pool of JDBC connections 145 | since they are "expensive" to create - it takes some time to establish the TCP-IP connection for example. 146 | Typically all projects use some sort of JDBC connection pooling; we'll use Hikari-CP in this 147 | tutorial however you can use whichever pool you wish, or no pool at all. You can also use DataSource 148 | offered by Spring or JavaEE. 149 | 150 | For example, to use an in-memory H2 database, just add H2 onto the classpath as 151 | a Gradle dependency: `compile 'com.h2database:h2:1.4.196'`. Then, 152 | configure vok-orm as follows: 153 | 154 | ```kotlin 155 | val cfg = HikariConfig().apply { 156 | jdbcUrl = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1" 157 | username = "sa" 158 | password = "" 159 | } 160 | JdbiOrm.setDataSource(HikariDataSource(cfg)) 161 | ``` 162 | 163 | After you have configured the JDBC URL, just call `JdbiOrm.setDataSource(HikariDataSource(cfg))` which will initialize 164 | Hikari-CP's connection pool. After the connection pool is initialized, you can simply call 165 | the `db{}` function to run the 166 | block in a database transaction. The `db{}` function will acquire new connection from the 167 | connection pool; then it will start a transaction and it will provide you with means to execute SQL commands: 168 | 169 | ```kotlin 170 | db { 171 | handle.createUpdate("delete from Category where id = :id") 172 | .bind("id", id) 173 | .execute() 174 | } 175 | ``` 176 | 177 | You can call this function from anywhere; you don't need to use dependency injection or anything like that. 178 | That is precisely how the `save()` function saves the bean - it simply calls the `db {}` function and executes 179 | an appropriate INSERT/UPDATE statement. 180 | 181 | The function will automatically roll back the transaction on any exception thrown out from the block (both checked and unchecked). 182 | 183 | After you're done, call `JdbiOrm.destroy()` to close the pool. 184 | 185 | > You can call methods of this library from anywhere. You don't need to be running inside of the JavaEE or Spring container or 186 | any container at all - you can actually use this library from a plain JavaSE main method. 187 | 188 | Full example of a `main()` method that does all of the above: 189 | 190 | ```kotlin 191 | fun main(args: Array) { 192 | val cfg = HikariConfig().apply { 193 | jdbcUrl = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1" 194 | username = "sa" 195 | password = "" 196 | } 197 | JdbiOrm.setDataSource(HikariDataSource(cfg)) 198 | db { 199 | con.createQuery("create TABLE CATEGORY (id bigint auto_increment PRIMARY KEY, name varchar(200) NOT NULL );").executeUpdate() 200 | } 201 | db { 202 | (0..100).forEach { Category(name = "cat $it").save() } 203 | } 204 | JdbiOrm.destroy() 205 | } 206 | ``` 207 | 208 | See the [vok-orm-playground](https://gitlab.com/mvysny/vok-orm-playground) 209 | project which contains such `main` method, all JDBC drivers pre-loaded and 210 | simple instructions on how to query different database kinds. 211 | 212 | > *Note*: for the sake of simplicity we're running the `CREATE TABLE` as a query. For a persistent database 213 | it's definitely better to use [Flyway](https://flywaydb.org/) as described below. 214 | 215 | ### Finding Categories 216 | 217 | The so-called finder (or Dao) methods actually resemble factory methods since they also produce instances of Categories. The best place for such 218 | methods is on the `Category` class itself. We can write all of the necessary finders ourselves, by using the `db{}` 219 | method as stated above; however vok-orm already provides a set of handy methods for you. All you need 220 | to do is for the companion object to extend the `Dao` class: 221 | 222 | ```kotlin 223 | data class Category(override var id: Long? = null, var name: String = "") : KEntity { 224 | companion object : Dao(Category::class.java) 225 | } 226 | ``` 227 | 228 | Since Category's companion object extends the `Dao` class, Category will now be outfitted 229 | with several useful finder methods (static extension methods 230 | that are attached to the [Dao](src/main/kotlin/com/github/vokorm/Dao.kt) interface itself): 231 | 232 | * `Category.findAll()` will return a list of all categories 233 | * `Category.getById(25L)` will fetch a category with the ID of 25, failing if there is no such category 234 | * `Category.findById(25L)` will fetch a category with ID of 25, returning `null` if there is no such category 235 | * `Category.deleteAll()` will delete all categories 236 | * `Category.deleteById(42L)` will delete a category with ID of 42 237 | * `Category.count()` will return the number of rows in the Category table. 238 | * `Category.findBy { "name = :name1 or name = :name2"("name1" to "Beer", "name2" to "Cider") }` will find all categories with the name of "Beer" or "Cider". 239 | This is an example of a parametrized select, from which you only need to provide the WHERE clause. 240 | * `Category.deleteBy { (Category::name eq "Beer") or (Category::name eq "Cider") }` will delete all categories 241 | matching given criteria. This is an example of a statically-typed matching criteria which 242 | is converted into the WHERE clause. 243 | * `Category.singleBy { "name = :name"("name" to "Beer") }` will fetch exactly one matching category, failing if there is no such category or there are more than one. 244 | * `Category.findSingleBy { "name = :name"("name" to "Beer") }` will fetch one matching category, failing if there are more than one. Returns `null` if there is none. 245 | * `Category.count { "name = :name"("name" to "Beer") }` will return the number of rows in the Category table matching given query. 246 | 247 | In the spirit of type safety, the finder methods will only accept `Long` (or whatever is the type of 248 | the primary key in the `KEntity` implementation clause). 249 | 250 | You can of course add your own custom finder methods into the Category companion object. For example: 251 | 252 | ```kotlin 253 | data class Category(override var id: Long? = null, var name: String = "") : KEntity { 254 | companion object : Dao { 255 | fun findByName(name: String): Category? = findSingleBy { Category::name eq name } 256 | fun getByName(name: String): Category = singleBy { Category::name eq name } 257 | fun existsWithName(name: String): Boolean = count { Category::name eq name } > 0 258 | } 259 | } 260 | ``` 261 | 262 | > **Note**: If you don't want to use the KEntity interface for some reason 263 | (for example when the table has no primary key), you can still include 264 | useful finder methods by making the companion object to implement the `DaoOfAny` 265 | interface. The finder methods such as `findById()` will accept 266 | `Any` as a primary key. 267 | 268 | ### Adding Reviews 269 | 270 | Let's add the second table, the "Review" table. The Review table is a list of reviews for 271 | various drinks; it back-references the drink category as a foreign key into the `Category` table: 272 | 273 | ```sql92 274 | create TABLE REVIEW ( 275 | id bigint auto_increment PRIMARY KEY, 276 | beverageName VARCHAR(200) not null, 277 | score TINYINT NOT NULL, 278 | date DATE not NULL, 279 | category BIGINT, 280 | count TINYINT not null 281 | ); 282 | alter table Review add CONSTRAINT r_score_range CHECK (score >= 1 and score <= 5); 283 | alter table Review add FOREIGN KEY (category) REFERENCES Category(ID); 284 | alter table Review add CONSTRAINT r_count_range CHECK (count >= 1 and count <= 99); 285 | create INDEX idx_beverage_name ON Review(beverageName); 286 | ``` 287 | 288 | The mapping class is as follows: 289 | ```kotlin 290 | /** 291 | * Represents a beverage review. 292 | * @property score the score, 1..5, 1 being worst, 5 being best 293 | * @property date when the review was done 294 | * @property category the beverage category [Category.id] 295 | * @property count times tasted, 1..99 296 | */ 297 | data class Review(override var id: Long? = null, 298 | var score: Int = 1, 299 | var beverageName: String = "", 300 | var date: LocalDate = LocalDate.now(), 301 | var category: Long? = null, 302 | var count: Int = 1) : KEntity { 303 | 304 | companion object : Dao(Review::class.java) 305 | } 306 | ``` 307 | 308 | Now if we want to delete a category, we need to first set the `Review.category` value to `null` for all reviews that 309 | are linked to that very category, otherwise 310 | we will get a foreign constraint violation. It's quite easy: just override the `delete()` method in the 311 | `Category` class as follows: 312 | 313 | ```kotlin 314 | data class Category(/*...*/) : KEntity { 315 | // ... 316 | override fun delete() { 317 | db { 318 | if (id != null) { 319 | handle.createQuery("update Review set category = NULL where category=:catId") 320 | .bind("catId", id!!) 321 | .executeUpdate() 322 | } 323 | super.delete() 324 | } 325 | } 326 | } 327 | ``` 328 | 329 | > **Note:** for all slightly more complex queries it's a good practice to simply use the JDBI API - we will simply pass in the SQL command as a String to JDBI. 330 | 331 | As you can see, you can use the JDBI connection yourself, to execute any kind of SELECT/UPDATE/INSERT/DELETE statements as you like. 332 | For example you can define static finder or computation method into the `Review` companion object: 333 | 334 | ```kotlin 335 | companion object : Dao(Review::class.java) { 336 | /** 337 | * Computes the total sum of [count] for all reviews belonging to given [categoryId]. 338 | * @return the total sum, 0 or greater. 339 | */ 340 | fun getTotalCountForReviewsInCategory(categoryId: Long): Long = db { 341 | handle.createQuery("select sum(r.count) from Review r where r.category = :catId") 342 | .bind("catId", categoryId) 343 | .mapTo(Long::class.java).one() ?: 0L 344 | } 345 | } 346 | ``` 347 | 348 | Then we can outfit the Category itself with this functionality, by adding an extension method to compute this value: 349 | ```kotlin 350 | fun Category.getTotalCountForReviews(): Long = Review.getTotalCountForReviewsInCategory(id!!) 351 | ``` 352 | 353 | Note how freely and simply we can add useful business logic methods to entities. It's because: 354 | 355 | * the entities are just plain old classes with no hidden fields and no runtime enhancements, and 356 | * because we can invoke `db{}` freely from anywhere. You don't need transaction annotations and injected entity managers, 357 | and you don't need huge container such as Spring or JavaEE which must instantiate your classes 358 | in order to activate those annotations and injections. 359 | Those are things of the past. 360 | 361 | ### Auto-generated IDs vs pre-provided IDs 362 | 363 | There are generally three cases for entity ID generation: 364 | 365 | * IDs generated by the database when the `INSERT` statement is executed 366 | * Natural IDs, such as a NaturalPerson with ID pre-provided by the government (social security number etc). 367 | * IDs created by the application, for example via `UUID.randomUUID()` 368 | 369 | The `save()` method is designed to work out-of-the-box only for the first case (IDs auto-generated by the database). In this 370 | case, `save()` emits `INSERT` when the ID is null, and `UPDATE` when the ID is not null. 371 | 372 | When the ID is pre-provided, you can only use `save()` method to update a row in the database; using `save()` to create a 373 | row in the database will throw an exception. In order to create an 374 | entity with a pre-provided ID, you need to use the `create()` method: 375 | ```kotlin 376 | NaturalPerson(id = "12345678", name = "Albedo").create() 377 | ``` 378 | 379 | For entities with IDs created by the application you can make `save()` work properly, by overriding the `create()` method 380 | in your entity as follows: 381 | ```kotlin 382 | override fun create(validate: Boolean) { 383 | id = UUID.randomUUID() 384 | super.create(validate) 385 | } 386 | ``` 387 | 388 | Even better, you can inherit from the `Entity` interface as follows: 389 | 390 | ```kotlin 391 | interface UuidEntity : KEntity { 392 | override fun create(validate: Boolean) { 393 | id = UUID.randomUUID() 394 | super.create(validate) 395 | } 396 | } 397 | ``` 398 | 399 | And simply make all of your entities implement the `UuidEntity` interface. 400 | 401 | ### Joins 402 | 403 | When we display a list of reviews (say, in a Vaadin Grid), we want to display an actual category name instead of the numeric category ID. 404 | We can take advantage of JDBI simply matching all SELECT column names into bean fields; all we have to do is to: 405 | 406 | * create a new class which contains both the `Review` field and add the `categoryName` field which will carry the category name information; 407 | * write a SELECT that will return all of the `Review` fields, and, additionally, the `categoryName` field 408 | 409 | Let's thus create a `ReviewWithCategory` class: 410 | 411 | ```kotlin 412 | class ReviewWithCategory : Serializable { 413 | @field:Nested 414 | var review: Review = Review() 415 | @field:ColumnName("categoryName") 416 | var categoryName: String? = null 417 | } 418 | ``` 419 | 420 | > Note the `@ColumnName` annotation which tells vok-orm that the field is named differently in the database. Often the database naming schema 421 | is different from Kotlin's naming schema, for example `NUMBER_OF_PETS` would be represented by the `numberOfPets` in the Kotlin class. 422 | You can use database aliases, for example `SELECT NUMBER_OF_PETS AS numberOfPets`. However note that you can't then add a `WHERE` clause on 423 | the `numberOfPets` alias - that's not supported by SQL databases. See [Issue #5](https://github.com/mvysny/vok-orm/issues/5) for more details. 424 | Currently we don't use WHERE in our examples so you're free to use aliases, but aliases do not work with Data Loaders and therefore it's a good 425 | practice to use `@ColumnName` instead of SQL aliases. 426 | 427 | Now we can add a new finder function into `ReviewWithCategory`'s companion object: 428 | 429 | ```kotlin 430 | companion object : DaoOfAny(ReviewWithCategory::class.java) { 431 | //... 432 | fun findReviews(): List = db { 433 | handle.createQuery("""select r.*, c.name 434 | FROM Review r left join Category c on r.category = c.id 435 | ORDER BY r.name""") 436 | .map(getRowMapper()) 437 | .list() 438 | } 439 | } 440 | ``` 441 | 442 | It also makes sense to add this function to `Review`'s companion object: 443 | ```kotlin 444 | companion object : Dao(Review::class.java) { 445 | //... 446 | fun findReviews() = ReviewWithCategory.findReviews() 447 | } 448 | ``` 449 | 450 | We can take JDBI's mapping capabilities to full use: we can craft any SELECT we want, 451 | and then we can create a holder class that will not be an entity itself, but will merely hold the result of that SELECT. 452 | The only thing that matters is that the class will have properties named exactly as the columns in the SELECT statement (or properly aliased 453 | using the `@ColumnName` annotation): 454 | 455 | ```kotlin 456 | data class Beverage(@field:ColumnName("beverageName") var name: String = "", @field:ColumnName("name") var category: String? = null) : Serializable { 457 | companion object { 458 | fun findAll(): List = db { 459 | handle.createQuery("select r.beverageName, c.name from Review r left join Category c on r.category = c.id") 460 | .map(FieldMapper.of(Beverage::class.java)) 461 | .list() 462 | } 463 | } 464 | } 465 | ``` 466 | 467 | We just have to make sure that all of the `Beverage`'s fields are pre-initialized, so that the `Beverage` class has a zero-arg constructor. 468 | If not, JDBI will throw an exception in runtime, stating that the `Beverage` class has no zero-arg constructor. 469 | 470 | ## Validations 471 | 472 | Often the database entities are connected to UI forms which need to provide sensible 473 | validation errors to the users as they enter invalid values. The validation 474 | could be done on the database level, but databases tend to provide unlocalized 475 | cryptic error messages. Also, some validations are either impossible to do, or very hard 476 | to do on the database level. That's why `vok-orm` provides additional validation 477 | layer for your entities. 478 | 479 | `vok-orm` uses [JSR303 Java Standard for Validation](https://en.wikipedia.org/wiki/Bean_Validation); you can 480 | quickly skim over [JSR303 tutorial](https://dzone.com/articles/bean-validation-made-simple) to see how to start 481 | using the validation. 482 | In a nutshell, you annotate your KEntity's fields with validation annotations; the fields are 483 | then checked for valid values with the JSR303 Validator (invoked when 484 | `entity.validate()`/`entity.save()`/`entity.create()` is called). The validation is 485 | also mentioned in [Vaadin-on-Kotlin Forms](http://www.vaadinonkotlin.eu/forms.html) documentation. 486 | 487 | For example: 488 | ```kotlin 489 | data class Person( 490 | override var id: Long? = null, 491 | 492 | @field:NotNull 493 | @field:Size(min = 1, max = 200) 494 | var name: String? = null, 495 | 496 | @field:NotNull 497 | @field:Min(15) 498 | @field:Max(100) 499 | var age: Int? = null) : KEntity 500 | val p = Person(name = "John", age = 10) 501 | p.validate() // throws an exception since age must be at least 15 502 | ``` 503 | 504 | *Important note:* the validation is an optional feature in `vok-orm`, and by default 505 | the validation is disabled. This fact is advertised in the `vok-orm` logs as the following message: 506 | 507 | > JSR 303 Validator Provider was not found on your classpath, disabling entity validation 508 | 509 | In order to activate the entity validations, you need to add a JSR303 Validation Provider jar 510 | to your classpath. Just use Hibernate-Validator (don't worry it will not pull in Hibernate nor 511 | JPA) and add this to your `build.gradle`: 512 | 513 | ```groovy 514 | dependencies { 515 | compile("org.hibernate.validator:hibernate-validator:6.0.17.Final") 516 | // EL is required: http://hibernate.org/validator/documentation/getting-started/ 517 | compile("org.glassfish:javax.el:3.0.1-b08") 518 | } 519 | ``` 520 | 521 | You can check out the [vok-orm-playground](https://gitlab.com/mvysny/vok-orm-playground) which 522 | has validations enabled and all necessary jars included. 523 | 524 | ## Data Loaders 525 | 526 | The support for [Data Loader](https://gitlab.com/mvysny/vok-dataloader) is deprecated and removed. 527 | 528 | ## Vaadin 529 | 530 | For Vaadin integration please see [jdbi-orm-vaadin](https://gitlab.com/mvysny/jdbi-orm-vaadin) 531 | which supports vok-orm too, and it provides support for all sorts of data providers including 532 | entity, POJO and joins/custom SQL statements. 533 | 534 | For Vaadin example apps, please take a look at: 535 | 536 | * [beverage-buddy-vok](https://github.com/mvysny/beverage-buddy-vok): A full-blown Vaadin Kotlin app which demoes vok-orm-based CRUD 537 | * [vok-security-demo](https://github.com/mvysny/vok-security-demo): uses vok-orm to load users 538 | * [vaadin-kotlin-pwa](https://github.com/mvysny/vaadin-kotlin-pwa): demoes Vaadin+Kotlin+CRUD as well 539 | 540 | ## Condition API 541 | 542 | The Condition API offers a programmatic way to create WHERE clauses; since jdbi-orm 2.0. 543 | This is very handy for use with Data Providers, but also for creating simple WHERE selects quickly and easily. 544 | First, you need to add column definitions to your entities: 545 | 546 | ```kotlin 547 | class Category : Entity { 548 | var id: Long? 549 | var name: String? 550 | companion object : Dao() { 551 | val ID = tableProperty(Person::id) 552 | val NAME = tableProperty(Person::name) 553 | val AGE = tableProperty(Person::age) 554 | } 555 | } 556 | ``` 557 | 558 | You need to do that for all of your database columns. This will allow you to create the Condition 559 | which can then be passed to your DAO: 560 | 561 | ```java 562 | List categories = Category.dao.findAllBy(Category.NAME.like("Beer%").and(Category.ID.gt(2))); 563 | ``` 564 | 565 | ## Full-Text Filters 566 | 567 | In order for the `FullTextFilter` filter to work, you must create a proper full-text index 568 | in your database for the column being matched. Please see the documentation for 569 | individual databases below. 570 | 571 | To customize the SQL scripts being generated, you can create a delegate 572 | `FilterToSqlConverter` which is able to handle `FullTextFilter`s and passes through 573 | all other filters to the default `VokOrm.filterToSqlConverter`. 574 | 575 | The `FullTextFilter` class cleans up the user input, removes any non-alphabetic and non-digit 576 | characters and turns user input into a set of words. The words will never contain 577 | characters such as `+` `/` `*` which are often used by the full text engine. 578 | Therefore, it's easy to join the words and produce a full-text query. 579 | 580 | In order for the Filter converter to know which syntax to produce, you must set 581 | your database variant in `VokOrm.databaseVariant`. The default one is 'Unknown' 582 | and since there is no full-text matching in SQL92, by default all `FullTextFilter` 583 | conversion will fail. 584 | 585 | ### H2 586 | 587 | Full-Text searches are supported. vok-orm uses FullTextLucene implementation since 588 | the native H2 implementation can't do partial matches (e.g. filter `car` won't match `carousel`). 589 | 590 | The following WHERE clauses are produced by default: 591 | 592 | ```sql 593 | $idColumn IN (SELECT CAST(FT.KEYS[1] AS BIGINT) AS ID FROM FTL_SEARCH_DATA(:$parameterName, 0, 0) FT WHERE FT.`TABLE`='${meta.databaseTableName.toUpperCase()}') 594 | ``` 595 | 596 | You need to call the following to init the engine and create the index: 597 | 598 | ```sql 599 | CREATE ALIAS IF NOT EXISTS FTL_INIT FOR "org.h2.fulltext.FullTextLucene.init"; 600 | CALL FTL_INIT(); 601 | CALL FTL_CREATE_INDEX('PUBLIC', 'TEST', 'NAME'); -- Adds index on the 'NAME' column of the 'TEST' table. 602 | ``` 603 | 604 | Make sure to use upper-case table+column names otherwise H2 will complain that the column/table doesn't exist. 605 | 606 | You will need to add Lucene on the classpath: 607 | 608 | ```gradle 609 | compile("org.apache.lucene:lucene-analyzers-common:5.5.5") 610 | compile("org.apache.lucene:lucene-queryparser:5.5.5") 611 | ``` 612 | 613 | Limitations: 614 | 615 | * Only tables with non-composite `BIGINT` primary keys are supported. You can 616 | lift this limitation by implementing your own `FilterToSqlConverter` and constructing 617 | your own query. 618 | 619 | See [H2 Full-Text search](https://www.h2database.com/html/tutorial.html#fulltext) for 620 | more info. 621 | 622 | ### PostgreSQL 623 | 624 | The following WHERE clauses are produced by default: 625 | `to_tsvector('english', $databaseColumnName) @@ to_tsquery('english', 'fat:* cat:*')`. 626 | 627 | A full-text index is not necessary in order for PostgreSQL to properly match records, 628 | however the performance will be horrible. I recommend to create the index, e.g. 629 | `CREATE INDEX pgweb_idx ON Test USING GIN (to_tsvector('english', name))`. 630 | 631 | See [PostgreSQL Full-Text search](https://www.postgresql.org/docs/9.5/textsearch-tables.html#TEXTSEARCH-TABLES-INDEX) 632 | for more info. 633 | 634 | ### MySQL/MariaDB 635 | 636 | You'll need to create a [FULLTEXT index](https://dev.mysql.com/doc/refman/8.0/en/fulltext-search.html) 637 | for the column, otherwise MySQL will match nothing. 638 | 639 | By default the following WHERE clauses are produced: e.g. when searching for "fat cat", 640 | this is emitted: `MATCH($databaseColumnName) AGAINST ("+fat* +cat*" IN BOOLEAN MODE)`. 641 | 642 | MySQL has a number of quirks to look after: 643 | 644 | * Sometimes MySQL will use another index instead of a full-text index: [MySQL sporadic MATCH AGAINST behaviour with unique index](https://stackoverflow.com/questions/45281641/mysql-sporadic-match-against-behaviour-with-unique-index) 645 | Either delete the offending index, or use [NativeFilter] and `IGNORE INDEX ()` 646 | 647 | Why the filter is using BOOLEAN mode instead of NATURAL LANGUAGE mode: 648 | 649 | * Random treating of words as stopwords because they're present in more than 50% of the rows: [MySQL Natural Language](https://dev.mysql.com/doc/refman/5.5/en/fulltext-natural-language.html). 650 | * No way to match word beginnings. 651 | 652 | ## Aliases 653 | 654 | Often database columns follow different naming convention than bean fields, e.g. database `CUSTOMER_NAME` should be mapped to the 655 | `CustomerAddress::customerName` field. The first thing to try is to use aliases in the SQL itself, for example 656 | ```sql 657 | select c.CUSTOMER_NAME as customerName from Customer c ...; 658 | ``` 659 | 660 | The problem with this approach is twofold: 661 | 662 | * Databases can't sort nor filter based on aliased column; 663 | please see [Issue 5](https://github.com/mvysny/vok-orm/issues/5) for more details. 664 | Using such queries with `SqlDataLoader` and trying to pass in filter such as `buildFilter { "customerName ILIKE cn"("cn" to "Foo%") }` will cause 665 | the select command to fail with `SqlException`. 666 | * INSERTs/UPDATEs issued by your entity `Dao` will fail since they will use the bean field names instead of actual column name 667 | and will emit `INSERT INTO Customer (customerName) values ($1)` instead of `INSERT INTO Customer (CUSTOMER_NAME) values ($1)` 668 | 669 | Therefore, instead of database-based aliases it's better to use the `@ColumnName` annotation on your beans, both natural entities 670 | such as `Customer` and projection-only entities such as `CustomerAddress`: 671 | 672 | ```kotlin 673 | data class Customer(@field:ColumnName("CUSTOMER_NAME") var name: String? = null) : KEntity 674 | data class CustomerAddress(@field:ColumnName("CUSTOMER_NAME") var customerName: String? = null) 675 | ``` 676 | 677 | The `@ColumnName` annotation is honored both by `Dao`s and by all data loaders. 678 | 679 | ## group by/aggregates/pivot table 680 | 681 | Since the group-by query may produce data with different data types than the bean itself contains (e.g. 682 | averaging integer age may produce a float/double result), it's best to create a new class to hold the 683 | outcome of the query: 684 | 685 | ```java 686 | public final class ReviewAvgScore implements Serializable { 687 | @ColumnName("beverageName") 688 | private String name; 689 | private float avgScore; 690 | // getters and setters 691 | 692 | public static List findAll() { 693 | return jdbi().withHandle(handle -> handle 694 | .createQuery("select beverageName, avg(score) as avgScore from Review group by beverageName order by beverageName") 695 | .map(FieldMapper.of(ReviewAvgScore.class)) 696 | .list()); 697 | } 698 | } 699 | ``` 700 | 701 | Note that we're not using the `Dao` here since the class is not backed by a table. 702 | 703 | Note that this approach is not fit for a configurable pivot table which may need to 704 | support dynamic criteria list. For that I'd recommend to: 705 | 706 | 1. create the SQL depending on the grouping/aggregate criteria list; 707 | 2. Use a custom JDBI Mapper, to map the JDBC rows into some kind of dynamic row, 708 | e.g. backed by a `HashMap`. 709 | 710 | ## A main() method Example 711 | 712 | Using the vok-orm library from a JavaSE main method; 713 | see the [vok-orm-playground](https://gitlab.com/mvysny/vok-orm-playground) for a very simple example project 714 | using `vok-orm`. 715 | 716 | ```kotlin 717 | data class Person( 718 | override var id: Long? = null, 719 | var name: String = "", 720 | var age: Int = 0, 721 | var dateOfBirth: LocalDate? = null, 722 | var recordCreatedAt: Instant? = null 723 | ) : KEntity { 724 | override fun save(validate: Boolean) { 725 | if (id == null) { 726 | recordCreatedAt = Instant.now() 727 | } 728 | super.save(validate) 729 | } 730 | 731 | companion object : Dao(Person::class.java) 732 | } 733 | 734 | fun main(args: Array) { 735 | val cfg = HikariConfig().apply { 736 | jdbcUrl = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1" 737 | } 738 | JdbiOrm.setDataSource(HikariDataSource(cfg)) 739 | db { 740 | con.createQuery( 741 | """create table if not exists Test ( 742 | id bigint primary key auto_increment, 743 | name varchar not null, 744 | age integer not null, 745 | dateOfBirth date, 746 | recordCreatedAt timestamp 747 | )""" 748 | ).executeUpdate() 749 | } 750 | 751 | // runs SELECT * FROM Person 752 | // prints [] 753 | println(Person.findAll()) 754 | 755 | // runs INSERT INTO Person (name, age, recordCreatedAt) values (:p1, :p2, :p3) 756 | Person(name = "John", age = 42).save() 757 | 758 | // runs SELECT * FROM Person 759 | // prints [Person(id=1, name=John, age=42, dateOfBirth=null, recordCreatedAt=2011-12-03T10:15:30Z)] 760 | println(Person.findAll()) 761 | 762 | // runs SELECT * FROM Person where id=:id 763 | // prints John 764 | println(Person.getById(1L).name) 765 | 766 | // mass-saves 11 persons in a single transaction. 767 | db { (0..10).forEach { Person(name = "person $it", age = it).save() } } 768 | 769 | JdbiOrm.destroy() 770 | } 771 | ``` 772 | 773 | # Using Flyway to migrate the database 774 | 775 | [Flyway](https://flywaydb.org/) is able to run DDL scripts on given database and track which scripts already ran. 776 | This way, you can simply add more scripts and Flyway will apply them, to migrate the database to the newest version. 777 | This even works in a cluster since Flyway will obtain a database lock, locking out other members of the cluster attempting 778 | to upgrade. 779 | 780 | Let's use the Category example from above. We need Flyway to run two scripts, to initialize the database: 781 | one creates the table, while other creates the indices. 782 | 783 | You don't need to use Flyway plugin. Just add the following Gradle dependency to your project: 784 | 785 | ```gradle 786 | compile "org.flywaydb:flyway-core:6.0.7" 787 | ``` 788 | 789 | Flyway expects the migration scripts named in a certain format, to know the order in which to execute them. 790 | Create the `db.migration` package in your `src/main/resources` and put two files there: the `V01__CreateCategory.sql` 791 | file: 792 | ```sql92 793 | create TABLE CATEGORY ( 794 | id bigint auto_increment PRIMARY KEY, 795 | name varchar(200) NOT NULL 796 | ); 797 | ``` 798 | The next one will be `V02__CreateIndexCategoryName.sql`: 799 | ```sql92 800 | create UNIQUE INDEX idx_category_name ON CATEGORY(name); 801 | ``` 802 | 803 | In order to run the migrations, just run the following after `JdbiOrm.setDataSource()`: 804 | ```kotlin 805 | val flyway = Flyway() 806 | flyway.dataSource = JdbiOrm.getDataSource() 807 | flyway.migrate() 808 | ``` 809 | 810 | # Using with Spring or JavaEE 811 | 812 | By default VoK-ORM connects to the JDBC database directly and uses its own instance of 813 | Hikari-CP to pool JDBC connections. That of course doesn't work with containers such as Spring or 814 | JavaEE which manage JDBC resources themselves. 815 | 816 | It is very easy to use VoK-ORM with Spring or JavaEE. All you need is to obtain 817 | an instance of `DataSource` when your server boots up, then simply set it to 818 | JdbiOrm via `JdbiOrm.setDataSource()`. VoK-ORM will then simply poll Spring or JavaEE 819 | DataSource for connections; Spring/JavaEE will then make sure the connections are pooled properly. 820 | 821 | You don't even need to call `JdbiOrm.destroy()` on Spring/JavaEE app shutdown: 822 | all `JdbiOrm.destroy()` does is that it closes the `DataSource`, however Spring/JavaEE 823 | will do that for us. 824 | 825 | # `vok-orm` design principles 826 | 827 | `vok-orm` is a very simple object-relational mapping library, built around the following ideas: 828 | 829 | * Simplicity is the most valued property; working with plain SQL commands is preferred over having a type-safe 830 | query language. If you want a type-safe database mapping library, try [Exposed](https://github.com/JetBrains/Exposed). 831 | * The database is the source of truth. JVM objects are nothing more than DTOs, 832 | merely capture snapshots of the JDBC `ResultSet` rows. The entities are populated by the 833 | means of reflection: for every column in 834 | the JDBC `ResultSet` an appropriate setter is invoked, to populate the data. 835 | * The entities are real POJOs: they do not track modifications, they do not automatically store modified 836 | values back into the database. They are not runtime-enhanced and can be final. 837 | * A switch from one type of database to another never happens. We understand that the programmer 838 | wants to exploit the full potential of the database, by writing SQLs tailored for that particular database. 839 | `vok-orm` should not attempt to generate SELECTs on behalf of the programmer (except for the very basic ones related to CRUD); 840 | instead it should simply allow SELECTs to be passed as Strings, and then map the result 841 | to an object of programmer's choosing. 842 | * No plugins required, no generated code necessary. It's tricky to run the code generators, 843 | it's questionable whether you want to commit generated stuff into git or not; if it's committed, 844 | is it still up-to-date? Who knows? It's better to write it by hand and commit it to git. 845 | 846 | As such, `vok-orm` has much in common with the [ActiveJDBC](https://github.com/javalite/activejdbc) project, in terms 847 | of design principles. The advantage of `vok-orm` is that it doesn't require any instrumentation to work 848 | (instead it uses Kotlin language features), and it's even simpler than ActiveJDBC. 849 | 850 | Please read [Back to Base - make SQL great again](http://mavi.logdown.com/posts/5771422) 851 | for the complete explanation of ideas behind this framework. 852 | 853 | This framework uses [JDBI](http://jdbi.org/) to map data from the JDBC `ResultSet` to POJOs; in addition it provides a very simple 854 | mechanism to store/update the data back to the database. 855 | 856 | ## Comparison with other database-related libraries 857 | 858 | * [ActiveJDBC](https://javalite.io/activejdbc) has much in common with jdbi-orm; the advantage of jdbi-orm 859 | is that we do not require any instrumentation to work (we use only Kotlin language features). 860 | * [JOOQ](https://www.jooq.org/) is great but requires initial generation of java code from your database scheme 861 | (you write your entities by hand with vok-orm), and promotes type-safe query building instead of plain SQLs. 862 | There's the usual set of problems coming with generated classes: you can't add your custom utility functions to those, 863 | you can't add validation annotations, etc. 864 | If you don't mind that, go for JOOQ - it's definitely more popular than jdbi-orm. 865 | * JPA: just no. We want real POJOs, not a dynamically-enhanced thing managed by the Entity Manager. Also see below. 866 | * Spring JdbcTemplate: not bad but it depends on Spring; jdbi-orm must be able to work on pure JVM, without Spring. 867 | * [ktorm](https://www.ktorm.org/) looks good, TODO comparison 868 | * [Exposed](https://github.com/JetBrains/Exposed) looks interesting too, TODO comparison. 869 | 870 | ## Why not JPA 871 | 872 | JPA promises simplicity of usage by providing an object-oriented API. However, this is achieved by 873 | creating a *virtual object database* layer over a relational database; that creates much complexity 874 | under the hood which leaks in various ways. In short, JPA is a double failure: it chose the wrong abstraction, 875 | and implemented it poorly. 876 | 877 | There are major issues in JPA which cannot be overlooked: 878 | 879 | * [Vaadin-on-Kotlin Issue #3 Remove JPA](https://github.com/mvysny/vaadin-on-kotlin/issues/3) 880 | * [Back to Base - make SQL great again](http://mavi.logdown.com/posts/5771422) 881 | * [Do-It-Yourself ORM as an Alternative to Hibernate](https://blog.philipphauer.de/do-it-yourself-orm-alternative-hibernate-drawbacks/) 882 | 883 | We strive to erase the virtual object database layer. We acknowledge the existence of 884 | the relational database; we only provide tools to ease the use of the database from a 885 | statically-typed OOP language. 886 | 887 | ## Running tests 888 | 889 | Running `./gradlew` or `./gradlew test` will run all tests on all databases (given that 890 | Docker is available on the host system). To run the tests on H2 only 891 | (the test suite will run much faster), run with `./gradlew -Dh2only=true` 892 | 893 | # License 894 | 895 | Licensed under the [MIT License](https://opensource.org/licenses/MIT). 896 | 897 | Copyright (c) 2017-2018 Martin Vysny 898 | 899 | All rights reserved. 900 | 901 | Permission is hereby granted, free of charge, to any person obtaining 902 | a copy of this software and associated documentation files (the 903 | "Software"), to deal in the Software without restriction, including 904 | without limitation the rights to use, copy, modify, merge, publish, 905 | distribute, sublicense, and/or sell copies of the Software, and to 906 | permit persons to whom the Software is furnished to do so, subject to 907 | the following conditions: 908 | 909 | The above copyright notice and this permission notice shall be 910 | included in all copies or substantial portions of the Software. 911 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 912 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 913 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 914 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 915 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 916 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 917 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 918 | --------------------------------------------------------------------------------