From b909d075f0fbc0c325d16c6923dfde7b68310850 Mon Sep 17 00:00:00 2001 From: bog-walk <82039410+bog-walk@users.noreply.github.com> Date: Thu, 8 Feb 2024 21:01:28 -0500 Subject: [PATCH] feat: EXPOSED-248 Support array column type (#1986) * feat: EXPOSED-248 Support array column type - Add new ArrayColumnType that stores and retrieves elements of a List. - Supported databases include: PostgreSQL, PostgreSQLNG, H2 (and variants). - Maximum array size can be set (for H2). - Currently on 1-dimensional arrays are supported. - Stored array can be accessed using index reference as well as sliced. - Updating a stored array using index reference is currently not supported. - Using an arry column with ANY or ALL operatora is not currently supported. * feat: EXPOSED-248 Support array column type - Add more tests - Add apiDump changes - Edit KDocs * feat: EXPOSED-248 Support array column type - Replace SchemaUtils if block with when block. - Add index reference get operator and update test. * feat: EXPOSED-248 Support array column type Drop elementAt() in favor of get() index reference operator. --- exposed-core/api/exposed-core.api | 38 +++ .../org/jetbrains/exposed/sql/ColumnType.kt | 51 ++++ .../kotlin/org/jetbrains/exposed/sql/Op.kt | 8 + .../exposed/sql/SQLExpressionBuilder.kt | 20 ++ .../org/jetbrains/exposed/sql/SchemaUtils.kt | 44 ++- .../kotlin/org/jetbrains/exposed/sql/Table.kt | 15 + .../sql/functions/array/ArrayFunctions.kt | 51 ++++ .../statements/api/PreparedStatementApi.kt | 3 + .../exposed/sql/vendors/FunctionProvider.kt | 18 ++ .../org/jetbrains/exposed/sql/vendors/H2.kt | 6 + .../exposed/sql/vendors/PostgreSQL.kt | 19 ++ exposed-jdbc/api/exposed-jdbc.api | 1 + .../jdbc/JdbcPreparedStatementImpl.kt | 4 + .../shared/types/ArrayColumnTypeTests.kt | 261 ++++++++++++++++++ 14 files changed, 525 insertions(+), 14 deletions(-) create mode 100644 exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/functions/array/ArrayFunctions.kt create mode 100644 exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/types/ArrayColumnTypeTests.kt diff --git a/exposed-core/api/exposed-core.api b/exposed-core/api/exposed-core.api index 12a2ce3043..54d3326c89 100644 --- a/exposed-core/api/exposed-core.api +++ b/exposed-core/api/exposed-core.api @@ -154,6 +154,20 @@ public final class org/jetbrains/exposed/sql/AndOp : org/jetbrains/exposed/sql/C public fun (Ljava/util/List;)V } +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 final fun getDelegate ()Lorg/jetbrains/exposed/sql/ColumnType; + public final fun getDelegateType ()Ljava/lang/String; + public final fun getMaximumCardinality ()Ljava/lang/Integer; + public fun notNullValueToDB (Ljava/lang/Object;)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 fun valueFromDB (Ljava/lang/Object;)Ljava/lang/Object; + public fun valueToString (Ljava/lang/Object;)Ljava/lang/String; +} + public final class org/jetbrains/exposed/sql/AutoIncColumnType : org/jetbrains/exposed/sql/IColumnType { public fun (Lorg/jetbrains/exposed/sql/ColumnType;Ljava/lang/String;Ljava/lang/String;)V public fun equals (Ljava/lang/Object;)Z @@ -1509,6 +1523,8 @@ public final class org/jetbrains/exposed/sql/OpKt { public static final fun andIfNotNull (Lorg/jetbrains/exposed/sql/Op;Lkotlin/jvm/functions/Function1;)Lorg/jetbrains/exposed/sql/Op; public static final fun andIfNotNull (Lorg/jetbrains/exposed/sql/Op;Lorg/jetbrains/exposed/sql/Expression;)Lorg/jetbrains/exposed/sql/Op; public static final fun andNot (Lorg/jetbrains/exposed/sql/Expression;Lkotlin/jvm/functions/Function1;)Lorg/jetbrains/exposed/sql/Op; + public static final fun arrayLiteral (Ljava/util/List;Lorg/jetbrains/exposed/sql/ColumnType;)Lorg/jetbrains/exposed/sql/LiteralOp; + public static final fun arrayParam (Ljava/util/List;Lorg/jetbrains/exposed/sql/ColumnType;)Lorg/jetbrains/exposed/sql/Expression; public static final fun blobParam (Lorg/jetbrains/exposed/sql/statements/api/ExposedBlob;)Lorg/jetbrains/exposed/sql/Expression; public static final fun booleanLiteral (Z)Lorg/jetbrains/exposed/sql/LiteralOp; public static final fun booleanParam (Z)Lorg/jetbrains/exposed/sql/Expression; @@ -1786,6 +1802,7 @@ public final class org/jetbrains/exposed/sql/SQLExpressionBuilderKt { public static final fun count (Lorg/jetbrains/exposed/sql/ExpressionWithColumnType;)Lorg/jetbrains/exposed/sql/Count; public static final fun countDistinct (Lorg/jetbrains/exposed/sql/Column;)Lorg/jetbrains/exposed/sql/Count; public static final fun function (Lorg/jetbrains/exposed/sql/ExpressionWithColumnType;Ljava/lang/String;)Lorg/jetbrains/exposed/sql/CustomFunction; + public static final fun get (Lorg/jetbrains/exposed/sql/ExpressionWithColumnType;I)Lorg/jetbrains/exposed/sql/functions/array/ArrayGet; public static final fun groupConcat (Lorg/jetbrains/exposed/sql/Expression;Ljava/lang/String;ZLkotlin/Pair;)Lorg/jetbrains/exposed/sql/GroupConcat; public static final fun groupConcat (Lorg/jetbrains/exposed/sql/Expression;Ljava/lang/String;Z[Lkotlin/Pair;)Lorg/jetbrains/exposed/sql/GroupConcat; public static synthetic fun groupConcat$default (Lorg/jetbrains/exposed/sql/Expression;Ljava/lang/String;ZLkotlin/Pair;ILjava/lang/Object;)Lorg/jetbrains/exposed/sql/GroupConcat; @@ -1796,6 +1813,8 @@ public final class org/jetbrains/exposed/sql/SQLExpressionBuilderKt { public static final fun min (Lorg/jetbrains/exposed/sql/ExpressionWithColumnType;)Lorg/jetbrains/exposed/sql/Min; public static final fun nextIntVal (Lorg/jetbrains/exposed/sql/Sequence;)Lorg/jetbrains/exposed/sql/NextVal; public static final fun nextLongVal (Lorg/jetbrains/exposed/sql/Sequence;)Lorg/jetbrains/exposed/sql/NextVal; + public static final fun slice (Lorg/jetbrains/exposed/sql/ExpressionWithColumnType;Ljava/lang/Integer;Ljava/lang/Integer;)Lorg/jetbrains/exposed/sql/functions/array/ArraySlice; + public static synthetic fun slice$default (Lorg/jetbrains/exposed/sql/ExpressionWithColumnType;Ljava/lang/Integer;Ljava/lang/Integer;ILjava/lang/Object;)Lorg/jetbrains/exposed/sql/functions/array/ArraySlice; public static final fun stdDevPop (Lorg/jetbrains/exposed/sql/ExpressionWithColumnType;I)Lorg/jetbrains/exposed/sql/StdDevPop; public static synthetic fun stdDevPop$default (Lorg/jetbrains/exposed/sql/ExpressionWithColumnType;IILjava/lang/Object;)Lorg/jetbrains/exposed/sql/StdDevPop; public static final fun stdDevSamp (Lorg/jetbrains/exposed/sql/ExpressionWithColumnType;I)Lorg/jetbrains/exposed/sql/StdDevSamp; @@ -2174,6 +2193,8 @@ public class org/jetbrains/exposed/sql/Table : org/jetbrains/exposed/sql/ColumnS public fun ()V 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 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 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 static synthetic fun autoIncrement$default (Lorg/jetbrains/exposed/sql/Table;Lorg/jetbrains/exposed/sql/Column;Ljava/lang/String;ILjava/lang/Object;)Lorg/jetbrains/exposed/sql/Column; @@ -2568,6 +2589,21 @@ public final class org/jetbrains/exposed/sql/XorBitOp : org/jetbrains/exposed/sq public fun toQueryBuilder (Lorg/jetbrains/exposed/sql/QueryBuilder;)V } +public final class org/jetbrains/exposed/sql/functions/array/ArrayGet : org/jetbrains/exposed/sql/Function { + public fun (Lorg/jetbrains/exposed/sql/Expression;ILorg/jetbrains/exposed/sql/IColumnType;)V + public final fun getExpression ()Lorg/jetbrains/exposed/sql/Expression; + public final fun getIndex ()I + public fun toQueryBuilder (Lorg/jetbrains/exposed/sql/QueryBuilder;)V +} + +public final class org/jetbrains/exposed/sql/functions/array/ArraySlice : org/jetbrains/exposed/sql/Function { + public fun (Lorg/jetbrains/exposed/sql/Expression;Ljava/lang/Integer;Ljava/lang/Integer;Lorg/jetbrains/exposed/sql/IColumnType;)V + public final fun getExpression ()Lorg/jetbrains/exposed/sql/Expression; + public final fun getLower ()Ljava/lang/Integer; + public final fun getUpper ()Ljava/lang/Integer; + public fun toQueryBuilder (Lorg/jetbrains/exposed/sql/QueryBuilder;)V +} + public final class org/jetbrains/exposed/sql/functions/math/ACosFunction : org/jetbrains/exposed/sql/CustomFunction { public fun (Lorg/jetbrains/exposed/sql/ExpressionWithColumnType;)V } @@ -3092,6 +3128,7 @@ public abstract interface class org/jetbrains/exposed/sql/statements/api/Prepare public abstract fun getResultSet ()Ljava/sql/ResultSet; public abstract fun getTimeout ()Ljava/lang/Integer; public abstract fun set (ILjava/lang/Object;)V + public abstract fun setArray (ILjava/lang/String;[Ljava/lang/Object;)V public abstract fun setFetchSize (Ljava/lang/Integer;)V public abstract fun setInputStream (ILjava/io/InputStream;)V public abstract fun setNull (ILorg/jetbrains/exposed/sql/IColumnType;)V @@ -3455,6 +3492,7 @@ public abstract class org/jetbrains/exposed/sql/vendors/FunctionProvider { protected final fun appendInsertToUpsertClause (Lorg/jetbrains/exposed/sql/QueryBuilder;Lorg/jetbrains/exposed/sql/Table;Ljava/util/List;Lorg/jetbrains/exposed/sql/Transaction;)V protected final fun appendJoinPartForUpdateClause (Lorg/jetbrains/exposed/sql/QueryBuilder;Lorg/jetbrains/exposed/sql/Table;Lorg/jetbrains/exposed/sql/Join;Lorg/jetbrains/exposed/sql/Transaction;)V protected final fun appendUpdateToUpsertClause (Lorg/jetbrains/exposed/sql/QueryBuilder;Lorg/jetbrains/exposed/sql/Table;Ljava/util/List;Ljava/util/List;Lorg/jetbrains/exposed/sql/Transaction;Z)V + public fun arraySlice (Lorg/jetbrains/exposed/sql/Expression;Ljava/lang/Integer;Ljava/lang/Integer;Lorg/jetbrains/exposed/sql/QueryBuilder;)V public fun cast (Lorg/jetbrains/exposed/sql/Expression;Lorg/jetbrains/exposed/sql/IColumnType;Lorg/jetbrains/exposed/sql/QueryBuilder;)V public fun charLength (Lorg/jetbrains/exposed/sql/Expression;Lorg/jetbrains/exposed/sql/QueryBuilder;)V public fun concat (Ljava/lang/String;Lorg/jetbrains/exposed/sql/QueryBuilder;[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 61bd79110a..2b976a65f2 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 @@ -1006,6 +1006,8 @@ class CustomEnumerationColumnType>( override fun nonNullValueToString(value: Any): String = super.nonNullValueToString(notNullValueToDB(value)) } +// Array columns + /** * Array column for storing arrays of any size and type. * @@ -1018,6 +1020,55 @@ internal object UntypedAndUnsizedArrayColumnType : ColumnType() { currentDialect.dataTypeProvider.untypedAndUnsizedArrayType() } +/** + * Array column for storing a collection of elements. + */ +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() { + override fun sqlType(): String = buildString { + append(delegate.sqlType()) + when { + currentDialect is H2Dialect -> append(" ARRAY", maximumCardinality?.let { "[$it]" } ?: "") + else -> append("[", maximumCardinality?.toString() ?: "", "]") + } + } + + /** The base SQL type of this array column's individual elements without extra column identifiers. */ + val delegateType: String + get() = delegate.sqlType().substringBefore('(') + + override fun valueFromDB(value: Any): Any = when { + value is java.sql.Array -> (value.array as Array<*>).map { e -> e?.let { delegate.valueFromDB(it) } } + else -> value + } + + override fun notNullValueToDB(value: Any): Any = when { + value is List<*> -> value.map { e -> e?.let { delegate.notNullValueToDB(it) } }.toTypedArray() + else -> value + } + + override fun valueToString(value: Any?): String = when { + value is List<*> -> { + val prefix = if (currentDialect is H2Dialect) "ARRAY [" else "ARRAY[" + value.joinToString(",", prefix, "]") { delegate.valueToString(it) } + } + else -> super.valueToString(value) + } + + override fun readObject(rs: ResultSet, index: Int): Any? = rs.getArray(index) + + override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { + when { + value is Array<*> -> stmt.setArray(index, delegateType, value) + else -> super.setParameter(stmt, index, value) + } + } +} + // Date/Time columns /** 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 0687f1fd7c..81e6c0689a 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 @@ -682,6 +682,10 @@ fun stringLiteral(value: String): LiteralOp = LiteralOp(TextColumnType() /** Returns the specified [value] as a decimal literal. */ fun decimalLiteral(value: BigDecimal): LiteralOp = LiteralOp(DecimalColumnType(value.precision(), value.scale()), value) +/** Returns the specified [value] as an array literal, with elements parsed by the [delegateType]. */ +fun arrayLiteral(value: List, delegateType: ColumnType): LiteralOp> = + LiteralOp(ArrayColumnType(delegateType), value) + // Query Parameters /** @@ -742,6 +746,10 @@ fun decimalParam(value: BigDecimal): Expression = QueryParameter(val /** Returns the specified [value] as a blob query parameter. */ fun blobParam(value: ExposedBlob): Expression = QueryParameter(value, BlobColumnType()) +/** Returns the specified [value] as an array query parameter, with elements parsed by the [delegateType]. */ +fun arrayParam(value: List, delegateType: ColumnType): Expression> = + QueryParameter(value, ArrayColumnType(delegateType)) + // 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 1c9b803265..a8045168bf 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 @@ -5,6 +5,8 @@ package org.jetbrains.exposed.sql import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.dao.id.EntityIDFunctionProvider import org.jetbrains.exposed.dao.id.IdTable +import org.jetbrains.exposed.sql.functions.array.ArrayGet +import org.jetbrains.exposed.sql.functions.array.ArraySlice import org.jetbrains.exposed.sql.ops.* import org.jetbrains.exposed.sql.vendors.FunctionProvider import org.jetbrains.exposed.sql.vendors.currentDialect @@ -129,6 +131,24 @@ fun allFrom(array: Array): Op = AllAnyFromArrayOp(false, array) /** Returns this table wrapped in the `ALL` operator. This function is only supported by MySQL, PostgreSQL, and H2 dialects. */ fun allFrom(table: Table): Op = AllAnyFromTableOp(false, table) +/** + * Returns the array element stored at the one-based [index] position, or `null` if the stored array itself is null. + * + * @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) + +/** + * Returns a subarray of elements stored from between [lower] and [upper] bounds (inclusive), + * or `null` if the stored array itself is null. + * **Note** If either bounds is left `null`, the database will use the stored array's respective lower or upper limit. + * + * @sample org.jetbrains.exposed.sql.tests.shared.types.ArrayColumnTypeTests.testSelectUsingArraySlice + */ +fun ?> ExpressionWithColumnType.slice(lower: Int? = null, upper: Int? = null): ArraySlice = + ArraySlice(this, lower, upper, ArrayColumnType((this.columnType as ArrayColumnType).delegate)) + // Sequence Manipulation Functions /** Advances this sequence and returns the new value. */ 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 53fb0e89eb..b034b1ea9c 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 @@ -1,6 +1,7 @@ package org.jetbrains.exposed.sql import org.jetbrains.exposed.exceptions.ExposedSQLException +import org.jetbrains.exposed.sql.SqlExpressionBuilder.asLiteral import org.jetbrains.exposed.sql.transactions.TransactionManager import org.jetbrains.exposed.sql.vendors.* import java.io.File @@ -187,23 +188,38 @@ object SchemaUtils { } else -> { - if (column.columnType is JsonColumnMarker) { - val processed = processForDefaultValue(exp) - when (dialect) { - is PostgreSQLDialect -> { - if (column.columnType.usesBinaryFormat) { - processed.replace(Regex("(\"|})(:|,)(\\[|\\{|\")"), "$1$2 $3") - } else { - processed + when { + column.columnType is JsonColumnMarker -> { + val processed = processForDefaultValue(exp) + when (dialect) { + is PostgreSQLDialect -> { + if (column.columnType.usesBinaryFormat) { + processed.replace(Regex("(\"|})(:|,)(\\[|\\{|\")"), "$1$2 $3") + } else { + processed + } } + is MariaDBDialect -> processed.trim('\'') + is MysqlDialect -> "_utf8mb4\\'${processed.trim('(', ')', '\'')}\\" + else -> processed.trim('\'') } - - is MariaDBDialect -> processed.trim('\'') - is MysqlDialect -> "_utf8mb4\\'${processed.trim('(', ')', '\'')}\\" - else -> processed.trim('\'') } - } else { - processForDefaultValue(exp) + column.columnType is ArrayColumnType && dialect is PostgreSQLDialect -> { + (value as List<*>) + .takeIf { it.isNotEmpty() } + ?.run { + val delegate = column.withColumnType(column.columnType.delegate) + val processed = map { + if (delegate.columnType is StringColumnType) { + "'$it'::text" + } else { + dbDefaultToString(delegate, delegate.asLiteral(it)) + } + } + "ARRAY$processed" + } ?: processForDefaultValue(exp) + } + else -> processForDefaultValue(exp) } } } 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 8e3eab41a2..75f3129c0e 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 @@ -811,6 +811,21 @@ open class Table(name: String = "") : ColumnSet(), DdlAware { toDb: (T) -> Any ): Column = registerColumn(name, CustomEnumerationColumnType(name, sql, fromDb, toDb)) + // Array columns + + /** + * Creates an array column, with the specified [name], for storing elements of a `List` using a base [columnType]. + * + * **Note** This column type is only supported by H2 and PostgreSQL dialects. + * + * @param name Name of the column. + * @param columnType Base column type for the individual elements. + * @param maximumCardinality The maximum amount of allowed elements. **Note** Providing an array size limit + * 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)) + // Auto-generated values /** diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/functions/array/ArrayFunctions.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/functions/array/ArrayFunctions.kt new file mode 100644 index 0000000000..45d86f8ecc --- /dev/null +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/functions/array/ArrayFunctions.kt @@ -0,0 +1,51 @@ +package org.jetbrains.exposed.sql.functions.array + +import org.jetbrains.exposed.sql.Expression +import org.jetbrains.exposed.sql.Function +import org.jetbrains.exposed.sql.IColumnType +import org.jetbrains.exposed.sql.QueryBuilder +import org.jetbrains.exposed.sql.append +import org.jetbrains.exposed.sql.vendors.H2Dialect +import org.jetbrains.exposed.sql.vendors.H2FunctionProvider +import org.jetbrains.exposed.sql.vendors.currentDialect +import org.jetbrains.exposed.sql.vendors.h2Mode + +/** + * Represents an SQL function that returns the array element stored at the one-based [index] position, + * or `null` if the stored array itself is null. + */ +class ArrayGet?>( + /** The array expression that is accessed. */ + val expression: Expression, + /** The one-based index position at which the stored array is accessed. */ + val index: Int, + columnType: IColumnType +) : Function(columnType) { + override fun toQueryBuilder(queryBuilder: QueryBuilder) { + queryBuilder { + append(expression, "[", index.toString(), "]") + } + } +} + +/** + * Represents an SQL function that returns a subarray of elements stored from between [lower] and [upper] bounds (inclusive), + * or `null` if the stored array itself is null. + */ +class ArraySlice?>( + /** The array expression from which the subarray is returned. */ + val expression: Expression, + /** The lower bounds (inclusive) of a subarray. If left `null`, the database will use the stored array's lower limit. */ + val lower: Int?, + /** The upper bounds (inclusive) of a subarray. If left `null`, the database will use the stored array's upper limit. */ + val upper: Int?, + columnType: IColumnType +) : Function(columnType) { + override fun toQueryBuilder(queryBuilder: QueryBuilder) { + val functionProvider = when (currentDialect.h2Mode) { + H2Dialect.H2CompatibilityMode.PostgreSQL -> H2FunctionProvider + else -> currentDialect.functionProvider + } + functionProvider.arraySlice(expression, lower, upper, queryBuilder) + } +} diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/statements/api/PreparedStatementApi.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/statements/api/PreparedStatementApi.kt index cff6bd460a..6634381b1d 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/statements/api/PreparedStatementApi.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/statements/api/PreparedStatementApi.kt @@ -64,6 +64,9 @@ interface PreparedStatementApi { /** Sets the statement parameter at the [index] position to the provided [inputStream]. */ fun setInputStream(index: Int, inputStream: InputStream) + /** Sets the statement parameter at the [index] position to the provided [array] of SQL [type]. */ + fun setArray(index: Int, type: String, array: Array<*>) + /** Closes the statement, if still open, and releases any of its database and/or driver resources. */ fun closeIfPossible() diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/FunctionProvider.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/FunctionProvider.kt index 2c0e59c1f8..20c0853d99 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/FunctionProvider.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/FunctionProvider.kt @@ -309,6 +309,24 @@ abstract class FunctionProvider { append("VAR_SAMP(", expression, ")") } + // Array Functions + + /** + * SQL function that returns a subarray of elements stored from between [lower] and [upper] bounds (inclusive), + * or `null` if the stored array itself is null. + * + * @param expression Array expression from which the subarray is returned. + * @param lower Lower bounds (inclusive) of a subarray. + * @param upper Upper bounds (inclusive) of a subarray. + * **Note** If either bounds is left `null`, the database will use the stored array's respective lower or upper limit. + * @param queryBuilder Query builder to append the SQL function to. + */ + open fun arraySlice(expression: Expression, lower: Int?, upper: Int?, queryBuilder: QueryBuilder) { + throw UnsupportedByDialectException( + "There's no generic SQL for ARRAY_SLICE. There must be a vendor specific implementation", currentDialect + ) + } + // JSON Functions /** diff --git a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/H2.kt b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/H2.kt index 7698ac77c6..64153e8c53 100644 --- a/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/H2.kt +++ b/exposed-core/src/main/kotlin/org/jetbrains/exposed/sql/vendors/H2.kt @@ -36,6 +36,12 @@ internal object H2FunctionProvider : FunctionProvider() { } } + override fun arraySlice(expression: Expression, lower: Int?, upper: Int?, queryBuilder: QueryBuilder) { + queryBuilder { + append("ARRAY_SLICE(", expression, ",$lower,$upper)") + } + } + override fun insert( ignore: Boolean, table: Table, 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 6cc822805e..383405417a 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 @@ -28,6 +28,15 @@ internal object PostgreSQLDataTypeProvider : DataTypeProvider() { val cast = if (e.columnType.usesBinaryFormat) "::jsonb" else "::json" "${super.processForDefaultValue(e)}$cast" } + e is LiteralOp<*> && e.columnType is ArrayColumnType -> { + val processed = super.processForDefaultValue(e) + processed + .takeUnless { it == "ARRAY[]" } + ?: run { + val cast = e.columnType.delegateType.lowercase() + "$processed::$cast[]" + } + } else -> super.processForDefaultValue(e) } @@ -121,6 +130,16 @@ internal object PostgreSQLFunctionProvider : FunctionProvider() { append(")") } + override fun arraySlice(expression: Expression, lower: Int?, upper: Int?, queryBuilder: QueryBuilder) { + queryBuilder { + append(expression, "[") + lower?.let { +it.toString() } + +":" + upper?.let { +it.toString() } + +"]" + } + } + override fun jsonExtract( expression: Expression, vararg path: String, diff --git a/exposed-jdbc/api/exposed-jdbc.api b/exposed-jdbc/api/exposed-jdbc.api index 5c2399fbee..775021d41b 100644 --- a/exposed-jdbc/api/exposed-jdbc.api +++ b/exposed-jdbc/api/exposed-jdbc.api @@ -75,6 +75,7 @@ public final class org/jetbrains/exposed/sql/statements/jdbc/JdbcPreparedStateme public fun getTimeout ()Ljava/lang/Integer; public final fun getWasGeneratedKeysRequested ()Z public fun set (ILjava/lang/Object;)V + public fun setArray (ILjava/lang/String;[Ljava/lang/Object;)V public fun setFetchSize (Ljava/lang/Integer;)V public fun setInputStream (ILjava/io/InputStream;)V public fun setNull (ILorg/jetbrains/exposed/sql/IColumnType;)V diff --git a/exposed-jdbc/src/main/kotlin/org/jetbrains/exposed/sql/statements/jdbc/JdbcPreparedStatementImpl.kt b/exposed-jdbc/src/main/kotlin/org/jetbrains/exposed/sql/statements/jdbc/JdbcPreparedStatementImpl.kt index 7ebf31a2c7..d21bb694a9 100644 --- a/exposed-jdbc/src/main/kotlin/org/jetbrains/exposed/sql/statements/jdbc/JdbcPreparedStatementImpl.kt +++ b/exposed-jdbc/src/main/kotlin/org/jetbrains/exposed/sql/statements/jdbc/JdbcPreparedStatementImpl.kt @@ -83,6 +83,10 @@ class JdbcPreparedStatementImpl( statement.setBinaryStream(index, inputStream, inputStream.available()) } + override fun setArray(index: Int, type: String, array: Array<*>) { + statement.setArray(index, statement.connection.createArrayOf(type, array)) + } + override fun closeIfPossible() { if (!statement.isClosed) statement.close() } diff --git a/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/types/ArrayColumnTypeTests.kt b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/types/ArrayColumnTypeTests.kt new file mode 100644 index 0000000000..39a1573fa8 --- /dev/null +++ b/exposed-tests/src/test/kotlin/org/jetbrains/exposed/sql/tests/shared/types/ArrayColumnTypeTests.kt @@ -0,0 +1,261 @@ +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.exceptions.ExposedSQLException +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.currentDialectTest +import org.jetbrains.exposed.sql.tests.shared.assertEquals +import org.jetbrains.exposed.sql.tests.shared.assertTrue +import org.jetbrains.exposed.sql.tests.shared.expectException +import org.jetbrains.exposed.sql.vendors.H2Dialect +import org.jetbrains.exposed.sql.vendors.PostgreSQLDialect +import org.jetbrains.exposed.sql.vendors.currentDialect +import org.junit.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertNull + +class ArrayColumnTypeTests : DatabaseTestsBase() { + private val arrayTypeUnsupportedDb = TestDB.entries - (TestDB.postgreSQLRelatedDB + TestDB.H2 + TestDB.H2_PSQL).toSet() + + object ArrayTestTable : IntIdTable("array_test_table") { + val numbers = array("numbers", IntegerColumnType()).default(listOf(5)) + val strings = array("strings", TextColumnType()).default(emptyList()) + val doubles = array("doubles", DoubleColumnType()).nullable() + } + + @Test + fun testCreateAndDropArrayColumns() { + withDb(excludeSettings = arrayTypeUnsupportedDb) { + try { + SchemaUtils.create(ArrayTestTable) + assertTrue(ArrayTestTable.exists()) + } finally { + SchemaUtils.drop(ArrayTestTable) + } + } + } + + @Test + fun testCreateMissingColumnsWithArrayDefaults() { + withTables(excludeSettings = arrayTypeUnsupportedDb, ArrayTestTable) { + try { + SchemaUtils.createMissingTablesAndColumns(ArrayTestTable) + assertTrue(SchemaUtils.statementsRequiredToActualizeScheme(ArrayTestTable).isEmpty()) + } finally { + SchemaUtils.drop(ArrayTestTable) + } + } + } + + @Test + fun testArrayColumnInsertAndSelect() { + withTables(excludeSettings = arrayTypeUnsupportedDb, ArrayTestTable) { + val numInput = listOf(1, 2, 3) + val stringInput = listOf("hi", "hey", "hello") + val doubleInput = listOf(1.0, 2.0, 3.0) + val id1 = ArrayTestTable.insertAndGetId { + it[numbers] = numInput + it[strings] = stringInput + it[doubles] = doubleInput + } + + val result1 = ArrayTestTable.selectAll().where { ArrayTestTable.id eq id1 }.single() + assertContentEquals(numInput, result1[ArrayTestTable.numbers]) + assertContentEquals(stringInput, result1[ArrayTestTable.strings]) + assertContentEquals(doubleInput, result1[ArrayTestTable.doubles]) + + val id2 = ArrayTestTable.insertAndGetId { + it[numbers] = emptyList() + it[strings] = emptyList() + it[doubles] = emptyList() + } + + val result2: ResultRow = ArrayTestTable.selectAll().where { ArrayTestTable.id eq id2 }.single() + assertTrue(result2[ArrayTestTable.numbers].isEmpty()) + assertTrue(result2[ArrayTestTable.strings].isEmpty()) + assertEquals(true, result2[ArrayTestTable.doubles]?.isEmpty()) + + val id3 = ArrayTestTable.insertAndGetId { + it[strings] = listOf(null, null, null, "null") + it[doubles] = null + } + + val result3 = ArrayTestTable.selectAll().where { ArrayTestTable.id eq id3 }.single() + assertEquals(5, result3[ArrayTestTable.numbers].single()) + assertTrue(result3[ArrayTestTable.strings].take(3).all { it == null }) + assertEquals("null", result3[ArrayTestTable.strings].last()) + assertNull(result3[ArrayTestTable.doubles]) + } + } + + @Test + fun testArrayMaxSize() { + val maxArraySize = 5 + val sizedTester = object : Table("sized_tester") { + val numbers = array("numbers", IntegerColumnType(), maxArraySize).default(emptyList()) + } + + withTables(excludeSettings = arrayTypeUnsupportedDb, sizedTester) { + val tooLongList = List(maxArraySize + 1) { i -> i + 1 } + if (currentDialectTest is PostgreSQLDialect) { + // PostgreSQL ignores any max cardinality value + sizedTester.insert { + it[numbers] = tooLongList + } + assertContentEquals(tooLongList, sizedTester.selectAll().single()[sizedTester.numbers]) + } else { + // H2 throws 'value too long for column' exception + expectException { + sizedTester.insert { + it[numbers] = tooLongList + } + } + } + } + } + + @Test + fun testSelectUsingArrayGet() { + withTables(excludeSettings = arrayTypeUnsupportedDb, ArrayTestTable) { + val numInput = listOf(1, 2, 3) + ArrayTestTable.insert { + it[numbers] = numInput + it[strings] = listOf("hi", "hello") + it[doubles] = null + } + + // SQL array indexes are one-based + val secondNumber = ArrayTestTable.numbers[2] + val result1 = ArrayTestTable.select(secondNumber).single()[secondNumber] + assertEquals(numInput[1], result1) + + val result2 = ArrayTestTable.selectAll().where { ArrayTestTable.strings[2] eq "hello" } + assertNull(result2.single()[ArrayTestTable.doubles]) + + val result3 = ArrayTestTable.selectAll().where { + ArrayTestTable.numbers[1] greaterEq ArrayTestTable.numbers[3] + } + assertTrue(result3.toList().isEmpty()) + + val nullArray = ArrayTestTable.doubles[2] + val result4 = ArrayTestTable.select(nullArray).single()[nullArray] + assertNull(result4) + } + } + + @Test + fun testSelectUsingArraySlice() { + withTables(excludeSettings = arrayTypeUnsupportedDb, ArrayTestTable) { + val numInput = listOf(1, 2, 3) + ArrayTestTable.insert { + it[numbers] = numInput + it[strings] = listOf(null, null, null, "hello") + it[doubles] = null + } + + val lastTwoNumbers = ArrayTestTable.numbers.slice(2, 3) // numbers[2:3] + val result1 = ArrayTestTable.select(lastTwoNumbers).single()[lastTwoNumbers] + assertContentEquals(numInput.takeLast(2), result1) + + val firstThreeStrings = ArrayTestTable.strings.slice(upper = 3) // strings[:3] + val result2 = ArrayTestTable.select(firstThreeStrings).single()[firstThreeStrings] + if (currentDialect is H2Dialect) { // H2 returns SQL NULL if any parameter in ARRAY_SLICE is null + assertNull(result2) + } else { + assertTrue(result2.filterNotNull().isEmpty()) + } + + val allNumbers = ArrayTestTable.numbers.slice() // numbers[:] + val result3 = ArrayTestTable.select(allNumbers).single()[allNumbers] + if (currentDialect is H2Dialect) { + assertNull(result3) + } else { + assertContentEquals(numInput, result3) + } + + val nullArray = ArrayTestTable.doubles.slice(1, 3) + val result4 = ArrayTestTable.select(nullArray).single()[nullArray] + assertNull(result4) + } + } + + @Test + fun testArrayLiteralAndArrayParam() { + withTables(excludeSettings = arrayTypeUnsupportedDb, ArrayTestTable) { + val numInput = listOf(1, 2, 3) + val doublesInput = List(5) { i -> (i + 1).toDouble() } + val id1 = ArrayTestTable.insertAndGetId { + it[numbers] = numInput + it[strings] = listOf(null, null, null, "hello") + it[doubles] = doublesInput + } + + val result1 = ArrayTestTable.select(ArrayTestTable.id).where { + (ArrayTestTable.numbers eq numInput) and (ArrayTestTable.strings neq emptyList()) + } + assertEquals(id1, result1.single()[ArrayTestTable.id]) + + val result2 = ArrayTestTable.select(ArrayTestTable.id).where { + ArrayTestTable.doubles eq arrayParam(doublesInput, DoubleColumnType()) + } + assertEquals(id1, result2.single()[ArrayTestTable.id]) + + if (currentDialectTest is PostgreSQLDialect) { + val lastStrings = ArrayTestTable.strings.slice(lower = 4) // strings[4:] + val result3 = ArrayTestTable.select(ArrayTestTable.id).where { + lastStrings eq arrayLiteral(listOf("hello"), TextColumnType()) + } + assertEquals(id1, result3.single()[ArrayTestTable.id]) + } + } + } + + @Test + fun testArrayColumnUpdate() { + withTables(excludeSettings = arrayTypeUnsupportedDb, ArrayTestTable) { + val id1 = ArrayTestTable.insertAndGetId { + it[doubles] = null + } + + assertNull(ArrayTestTable.selectAll().single()[ArrayTestTable.doubles]) + + val updatedDoubles = listOf(9.0) + ArrayTestTable.update({ ArrayTestTable.id eq id1 }) { + it[doubles] = updatedDoubles + } + + assertContentEquals(updatedDoubles, ArrayTestTable.selectAll().single()[ArrayTestTable.doubles]) + } + } + + class ArrayTestDao(id: EntityID) : IntEntity(id) { + companion object : IntEntityClass(ArrayTestTable) + + var numbers by ArrayTestTable.numbers + var strings by ArrayTestTable.strings + var doubles by ArrayTestTable.doubles + } + + @Test + fun testArrayColumnWithDAOFunctions() { + withTables(excludeSettings = arrayTypeUnsupportedDb, ArrayTestTable) { + val numInput = listOf(1, 2, 3) + val entity1 = ArrayTestDao.new { + numbers = numInput + doubles = null + } + assertContentEquals(numInput, entity1.numbers) + assertTrue(entity1.strings.isEmpty()) + + val doublesInput = listOf(9.0) + entity1.doubles = doublesInput + + assertContentEquals(doublesInput, ArrayTestDao.all().single().doubles) + } + } +}