Skip to content

Commit

Permalink
feat: EXPOSED-359 Add support for multidimensional arrays
Browse files Browse the repository at this point in the history
  • Loading branch information
obabichevjb committed Oct 14, 2024
1 parent 58d96f0 commit 4dc8971
Show file tree
Hide file tree
Showing 6 changed files with 505 additions and 0 deletions.
34 changes: 34 additions & 0 deletions documentation-website/Writerside/topics/Data-Types.topic
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,40 @@
</code-block>
</chapter>
</chapter>
<chapter title="How to use Multi-Dimensional Array types" id="how-to-use-multi-dimensional-array-types">
<p>PostgreSQL database supports the explicit ARRAY data type, which includes support for multi-dimensional arrays.</p>
<p>Exposed supports columns defined as multi-dimensional arrays, with the stored contents being any
out-of-the-box or custom data type.
If the contents are of a type with a supported <code>ColumnType</code> in the <code>exposed-core</code>
module, the column can be simply defined with that type:</p>

<code-block lang="kotlin">
object Teams : Table("teams") {
val memberIds = multi2Array<UUID>("member_ids")
val memberNames = multi3Array<String>("member_names")
val budgets = multi2Array<Double>("budgets")
}
</code-block>
<p>If more control is needed over the base content type, or if the latter is user-defined or from a non-core
module, the explicit type should be provided to the function:</p>

<code-block lang="kotlin">
object Teams : Table("teams") {
val memberIds = multi2Array<UUID>("member_ids")
val memberNames = multi3Array<String>("member_names", VarCharColumnType(colLength = 32))
}
</code-block>

<p>A multi-dimensional array column accepts inserts and retrieves stored array contents as a Kotlin nested <code>List</code>:</p>

<code-block lang="kotlin">
Teams.insert {
it[memberIds] = List(5) { List(5) { UUID.randomUUID() } }
it[memberNames] = List(3) { List(3) { List(3) { i -> "Member ${'A' + i}" } } }
it[budgets] = listOf(listOf(9999.0, 8888.0))
}
</code-block>
</chapter>
<chapter title="Custom Data Types" id="custom-data-types">
<p>If a database-specific data type is not immediately supported by Exposed, any existing and open column type
class can be extended or
Expand Down
18 changes: 18 additions & 0 deletions exposed-core/api/exposed-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -1632,6 +1632,24 @@ public final class org/jetbrains/exposed/sql/ModOp : org/jetbrains/exposed/sql/E
public final class org/jetbrains/exposed/sql/ModOp$Companion {
}

public final class org/jetbrains/exposed/sql/MultiArrayColumnType : org/jetbrains/exposed/sql/ColumnType {
public fun <init> (Lorg/jetbrains/exposed/sql/ColumnType;ILjava/util/List;)V
public synthetic fun <init> (Lorg/jetbrains/exposed/sql/ColumnType;ILjava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getDelegate ()Lorg/jetbrains/exposed/sql/ColumnType;
public final fun getDelegateType ()Ljava/lang/String;
public final fun getDimensions ()I
public final fun getMaximumCardinality ()Ljava/util/List;
public synthetic fun nonNullValueToString (Ljava/lang/Object;)Ljava/lang/String;
public fun nonNullValueToString (Ljava/util/List;)Ljava/lang/String;
public synthetic fun notNullValueToDB (Ljava/lang/Object;)Ljava/lang/Object;
public fun notNullValueToDB (Ljava/util/List;)Ljava/lang/Object;
public fun readObject (Ljava/sql/ResultSet;I)Ljava/lang/Object;
public fun setParameter (Lorg/jetbrains/exposed/sql/statements/api/PreparedStatementApi;ILjava/lang/Object;)V
public fun sqlType ()Ljava/lang/String;
public synthetic fun valueFromDB (Ljava/lang/Object;)Ljava/lang/Object;
public fun valueFromDB (Ljava/lang/Object;)Ljava/util/List;
}

public final class org/jetbrains/exposed/sql/NeqOp : org/jetbrains/exposed/sql/ComparisonOp {
public fun <init> (Lorg/jetbrains/exposed/sql/Expression;Lorg/jetbrains/exposed/sql/Expression;)V
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,93 @@ class ArrayColumnType<E>(
}
}

/**
* Multi-dimensional array column type for storing a collection of nested elements.
*
* @property delegate The base column type associated with this array column's individual elements.
* @property dimensions The number of dimensions of the multi-dimensional array.
* @property maximumCardinality The maximum cardinality (number of allowed elements) for each dimension of the array.
*
* **Note:** The maximum cardinality is considered for each dimension, but it is ignored by the PostgreSQL database.
* Validation is performed on the client side.
*/
class MultiArrayColumnType<T, R : List<Any>>(
val delegate: ColumnType<T & Any>,
val dimensions: Int,
val maximumCardinality: List<Int>? = null
) : ColumnType<R>() {
val delegateType: String
get() = delegate.sqlType().substringBefore('(')

override fun sqlType(): String {
if (maximumCardinality != null) {
require(maximumCardinality.size == dimensions) {
"The size of cardinality list must be equal to the amount of array dimensions. " +
"Dimensions: $dimensions, cardinality size: ${maximumCardinality.size}"
}
}
return delegate.sqlType() +
(maximumCardinality?.let { cardinality -> cardinality.joinToString("") { "[$it]" } } ?: "[]".repeat(dimensions))
}

override fun notNullValueToDB(value: R): Any {
validateValue(value)
return recursiveNotNullValueToDB(value, dimensions)
}

private fun recursiveNotNullValueToDB(value: Any, level: Int): Array<Any> = when {
level > 1 -> (value as List<Any>).map { recursiveNotNullValueToDB(it, level - 1) }.toTypedArray()
else -> (value as List<T & Any>).map { delegate.notNullValueToDB(it) }.toTypedArray()
}

@Suppress("UNCHECKED_CAST")
override fun valueFromDB(value: Any): R? {
return when {
value is Array<*> -> recursiveValueFromDB(value, dimensions) as R?
else -> value as R?
}
}

private fun recursiveValueFromDB(value: Any?, level: Int): List<Any?> = when {
level > 1 -> (value as Array<Any?>).map { recursiveValueFromDB(it, level - 1) }
else -> (value as Array<Any>).map { delegate.valueFromDB(it) }
}

override fun readObject(rs: ResultSet, index: Int): Any? {
return rs.getArray(index)?.array
}

override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) {
when {
value is Array<*> -> stmt.setArray(index, delegateType, value)
else -> super.setParameter(stmt, index, value)
}
}

override fun nonNullValueToString(value: R): String {
return "ARRAY" + recursiveNonNullValueToString(value, dimensions)
}

private fun recursiveNonNullValueToString(value: Any?, level: Int): String = when {
level > 1 -> (value as List<Any?>).joinToString(",", "[", "]") { recursiveNonNullValueToString(it, level - 1) }
else -> (value as List<T & Any>).joinToString(",", "[", "]") { delegate.nonNullValueAsDefaultString(it) }
}

private fun validateValue(value: R) {
validateValueRecursive(value, dimensions)
}

private fun validateValueRecursive(value: R, level: Int) {
if (maximumCardinality == null) return
require(value.size <= maximumCardinality[dimensions - level]) {
"Value must have no more than ${maximumCardinality[dimensions - level]} elements, but it has ${value.size}"
}
if (level > 1) {
(value as List<R>).forEach { validateValueRecursive(it, level - 1) }
}
}
}

private fun isArrayOfByteArrays(value: Array<*>) =
value.all { it is ByteArray }

Expand Down
44 changes: 44 additions & 0 deletions exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Op.kt
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,50 @@ inline fun <reified T : Any> arrayLiteral(value: List<T>, delegateType: ColumnTy
return LiteralOp(ArrayColumnType(delegateType ?: resolveColumnType(T::class)), value)
}

/**
* Returns the specified 3-dimensional [value] as an array literal, with elements parsed by the [delegateType] if provided.
*
* @param value The 3-dimensional list of elements to be represented as an array literal.
* @param delegateType An optional parameter which provides an explicit column type to parse the elements. If not provided,
* the column type will be resolved based on the element type [T].
* @return A `LiteralOp` representing the 3-dimensional array literal for the specified [value].
* @throws IllegalStateException If no column type mapping is found and a [delegateType] is not provided.
*/
inline fun <reified T : Any> multi3ArrayLiteral(value: List<List<List<T>>>, delegateType: ColumnType<T>? = null): LiteralOp<List<List<List<T>>>> =
multiArrayLiteral(value, dimensions = 3, delegateType)

/**
* Returns the specified 2-dimensional [value] as an array literal, with elements parsed by the [delegateType] if provided.
*
* @param value The 2-dimensional list of elements to be represented as an array literal.
* @param delegateType An optional parameter which provides an explicit column type to parse the elements. If not provided,
* the column type will be resolved based on the element type [T].
* @return A `LiteralOp` representing the 2-dimensional array literal for the specified [value].
* @throws IllegalStateException If no column type mapping is found and a [delegateType] is not provided.
*/
inline fun <reified T : Any> multi2ArrayLiteral(value: List<List<T>>, delegateType: ColumnType<T>? = null): LiteralOp<List<List<T>>> =
multiArrayLiteral(value, dimensions = 2, delegateType)

/**
* Returns the specified multi-dimensional [value] as an array literal, with elements parsed by the [delegateType] if provided.
* The number of dimensions is specified by the [dimensions] parameter.
*
* **Note:** If [delegateType] is left `null`, the associated column type will be resolved according to the
* internal mapping of the element's type in [resolveColumnType].
*
* @param value The multi-dimensional list of elements to be represented as an array literal.
* @param dimensions The number of dimensions of the array. This value should be greater than 1.
* @param delegateType An optional parameter which provides an explicit column type to parse the elements. If not provided,
* the column type will be resolved based on the element type [T].
* @return A `LiteralOp` representing the multi-dimensional array literal for the specified [value].
* @throws IllegalArgumentException If [dimensions] is less than or equal to 1.
* @throws IllegalStateException If no column type mapping is found and a [delegateType] is not provided.
*/
inline fun <reified T : Any, R : List<Any>> multiArrayLiteral(value: R, dimensions: Int, delegateType: ColumnType<T>? = null): LiteralOp<R> {
@OptIn(InternalApi::class)
return LiteralOp(MultiArrayColumnType(delegateType ?: resolveColumnType(T::class), dimensions), value)
}

// Query Parameters

/**
Expand Down
60 changes: 60 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 @@ -944,6 +944,65 @@ open class Table(name: String = "") : ColumnSet(), DdlAware {
return array(name, resolveColumnType(E::class), maximumCardinality)
}

/**
* Creates a 3-dimensional array column, with the specified [name], for storing elements of a nested `List`.
*
* **Note:** This column type is only supported by PostgreSQL dialect.
*
* @param name Name of the column.
* @param maximumCardinality The maximum cardinality (number of allowed elements) for each dimension in the array.
*
* **Note:** Providing an array size limit when using the PostgreSQL dialect is allowed, but this value will be ignored by the database.
* The whole validation is performed on the client side.
*
* @return A column instance that represents a 3-dimensional list of elements of type [T].
* @throws IllegalStateException If no column type mapping is found.
*/
inline fun <reified T : Any> Table.multi3Array(name: String, maximumCardinality: List<Int>? = null): Column<List<List<List<T>>>> =
multiArray<T, List<List<List<T>>>>(name, dimensions = 3, maximumCardinality)

/**
* Creates a 2-dimensional array column, with the specified [name], for storing elements of a nested `List`.
*
* **Note:** This column type is only supported by PostgreSQL dialect.
*
* @param name Name of the column.
* @param maximumCardinality The maximum cardinality (number of allowed elements) for each dimension in the array.
*
* **Note:** Providing an array size limit when using the PostgreSQL dialect is allowed, but this value will be ignored by the database.
* The whole validation is performed on the client side.
*
* @return A column instance that represents a 2-dimensional list of elements of type [T].
* @throws IllegalStateException If no column type mapping is found.
*/
inline fun <reified T : Any> Table.multi2Array(name: String, maximumCardinality: List<Int>? = null): Column<List<List<T>>> =
multiArray<T, List<List<T>>>(name, dimensions = 2, maximumCardinality)

/**
* Creates a multi-dimensional array column, with the specified [name], for storing elements of a nested `List`.
* The number of dimensions is specified by the [dimensions] parameter.
*
* **Note:** This column type is only supported by PostgreSQL dialect.
*
* @param name Name of the column.
* @param dimensions The number of dimensions of the array. This value should be greater than 1.
* @param maximumCardinality The maximum cardinality (number of allowed elements) for each dimension in the array.
*
* **Note:** Providing an array size limit when using the PostgreSQL dialect is allowed, but this value will be ignored by the database.
* The whole validation is performed on the client side.
*
* @return A column instance that represents a multi-dimensional list of elements of type [T].
* @throws IllegalArgumentException If [dimensions] is less than or equal to 1.
* @throws IllegalStateException If no column type mapping is found.
*/
inline fun <reified T : Any, R : List<Any>> Table.multiArray(name: String, dimensions: Int, maximumCardinality: List<Int>? = null): Column<R> {
if (dimensions <= 1) {
error("Dimension $dimensions should be greater than 1")
}
@OptIn(InternalApi::class)
return registerColumn(name, MultiArrayColumnType(resolveColumnType(T::class), dimensions, maximumCardinality))
}

// Auto-generated values

/**
Expand Down Expand Up @@ -1689,6 +1748,7 @@ open class Table(name: String = "") : ColumnSet(), DdlAware {
H2Dialect.H2CompatibilityMode.PostgreSQL -> checkConstraints.filterNot { (name, _) ->
name.startsWith("${generatedSignedCheckPrefix}short")
}

else -> checkConstraints.filterNot { (name, _) ->
name.startsWith(generatedSignedCheckPrefix)
}
Expand Down
Loading

0 comments on commit 4dc8971

Please sign in to comment.