From 4dc89714d8cc52fbc0afb8b0be1bed6794dc60f1 Mon Sep 17 00:00:00 2001 From: Oleg Babichev Date: Mon, 23 Sep 2024 13:08:32 +0200 Subject: [PATCH] feat: EXPOSED-359 Add support for multidimensional arrays --- .../Writerside/topics/Data-Types.topic | 34 +++ exposed-core/api/exposed-core.api | 18 ++ .../org/jetbrains/exposed/sql/ColumnType.kt | 87 ++++++ .../kotlin/org/jetbrains/exposed/sql/Op.kt | 44 +++ .../kotlin/org/jetbrains/exposed/sql/Table.kt | 60 ++++ .../shared/types/MultiArrayColumnTypeTests.kt | 262 ++++++++++++++++++ 6 files changed, 505 insertions(+) create mode 100644 exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/types/MultiArrayColumnTypeTests.kt diff --git a/documentation-website/Writerside/topics/Data-Types.topic b/documentation-website/Writerside/topics/Data-Types.topic index 07c87e6f74..e8c17aba77 100644 --- a/documentation-website/Writerside/topics/Data-Types.topic +++ b/documentation-website/Writerside/topics/Data-Types.topic @@ -325,6 +325,40 @@ + +

PostgreSQL database supports the explicit ARRAY data type, which includes support for multi-dimensional arrays.

+

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 ColumnType in the exposed-core + module, the column can be simply defined with that type:

+ + + object Teams : Table("teams") { + val memberIds = multi2Array("member_ids") + val memberNames = multi3Array("member_names") + val budgets = multi2Array("budgets") + } + +

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:

+ + + object Teams : Table("teams") { + val memberIds = multi2Array("member_ids") + val memberNames = multi3Array("member_names", VarCharColumnType(colLength = 32)) + } + + +

A multi-dimensional array column accepts inserts and retrieves stored array contents as a Kotlin nested List:

+ + + 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)) + } + +

If a database-specific data type is not immediately supported by Exposed, any existing and open column type class can be extended or diff --git a/exposed-core/api/exposed-core.api b/exposed-core/api/exposed-core.api index b21738db24..163f9a4dd5 100644 --- a/exposed-core/api/exposed-core.api +++ b/exposed-core/api/exposed-core.api @@ -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 (Lorg/jetbrains/exposed/sql/ColumnType;ILjava/util/List;)V + public synthetic fun (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 (Lorg/jetbrains/exposed/sql/Expression;Lorg/jetbrains/exposed/sql/Expression;)V } diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/ColumnType.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/ColumnType.kt index d1ef2b826f..cd08e20ab5 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/ColumnType.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/ColumnType.kt @@ -1286,6 +1286,93 @@ class ArrayColumnType( } } +/** + * 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>( + val delegate: ColumnType, + val dimensions: Int, + val maximumCardinality: List? = null +) : ColumnType() { + 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 = when { + level > 1 -> (value as List).map { recursiveNotNullValueToDB(it, level - 1) }.toTypedArray() + else -> (value as List).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 = when { + level > 1 -> (value as Array).map { recursiveValueFromDB(it, level - 1) } + else -> (value as Array).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).joinToString(",", "[", "]") { recursiveNonNullValueToString(it, level - 1) } + else -> (value as List).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).forEach { validateValueRecursive(it, level - 1) } + } + } +} + private fun isArrayOfByteArrays(value: Array<*>) = value.all { it is ByteArray } diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Op.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Op.kt index 637344066e..d6f1fc29f5 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Op.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Op.kt @@ -692,6 +692,50 @@ inline fun arrayLiteral(value: List, 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 multi3ArrayLiteral(value: List>>, delegateType: ColumnType? = null): LiteralOp>>> = + 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 multi2ArrayLiteral(value: List>, delegateType: ColumnType? = null): LiteralOp>> = + 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 > multiArrayLiteral(value: R, dimensions: Int, delegateType: ColumnType? = null): LiteralOp { + @OptIn(InternalApi::class) + return LiteralOp(MultiArrayColumnType(delegateType ?: resolveColumnType(T::class), dimensions), value) +} + // Query Parameters /** diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Table.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Table.kt index c070bbfeab..fcb828a65f 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Table.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/Table.kt @@ -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 Table.multi3Array(name: String, maximumCardinality: List? = null): Column>>> = + multiArray>>>(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 Table.multi2Array(name: String, maximumCardinality: List? = null): Column>> = + multiArray>>(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 > Table.multiArray(name: String, dimensions: Int, maximumCardinality: List? = null): Column { + 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 /** @@ -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) } diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/types/MultiArrayColumnTypeTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/types/MultiArrayColumnTypeTests.kt new file mode 100644 index 0000000000..30d7f3d31a --- /dev/null +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/types/MultiArrayColumnTypeTests.kt @@ -0,0 +1,262 @@ +package org.jetbrains.exposed.sql.tests.shared.types + +import org.jetbrains.exposed.dao.IntEntity +import org.jetbrains.exposed.dao.IntEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.insertAndGetId +import org.jetbrains.exposed.sql.multi2ArrayLiteral +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.tests.DatabaseTestsBase +import org.jetbrains.exposed.sql.tests.TestDB +import org.jetbrains.exposed.sql.tests.shared.assertEqualLists +import org.jetbrains.exposed.sql.tests.shared.assertEquals +import org.jetbrains.exposed.sql.tests.shared.expectException +import org.jetbrains.exposed.sql.update +import org.jetbrains.exposed.sql.upsert +import org.junit.Test +import kotlin.test.assertNull + +class MultiArrayColumnTypeTests : DatabaseTestsBase() { + + private val multiArrayTypeUnsupportedDb = TestDB.ALL - TestDB.ALL_POSTGRES.toSet() + + @Test + fun test2xMultiArray() { + val tester = object : IntIdTable("test_table") { + val multiArray = multi2Array("multi_array") + } + + withTables(excludeSettings = multiArrayTypeUnsupportedDb, tester) { + val list = listOf(listOf(1, 2, 3), listOf(4, 5, 6), listOf(7, 8, 9)) + val statement = tester.insert { + it[multiArray] = list + } + assertEqualLists(list.flatten(), statement[tester.multiArray].flatten()) + + val value = tester.selectAll().first()[tester.multiArray] + assertEqualLists(list.flatten(), value.flatten()) + } + } + + @Test + fun test3xMultiArray() { + val tester = object : IntIdTable("test_table") { + val multiArray = multi3Array("multi_array") + } + + withTables(excludeSettings = multiArrayTypeUnsupportedDb, tester) { + val list = listOf( + listOf(listOf(1, 2), listOf(3, 4)), + listOf(listOf(5, 6), listOf(7, 8)) + ) + tester.insert { + it[multiArray] = list + } + + val value = tester.selectAll().first()[tester.multiArray] + assertEqualLists(list.flatten().flatten(), value.flatten().flatten()) + } + } + + @Test + fun test5xMultiArray() { + val tester = object : IntIdTable("test_table") { + val multiArray = multiArray>>>>>("multi_array", 5) + } + + withTables(excludeSettings = multiArrayTypeUnsupportedDb, tester) { + val list = listOf(listOf(listOf(listOf(listOf("Hallo", "MultiDimensional", "Array"))))) + tester.insert { + it[multiArray] = list + } + + val value = tester.selectAll().first()[tester.multiArray] + assertEqualLists( + list.flatten().flatten().flatten().flatten(), + value.flatten().flatten().flatten().flatten() + ) + } + } + + @Test + fun testMultiArrayDefault() { + val default = listOf(listOf(1, 2), listOf(3, 4)) + + val tester = object : IntIdTable("test_table") { + val multiArray = multi2Array("multi_array") + .default(default) + } + + val testerDatabaseGenerated = object : IntIdTable("test_table") { + val multiArray = multi2Array("multi_array") + .databaseGenerated() + } + + withTables(excludeSettings = multiArrayTypeUnsupportedDb, tester) { + val statement = testerDatabaseGenerated.insert {} + assertEqualLists(default.flatten(), statement[testerDatabaseGenerated.multiArray].flatten()) + + val value = testerDatabaseGenerated.selectAll().first()[tester.multiArray] + assertEqualLists(default.flatten(), value.flatten()) + } + } + + @Test + fun testMultiArrayCardinality() { + val list = listOf(listOf(1, 2, 3), listOf(4, 5, 6)) + + val tester = object : IntIdTable("test_table") { + val multiArray = multi2Array("multi_array", maximumCardinality = listOf(2, 2)) + } + + withTables(excludeSettings = multiArrayTypeUnsupportedDb, tester) { + expectException { + tester.insert { + it[tester.multiArray] = list + } + } + } + } + + @Test + fun testMultiArrayWithNullable() { + val tester = object : IntIdTable("test_table") { + val multiArray = multi2Array("multi_array") + .nullable() + } + + withTables(excludeSettings = multiArrayTypeUnsupportedDb, tester) { + val statement = tester.insert { + it[multiArray] = null + } + assertNull(statement[tester.multiArray]) + assertNull(tester.selectAll().first()[tester.multiArray]) + } + } + + @Test + fun testMultiArrayLiteral() { + val tester = object : IntIdTable("test_table") { + val multiArray = multi2Array("multi_array") + } + + withTables(excludeSettings = multiArrayTypeUnsupportedDb, tester) { + val list = listOf(listOf(1, 2), listOf(3, 4)) + + tester.insert { + it[multiArray] = multi2ArrayLiteral(list) + } + + val value = tester.selectAll().first()[tester.multiArray] + assertEqualLists(list.flatten(), value.flatten()) + } + } + + @Test + fun testMultiArrayUpdate() { + val tester = object : IntIdTable("test_table") { + val multiArray = multi2Array("multi_array") + } + + withTables(excludeSettings = multiArrayTypeUnsupportedDb, tester) { + val initialArray = listOf(listOf(1, 2), listOf(3, 4)) + + val insertedId = tester.insert { + it[multiArray] = initialArray + } get tester.id + + var value = tester.selectAll().where { tester.id eq insertedId }.first()[tester.multiArray] + assertEqualLists(initialArray.flatten(), value.flatten()) + + val updatedArray = listOf(listOf(5, 6), listOf(7, 8)) + + // Perform the update + tester.update({ tester.id eq insertedId }) { + it[multiArray] = updatedArray + } + + value = tester.selectAll().where { tester.id eq insertedId }.first()[tester.multiArray] + assertEqualLists(updatedArray.flatten(), value.flatten()) + } + } + + @Test + fun testMultiArrayUpsert() { + val tester = object : IntIdTable("test_table") { + val multiArray = multi2Array("multi_array") + } + + withTables(excludeSettings = multiArrayTypeUnsupportedDb, tester) { + val initialArray = listOf(listOf(1, 2), listOf(3, 4)) + + val id = tester.insertAndGetId { + it[multiArray] = initialArray + } + + var value = tester.selectAll().where { tester.id eq id }.first()[tester.multiArray] + assertEqualLists(initialArray.flatten(), value.flatten()) + + val updatedArray = listOf(listOf(5, 6), listOf(7, 8)) + + tester.upsert(tester.id, onUpdate = { it[tester.multiArray] = updatedArray }) { + it[tester.id] = id + it[multiArray] = initialArray + } + + value = tester.selectAll().where { tester.id eq id }.first()[tester.multiArray] + assertEqualLists(updatedArray.flatten(), value.flatten()) + + tester.upsert(tester.id) { + it[multiArray] = updatedArray + } + + assertEquals(2, tester.selectAll().count()) + } + } + + object MultiArrayTable : IntIdTable() { + val multiArray = multi2Array("multi_array") + } + + class MultiArrayEntity(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(MultiArrayTable) + + var multiArray by MultiArrayTable.multiArray + } + + @Test + fun testMultiArrayEntityCreate() { + withTables(excludeSettings = multiArrayTypeUnsupportedDb, MultiArrayTable) { + val initialArray = listOf(listOf(1, 2), listOf(3, 4)) + + val entity = MultiArrayEntity.new { + multiArray = initialArray + } + + assertEqualLists(initialArray.flatten(), entity.multiArray.flatten()) + + val fetchedList = MultiArrayEntity.findById(entity.id)?.multiArray + assertEqualLists(initialArray.flatten(), fetchedList!!.flatten()) + } + } + + @Test + fun testMultiArrayEntityUpdate() { + withTables(excludeSettings = multiArrayTypeUnsupportedDb, MultiArrayTable) { + val initialArray = listOf(listOf(1, 2), listOf(3, 4)) + + val entity = MultiArrayEntity.new { + multiArray = initialArray + } + + val updatedArray = listOf(listOf(5, 6), listOf(7, 8)) + entity.multiArray = updatedArray + + val fetchedEntity = MultiArrayEntity.findById(entity.id) + assertEquals(entity, fetchedEntity) + assertEqualLists(updatedArray.flatten(), fetchedEntity!!.multiArray.flatten()) + } + } +}