diff --git a/src/main/kotlin/com/github/vokorm/ConnectionUtils.kt b/src/main/kotlin/com/github/vokorm/ConnectionUtils.kt index da65948..42f7d5f 100644 --- a/src/main/kotlin/com/github/vokorm/ConnectionUtils.kt +++ b/src/main/kotlin/com/github/vokorm/ConnectionUtils.kt @@ -7,7 +7,10 @@ import org.sql2o.Query * Finds all instances of given entity. Fails if there is no table in the database with the name of [databaseTableName]. The list is eager * and thus it's useful for smallish tables only. */ -fun Connection.findAll(clazz: Class): List = createQuery("select * from ${clazz.entityMeta.databaseTableName}").executeAndFetch(clazz) +fun Connection.findAll(clazz: Class): List = + createQuery("select * from ${clazz.entityMeta.databaseTableName}") + .setColumnMappings(clazz.entityMeta.getSql2oColumnMappings()) + .executeAndFetch(clazz) /** * Retrieves entity with given [id]. Returns null if there is no such entity. @@ -15,6 +18,7 @@ fun Connection.findAll(clazz: Class): List = createQuery("select fun Connection.findById(clazz: Class, id: Any): T? = createQuery("select * from ${clazz.entityMeta.databaseTableName} where id = :id") .addParameter("id", id) + .setColumnMappings(clazz.entityMeta.getSql2oColumnMappings()) .executeAndFetchFirst(clazz) /** @@ -75,6 +79,7 @@ fun Connection.findBy(clazz: Class, limit: Int, filter: Filter): require (limit >= 0) { "$limit is less than 0" } val query = createQuery("select * from ${clazz.entityMeta.databaseTableName} where ${filter.toSQL92()} limit $limit") filter.getSQL92Parameters().entries.forEach { (name, value) -> query.addParameter(name, value) } + query.setColumnMappings(clazz.entityMeta.getSql2oColumnMappings()) return query.executeAndFetch(clazz) } @@ -82,7 +87,7 @@ fun Connection.findBy(clazz: Class, limit: Int, filter: Filter): * Deletes all entities with given [clazz] matching given criteria [block]. */ fun Connection.deleteBy(clazz: Class, block: SqlWhereBuilder.()-> Filter) { - val filter = block(SqlWhereBuilder()) + val filter = block(SqlWhereBuilder(clazz)) val query = createQuery("delete from ${clazz.entityMeta.databaseTableName} where ${filter.toSQL92()}") filter.getSQL92Parameters().entries.forEach { (name, value) -> query.addParameter(name, value) } query.executeUpdate() diff --git a/src/main/kotlin/com/github/vokorm/Dao.kt b/src/main/kotlin/com/github/vokorm/Dao.kt index abb8dfa..4ae66e4 100644 --- a/src/main/kotlin/com/github/vokorm/Dao.kt +++ b/src/main/kotlin/com/github/vokorm/Dao.kt @@ -70,7 +70,7 @@ inline fun DaoOfAny.getById(id: Any): T = db { con.getById(T * @throws IllegalArgumentException if there is no entity matching given criteria, or if there are two or more matching entities. */ inline fun > Dao.getBy(noinline block: SqlWhereBuilder.()-> Filter): T { - val filter = block(SqlWhereBuilder()) + val filter = block(SqlWhereBuilder(T::class.java)) return db { con.getBy(T::class.java, filter) } } @@ -88,7 +88,7 @@ inline fun > Dao.getBy(noinline block: SqlWher * @throws IllegalArgumentException if there is no entity matching given criteria, or if there are two or more matching entities. */ inline fun DaoOfAny.getBy(noinline block: SqlWhereBuilder.()-> Filter): T { - val filter = block(SqlWhereBuilder()) + val filter = block(SqlWhereBuilder(T::class.java)) return db { con.getBy(T::class.java, filter) } } @@ -107,7 +107,7 @@ inline fun DaoOfAny.getBy(noinline block: SqlWhereBuilder * @throws IllegalArgumentException if there are two or more matching entities. */ inline fun > Dao.findSpecificBy(noinline block: SqlWhereBuilder.()-> Filter): T? { - val filter = block(SqlWhereBuilder()) + val filter = block(SqlWhereBuilder(T::class.java)) return db { con.findSpecificBy(T::class.java, filter) } } @@ -125,7 +125,7 @@ inline fun > Dao.findSpecificBy(noinline block * @throws IllegalArgumentException if there are two or more matching entities. */ inline fun DaoOfAny.findSpecificBy(noinline block: SqlWhereBuilder.()-> Filter): T? { - val filter = block(SqlWhereBuilder()) + val filter = block(SqlWhereBuilder(T::class.java)) return db { con.findSpecificBy(T::class.java, filter) } } @@ -163,12 +163,12 @@ inline fun DaoOfAny.count(): Long = db { con.getCount(T::cla /** * Counts all rows in given table which matches given [block] clause. */ -inline fun > Dao.count(noinline block: SqlWhereBuilder.()-> Filter): Long = db { con.getCount(T::class.java, SqlWhereBuilder().block()) } +inline fun > Dao.count(noinline block: SqlWhereBuilder.()-> Filter): Long = db { con.getCount(T::class.java, SqlWhereBuilder(T::class.java).block()) } /** * Counts all rows in given table which matches given [block] clause. */ -inline fun DaoOfAny.count(noinline block: SqlWhereBuilder.()-> Filter): Long = db { con.getCount(T::class.java, SqlWhereBuilder().block()) } +inline fun DaoOfAny.count(noinline block: SqlWhereBuilder.()-> Filter): Long = db { con.getCount(T::class.java, SqlWhereBuilder(T::class.java).block()) } /** * Deletes row with given ID. Does nothing if there is no such row. @@ -236,7 +236,7 @@ inline val > Dao.meta: EntityMeta * ``` */ inline fun DaoOfAny.findBy(limit: Int = Int.MAX_VALUE, noinline block: SqlWhereBuilder.()-> Filter): List = - db { con.findBy(T::class.java, limit, block(SqlWhereBuilder())) } + db { con.findBy(T::class.java, limit, block(SqlWhereBuilder(T::class.java))) } /** @@ -255,4 +255,4 @@ inline fun DaoOfAny.findBy(limit: Int = Int.MAX_VALUE, noinl * ``` */ inline fun > Dao.findBy(limit: Int = Int.MAX_VALUE, noinline block: SqlWhereBuilder.()-> Filter): List = - db { con.findBy(T::class.java, limit, block(SqlWhereBuilder())) } + db { con.findBy(T::class.java, limit, block(SqlWhereBuilder(T::class.java))) } diff --git a/src/main/kotlin/com/github/vokorm/Filters.kt b/src/main/kotlin/com/github/vokorm/Filters.kt index b105d0b..11e7db9 100644 --- a/src/main/kotlin/com/github/vokorm/Filters.kt +++ b/src/main/kotlin/com/github/vokorm/Filters.kt @@ -1,14 +1,10 @@ package com.github.vokorm -import java.beans.Introspector import java.io.Serializable -import java.lang.reflect.Method import java.util.function.BiPredicate import java.util.function.Predicate import kotlin.reflect.KProperty1 -interface SerializablePredicate : Predicate, Serializable - /** * A generic filter which filters items of type [T]. Implementors must define how exactly the items are filtered. The filter is retrofitted as a serializable * predicate so that in-memory filtering is also supported. @@ -18,40 +14,29 @@ interface SerializablePredicate : Predicate, Serializable * @param T the bean type upon which we will perform the filtering. This is not used by the filter directly, but it's required by [Predicate] which this * interface extends. */ -interface Filter : SerializablePredicate { +interface Filter : Serializable { infix fun and(other: Filter): Filter = AndFilter(setOf(this, other)) infix fun or(other: Filter): Filter = OrFilter(setOf(this, other)) /** * Attempts to convert this filter into a SQL 92 WHERE-clause representation (omitting the `WHERE` keyword). There are two types of filters: * * Filters which do not match column to a value, for example [AndFilter] which produces something like `(filter1 and filter2)` * * Filters which do match column to a value, for example [LikeFilter] which produces things like `name LIKE :name`. All [BeanFilter]s are expected - * to match a database column to a value; that value is automatically prefilled into the JDBC query string under the [BeanFilter.propertyName]. + * to match a database column to a value; that value is automatically prefilled into the JDBC query string under the [BeanFilter.databaseColumnName]. * * Examples of returned values: * * `name = :name` * * `(age >= :age AND name ILIKE :name)` */ - fun toSQL92(): String = throw IllegalStateException("$this cannot be converted to sql92 filter") - fun getSQL92Parameters(): Map = throw IllegalStateException("$this cannot be converted to sql92 filter") + fun toSQL92(): String + fun getSQL92Parameters(): Map } /** - * Filters beans by comparing given [propertyName] to some expected [value]. Check out implementors for further details. + * Filters beans by comparing given [databaseColumnName] to some expected [value]. Check out implementors for further details. */ abstract class BeanFilter : Filter { - abstract val propertyName: String + abstract val databaseColumnName: String abstract val value: Any? - @Transient - private var readMethod: Method? = null - private fun getGetter(item: T): Method { - if (readMethod == null) { - val propertyDescriptor = Introspector.getBeanInfo(item.javaClass).propertyDescriptors.first { it.name == propertyName } - ?: throw IllegalStateException("Bean ${item.javaClass} has no property $propertyName") - readMethod = propertyDescriptor.readMethod ?: throw IllegalStateException("Bean ${item.javaClass} has no readMethod for property $propertyDescriptor") - } - return readMethod!! - } - protected fun getValue(item: T): Any? = getGetter(item).invoke(item) /** * A simple way on how to make parameter names unique and tie them to filter instances ;) */ @@ -62,10 +47,9 @@ abstract class BeanFilter : Filter { /** * A filter which tests for value equality. Allows nulls. */ -data class EqFilter(override val propertyName: String, override val value: Any?) : BeanFilter() { - override fun test(t: T) = getValue(t) == value - override fun toString() = "$propertyName = $value" - override fun toSQL92() = "$propertyName = :$parameterName" +data class EqFilter(override val databaseColumnName: String, override val value: Any?) : BeanFilter() { + override fun toString() = "$databaseColumnName = $value" + override fun toSQL92() = "$databaseColumnName = :$parameterName" } enum class CompareOperator(val sql92Operator: String) : BiPredicate?, Comparable> { @@ -79,41 +63,40 @@ enum class CompareOperator(val sql92Operator: String) : BiPredicate(override val propertyName: String, override val value: Comparable, val operator: CompareOperator) : BeanFilter() { - @Suppress("UNCHECKED_CAST") - override fun test(t: T) = operator.test(getValue(t) as Comparable?, value) - override fun toString() = "$propertyName ${operator.sql92Operator} $value" - override fun toSQL92() = "$propertyName ${operator.sql92Operator} :$parameterName" +data class OpFilter(override val databaseColumnName: String, override val value: Comparable, val operator: CompareOperator) : BeanFilter() { + override fun toString() = "$databaseColumnName ${operator.sql92Operator} $value" + override fun toSQL92() = "$databaseColumnName ${operator.sql92Operator} :$parameterName" } -data class IsNullFilter(override val propertyName: String) : BeanFilter() { +data class IsNullFilter(override val databaseColumnName: String) : BeanFilter() { override val value: Any? = null - override fun test(t: T) = getValue(t) == null - override fun toSQL92() = "$propertyName is null" + override fun toSQL92() = "$databaseColumnName is null" } -data class IsNotNullFilter(override val propertyName: String) : BeanFilter() { +data class IsNotNullFilter(override val databaseColumnName: String) : BeanFilter() { override val value: Any? = null - override fun test(t: T) = getValue(t) != null - override fun toSQL92() = "$propertyName is not null" + override fun toSQL92() = "$databaseColumnName is not null" } /** - * A LIKE filter. Since it does substring matching, it performs quite badly in the databases. You should use full text search + * A LIKE filter. It performs the 'starts-with' matching which tends to perform quite well on indexed columns. If you need a substring + * matching, then you actually need to employ full text search * capabilities of your database. For example [PostgreSQL full-text search](https://www.postgresql.org/docs/9.5/static/textsearch.html). - * @param substring the substring, automatically prepended and appended with `%` when the SQL query is constructed. The substring is matched + * + * There is no point in supporting substring matching: it performs a full table scan when used, regardless of whether the column contains + * the index or not. If you really wish for substring matching, you probably want a full-text search instead which is implemented using + * a different keywords. + * @param substring the substring, automatically appended with `%` when the SQL query is constructed. The substring is matched * case-sensitive. */ -class LikeFilter(override val propertyName: String, substring: String) : BeanFilter() { - private val substring = substring.trim() - override val value = "%${substring.trim()}%" - override fun test(t: T) = (getValue(t) as? String)?.contains(substring) ?: false - override fun toString() = """$propertyName LIKE "$value"""" +class LikeFilter(override val databaseColumnName: String, substring: String) : BeanFilter() { + override val value = "${substring.trim()}%" + override fun toString() = """$databaseColumnName LIKE "$value"""" override fun equals(other: Any?): Boolean { if (this === other) return true if (other?.javaClass != javaClass) return false other as LikeFilter<*> - if (propertyName != other.propertyName) return false + if (databaseColumnName != other.databaseColumnName) return false if (value != other.value) return false return true } @@ -122,25 +105,29 @@ class LikeFilter(override val propertyName: String, substring: String) : result = 31 * result + value.hashCode() return result } - override fun toSQL92() = "$propertyName LIKE :$parameterName" + override fun toSQL92() = "$databaseColumnName LIKE :$parameterName" } /** - * An ILIKE (case-insensitive) filter. Since it does substring matching, it performs quite badly in the databases. You should use full text search + * An ILIKE filter, performs case-insensitive matching. It performs the 'starts-with' matching which tends to perform quite well on indexed columns. If you need a substring + * matching, then you actually need to employ full text search * capabilities of your database. For example [PostgreSQL full-text search](https://www.postgresql.org/docs/9.5/static/textsearch.html). - * @param substring the substring, automatically prepended and appended with `%` when the SQL query is constructed. The substring is matched + * + * There is no point in supporting substring matching: it performs a full table scan when used, regardless of whether the column contains + * the index or not. If you really wish for substring matching, you probably want a full-text search instead which is implemented using + * a different keywords. + * @param substring the substring, automatically appended with `%` when the SQL query is constructed. The substring is matched * case-insensitive. */ -class ILikeFilter(override val propertyName: String, substring: String) : BeanFilter() { +class ILikeFilter(override val databaseColumnName: String, substring: String) : BeanFilter() { private val substring = substring.trim() override val value = "%${substring.trim()}%" - override fun test(t: T) = (getValue(t) as? String)?.contains(substring, ignoreCase = true) ?: false - override fun toString() = """$propertyName ILIKE "$value"""" + override fun toString() = """$databaseColumnName ILIKE "$value"""" override fun equals(other: Any?): Boolean { if (this === other) return true if (other?.javaClass != javaClass) return false other as ILikeFilter<*> - if (propertyName != other.propertyName) return false + if (databaseColumnName != other.databaseColumnName) return false if (value != other.value) return false return true } @@ -149,12 +136,11 @@ class ILikeFilter(override val propertyName: String, substring: String) result = 31 * result + value.hashCode() return result } - override fun toSQL92() = "$propertyName ILIKE :$parameterName" + override fun toSQL92() = "$databaseColumnName ILIKE :$parameterName" } class AndFilter(children: Set>) : Filter { val children: Set> = children.flatMap { if (it is AndFilter) it.children else listOf(it) }.toSet() - override fun test(t: T) = children.all { it.test(t) } override fun toString() = children.joinToString(" and ", "(", ")") override fun equals(other: Any?): Boolean { if (this === other) return true @@ -175,7 +161,6 @@ class AndFilter(children: Set>) : Filter { class OrFilter(children: Set>) : Filter { val children: Set> = children.flatMap { if (it is OrFilter) it.children else listOf(it) }.toSet() - override fun test(t: T) = children.any { it.test(t) } override fun toString() = children.joinToString(" or ", "(", ")") override fun equals(other: Any?): Boolean { if (this === other) return true @@ -211,47 +196,61 @@ fun Set>.or(): Filter? = when (size) { * Containing these functions in this class will prevent polluting of the KProperty1 interface and also makes it type-safe. * * This looks like too much Kotlin syntax magic. Promise me to use this for simple Entities and/or programmatic where creation only ;) + * @param clazz builds the query for this class. */ -class SqlWhereBuilder { - infix fun KProperty1.eq(value: R): Filter = EqFilter(name, value) +class SqlWhereBuilder(val clazz: Class) { + private val meta = clazz.entityMeta + private val KProperty1.dbname: String get() = meta.fields[meta.propertyToField(this)]!! + + infix fun KProperty1.eq(value: R): Filter = EqFilter(dbname, value) @Suppress("UNCHECKED_CAST") infix fun KProperty1.le(value: R): Filter = - OpFilter(name, value as Comparable, CompareOperator.le) + OpFilter(dbname, value as Comparable, CompareOperator.le) @Suppress("UNCHECKED_CAST") infix fun KProperty1.lt(value: R): Filter = - OpFilter(name, value as Comparable, CompareOperator.lt) + OpFilter(dbname, value as Comparable, CompareOperator.lt) @Suppress("UNCHECKED_CAST") infix fun KProperty1.ge(value: R): Filter = - OpFilter(name, value as Comparable, CompareOperator.ge) + OpFilter(dbname, value as Comparable, CompareOperator.ge) @Suppress("UNCHECKED_CAST") infix fun KProperty1.gt(value: R): Filter = - OpFilter(name, value as Comparable, CompareOperator.gt) + OpFilter(dbname, value as Comparable, CompareOperator.gt) /** - * A LIKE filter. Since it does substring matching, it performs quite badly in the databases. You should use full text search + * A LIKE filter. It performs the 'starts-with' matching which tends to perform quite well on indexed columns. If you need a substring + * matching, then you actually need to employ full text search * capabilities of your database. For example [PostgreSQL full-text search](https://www.postgresql.org/docs/9.5/static/textsearch.html). - * @param substring the substring, automatically prepended and appended with `%` when the SQL query is constructed. The substring is matched + * + * There is no point in supporting substring matching: it performs a full table scan when used, regardless of whether the column contains + * the index or not. If you really wish for substring matching, you probably want a full-text search instead which is implemented using + * a different keywords. + * @param value the prefix, automatically appended with `%` when the SQL query is constructed. The substring is matched * case-sensitive. */ - infix fun KProperty1.like(value: String): Filter = LikeFilter(name, value) + infix fun KProperty1.like(value: String): Filter = LikeFilter(dbname, value) /** - * An ILIKE (case-insensitive) filter. Since it does substring matching, it performs quite badly in the databases. You should use full text search + * An ILIKE filter, performs case-insensitive matching. It performs the 'starts-with' matching which tends to perform quite well on indexed columns. If you need a substring + * matching, then you actually need to employ full text search * capabilities of your database. For example [PostgreSQL full-text search](https://www.postgresql.org/docs/9.5/static/textsearch.html). - * @param substring the substring, automatically prepended and appended with `%` when the SQL query is constructed. The substring is matched + * + * There is no point in supporting substring matching: it performs a full table scan when used, regardless of whether the column contains + * the index or not. If you really wish for substring matching, you probably want a full-text search instead which is implemented using + * a different keywords. + * @param value the substring, automatically appended with `%` when the SQL query is constructed. The substring is matched * case-insensitive. */ - infix fun KProperty1.ilike(substring: String): Filter = ILikeFilter(name, substring) + infix fun KProperty1.ilike(value: String): Filter = ILikeFilter(dbname, value) /** * Matches only values contained in given range. * @param range the range */ infix fun KProperty1.between(range: ClosedRange): Filter where R: Number, R: Comparable = this.ge(range.start as Number) and this.le(range.endInclusive as Number) - val KProperty1.isNull: Filter get() = IsNullFilter(name) - val KProperty1.isNotNull: Filter get() = IsNotNullFilter(name) - val KProperty1.isTrue: Filter get() = EqFilter(name, true) - val KProperty1.isFalse: Filter get() = EqFilter(name, false) + val KProperty1.isNull: Filter get() = IsNullFilter(dbname) + val KProperty1.isNotNull: Filter get() = IsNotNullFilter(dbname) + val KProperty1.isTrue: Filter get() = EqFilter(dbname, true) + val KProperty1.isFalse: Filter get() = EqFilter(dbname, false) /** * Allows for a native query: `"age < :age_p"("age_p" to 60)` @@ -265,7 +264,6 @@ class SqlWhereBuilder { * Does not support in-memory filtering and will throw an exception. */ data class NativeSqlFilter(val where: String, val params: Map) : Filter { - override fun test(t: T) = throw IllegalStateException("Native sql filter does not support in-memory filtering") override fun toSQL92() = where override fun getSQL92Parameters(): Map = params } @@ -273,4 +271,4 @@ data class NativeSqlFilter(val where: String, val params: Map buildFilter(block: SqlWhereBuilder.()-> Filter): Filter = block(SqlWhereBuilder()) +inline fun buildFilter(block: SqlWhereBuilder.()-> Filter): Filter = block(SqlWhereBuilder(T::class.java)) diff --git a/src/main/kotlin/com/github/vokorm/Mapping.kt b/src/main/kotlin/com/github/vokorm/Mapping.kt index fd1f6ed..e912fbc 100644 --- a/src/main/kotlin/com/github/vokorm/Mapping.kt +++ b/src/main/kotlin/com/github/vokorm/Mapping.kt @@ -1,13 +1,16 @@ package com.github.vokorm +import org.sql2o.Query import java.io.Serializable import java.lang.reflect.Field import java.lang.reflect.Modifier +import java.util.* import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentMap import javax.validation.ConstraintViolation import javax.validation.ConstraintViolationException import javax.validation.ValidationException +import kotlin.reflect.KProperty1 /** * Optional annotation which allows you to change the table name. @@ -16,6 +19,12 @@ import javax.validation.ValidationException @Target(AnnotationTarget.CLASS) annotation class Table(val dbname: String = "") +/** + * Optional annotation which configures the underlying column name for a field. + */ +@Target(AnnotationTarget.FIELD) +annotation class As(val databaseColumnName: String) + /** * Annotate a field with this to exclude it from being mapped into a database table column. */ @@ -57,10 +66,11 @@ interface Entity : Serializable { validate() db { if (id == null) { - // not yet in the database, insert + // not yet in the database, run the INSERT statement val fields = meta.persistedFieldDbNames - meta.idDbname con.createQuery("insert into ${meta.databaseTableName} (${fields.joinToString()}) values (${fields.map { ":$it" }.joinToString()})", true) - .bind(this@Entity) + .bindAliased(this@Entity) + .setColumnMappings(meta.getSql2oColumnMappings()) .executeUpdate() val key = requireNotNull(con.key) { "The database have returned null key for the created record. Have you used AUTO INCREMENT or SERIAL for primary key?" } @Suppress("UNCHECKED_CAST") @@ -68,7 +78,8 @@ interface Entity : Serializable { } else { val fields = meta.persistedFieldDbNames - meta.idDbname con.createQuery("update ${meta.databaseTableName} set ${fields.map { "$it = :$it" }.joinToString()} where ${meta.idDbname} = :${meta.idDbname}") - .bind(this@Entity) + .bindAliased(this@Entity) + .setColumnMappings(meta.getSql2oColumnMappings()) .executeUpdate() } } @@ -121,7 +132,7 @@ data class EntityMeta(val entityClass: Class) : Serializable { /** * A list of database names of all persisted fields in this entity. */ - val persistedFieldDbNames: Set get() = entityClass.persistedFieldNames + val persistedFieldDbNames: Set get() = entityClass.persistedFieldNames.values.toSet() /** * The database name of the ID column. @@ -137,6 +148,21 @@ data class EntityMeta(val entityClass: Class) : Serializable { * The type of the `id` property as declared in the entity. */ val idClass: Class<*> get() = idField.type + + /** + * All fields in the entity, maps the field to the database column name. + */ + val fields: Map get() = Collections.unmodifiableMap(entityClass.persistedFieldNames) + + fun propertyToField(property: KProperty1<*, *>): Field { + val result = fields.keys.first { it.name == property.name } + return checkNotNull(result) { "There is no such property $property in $entityClass, available fields: ${fields.keys.map { it.name }}" } + } + + /** + * Returns a map which maps from database name to the bean property name. + */ + fun getSql2oColumnMappings(): Map = fields.map { it.value to it.key.name }.toMap() } private fun Class<*>.findDeclaredField(name: String): Field? { @@ -167,23 +193,40 @@ private inline val Field.isStatic get() = Modifier.isStatic(modifiers) private val Field.isPersisted get() = !isTransient && !isSynthetic && !isStatic && !isAnnotationPresent(Ignore::class.java) && name != "Companion" /** - * Lists all persisted fields + * Lists all persisted fields. */ private val Class<*>.persistedFields: List get() = when { this == Object::class.java -> listOf() else -> declaredFields.filter { it.isPersisted } + superclass.persistedFields } -private val persistedFieldNamesCache: ConcurrentMap, Set> = ConcurrentHashMap, Set>() +private val persistedFieldNamesCache: ConcurrentMap, Map> = ConcurrentHashMap, Map>() /** - * The database name of given field. Defaults to [Field.name], cannot be currently changed. + * The database name of given field. Defaults to [Field.name], but it can be changed via the [As] annotation. */ -private val Field.dbname: String get() = name +private val Field.dbname: String get() { + val a = getAnnotation(As::class.java)?.databaseColumnName + return if (a == null) name else a +} /** - * Returns the list of database column names in an entity. + * Returns the list of fields in an entity, mapped to the database name as specified by [Field.dbname]. */ -val Class.persistedFieldNames: Set get() +private val Class.persistedFieldNames: Map get() // thread-safety: this may compute the same value multiple times during high contention, this is OK -= persistedFieldNamesCache.getOrPut(this) { (persistedFields.map { it.dbname }).toSet() } += persistedFieldNamesCache.getOrPut(this) { (persistedFields.associate { it to it.dbname }) } + +/** + * Similar to [Query.bind] but honors the [As] annotation. + */ +fun Query.bindAliased(entity: Any): Query { + val meta = entity.javaClass.entityMeta + meta.fields.forEach { field, dbname -> + if (paramNameToIdxMap.containsKey(dbname)) { + field.isAccessible = true + addParameter(dbname, field.type as Class, field.get(entity)) + } + } + return this +} diff --git a/src/main/kotlin/com/github/vokorm/dataloader/DataLoader.kt b/src/main/kotlin/com/github/vokorm/dataloader/DataLoader.kt index b0f9980..a4488a3 100644 --- a/src/main/kotlin/com/github/vokorm/dataloader/DataLoader.kt +++ b/src/main/kotlin/com/github/vokorm/dataloader/DataLoader.kt @@ -39,8 +39,8 @@ fun DataLoader.withFilter(filter: Filter): DataLoader = Filter /** * Returns a new data loader which always applies given [filter] and ANDs it with any filter given to [DataLoader.getCount] or [DataLoader.fetch]. */ -fun DataLoader.withFilter(block: SqlWhereBuilder.()->Filter): DataLoader = - withFilter(SqlWhereBuilder().block()) +inline fun DataLoader.withFilter(block: SqlWhereBuilder.()->Filter): DataLoader = + withFilter(SqlWhereBuilder(T::class.java).block()) internal class FilteredDataLoader(val filter: Filter, val delegate: DataLoader) : DataLoader { private fun and(other: Filter?) = if (other == null) filter else filter.and(other) diff --git a/src/main/kotlin/com/github/vokorm/dataloader/EntityDataLoader.kt b/src/main/kotlin/com/github/vokorm/dataloader/EntityDataLoader.kt index eb511cc..6002a94 100644 --- a/src/main/kotlin/com/github/vokorm/dataloader/EntityDataLoader.kt +++ b/src/main/kotlin/com/github/vokorm/dataloader/EntityDataLoader.kt @@ -33,9 +33,11 @@ class EntityDataLoader>(val clazz: Class) : DataLoader { // MariaDB requires LIMIT first, then OFFSET: https://mariadb.com/kb/en/library/limit/ if (range != 0..Int.MAX_VALUE) append(" LIMIT ${range.length} OFFSET ${range.start}") } + val dbnameToJavaFieldName = clazz.entityMeta.getSql2oColumnMappings() return db { con.createQuery(sql) .fillInParamsFromFilters(filter) + .setColumnMappings(dbnameToJavaFieldName) .executeAndFetch(clazz) } } diff --git a/src/main/kotlin/com/github/vokorm/dataloader/SqlDataLoader.kt b/src/main/kotlin/com/github/vokorm/dataloader/SqlDataLoader.kt index eea927a..f00b7a5 100644 --- a/src/main/kotlin/com/github/vokorm/dataloader/SqlDataLoader.kt +++ b/src/main/kotlin/com/github/vokorm/dataloader/SqlDataLoader.kt @@ -2,6 +2,7 @@ package com.github.vokorm.dataloader import com.github.vokorm.Filter import com.github.vokorm.db +import com.github.vokorm.entityMeta import org.sql2o.Query /** @@ -14,7 +15,7 @@ import org.sql2o.Query * data class CustomerAddress(val customerName: String, val address: String) * * val provider = SqlDataLoader(CustomerAddress::class.java, """select c.name as customerName, a.street || ' ' || a.city as address - * from Customer c inner join Address a on c.address_id=a.id where 1=1 {{WHERE}} order by 1=1{{ORDER}} {{PAGING}}""", idMapper = { it }) + * from Customer c inner join Address a on c.address_id=a.id where 1=1 {{WHERE}} order by 1=1{{ORDER}} {{PAGING}}""") * ``` * * (Note how select column names must correspond to field names in the `CustomerAddress` class) @@ -55,6 +56,7 @@ class SqlDataLoader(val clazz: Class, val sql: String, val params: Ma val q = con.createQuery(computeSQL(false, filter, sortBy, range)) params.entries.forEach { (name, value) -> q.addParameter(name, value) } q.fillInParamsFromFilters(filter) + q.columnMappings = clazz.entityMeta.getSql2oColumnMappings() q.executeAndFetch(clazz) } diff --git a/src/test/kotlin/com/github/vokorm/Databases.kt b/src/test/kotlin/com/github/vokorm/Databases.kt index 2106db0..72a68cf 100644 --- a/src/test/kotlin/com/github/vokorm/Databases.kt +++ b/src/test/kotlin/com/github/vokorm/Databases.kt @@ -17,7 +17,9 @@ data class Person( var dateOfBirth: LocalDate? = null, var created: Date? = null, var modified: Instant? = null, - var alive: Boolean? = null, + // test of aliased field + @As("alive") + var isAlive25: Boolean? = null, var maritalStatus: MaritalStatus? = null ) : Entity { diff --git a/src/test/kotlin/com/github/vokorm/FiltersTest.kt b/src/test/kotlin/com/github/vokorm/FiltersTest.kt index 1915401..b910512 100644 --- a/src/test/kotlin/com/github/vokorm/FiltersTest.kt +++ b/src/test/kotlin/com/github/vokorm/FiltersTest.kt @@ -11,23 +11,14 @@ class FiltersTest : DynaTest({ return sql } fun sql(block: SqlWhereBuilder.()-> Filter): String { - val filter: Filter = block(SqlWhereBuilder()) + val filter: Filter = block(SqlWhereBuilder(Person::class.java)) return unmangleParameterNames(filter.toSQL92(), filter.getSQL92Parameters()) } test("ToSQL92") { expect("age = :25") { sql { Person::age eq 25 } } expect("(age >= :25 and age <= :50)") { sql { Person::age between 25..50 } } - expect("((age >= :25 and age <= :50) or alive = :true)") { sql { (Person::age between 25..50) or (Person::alive eq true) } } - } - - test("LikeFilterInMemory") { - expect(false) { LikeFilter("name", "A") - .test(Person(name = "kari", age = 35)) } - expect(true) { LikeFilter("name", " a ") - .test(Person(name = "kari", age = 35)) } - expect(true) { ILikeFilter("name", "A") - .test(Person(name = "kari", age = 35)) } + expect("((age >= :25 and age <= :50) or alive = :true)") { sql { (Person::age between 25..50) or (Person::isAlive25 eq true) } } } test("Equals") { diff --git a/src/test/kotlin/com/github/vokorm/dataloader/EntityDataLoaderTest.kt b/src/test/kotlin/com/github/vokorm/dataloader/EntityDataLoaderTest.kt index 85c49a6..751e213 100644 --- a/src/test/kotlin/com/github/vokorm/dataloader/EntityDataLoaderTest.kt +++ b/src/test/kotlin/com/github/vokorm/dataloader/EntityDataLoaderTest.kt @@ -53,5 +53,10 @@ class EntityDataProviderTest : DynaTest({ val ds = Person.dataLoader.withFilter { Person::age lt 60 and "age > :age"("age" to 29)} expect((30..59).toList()) { ds.fetch().map { it.age } } } + + test("alias") { + db { Person(name = "test", age = 5, isAlive25 = false).save() } + expectList(false) { Person.dataLoader.fetch().map { it.isAlive25 } } + } } }) diff --git a/src/test/kotlin/com/github/vokorm/dataloader/SqlDataLoaderTest.kt b/src/test/kotlin/com/github/vokorm/dataloader/SqlDataLoaderTest.kt index 6ee60ea..2852275 100644 --- a/src/test/kotlin/com/github/vokorm/dataloader/SqlDataLoaderTest.kt +++ b/src/test/kotlin/com/github/vokorm/dataloader/SqlDataLoaderTest.kt @@ -4,6 +4,7 @@ import com.github.mvysny.dynatest.DynaTest import com.github.mvysny.dynatest.expectList import com.github.mvysny.dynatest.expectThrows import com.github.vokorm.* +import org.hibernate.validator.constraints.Length import kotlin.test.expect class SqlDataLoaderTest : DynaTest({ @@ -69,5 +70,20 @@ class SqlDataLoaderTest : DynaTest({ expect(25) { dp.getCount() } expect((26..50).map { "name $it" }) { dp.fetch(range = 0..Int.MAX_VALUE).map { it.name } } } + + // https://github.com/mvysny/vok-orm/issues/5 + test("alias") { + db { (0..49).forEach { Person(name = "name $it", age = it).save() } } + + @Table("Test") data class SelectResult2(@As("name") var personName: String) + + val loader = SqlDataLoader( + SelectResult2::class.java, + "select p.name from Test p where 1=1 {{WHERE}} order by 1=1{{ORDER}} {{PAGING}}" + ) + expect(50) { loader.getCount(null) } + expect(1) { loader.getCount(buildFilter { SelectResult2::personName eq "name 20" })} + expectList(SelectResult2("name 20")) { loader.fetch(buildFilter { SelectResult2::personName eq "name 20" }) } + } } })