From 1bbb271b8e138edbccae63d77bcf02d70953880d Mon Sep 17 00:00:00 2001 From: obabichevjb <166523824+obabichevjb@users.noreply.github.com> Date: Wed, 16 Oct 2024 11:20:16 +0200 Subject: [PATCH] feat!: EXPOSED-359 Add support for multidimensional arrays (#2250) * feat!: EXPOSED-359 Add support for multidimensional arrays --- .../Writerside/topics/Breaking-Changes.md | 3 + .../Writerside/topics/Data-Types.topic | 55 ++- exposed-core/api/exposed-core.api | 9 +- .../org/jetbrains/exposed/sql/ColumnType.kt | 111 ++++-- .../kotlin/org/jetbrains/exposed/sql/Op.kt | 38 +- .../exposed/sql/SQLExpressionBuilder.kt | 8 +- .../org/jetbrains/exposed/sql/SchemaUtils.kt | 2 +- .../kotlin/org/jetbrains/exposed/sql/Table.kt | 43 ++- .../jetbrains/exposed/sql/ops/AllAnyOps.kt | 4 +- .../exposed/sql/vendors/PostgreSQL.kt | 2 +- .../shared/types/MultiArrayColumnTypeTests.kt | 331 ++++++++++++++++++ 11 files changed, 557 insertions(+), 49 deletions(-) create mode 100644 exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/types/MultiArrayColumnTypeTests.kt diff --git a/documentation-website/Writerside/topics/Breaking-Changes.md b/documentation-website/Writerside/topics/Breaking-Changes.md index 04c9afeccc..ee3e5cefd8 100644 --- a/documentation-website/Writerside/topics/Breaking-Changes.md +++ b/documentation-website/Writerside/topics/Breaking-Changes.md @@ -8,6 +8,9 @@ * In Oracle and H2 Oracle, the `uinteger()` column now maps to data type `NUMBER(10)` instead of `NUMBER(13)`. * In Oracle and H2 Oracle, the `integer()` column now maps to data type `NUMBER(10)` and `INTEGER` respectively, instead of `NUMBER(12)`. In Oracle and SQLite, using the integer column in a table now also creates a CHECK constraint to ensure that no out-of-range values are inserted. +* `ArrayColumnType` now supports multidimensional arrays and includes an additional generic parameter. + If it was previously used for one-dimensional arrays with the parameter `T` like `ArrayColumnType`, + it should now be defined as `ArrayColumnType>`. For instance, `ArrayColumnType` should now be `ArrayColumnType>`. ## 0.55.0 * The `DeleteStatement` property `table` is now deprecated in favor of `targetsSet`, which holds a `ColumnSet` that may be a `Table` or `Join`. diff --git a/documentation-website/Writerside/topics/Data-Types.topic b/documentation-website/Writerside/topics/Data-Types.topic index 07c87e6f74..fb4c468c0b 100644 --- a/documentation-website/Writerside/topics/Data-Types.topic +++ b/documentation-website/Writerside/topics/Data-Types.topic @@ -252,31 +252,52 @@ -

PostgreSQL and H2 databases support the explicit ARRAY data type.

-

Exposed currently only supports columns defined as one-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:

+

PostgreSQL and H2 databases support the explicit ARRAY data type, + with multi-dimensional arrays being supported by PostgreSQL.

+ +

Exposed allows defining columns as 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") { + // Single-dimensional arrays val memberIds = array<UUID>("member_ids") val memberNames = array<String>("member_names") val budgets = array<Double>("budgets") + + // Multi-dimensional arrays + val nestedMemberIds = array<UUID, List<List<UUID>>>( + "nested_member_ids", dimensions = 2 + ) + val hierarchicalMemberNames = array<String, List<List<List<String>>>>( + "hierarchical_member_names", dimensions = 3 + ) } +

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 = array<UUID>("member_ids") + // Single-dimensional arrays val memberNames = array<String>("member_names", VarCharColumnType(colLength = 32)) val deadlines = array<LocalDate>("deadlines", KotlinLocalDateColumnType()).nullable() - val budgets = array<Double>("budgets") val expenses = array<Double?>("expenses", DoubleColumnType()).default(emptyList()) + + // Multi-dimensional arrays + val nestedMemberIds = array<UUID, List<List<UUID>>>( + "nested_member_ids", dimensions = 2 + ) + val hierarchicalMemberNames = array<String, List<List<List<String>>>>( + "hierarchical_member_names", + VarCharColumnType(colLength = 32), + dimensions = 3 + ) } +

This will prevent an exception being thrown if Exposed cannot find an associated column mapping for the defined type. Null array contents are allowed, and the explicit column type should be provided for these columns as @@ -285,9 +306,16 @@ Teams.insert { + // Single-dimensional arrays it[memberIds] = List(5) { UUID.randomUUID() } it[memberNames] = List(5) { i -> "Member ${'A' + i}" } it[budgets] = listOf(9999.0) + + // Multi-dimensional arrays + it[nestedMemberIds] = List(5) { List(5) { UUID.randomUUID() } } + it[hierarchicalMemberNames] = List(3) { List(3) { List(3) { + i -> "Member ${'A' + i}" + } } } } @@ -300,6 +328,14 @@ .select(firstMember) .where { Teams.expenses[1] greater Teams.budgets[1] } + +

This also applies to multidimensional arrays:

+ + Teams + .selectAll() + .where { Teams.hierarchicalMemberNames[1][1] eq "Mr. Smith" } + + Both PostgreSQL and H2 use a one-based indexing convention, so the first element is retrieved by using index 1. @@ -310,6 +346,11 @@ Teams.select(Teams.deadlines.slice(1, 3)) + +

In the case of multidimensional arrays, the slice() calls can be nested:

+ + Teams.select(Teams.hierarchicalMemberNames.slice(1, 2).slice(3, 4)) +

Both arguments for these bounds are optional if using PostgreSQL.

An array column can also be used as an argument for the ANY and ALL SQL operators, either by providing the entire column or a new array expression via slice():

diff --git a/exposed-core/api/exposed-core.api b/exposed-core/api/exposed-core.api index b21738db24..6659110f58 100644 --- a/exposed-core/api/exposed-core.api +++ b/exposed-core/api/exposed-core.api @@ -205,9 +205,12 @@ public final class org/jetbrains/exposed/sql/AndOp : org/jetbrains/exposed/sql/C public final class org/jetbrains/exposed/sql/ArrayColumnType : org/jetbrains/exposed/sql/ColumnType { public fun (Lorg/jetbrains/exposed/sql/ColumnType;Ljava/lang/Integer;)V public synthetic fun (Lorg/jetbrains/exposed/sql/ColumnType;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/jetbrains/exposed/sql/ColumnType;Ljava/util/List;I)V + public synthetic fun (Lorg/jetbrains/exposed/sql/ColumnType;Ljava/util/List;IILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getDelegate ()Lorg/jetbrains/exposed/sql/ColumnType; public final fun getDelegateType ()Ljava/lang/String; - public final fun getMaximumCardinality ()Ljava/lang/Integer; + public final fun getDimensions ()I + public final fun getMaximumCardinality ()Ljava/util/List; public synthetic fun nonNullValueAsDefaultString (Ljava/lang/Object;)Ljava/lang/String; public fun nonNullValueAsDefaultString (Ljava/util/List;)Ljava/lang/String; public synthetic fun nonNullValueToString (Ljava/lang/Object;)Ljava/lang/String; @@ -219,8 +222,6 @@ public final class org/jetbrains/exposed/sql/ArrayColumnType : org/jetbrains/exp 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 synthetic fun valueToString (Ljava/lang/Object;)Ljava/lang/String; - public fun valueToString (Ljava/util/List;)Ljava/lang/String; } public final class org/jetbrains/exposed/sql/AutoIncColumnType : org/jetbrains/exposed/sql/IColumnType { @@ -2467,7 +2468,9 @@ public class org/jetbrains/exposed/sql/Table : org/jetbrains/exposed/sql/ColumnS public fun (Ljava/lang/String;)V public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun array (Ljava/lang/String;Lorg/jetbrains/exposed/sql/ColumnType;Ljava/lang/Integer;)Lorg/jetbrains/exposed/sql/Column; + public final fun array (Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;Lorg/jetbrains/exposed/sql/ColumnType;Ljava/util/List;I)Lorg/jetbrains/exposed/sql/Column; public static synthetic fun array$default (Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;Lorg/jetbrains/exposed/sql/ColumnType;Ljava/lang/Integer;ILjava/lang/Object;)Lorg/jetbrains/exposed/sql/Column; + public static synthetic fun array$default (Lorg/jetbrains/exposed/sql/Table;Lorg/jetbrains/exposed/sql/Table;Ljava/lang/String;Lorg/jetbrains/exposed/sql/ColumnType;Ljava/util/List;IILjava/lang/Object;)Lorg/jetbrains/exposed/sql/Column; public final fun autoGenerate (Lorg/jetbrains/exposed/sql/Column;)Lorg/jetbrains/exposed/sql/Column; public final fun autoIncrement (Lorg/jetbrains/exposed/sql/Column;Ljava/lang/String;)Lorg/jetbrains/exposed/sql/Column; public final fun autoIncrement (Lorg/jetbrains/exposed/sql/Column;Lorg/jetbrains/exposed/sql/Sequence;)Lorg/jetbrains/exposed/sql/Column; 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..1a6519b76f 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 @@ -1233,47 +1233,79 @@ class CustomEnumerationColumnType>( // Array columns /** - * Array column for storing a collection of elements. + * 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. */ -class ArrayColumnType( - /** Returns the base column type of this array column's individual elements. */ - val delegate: ColumnType, - /** Returns the maximum amount of allowed elements in this array column. */ - val maximumCardinality: Int? = null -) : ColumnType>() { +class ArrayColumnType>( + val delegate: ColumnType, + val maximumCardinality: List? = null, + val dimensions: Int = 1 +) : ColumnType() { + /** + * Constructor with maximum cardinality for a single dimension. + * + * @param delegate The base column type associated with this array column's individual elements. + * @param maximumCardinality The maximum cardinality (number of allowed elements) for the array. + */ + constructor(delegate: ColumnType, maximumCardinality: Int? = null) : this(delegate, maximumCardinality?.let { listOf(it) }) + + /** + * The SQL type definition of the delegate column type without any potential array dimensions. + */ + val delegateType: String + get() = delegate.sqlType().substringBefore('(') + override fun sqlType(): String = buildString { + 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}" + } + } append(delegate.sqlType()) when { - currentDialect is H2Dialect -> append(" ARRAY", maximumCardinality?.let { "[$it]" } ?: "") - else -> append("[", maximumCardinality?.toString() ?: "", "]") + currentDialect is H2Dialect -> { + require(dimensions == 1) { + "H2 does not support multidimensional arrays. " + + "`dimensions` parameter for H2 database must be 1" + } + append(" ARRAY", maximumCardinality?.let { "[${it.first()}]" } ?: "") + } + + else -> append(maximumCardinality?.let { cardinality -> cardinality.joinToString("") { "[$it]" } } ?: "[]".repeat(dimensions)) } } - /** The base SQL type of this array column's individual elements without extra column identifiers. */ - val delegateType: String - get() = delegate.sqlType().substringBefore('(') - - @Suppress("UNCHECKED_CAST") - override fun valueFromDB(value: Any): List = when { - value is java.sql.Array -> (value.array as Array<*>).map { e -> e?.let { delegate.valueFromDB(it) } as E } - else -> value as? List ?: error("Unexpected value $value of type ${value::class.qualifiedName}") + override fun notNullValueToDB(value: R): Any { + return recursiveNotNullValueToDB(value, dimensions) } - override fun notNullValueToDB(value: List): Any = value.map { e -> e?.let { delegate.notNullValueToDB(it) } }.toTypedArray() - - override fun valueToString(value: List?): String = if (value != null) nonNullValueToString(value) else super.valueToString(null) + 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 { it?.let { delegate.notNullValueToDB(it) } }.toTypedArray() + } - override fun nonNullValueToString(value: List): String { - val prefix = if (currentDialect is H2Dialect) "ARRAY [" else "ARRAY[" - return value.joinToString(",", prefix, "]") { delegate.valueToString(it) } + @Suppress("UNCHECKED_CAST") + override fun valueFromDB(value: Any): R? { + return when { + value is Array<*> -> recursiveValueFromDB(value, dimensions) as R? + else -> value as R? + } } - override fun nonNullValueAsDefaultString(value: List): String { - val prefix = if (currentDialect is H2Dialect) "ARRAY [" else "ARRAY[" - return value.joinToString(",", prefix, "]") { delegate.valueAsDefaultString(it) } + private fun recursiveValueFromDB(value: Any?, level: Int): List = when { + level > 1 -> (value as Array).map { recursiveValueFromDB(it, level - 1) } + else -> (value as Array).map { it?.let { delegate.valueFromDB(it) } } } - override fun readObject(rs: ResultSet, index: Int): Any? = rs.getArray(index) + override fun readObject(rs: ResultSet, index: Int): Any? { + return rs.getArray(index)?.array + } override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { when { @@ -1284,6 +1316,31 @@ class ArrayColumnType( else -> super.setParameter(stmt, index, value) } } + + override fun nonNullValueToString(value: R): String { + return arrayLiteralPrefix() + 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.nonNullValueToString(it) } + } + + override fun nonNullValueAsDefaultString(value: R): String { + return arrayLiteralPrefix() + recursiveNonNullValueAsDefaultString(value, dimensions) + } + + private fun recursiveNonNullValueAsDefaultString(value: Any?, level: Int): String = when { + level > 1 -> (value as List).joinToString(",", "[", "]") { recursiveNonNullValueAsDefaultString(it, level - 1) } + else -> (value as List).joinToString(",", "[", "]") { delegate.nonNullValueAsDefaultString(it) } + } + + private fun arrayLiteralPrefix(): String { + return when { + currentDialect is H2Dialect -> "ARRAY " + else -> "ARRAY" + } + } } private fun isArrayOfByteArrays(value: Array<*>) = 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..8ef2135bdf 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 @@ -687,9 +687,24 @@ fun decimalLiteral(value: BigDecimal): LiteralOp = LiteralOp(Decimal * * @throws IllegalStateException If no column type mapping is found and a [delegateType] is not provided. */ -inline fun arrayLiteral(value: List, delegateType: ColumnType? = null): LiteralOp> { +inline fun arrayLiteral(value: List, delegateType: ColumnType? = null): LiteralOp> = + arrayLiteral(value, 1, delegateType) + +/** + * Returns the specified [value] as an array literal, with elements parsed by the [delegateType] if provided. + * + * **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]. + * + * **Note:** Because arrays can have varying dimensions, you must specify the type of elements + * and the number of dimensions when using array literals. + * For example, use `arrayLiteral>>(list, dimensions = 2)`. + * + * @throws IllegalStateException If no column type mapping is found and a [delegateType] is not provided. + */ +inline fun > arrayLiteral(value: R, dimensions: Int, delegateType: ColumnType? = null): LiteralOp { @OptIn(InternalApi::class) - return LiteralOp(ArrayColumnType(delegateType ?: resolveColumnType(T::class)), value) + return LiteralOp(ArrayColumnType(delegateType ?: resolveColumnType(T::class), dimensions = dimensions), value) } // Query Parameters @@ -776,9 +791,24 @@ fun blobParam(value: ExposedBlob, useObjectIdentifier: Boolean = false): Express * * @throws IllegalStateException If no column type mapping is found and a [delegateType] is not provided. */ -inline fun arrayParam(value: List, delegateType: ColumnType? = null): Expression> { +inline fun arrayParam(value: List, delegateType: ColumnType? = null): Expression> = + arrayParam(value, 1, delegateType) + +/** + * Returns the specified [value] as an array query parameter, with elements parsed by the [delegateType] if provided. + * + * **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]. + * + * **Note:** Because arrays can have varying dimensions, you must specify the type of elements + * and the number of dimensions when using array literals. + * For example, use `arrayParam>>(list, dimensions = 2)`. + * + * @throws IllegalStateException If no column type mapping is found and a [delegateType] is not provided. + */ +inline fun > arrayParam(value: R, dimensions: Int, delegateType: ColumnType? = null): Expression { @OptIn(InternalApi::class) - return QueryParameter(value, ArrayColumnType(delegateType ?: resolveColumnType(T::class))) + return QueryParameter(value, ArrayColumnType(delegateType ?: resolveColumnType(T::class), dimensions = dimensions)) } // Misc. diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SQLExpressionBuilder.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SQLExpressionBuilder.kt index 574f07dfa1..7b7d10109c 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SQLExpressionBuilder.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SQLExpressionBuilder.kt @@ -198,8 +198,12 @@ fun ?> allFrom(expression: Expression): Op = AllAnyFromExpr * * @sample org.jetbrains.exposed.sql.tests.shared.types.ArrayColumnTypeTests.testSelectUsingArrayGet */ -infix operator fun ?> ExpressionWithColumnType.get(index: Int): ArrayGet = - ArrayGet(this, index, (this.columnType as ArrayColumnType).delegate) +infix operator fun ?> ExpressionWithColumnType.get(index: Int): ArrayGet { + return when (this) { + is ArrayGet<*, *> -> ArrayGet(this as Expression, index, this.columnType as IColumnType) as ArrayGet + else -> ArrayGet(this, index, (this.columnType as ArrayColumnType>).delegate) + } +} /** * Returns a subarray of elements stored from between [lower] and [upper] bounds (inclusive), diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SchemaUtils.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SchemaUtils.kt index 17e8c8226a..378f8e6216 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SchemaUtils.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/SchemaUtils.kt @@ -221,7 +221,7 @@ object SchemaUtils { } } - column.columnType is ArrayColumnType<*> && dialect is PostgreSQLDialect -> { + column.columnType is ArrayColumnType<*, *> && dialect is PostgreSQLDialect -> { (value as List<*>) .takeIf { it.isNotEmpty() } ?.run { 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..42c08edb3f 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 @@ -922,7 +922,7 @@ open class Table(name: String = "") : ColumnSet(), DdlAware { * when using the PostgreSQL dialect is allowed, but this value will be ignored by the database. */ fun array(name: String, columnType: ColumnType, maximumCardinality: Int? = null): Column> = - registerColumn(name, ArrayColumnType(columnType.apply { nullable = true }, maximumCardinality)) + array>(name, columnType, dimensions = 1, maximumCardinality = maximumCardinality?.let { listOf(it) }) /** * Creates an array column, with the specified [name], for storing elements of a `List`. @@ -939,11 +939,47 @@ open class Table(name: String = "") : ColumnSet(), DdlAware { * when using the PostgreSQL dialect is allowed, but this value will be ignored by the database. * @throws IllegalStateException If no column type mapping is found. */ - inline fun array(name: String, maximumCardinality: Int? = null): Column> { + inline fun array(name: String, maximumCardinality: Int? = null): Column> = + array>(name, maximumCardinality?.let { listOf(it) }, dimensions = 1) + + /** + * 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 maximumCardinality The maximum cardinality (number of allowed elements) for each dimension in the array. + * @param dimensions The number of dimensions of the array. + * + * **Note:** Providing an array size limit when using the PostgreSQL dialect is allowed, but this value will be ignored by the database. + * + * @return A column instance that represents a multi-dimensional list of elements of type [T]. + * @throws IllegalStateException If no column type mapping is found. + */ + inline fun > Table.array(name: String, maximumCardinality: List? = null, dimensions: Int): Column { @OptIn(InternalApi::class) - return array(name, resolveColumnType(E::class), maximumCardinality) + return array(name, resolveColumnType(T::class), maximumCardinality, dimensions) } + /** + * 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 maximumCardinality The maximum cardinality (number of allowed elements) for each dimension in the array. + * @param dimensions The number of dimensions of the array. + * + * **Note:** Providing an array size limit when using the PostgreSQL dialect is allowed, but this value will be ignored by the database. + * + * @return A column instance that represents a multi-dimensional list of elements of type [E]. + * @throws IllegalStateException If no column type mapping is found. + */ + fun > Table.array(name: String, columnType: ColumnType, maximumCardinality: List? = null, dimensions: Int): Column = + registerColumn(name, ArrayColumnType(columnType, maximumCardinality, dimensions)) + // Auto-generated values /** @@ -1689,6 +1725,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-core/src/main/kotlin/org/jetbrains/exposed/sql/ops/AllAnyOps.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/ops/AllAnyOps.kt index 0c170c9823..2d44ab8133 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/ops/AllAnyOps.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/ops/AllAnyOps.kt @@ -41,6 +41,8 @@ class AllAnyFromSubQueryOp( * against an array of values. * * **Note** This operation is only supported by PostgreSQL and H2 dialects. + * + * **Note** This operation is supported only for 1 dimensional arrays */ class AllAnyFromArrayOp( isAny: Boolean, @@ -48,7 +50,7 @@ class AllAnyFromArrayOp( private val delegateType: ColumnType ) : AllAnyFromBaseOp>(isAny, array) { override fun QueryBuilder.registerSubSearchArgument(subSearch: List) { - registerArgument(ArrayColumnType(delegateType), subSearch) + registerArgument(ArrayColumnType>(delegateType), subSearch) } } diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/PostgreSQL.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/PostgreSQL.kt index c555633382..22b3ad0876 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/PostgreSQL.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/PostgreSQL.kt @@ -32,7 +32,7 @@ internal object PostgreSQLDataTypeProvider : DataTypeProvider() { e is LiteralOp<*> && e.columnType is BlobColumnType && e.columnType.useObjectIdentifier && (currentDialect as? H2Dialect) == null -> { "lo_from_bytea(0, ${super.processForDefaultValue(e)} :: bytea)" } - e is LiteralOp<*> && e.columnType is ArrayColumnType<*> -> { + e is LiteralOp<*> && e.columnType is ArrayColumnType<*, *> -> { val processed = super.processForDefaultValue(e) processed .takeUnless { it == "ARRAY[]" } 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..ff0d61cec6 --- /dev/null +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/types/MultiArrayColumnTypeTests.kt @@ -0,0 +1,331 @@ +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.* +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.junit.Test +import kotlin.test.assertNull + +private inline fun Table.array3(name: String, maximumCardinality: List? = null): Column>>> = + array>>>(name, maximumCardinality, dimensions = 3) + +private inline fun Table.array2(name: String, maximumCardinality: List? = null): Column>> = + array>>(name, maximumCardinality, dimensions = 2) + +class MultiArrayColumnTypeTests : DatabaseTestsBase() { + + private val multiArrayTypeUnsupportedDb = TestDB.ALL - TestDB.ALL_POSTGRES.toSet() + + @Test + fun test2xMultiArray() { + val tester = object : IntIdTable("test_table") { + val multiArray = array2("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 = array3("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 = array>>>>>("multi_array", dimensions = 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 = array2("multi_array") + .default(default) + } + + val testerDatabaseGenerated = object : IntIdTable("test_table") { + val multiArray = array2("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 = array2("multi_array", maximumCardinality = listOf(2, 2)) + } + + withTables(excludeSettings = multiArrayTypeUnsupportedDb, tester) { + tester.insert { + it[tester.multiArray] = list + } + + assertEqualLists(list.flatten(), tester.selectAll().first()[tester.multiArray].flatten()) + } + } + + @Test + fun testMultiArrayWithNullable() { + val tester = object : IntIdTable("test_table") { + val multiArray = array2("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 = array2("multi_array") + } + + withTables(excludeSettings = multiArrayTypeUnsupportedDb, tester) { + val list = listOf(listOf(1, 2), listOf(3, 4)) + + tester.insert { + it[multiArray] = arrayLiteral>>(list, dimensions = 2) + } + + val value = tester.selectAll().first()[tester.multiArray] + assertEqualLists(list.flatten(), value.flatten()) + } + } + + @Test + fun testMultiArrayParam() { + val tester = object : IntIdTable("test_table") { + val multiArray = array2("multi_array") + } + + withTables(excludeSettings = multiArrayTypeUnsupportedDb, tester) { + val list = listOf(listOf(1, 2), listOf(3, 4)) + + tester.insert { + it[multiArray] = arrayParam>>(list, dimensions = 2) + } + + val value = tester.selectAll().first()[tester.multiArray] + assertEqualLists(list.flatten(), value.flatten()) + } + } + + @Test + fun testMultiArrayUpdate() { + val tester = object : IntIdTable("test_table") { + val multiArray = array2("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 = array2("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()) + } + } + + @Test + fun testMultiArrayGetFunction() { + val tester = object : IntIdTable("test_table") { + val multiArray = array2("multi_array") + } + + withTables(excludeSettings = multiArrayTypeUnsupportedDb, tester) { + tester.batchInsert( + listOf( + listOf(listOf(1, 1), listOf(1, 4)), + listOf(listOf(1, 1), listOf(2, 4)), + listOf(listOf(1, 1), listOf(1, 6)), + ) + ) { + this[tester.multiArray] = it + } + + val values = tester.selectAll().where { tester.multiArray[2][2] eq 4 }.map { it[tester.multiArray] } + assertEquals(2, values.size) + assertEqualLists( + listOf( + listOf(listOf(1, 1), listOf(1, 4)), + listOf(listOf(1, 1), listOf(2, 4)), + ), + values + ) + + assertEquals(0, tester.selectAll().where { tester.multiArray[2][2] greater 10 }.map { it[tester.multiArray] }.size) + } + } + + @Test + fun testMultiArraySliceFunction() { + val tester = object : IntIdTable("test_table") { + val multiArray = array2("multi_array") + } + + withTables(excludeSettings = multiArrayTypeUnsupportedDb, tester) { + tester.insert { + it[multiArray] = listOf( + listOf(1, 2, 3, 4), listOf(5, 6, 7, 8), listOf(9, 10, 11, 12), listOf(13, 14, 15, 16) + ) + } + + val alias = tester.multiArray.slice(1, 2).slice(2, 3) + + val query = tester.select(alias).first() + assertEqualLists(listOf(2, 3, 6, 7), query[alias].flatten()) + } + } + + object MultiArrayTable : IntIdTable() { + val multiArray = array2("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()) + } + } +}