Skip to content

Commit

Permalink
feat: EXPOSED-388 Support for column type converters (#2143)
Browse files Browse the repository at this point in the history
* feat!: EXPOSED-388 Support for column type converters
  • Loading branch information
obabichevjb authored Jul 23, 2024
1 parent 4a44078 commit 94e8576
Show file tree
Hide file tree
Showing 10 changed files with 756 additions and 133 deletions.
62 changes: 62 additions & 0 deletions documentation-website/Writerside/topics/Deep-Dive-into-DSL.md
Original file line number Diff line number Diff line change
Expand Up @@ -766,3 +766,65 @@ The values specified in the statement block will be used for the insert statemen
In the example above, if the original row was inserted with a user-defined <code>rating</code>, then <code>replace()</code> was executed with a block that omitted the <code>rating</code> column,
the newly inserted row would store the default rating value. This is because the old row was completely deleted first.
</note>

## Column transformation

Column transformations allow to define custom transformations between database column types and application's data types.
This can be particularly useful when you need to store data in one format but work with it in another format within your application.

Consider the following example, where we define a table to store meal times and transform these times into meal types:

```kotlin
enum class Meal {
BREAKFAST,
LUNCH,
DINNER
}

object Meals : Table() {
val mealTime: Column<Meal> = time("meal_time")
.transform(
wrap = {
when {
it.hour < 10 -> Meal.BREAKFAST
it.hour < 15 -> Meal.LUNCH
else -> Meal.DINNER
}
},
unwrap = {
when (it) {
Meal.BREAKFAST -> LocalTime(8, 0)
Meal.LUNCH -> LocalTime(12, 0)
Meal.DINNER -> LocalTime(18, 0)
}
}
)
}
```

The `transform` function is used to apply custom transformations to the `mealTime` column:

- The `wrap` function transforms the stored `LocalTime` values into `Meal` enums. It checks the hour of the stored time and returns the corresponding meal type.
- The `unwrap` function transforms `Meal` enums back into `LocalTime` values for storage in the database.

Transformation could be also defined as an implementation of `ColumnTransformer` interface and reused among different tables:

```kotlin
class MealTimeTransformer : ColumnTransformer<LocalTime, Meal> {
override fun wrap(value: LocalTime): Meal = when {
value.hour < 10 -> Meal.BREAKFAST
value.hour < 15 -> Meal.LUNCH
else -> Meal.DINNER
}

override fun unwrap(value: Meal): LocalTime = when (value) {
Meal.BREAKFAST -> LocalTime(8, 0)
Meal.LUNCH -> LocalTime(12, 0)
Meal.DINNER -> LocalTime(18, 0)
}
}

object Meals : Table() {
val mealTime: Column<Meal> = time("meal_time").transform(MealTimeTransformer())
}
```
23 changes: 23 additions & 0 deletions exposed-core/api/exposed-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,11 @@ public abstract class org/jetbrains/exposed/sql/ColumnSet : org/jetbrains/expose
public final fun slice (Lorg/jetbrains/exposed/sql/Expression;[Lorg/jetbrains/exposed/sql/Expression;)Lorg/jetbrains/exposed/sql/FieldSet;
}

public abstract interface class org/jetbrains/exposed/sql/ColumnTransformer {
public abstract fun unwrap (Ljava/lang/Object;)Ljava/lang/Object;
public abstract fun wrap (Ljava/lang/Object;)Ljava/lang/Object;
}

public abstract class org/jetbrains/exposed/sql/ColumnType : org/jetbrains/exposed/sql/IColumnType {
public fun <init> ()V
public fun <init> (Z)V
Expand All @@ -508,12 +513,26 @@ public abstract class org/jetbrains/exposed/sql/ColumnType : org/jetbrains/expos
}

public final class org/jetbrains/exposed/sql/ColumnTypeKt {
public static final fun columnTransformer (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lorg/jetbrains/exposed/sql/ColumnTransformer;
public static final fun getAutoIncColumnType (Lorg/jetbrains/exposed/sql/Column;)Lorg/jetbrains/exposed/sql/AutoIncColumnType;
public static final fun isAutoInc (Lorg/jetbrains/exposed/sql/IColumnType;)Z
public static final fun resolveColumnType (Lkotlin/reflect/KClass;Lorg/jetbrains/exposed/sql/ColumnType;)Lorg/jetbrains/exposed/sql/ColumnType;
public static synthetic fun resolveColumnType$default (Lkotlin/reflect/KClass;Lorg/jetbrains/exposed/sql/ColumnType;ILjava/lang/Object;)Lorg/jetbrains/exposed/sql/ColumnType;
}

public final class org/jetbrains/exposed/sql/ColumnWithTransform : org/jetbrains/exposed/sql/ColumnType, org/jetbrains/exposed/sql/ColumnTransformer {
public fun <init> (Lorg/jetbrains/exposed/sql/IColumnType;Lorg/jetbrains/exposed/sql/ColumnTransformer;)V
public final fun getDelegate ()Lorg/jetbrains/exposed/sql/IColumnType;
public fun getNullable ()Z
public fun nonNullValueToString (Ljava/lang/Object;)Ljava/lang/String;
public fun notNullValueToDB (Ljava/lang/Object;)Ljava/lang/Object;
public fun setNullable (Z)V
public fun sqlType ()Ljava/lang/String;
public fun unwrap (Ljava/lang/Object;)Ljava/lang/Object;
public fun valueFromDB (Ljava/lang/Object;)Ljava/lang/Object;
public fun wrap (Ljava/lang/Object;)Ljava/lang/Object;
}

public abstract class org/jetbrains/exposed/sql/ComparisonOp : org/jetbrains/exposed/sql/Op, org/jetbrains/exposed/sql/ComplexExpression, org/jetbrains/exposed/sql/Op$OpBoolean {
public fun <init> (Lorg/jetbrains/exposed/sql/Expression;Lorg/jetbrains/exposed/sql/Expression;Ljava/lang/String;)V
public final fun getExpr1 ()Lorg/jetbrains/exposed/sql/Expression;
Expand Down Expand Up @@ -2462,6 +2481,10 @@ public class org/jetbrains/exposed/sql/Table : org/jetbrains/exposed/sql/ColumnS
public final fun short (Ljava/lang/String;)Lorg/jetbrains/exposed/sql/Column;
public final fun text (Ljava/lang/String;Ljava/lang/String;Z)Lorg/jetbrains/exposed/sql/Column;
public static synthetic fun text$default (Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;Ljava/lang/String;ZILjava/lang/Object;)Lorg/jetbrains/exposed/sql/Column;
public final fun transform (Lorg/jetbrains/exposed/sql/Column;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lorg/jetbrains/exposed/sql/Column;
public final fun transform (Lorg/jetbrains/exposed/sql/Column;Lorg/jetbrains/exposed/sql/ColumnTransformer;)Lorg/jetbrains/exposed/sql/Column;
public final fun transformNullable (Lorg/jetbrains/exposed/sql/Column;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lorg/jetbrains/exposed/sql/Column;
public final fun transformNullable (Lorg/jetbrains/exposed/sql/Column;Lorg/jetbrains/exposed/sql/ColumnTransformer;)Lorg/jetbrains/exposed/sql/Column;
public final fun ubyte (Ljava/lang/String;)Lorg/jetbrains/exposed/sql/Column;
public final fun uinteger (Ljava/lang/String;)Lorg/jetbrains/exposed/sql/Column;
public final fun ulong (Ljava/lang/String;)Lorg/jetbrains/exposed/sql/Column;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,61 @@ class EntityIDColumnType<T : Comparable<T>>(
override fun hashCode(): Int = 31 * super.hashCode() + idColumn.hashCode()
}

/**
* An interface defining the transformation between a source column type and a target type.
*
* @param Wrapped The type of the column values after transformation
* @param Unwrapped The type of the column values without transformation
*/
interface ColumnTransformer<Unwrapped, Wrapped> {
/**
* Returns the underlying column value without a transformation applied ([Wrapped] -> [Unwrapped]).
*/
fun unwrap(value: Wrapped): Unwrapped

/**
* Applies transformation to the underlying column value ([Unwrapped] -> [Wrapped])
*/
fun wrap(value: Unwrapped): Wrapped
}

fun <Unwrapped, Wrapped>columnTransformer(unwrap: (value: Wrapped) -> Unwrapped, wrap: (value: Unwrapped) -> Wrapped): ColumnTransformer<Unwrapped, Wrapped> {
return object : ColumnTransformer<Unwrapped, Wrapped> {
override fun unwrap(value: Wrapped): Unwrapped = unwrap(value)
override fun wrap(value: Unwrapped): Wrapped = wrap(value)
}
}

/**
* A class that provides the transformation between a source column type and a target type.
*
* [ColumnWithTransform] is [ColumnType] by itself and can be used for defining columns.
*
* @param Wrapped The type to which the column value of type [Unwrapped] is transformed
* @param Unwrapped The type of the column
* @param delegate The original column's [IColumnType]
*/
class ColumnWithTransform<Unwrapped : Any, Wrapped : Any>(
val delegate: IColumnType<Unwrapped>,
private val transformer: ColumnTransformer<Unwrapped, Wrapped>
) : ColumnType<Wrapped>(), ColumnTransformer<Unwrapped, Wrapped> by transformer {
override fun sqlType() = delegate.sqlType()

override fun valueFromDB(value: Any): Wrapped? {
return delegate.valueFromDB(value)?.let { transformer.wrap(it) }
}

override fun notNullValueToDB(value: Wrapped): Any {
return delegate.notNullValueToDB(transformer.unwrap(value))
}

override fun nonNullValueToString(value: Wrapped): String {
return delegate.nonNullValueToString(transformer.unwrap(value))
}

override var nullable = delegate.nullable
}

// Numeric columns

/**
Expand Down
144 changes: 144 additions & 0 deletions exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Table.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1204,6 +1204,150 @@ open class Table(name: String = "") : ColumnSet(), DdlAware {
extraDefinitions.addAll(definition)
}

/**
* Transforms a column by specifying transformation functions.
*
* Sample:
* ```kotlin
* object TestTable : IntIdTable() {
* val stringToInteger = integer("stringToInteger")
* .transform(wrap = { it.toString() }, unwrap = { it.toInt() })
* }
* ```
*
* @param Wrapped The type into which the value of the underlying column will be transformed.
* @param Unwrapped The type of the original column.
* @param wrap A function to transform from the source type [Unwrapped] to the target type [Wrapped].
* @param unwrap A function to transform from the target type [Wrapped] to the source type [Unwrapped].
* @return A new column of type [Wrapped] with the applied transformations.
*/
fun <Unwrapped : Any, Wrapped : Any> Column<Unwrapped>.transform(
wrap: (Unwrapped) -> Wrapped,
unwrap: (Wrapped) -> Unwrapped
): Column<Wrapped> = transform(columnTransformer(unwrap, wrap))

/**
* Transforms a column by specifying a transformer.
*
* Sample:
* ```kotlin
* object StringToIntListTransformer : ColumnTransformer<String, List<Int>> {
* override fun wrap(value: String): List<Int> {
* val result = value.split(",").map { it.toInt() }
* return result
* }
*
* override fun unwrap(value: List<Int>): String = value.joinToString(",")
* }
*
* object TestTable : IntIdTable() {
* val numbers = text("numbers").transform(StringToIntListTransformer)
* }
* ```
*
* @param Wrapped The type into which the value of the underlying column will be transformed.
* @param Unwrapped The type of the original column.
* @param transformer An instance of [ColumnTransformer] to handle the transformations.
* @return A new column of type [Wrapped] with the applied transformations.
*/
fun <Unwrapped : Any, Wrapped : Any> Column<Unwrapped>.transform(
transformer: ColumnTransformer<Unwrapped, Wrapped>
): Column<Wrapped> = transform(ColumnWithTransform(columnType, transformer))

/**
* Applies the transformation column type to the column.
*
* @param Unwrapped The type of the original column.
* @param Wrapped The type into which the value of the underlying column will be transformed.
* @param wrappedColumnType The [ColumnWithTransform] instance with the transformation logic.
* @return A new column of type [Wrapped] with the applied transformation column type.
*/
private fun <Unwrapped : Any, Wrapped : Any> Column<Unwrapped>.transform(wrappedColumnType: ColumnWithTransform<Unwrapped, Wrapped>): Column<Wrapped> {
val newColumn: Column<Wrapped> = Column(table, name, wrappedColumnType)
newColumn.foreignKey = foreignKey
newColumn.defaultValueFun = defaultValueFun?.let { { wrappedColumnType.wrap(it()) } }
@Suppress("UNCHECKED_CAST")
newColumn.dbDefaultValue = dbDefaultValue as Expression<Wrapped>?
newColumn.isDatabaseGenerated = isDatabaseGenerated
newColumn.extraDefinitions = extraDefinitions
return replaceColumn(this, newColumn)
}

/**
* Transforms a nullable column by specifying transformation functions.
*
* Sample:
* ```kotlin
* object TestTable : IntIdTable() {
* val nullableStringToInteger = integer("nullableStringToInteger")
* .nullable()
* .transform(wrap = { it.toString() }, unwrap = { it.toInt() })
* }
* ```
*
* @param Wrapped The type into which the value of the underlying column will be transformed.
* @param Unwrapped The type of the original column.
* @param wrap A function to transform from the source type [Unwrapped] to the target type [Wrapped].
* @param unwrap A function to transform from the target type [Wrapped] to the source type [Unwrapped].
* @return A new column of type [Wrapped]`?` with the applied transformations.
*/
@JvmName("transformNullable")
fun <Unwrapped : Any, Wrapped : Any> Column<Unwrapped?>.transform(
wrap: (Unwrapped) -> Wrapped,
unwrap: (Wrapped) -> Unwrapped
): Column<Wrapped?> = transform(columnTransformer(unwrap, wrap))

/**
* Transforms a nullable column by specifying a transformer.
*
* Sample:
* ```kotlin
* object StringToIntListTransformer : ColumnTransformer<String, List<Int>> {
* override fun wrap(value: String): List<Int> {
* val result = value.split(",").map { it.toInt() }
* return result
* }
*
* override fun unwrap(value: List<Int>): String = value.joinToString(",")
* }
*
* object TestTable : IntIdTable() {
* val numbers = text("numbers").nullable().transform(StringToIntListTransformer)
* }
* ```
*
* @param Wrapped The type into which the value of the underlying column will be transformed.
* @param Unwrapped The type of the original column.
* @param transformer An instance of [ColumnTransformer] to handle the transformations.
* @return A new column of type [Wrapped]`?` with the applied transformations.
*/
@JvmName("transformNullable")
fun <Unwrapped : Any, Wrapped : Any> Column<Unwrapped?>.transform(
transformer: ColumnTransformer<Unwrapped, Wrapped>
): Column<Wrapped?> = transform(ColumnWithTransform(columnType, transformer))

/**
* Applies the transformation column type to the nullable column.
*
* @param Wrapped The type into which the value of the underlying column will be transformed.
* @param Unwrapped The type of the original column.
* @param toColumnType The [ColumnWithTransform] instance with the transformation logic.
* @return A new column of type [Wrapped]`?` with the applied transformation column type.
*/
@JvmName("transformNullable")
private fun <Unwrapped : Any, Wrapped : Any> Column<Unwrapped?>.transform(
toColumnType: ColumnWithTransform<Unwrapped, Wrapped>
): Column<Wrapped?> {
val newColumn = Column<Wrapped?>(table, name, toColumnType)
newColumn.foreignKey = foreignKey
newColumn.defaultValueFun = defaultValueFun?.let { { it()?.let { value -> toColumnType.wrap(value) } } }
@Suppress("UNCHECKED_CAST")
newColumn.dbDefaultValue = dbDefaultValue as Expression<Wrapped?>?
newColumn.isDatabaseGenerated = isDatabaseGenerated
newColumn.extraDefinitions = extraDefinitions
return replaceColumn(this, newColumn)
}

// Indices

/**
Expand Down
Loading

0 comments on commit 94e8576

Please sign in to comment.