diff --git a/.github/workflows/test.sh b/.github/workflows/test.sh index 4ba9e9b..d232f13 100755 --- a/.github/workflows/test.sh +++ b/.github/workflows/test.sh @@ -81,6 +81,6 @@ echo "HOST=localhost" >> src/test/resources/config.properties echo "PORT=1521" >> src/test/resources/config.properties echo "USER=test" >> src/test/resources/config.properties echo "PASSWORD=test" >> src/test/resources/config.properties -echo "CONNECT_TIMEOUT=120" >> src/test/resources/config.properties -echo "SQL_TIMEOUT=120" >> src/test/resources/config.properties +echo "CONNECT_TIMEOUT=180" >> src/test/resources/config.properties +echo "SQL_TIMEOUT=180" >> src/test/resources/config.properties mvn clean compile test diff --git a/README.md b/README.md index 3352062..bfc0230 100644 --- a/README.md +++ b/README.md @@ -570,7 +570,55 @@ prefetched entirely, a smaller prefetch size can be configured using the option, and the LOB can be consumed as a stream. By mapping LOB columns to `Blob` or `Clob` objects, the content can be consumed as a reactive stream. -### REF Cursors +### ARRAY +Oracle Database supports `ARRAY` as a user defined type only. A `CREATE TYPE` +command is used to define an `ARRAY` type: +```sql +CREATE TYPE MY_ARRAY AS ARRAY(8) OF NUMBER +``` +Oracle R2DBC defines `oracle.r2dbc.OracleR2dbcType.ArrayType` as a `Type` for +representing user defined `ARRAY` types. A `Parameter` with a type of +`ArrayType` must be used when binding array values to a `Statement`. +```java +Publisher arrayBindExample(Connection connection) { + Statement statement = + connection.createStatement("INSERT INTO example VALUES (:array_bind)"); + + // Use the name defined for an ARRAY type: + // CREATE TYPE MY_ARRAY AS ARRAY(8) OF NUMBER + ArrayType arrayType = OracleR2dbcTypes.arrayType("MY_ARRAY"); + Integer[] arrayValues = {1, 2, 3}; + statement.bind("arrayBind", Parameters.in(arrayType, arrayValues)); + + return statement.execute(); +} +``` +A `Parameter` with a type of `ArrayType` must also be used when binding OUT +parameters of a PL/SQL call. +```java +Publisher arrayOutBindExample(Connection connection) { + Statement statement = + connection.createStatement("BEGIN; exampleCall(:array_bind); END;"); + + // Use the name defined for an ARRAY type: + // CREATE TYPE MY_ARRAY AS ARRAY(8) OF NUMBER + ArrayType arrayType = OracleR2dbcTypes.arrayType("MY_ARRAY"); + statement.bind("arrayBind", Parameters.out(arrayType)); + + return statement.execute(); +} +``` +`ARRAY` values may be consumed from a `Row` or `OutParameter` as a Java array. +The element type of the Java array may be any Java type that is supported as +a mapping for the SQL type of the `ARRAY`. For instance, if the `ARRAY` type is +`NUMBER`, then a `Integer[]` mapping is supported: +```java +Publisher arrayMapExample(Result result) { + return result.map(readable -> readable.get("arrayValue", Integer[].class)); +} +``` + +### REF Cursor Use the `oracle.r2dbc.OracleR2dbcTypes.REF_CURSOR` type to bind `SYS_REFCURSOR` out parameters: ```java diff --git a/src/main/java/oracle/r2dbc/OracleR2dbcTypes.java b/src/main/java/oracle/r2dbc/OracleR2dbcTypes.java index 08c6c56..01dd18c 100644 --- a/src/main/java/oracle/r2dbc/OracleR2dbcTypes.java +++ b/src/main/java/oracle/r2dbc/OracleR2dbcTypes.java @@ -20,7 +20,10 @@ */ package oracle.r2dbc; +import io.r2dbc.spi.Parameter; +import io.r2dbc.spi.R2dbcType; import io.r2dbc.spi.Result; +import io.r2dbc.spi.Statement; import io.r2dbc.spi.Type; import oracle.sql.json.OracleJsonObject; @@ -29,6 +32,7 @@ import java.time.Duration; import java.time.LocalDateTime; import java.time.Period; +import java.util.Objects; /** * SQL types supported by Oracle Database that are not defined as standard types @@ -99,10 +103,102 @@ private OracleR2dbcTypes() {} public static final Type REF_CURSOR = new TypeImpl(Result.class, "SYS_REFCURSOR"); + /** + *

+ * Creates an {@link ArrayType} representing a user defined {@code ARRAY} + * type. The {@code name} passed to this method must identify the name of a + * user defined {@code ARRAY} type. + *

+ * Typically, the name passed to this method should be UPPER CASE, unless the + * {@code CREATE TYPE} command that created the type used an "enquoted" type + * name. + *

+ * The {@code ArrayType} object returned by this method may be used to create + * a {@link Parameter} that binds an array value to a {@link Statement}. + *

{@code
+   * Publisher arrayBindExample(Connection connection) {
+   *   Statement statement =
+   *     connection.createStatement("INSERT INTO example VALUES (:array_bind)");
+   *
+   *   // Use the name defined for an ARRAY type:
+   *   // CREATE TYPE MY_ARRAY AS ARRAY(8) OF NUMBER
+   *   ArrayType arrayType = OracleR2dbcTypes.arrayType("MY_ARRAY");
+   *   Integer[] arrayValues = {1, 2, 3};
+   *   statement.bind("arrayBind", Parameters.in(arrayType, arrayValues));
+   *
+   *   return statement.execute();
+   * }
+   * }
+ * @param name Name of a user defined ARRAY type. Not null. + * @return A {@code Type} object representing the user defined ARRAY type. Not + * null. + */ + public static ArrayType arrayType(String name) { + return new ArrayTypeImpl(Objects.requireNonNull(name, "name is null")); + } + + /** + * Extension of the standard {@link Type} interface used to represent user + * defined ARRAY types. An instance of {@code ArrayType} must be used when + * binding an array value to a {@link Statement} created by the Oracle R2DBC + * Driver. + *

+ * Oracle Database does not support an anonymous {@code ARRAY} type, which is + * what the standard {@link R2dbcType#COLLECTION} type represents. Oracle + * Database only supports {@code ARRAY} types which are declared as a user + * defined type, as in: + *

{@code
+   * CREATE TYPE MY_ARRAY AS ARRAY(8) OF NUMBER
+   * }
+ * In order to bind an array, the name of a user defined ARRAY type must + * be known to Oracle R2DBC. Instances of {@code ArrayType} retain the name + * that is provided to the {@link #arrayType(String)} factory method. + */ + public interface ArrayType extends Type { + + /** + * {@inheritDoc} + * Returns {@code Object[].class}, which is the standard mapping for + * {@link R2dbcType#COLLECTION}. The true default type mapping is the array + * variant of the default mapping for the element type of the {@code ARRAY}. + * For instance, an {@code ARRAY} of {@code VARCHAR} maps to a + * {@code String[]} by default. + */ + @Override + Class getJavaType(); + + /** + * {@inheritDoc} + * Returns the name of this user defined {@code ARRAY} type. For instance, + * this method returns "MY_ARRAY" if the type is declared as: + *
{@code
+     * CREATE TYPE MY_ARRAY AS ARRAY(8) OF NUMBER
+     * }
+ */ + @Override + String getName(); + } + + /** Concrete implementation of the {@code ArrayType} interface */ + private static final class ArrayTypeImpl + extends TypeImpl implements ArrayType { + + /** + * Constructs an ARRAY type with the given {@code name}. The constructed + * {@code ArrayType} as a default Java type mapping of + * {@code Object[].class}. This is consistent with the standard + * {@link R2dbcType#COLLECTION} type. + * @param name User defined name of the type. Not null. + */ + ArrayTypeImpl(String name) { + super(Object[].class, name); + } + } + /** * Implementation of the {@link Type} SPI. */ - private static final class TypeImpl implements Type { + private static class TypeImpl implements Type { /** * The Java Language mapping of this SQL type. diff --git a/src/main/java/oracle/r2dbc/impl/OracleReadableImpl.java b/src/main/java/oracle/r2dbc/impl/OracleReadableImpl.java index 4d4b56c..5fe796e 100755 --- a/src/main/java/oracle/r2dbc/impl/OracleReadableImpl.java +++ b/src/main/java/oracle/r2dbc/impl/OracleReadableImpl.java @@ -26,24 +26,43 @@ import io.r2dbc.spi.OutParameters; import io.r2dbc.spi.OutParametersMetadata; import io.r2dbc.spi.R2dbcException; - import io.r2dbc.spi.R2dbcType; import io.r2dbc.spi.Result; import io.r2dbc.spi.Row; import io.r2dbc.spi.RowMetadata; import io.r2dbc.spi.Type; +import oracle.jdbc.OracleArray; +import oracle.jdbc.OracleConnection; import oracle.r2dbc.OracleR2dbcTypes; import oracle.r2dbc.impl.ReactiveJdbcAdapter.JdbcReadable; import oracle.r2dbc.impl.ReadablesMetadata.OutParametersMetadataImpl; import oracle.r2dbc.impl.ReadablesMetadata.RowMetadataImpl; +import java.math.BigDecimal; import java.nio.ByteBuffer; +import java.sql.Array; import java.sql.ResultSet; +import java.sql.SQLException; import java.sql.Timestamp; +import java.sql.Types; +import java.time.Duration; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.Period; +import java.time.temporal.ChronoUnit; +import java.util.Map; import java.util.NoSuchElementException; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.function.IntFunction; +import static java.lang.String.format; +import static oracle.r2dbc.impl.OracleR2dbcExceptions.fromJdbc; import static oracle.r2dbc.impl.OracleR2dbcExceptions.requireNonNull; +import static oracle.r2dbc.impl.OracleR2dbcExceptions.runJdbc; +import static oracle.r2dbc.impl.OracleR2dbcExceptions.toR2dbcException; /** *

@@ -58,8 +77,9 @@ */ class OracleReadableImpl implements io.r2dbc.spi.Readable { - /** Adapts JDBC Driver APIs into Reactive Streams APIs */ - private final ReactiveJdbcAdapter adapter; + + /** The JDBC connection that created this readable */ + private final java.sql.Connection jdbcConnection; /** This values of this {@code Readable}. Values are supplied by a JDBC Driver */ private final JdbcReadable jdbcReadable; @@ -67,6 +87,9 @@ class OracleReadableImpl implements io.r2dbc.spi.Readable { /** Metadata of the values of this {@code Readable}. */ private final ReadablesMetadata readablesMetadata; + /** Adapts JDBC Driver APIs into Reactive Streams APIs */ + private final ReactiveJdbcAdapter adapter; + /** * A collection of results that depend on the JDBC statement which created * this readable to remain open until all results are consumed. @@ -79,14 +102,17 @@ class OracleReadableImpl implements io.r2dbc.spi.Readable { * {@code jdbcReadable} and obtains metadata of the values from * {@code resultMetadata}. *

- * + * @param jdbcConnection JDBC connection that created the + * {@code jdbcReadable}. Not null. * @param jdbcReadable Readable values from a JDBC Driver. Not null. * @param readablesMetadata Metadata of each value. Not null. * @param adapter Adapts JDBC calls into reactive streams. Not null. */ private OracleReadableImpl( - DependentCounter dependentCounter, JdbcReadable jdbcReadable, - ReadablesMetadata readablesMetadata, ReactiveJdbcAdapter adapter) { + java.sql.Connection jdbcConnection, DependentCounter dependentCounter, + JdbcReadable jdbcReadable, ReadablesMetadata readablesMetadata, + ReactiveJdbcAdapter adapter) { + this.jdbcConnection = jdbcConnection; this.dependentCounter = dependentCounter; this.jdbcReadable = jdbcReadable; this.readablesMetadata = readablesMetadata; @@ -99,6 +125,8 @@ private OracleReadableImpl( * provided {@code jdbcReadable} and {@code metadata}. The metadata * object is used to determine the default type mapping of column values. *

+ * @param jdbcConnection JDBC connection that created the + * {@code jdbcReadable}. Not null. * @param jdbcReadable Row data from the Oracle JDBC Driver. Not null. * @param metadata Meta-data for the specified row. Not null. * @param adapter Adapts JDBC calls into reactive streams. Not null. @@ -106,16 +134,21 @@ private OracleReadableImpl( * {@code metadata}. Not null. */ static Row createRow( - DependentCounter dependentCounter, JdbcReadable jdbcReadable, - RowMetadataImpl metadata, ReactiveJdbcAdapter adapter) { - return new RowImpl(dependentCounter, jdbcReadable, metadata, adapter); + java.sql.Connection jdbcConnection, DependentCounter dependentCounter, + JdbcReadable jdbcReadable, RowMetadataImpl metadata, + ReactiveJdbcAdapter adapter) { + return new RowImpl( + jdbcConnection, dependentCounter, jdbcReadable, metadata, adapter); } + /** *

* Creates a new {@code OutParameters} that supplies values and metadata from * the provided {@code jdbcReadable} and {@code rowMetadata}. The metadata * object is used to determine the default type mapping of column values. *

+ * @param jdbcConnection JDBC connection that created the + * {@code jdbcReadable}. Not null. * @param jdbcReadable Row data from the Oracle JDBC Driver. Not null. * @param metadata Meta-data for the specified row. Not null. * @param adapter Adapts JDBC calls into reactive streams. Not null. @@ -123,10 +156,11 @@ static Row createRow( * {@code metadata}. Not null. */ static OutParameters createOutParameters( - DependentCounter dependentCounter, JdbcReadable jdbcReadable, - OutParametersMetadataImpl metadata, ReactiveJdbcAdapter adapter) { + java.sql.Connection jdbcConnection, DependentCounter dependentCounter, + JdbcReadable jdbcReadable, OutParametersMetadataImpl metadata, + ReactiveJdbcAdapter adapter) { return new OutParametersImpl( - dependentCounter, jdbcReadable, metadata, adapter); + jdbcConnection, dependentCounter, jdbcReadable, metadata, adapter); } /** @@ -222,6 +256,13 @@ else if (LocalDateTime.class.equals(type)) { else if (Result.class.equals(type)) { value = getResult(index); } + else if (type.isArray() + && R2dbcType.COLLECTION.equals(readablesMetadata.get(index).getType())) { + // Note that a byte[] would be a valid mapping for a RAW column, so this + // branch is only taken if the target type is an array, and the column + // type is a SQL ARRAY (ie: COLLECTION). + value = getJavaArray(index, type.getComponentType()); + } else if (Object.class.equals(type)) { // Use the default type mapping if Object.class has been specified. // This method is invoked recursively with the default mapping, so long @@ -341,6 +382,400 @@ private LocalDateTime getLocalDateTime(int index) { } } + /** + *

+ * Converts the value of a column at the specified {@code index} to a Java + * array. + *

+ * JDBC drivers are not required to support conversion to Java arrays for any + * SQL type. However, an R2DBC driver must support conversions to Java arrays + * for ARRAY values. This method is implemented to handle that case. + *

+ * @param index 0 based column index + * @param javaType Type of elements stored in the array. Not null. + * @return A Java array, or {@code null} if the column value is null. + */ + private Object getJavaArray(int index, Class javaType) { + OracleArray oracleArray = jdbcReadable.getObject(index, OracleArray.class); + + return oracleArray == null + ? null + : convertOracleArray(oracleArray, javaType); + } + + /** + * Converts an {@code OracleArray} from Oracle JDBC into the Java array + * variant of the specified {@code javaType}. If the {@code javaType} is + * {@code Object.class}, this method converts to the R2DBC standard mapping + * for the SQL type of the ARRAY elements. + * @param oracleArray Array from Oracle JDBC. Not null. + * @param javaType Type to convert to. Not null. + * @return The converted array. + */ + private Object convertOracleArray( + OracleArray oracleArray, Class javaType) { + try { + // Convert to primitive array types using API extensions on OracleArray + if (javaType.isPrimitive()) + return convertPrimitiveArray(oracleArray, javaType); + + // Check if Row.get(int/String) was called without a Class argument. + // In this case, the default mapping is declared as Object[] in + // SqlTypeMap, and this method gets called with Object.class. A default + // mapping is used in this case. + Class convertedType = getArrayTypeMapping(oracleArray, javaType); + + // Attempt to have Oracle JDBC convert to the desired type + Object[] javaArray = + (Object[]) oracleArray.getArray( + Map.of(oracleArray.getBaseTypeName(), convertedType)); + + // Oracle JDBC may ignore the Map argument in many cases, and just + // maps values to their default JDBC type. The convertArray method + // will correct this by converting the array to the desired type. + return convertArray(javaArray, convertedType); + } + catch (SQLException sqlException) { + throw toR2dbcException(sqlException); + } + finally { + runJdbc(oracleArray::free); + } + } + + /** + * Returns the Java array type that an ARRAY will be mapped to. This method + * is used to determine a default type mapping when {@link #get(int)} or + * {@link #get(String)} are called. In this case, the {@code javaType} + * argument is expected to be {@code Object.class} and this method returns + * a default mapping based on the element type of the ARRAY. Otherwise, if + * the {@code javaType} is something more specific, this method just returns + * it. + */ + private Class getArrayTypeMapping( + OracleArray oracleArray, Class javaType) { + + if (!Object.class.equals(javaType)) + return javaType; + + int jdbcType = fromJdbc(oracleArray::getBaseType); + + // Check if the array is multi-dimensional + if (jdbcType == Types.ARRAY) { + + Object[] oracleArrays = (Object[]) fromJdbc(oracleArray::getArray); + + // An instance of OracleArray representing base type is needed in order to + // know the base type of the next dimension. + final OracleArray oracleArrayElement; + if (oracleArrays.length > 0) { + oracleArrayElement = (OracleArray) oracleArrays[0]; + } + else { + // The array is empty, so an OracleArray will need to be created. The + // type information for the ARRAY should be cached by Oracle JDBC, and + // so createOracleArray should not perform a blocking call. + oracleArrayElement = (OracleArray) fromJdbc(() -> + jdbcConnection.unwrap(OracleConnection.class) + .createOracleArray(oracleArray.getBaseTypeName(), new Object[0])); + } + + // Recursively call getJavaArrayType, creating a Java array at each level + // of recursion until a non-array SQL type is found. Returning back up the + // stack, the top level of recursion will then create an array with + // the right number of dimensions, and the class of this multi-dimensional + // array is returned. + return java.lang.reflect.Array.newInstance( + getArrayTypeMapping(oracleArrayElement, Object.class), 0) + .getClass(); + } + + // If the element type is DATE, handle it as if it were TIMESTAMP. + // This is consistent with how DATE columns are usually handled, and + // reflects the fact that Oracle DATE values have a time component. + Type r2dbcType = SqlTypeMap.toR2dbcType( + jdbcType == Types.DATE ? Types.TIMESTAMP : jdbcType); + + // Use R2DBC's default type mapping, if the SQL type is recognized. + // Otherwise, leave the javaType as Object.class and Oracle JDBC's + // default type mapping will be used. + return r2dbcType != null + ? r2dbcType.getJavaType() + : javaType; + } + + /** + * Converts an array from Oracle JDBC into a Java array of a primitive type, + * such as int[], boolean[], etc. This method is handles the case where user + * code explicitly requests a primitive array by passing the class type + * to {@link #get(int, Class)} or {@link #get(String, Class)}. + */ + private Object convertPrimitiveArray( + OracleArray oracleArray, Class primitiveType) { + try { + if (boolean.class.equals(primitiveType)) { + // OracleArray does not support conversion to boolean[], so map shorts + // to booleans. + short[] shorts = oracleArray.getShortArray(); + boolean[] booleans = new boolean[shorts.length]; + for (int i = 0; i < shorts.length; i++) + booleans[i] = shorts[i] != 0; + return booleans; + } + else if (byte.class.equals(primitiveType)) { + // OracleArray does not support conversion to byte[], so map shorts to + // bytes + short[] shorts = oracleArray.getShortArray(); + byte[] bytes = new byte[shorts.length]; + for (int i = 0; i < shorts.length; i++) + bytes[i] = (byte) shorts[i]; + return bytes; + } + else if (short.class.equals(primitiveType)) { + return oracleArray.getShortArray(); + } + else if (int.class.equals(primitiveType)) { + return oracleArray.getIntArray(); + } + else if (long.class.equals(primitiveType)) { + return oracleArray.getLongArray(); + } + else if (float.class.equals(primitiveType)) { + return oracleArray.getFloatArray(); + } + else if (double.class.equals(primitiveType)) { + return oracleArray.getDoubleArray(); + } + else { + // Attempt to have Oracle JDBC convert the array + Object javaArray = oracleArray.getArray( + Map.of(oracleArray.getSQLTypeName(), primitiveType)); + + // Check if Oracle JDBC ignored the Map argument or not + Class javaArrayType = javaArray.getClass().getComponentType(); + if (primitiveType.equals(javaArrayType)) + return javaArray; + + throw unsupportedArrayConversion(javaArrayType, primitiveType); + } + } + catch (SQLException sqlException) { + throw toR2dbcException(sqlException); + } + } + + /** + * Converts a given {@code array} to an array of a {@code desiredType}. This + * method handles arrays returned by {@link Array#getArray(Map)}, which may + * contain objects that are the default desiredType mapping for a JDBC driver. + * This method converts the default JDBC desiredType mappings to the default + * R2DBC desiredType mappings. + */ + @SuppressWarnings("unchecked") + private T[] convertArray(Object[] array, Class desiredType) { + + if (desiredType.isAssignableFrom(array.getClass().getComponentType())) + return (T[])array; + + if (array.length == 0) + return (T[]) java.lang.reflect.Array.newInstance(desiredType, 0); + + // The array's component type could be Object.class; Oracle JDBC returns an + // Object[] in some cases. Search for a non-null element to determine the + // true type of type objects in the array. + Class elementType = null; + for (Object element : array) { + if (element != null) { + elementType = array[0].getClass(); + break; + } + } + + // If all array elements are null, then return an array of the desired type + // with all null elements. + if (elementType == null) { + return (T[]) java.lang.reflect.Array.newInstance( + desiredType, array.length); + } + + if (desiredType.isAssignableFrom(elementType)) { + // The elements are of the desired type, but the array type is something + // different, like an Object[] full of BigDecimal. + return (T[]) mapArray( + array, + length -> (T[])java.lang.reflect.Array.newInstance(desiredType, length), + Function.identity()); + } + else if (elementType.equals(BigDecimal.class)) { + if (desiredType.equals(Boolean.class)) { + return (T[]) mapArray( + (BigDecimal[])array, Boolean[]::new, + bigDecimal -> bigDecimal.shortValue() != 0); + } + else if (desiredType.equals(Byte.class)) { + return (T[]) mapArray( + (BigDecimal[])array, Byte[]::new, BigDecimal::byteValue); + } + else if (desiredType.equals(Short.class)) { + return (T[]) mapArray( + (BigDecimal[])array, Short[]::new, BigDecimal::shortValue); + } + else if (desiredType.equals(Integer.class)) { + return (T[]) mapArray( + (BigDecimal[])array, Integer[]::new, BigDecimal::intValue); + } + else if (desiredType.equals(Long.class)) { + return (T[]) mapArray( + (BigDecimal[])array, Long[]::new, BigDecimal::longValue); + } + else if (desiredType.equals(Float.class)) { + return (T[]) mapArray( + (BigDecimal[])array, Float[]::new, BigDecimal::floatValue); + } + else if (desiredType.equals(Double.class)) { + return (T[]) mapArray( + (BigDecimal[])array, Double[]::new, BigDecimal::doubleValue); + } + } + else if (byte[].class.equals(elementType) + && desiredType.isAssignableFrom(ByteBuffer.class)) { + return (T[]) mapArray( + (byte[][])array, ByteBuffer[]::new, ByteBuffer::wrap); + } + else if (java.sql.Timestamp.class.isAssignableFrom(elementType)) { + // Note that DATE values are represented as Timestamp by OracleArray. + // For this reason, there is no branch for java.sql.Date conversions in + // this method. + if (desiredType.isAssignableFrom(LocalDateTime.class)) { + return (T[]) mapArray( + (java.sql.Timestamp[]) array, LocalDateTime[]::new, + Timestamp::toLocalDateTime); + } + else if (desiredType.isAssignableFrom(LocalDate.class)) { + return (T[]) mapArray( + (java.sql.Timestamp[]) array, LocalDate[]::new, + timestamp -> timestamp.toLocalDateTime().toLocalDate()); + } + else if (desiredType.isAssignableFrom(LocalTime.class)) { + return (T[]) mapArray( + (java.sql.Timestamp[]) array, LocalTime[]::new, + timestamp -> timestamp.toLocalDateTime().toLocalTime()); + } + } + else if (java.time.OffsetDateTime.class.isAssignableFrom(elementType)) { + // This branch handles mapping from TIMESTAMP WITH LOCAL TIME ZONE values. + // OracleArray maps these to OffsetDateTime, regardless of the Map + // argument to OracleArray.getArray(Map). Oracle R2DBC defines + // LocalDateTime as their default mapping. + if (desiredType.isAssignableFrom(LocalDateTime.class)) { + return (T[]) mapArray( + (java.time.OffsetDateTime[]) array, LocalDateTime[]::new, + OffsetDateTime::toLocalDateTime); + } + else if (desiredType.isAssignableFrom(LocalDate.class)) { + return (T[]) mapArray( + (java.time.OffsetDateTime[]) array, LocalDate[]::new, + OffsetDateTime::toLocalDate); + } + else if (desiredType.isAssignableFrom(LocalTime.class)) { + return (T[]) mapArray( + (java.time.OffsetDateTime[]) array, LocalTime[]::new, + OffsetDateTime::toLocalTime); + } + } + else if (oracle.sql.INTERVALYM.class.isAssignableFrom(elementType) + && desiredType.isAssignableFrom(java.time.Period.class)) { + return (T[]) mapArray( + (oracle.sql.INTERVALYM[]) array, java.time.Period[]::new, + intervalym -> { + // The binary representation is specified in the JavaDoc of + // oracle.sql.INTERVALYM. In 21.x, the JavaDoc has bug: It neglects + // to mention that the year value is offset by 0x80000000 + ByteBuffer byteBuffer = ByteBuffer.wrap(intervalym.shareBytes()); + return Period.of( + byteBuffer.getInt() - 0x80000000, // 4 byte year + (byte)(byteBuffer.get() - 60), // 1 byte month + 0); // day + }); + } + else if (oracle.sql.INTERVALDS.class.isAssignableFrom(elementType) + && desiredType.isAssignableFrom(java.time.Duration.class)) { + return (T[]) mapArray( + (oracle.sql.INTERVALDS[]) array, java.time.Duration[]::new, + intervalds -> { + // The binary representation is specified in the JavaDoc of + // oracle.sql.INTERVALDS. In 21.x, the JavaDoc has bug: It neglects + // to mention that the day and fractional second values are offset by + // 0x80000000 + ByteBuffer byteBuffer = ByteBuffer.wrap(intervalds.shareBytes()); + return Duration.of( + TimeUnit.DAYS.toNanos(byteBuffer.getInt() - 0x80000000)// 4 byte day + + TimeUnit.HOURS.toNanos(byteBuffer.get() - 60) // 1 byte hour + + TimeUnit.MINUTES.toNanos(byteBuffer.get() - 60) // 1 byte minute + + TimeUnit.SECONDS.toNanos(byteBuffer.get() - 60) // 1 byte second + + byteBuffer.getInt() - 0x80000000, // 4 byte fractional second + ChronoUnit.NANOS); + }); + } + else if (oracle.jdbc.OracleArray.class.isAssignableFrom(elementType) + && desiredType.isArray()) { + // Recursively convert a multi-dimensional array. + return (T[]) mapArray( + array, + length -> + (Object[]) java.lang.reflect.Array.newInstance(desiredType, length), + oracleArray -> + convertOracleArray( + (OracleArray) oracleArray, desiredType.getComponentType())); + } + // OracleArray seems to support mapping TIMESTAMP WITH TIME ZONE to + // OffsetDateTime, so that case is not handled in this method + + throw unsupportedArrayConversion(elementType, desiredType); + } + + /** + * Returns an exception indicating a type of elements in a Java array can + * not be converted to a different type. + */ + private static IllegalArgumentException unsupportedArrayConversion( + Class fromType, Class toType) { + return new IllegalArgumentException(format( + "Conversion from array of %s to array of %s is not supported", + fromType.getName(), toType.getName())); + } + + /** + *

+ * Maps the elements of a given {@code array} using a {@code mappingFunction}, + * and returns an array generated by an {@code arrayAllocator} that stores the + * mapped elements. + *

+ * The {@code array} may contain {@code null} elements. A {@code null} element + * is automatically converted to {@code null}, and not supplied as input to + * the {@code mappingFunction}. + *

+ * @param array Array of elements to convert. Not null. + * @param arrayAllocator Allocates an array of an input length. Not null. + * @param mappingFunction Maps elements from the {@code array}. Not null. + * @return Array of mapped elements. + */ + private static U[] mapArray( + T[] array, IntFunction arrayAllocator, + Function mappingFunction) { + + U[] result = arrayAllocator.apply(array.length); + + for (int i = 0; i < array.length; i++) { + T arrayValue = array[i]; + result[i] = arrayValue == null + ? null + : mappingFunction.apply(array[i]); + } + + return result; + } + /** *

* Converts the value of a column at the specified {@code index} to a @@ -406,15 +841,17 @@ private static final class RowImpl * {@code jdbcReadable}, and uses the specified {@code rowMetadata} to * determine the default type mapping of column values. *

- * + * @param jdbcConnection JDBC connection that created the + * {@code jdbcReadable}. Not null. * @param jdbcReadable Row data from the Oracle JDBC Driver. Not null. * @param metadata Meta-data for the specified row. Not null. * @param adapter Adapts JDBC calls into reactive streams. Not null. */ private RowImpl( - DependentCounter dependentCounter, JdbcReadable jdbcReadable, - RowMetadataImpl metadata, ReactiveJdbcAdapter adapter) { - super(dependentCounter, jdbcReadable, metadata, adapter); + java.sql.Connection jdbcConnection, DependentCounter dependentCounter, + JdbcReadable jdbcReadable, RowMetadataImpl metadata, + ReactiveJdbcAdapter adapter) { + super(jdbcConnection, dependentCounter, jdbcReadable, metadata, adapter); this.metadata = metadata; } @@ -447,15 +884,17 @@ private static final class OutParametersImpl * {@code jdbcReadable} and obtains metadata of the values from * {@code outParametersMetaData}. *

- * + * @param jdbcConnection JDBC connection that created the + * {@code jdbcReadable}. Not null. * @param jdbcReadable Readable values from a JDBC Driver. Not null. * @param metadata Metadata of each value. Not null. * @param adapter Adapts JDBC calls into reactive streams. Not null. */ private OutParametersImpl( - DependentCounter dependentCounter, JdbcReadable jdbcReadable, - OutParametersMetadataImpl metadata, ReactiveJdbcAdapter adapter) { - super(dependentCounter,jdbcReadable, metadata, adapter); + java.sql.Connection jdbcConnection, DependentCounter dependentCounter, + JdbcReadable jdbcReadable, OutParametersMetadataImpl metadata, + ReactiveJdbcAdapter adapter) { + super(jdbcConnection, dependentCounter,jdbcReadable, metadata, adapter); this.metadata = metadata; } diff --git a/src/main/java/oracle/r2dbc/impl/OracleReadableMetadataImpl.java b/src/main/java/oracle/r2dbc/impl/OracleReadableMetadataImpl.java index a5b9996..5fa201b 100755 --- a/src/main/java/oracle/r2dbc/impl/OracleReadableMetadataImpl.java +++ b/src/main/java/oracle/r2dbc/impl/OracleReadableMetadataImpl.java @@ -263,6 +263,11 @@ public Type getType() { */ static OutParameterMetadata createParameterMetadata( String name, Type type) { + + // Translate the Oracle specific ArrayType to the standard COLLECTION type + if (type instanceof OracleR2dbcTypes.ArrayType) + type = R2dbcType.COLLECTION; + return new OracleOutParameterMetadataImpl( type, name, Nullability.NULLABLE, null, null); } diff --git a/src/main/java/oracle/r2dbc/impl/OracleResultImpl.java b/src/main/java/oracle/r2dbc/impl/OracleResultImpl.java index 502971f..daa2992 100644 --- a/src/main/java/oracle/r2dbc/impl/OracleResultImpl.java +++ b/src/main/java/oracle/r2dbc/impl/OracleResultImpl.java @@ -456,8 +456,9 @@ protected Publisher mapDependentSegments( // Avoiding object allocation by reusing the same Row object ReusableJdbcReadable reusableJdbcReadable = new ReusableJdbcReadable(); - Row row = - createRow(dependentCounter, reusableJdbcReadable, metadata, adapter); + Row row = createRow( + fromJdbc(() -> resultSet.getStatement().getConnection()), + dependentCounter, reusableJdbcReadable, metadata, adapter); return adapter.publishRows(resultSet, jdbcReadable -> { reusableJdbcReadable.current = jdbcReadable; diff --git a/src/main/java/oracle/r2dbc/impl/OracleStatementImpl.java b/src/main/java/oracle/r2dbc/impl/OracleStatementImpl.java index 2555ae1..563b45b 100755 --- a/src/main/java/oracle/r2dbc/impl/OracleStatementImpl.java +++ b/src/main/java/oracle/r2dbc/impl/OracleStatementImpl.java @@ -27,13 +27,17 @@ import io.r2dbc.spi.Result; import io.r2dbc.spi.Statement; import io.r2dbc.spi.Type; +import oracle.jdbc.OracleConnection; +import oracle.r2dbc.OracleR2dbcTypes; import oracle.r2dbc.impl.ReactiveJdbcAdapter.JdbcReadable; import oracle.r2dbc.impl.ReadablesMetadata.OutParametersMetadataImpl; +import oracle.sql.INTERVALYM; import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import java.nio.ByteBuffer; +import java.sql.Array; import java.sql.BatchUpdateException; import java.sql.CallableStatement; import java.sql.Connection; @@ -43,6 +47,7 @@ import java.sql.SQLType; import java.sql.SQLWarning; import java.time.Duration; +import java.time.Period; import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedList; @@ -727,18 +732,8 @@ private void bindParameter(int index, Parameter parameter) { throw outParameterWithGeneratedValues(); } - // TODO: This method should check if Java type can be converted to the - // specified SQL type. If the conversion is unsupported, then JDBC - // setObject(...) will throw when this statement is executed. The correct - // behavior is to throw IllegalArgumentException here, and not from - // execute() - Type r2dbcType = - requireNonNull(parameter.getType(), "Parameter type is null"); - SQLType jdbcType = toJdbcType(r2dbcType); - - if (jdbcType == null) - throw new IllegalArgumentException("Unsupported SQL type: " + r2dbcType); - + requireSupportedSqlType(requireNonNull( + parameter.getType(), "Parameter type is null")); requireSupportedJavaType(parameter.getValue()); bindValues[index] = parameter; } @@ -850,6 +845,19 @@ private static void requireSupportedJavaType(Object object) { } } + /** + * Checks that the SQL type identified by an {@code r2dbcType} is supported + * as a bind parameter. + * @param r2dbcType SQL type. Not null. + * @throws IllegalArgumentException If the SQL type is not supported. + */ + private static void requireSupportedSqlType(Type r2dbcType) { + SQLType jdbcType = toJdbcType(r2dbcType); + + if (jdbcType == null) + throw new IllegalArgumentException("Unsupported SQL type: " + r2dbcType); + } + /** * Returns an exception indicating that it is not possible to execute a * statement that returns both out-parameters and generated values. There @@ -989,7 +997,20 @@ final Publisher execute() { /** *

- * Sets {@link #binds} on the {@link #preparedStatement}. The + * Sets {@link #binds} on the {@link #preparedStatement}, as specified by + * {@link #bind(Object[])}. This method is called when this statement is + * executed. Subclasess override this method to perform additional actions. + *

+ * @return A {@code Publisher} that emits {@code onComplete} when all + * {@code binds} have been set. + */ + protected Publisher bind() { + return bind(binds); + } + + /** + *

+ * Sets the given {@code binds} on the {@link #preparedStatement}. The * returned {@code Publisher} completes after all bind values have * materialized and been set on the {@code preparedStatement}. *

@@ -1001,41 +1022,27 @@ final Publisher execute() { * @return A {@code Publisher} that emits {@code onComplete} when all * {@code binds} have been set. */ - protected Publisher bind() { - return bind(binds); - } - protected final Publisher bind(Object[] binds) { return adapter.getLock().flatMap(() -> { + List> bindPublishers = null; + for (int i = 0; i < binds.length; i++) { + // Out binds are handled in the registerOutParameters method of the + // JdbcCall subclass. if (binds[i] instanceof Parameter.Out && !(binds[i] instanceof Parameter.In)) continue; - Object jdbcValue = convertBind(binds[i]); - SQLType jdbcType = - binds[i] instanceof Parameter - ? toJdbcType(((Parameter) binds[i]).getType()) - : null; // JDBC infers the type - - if (jdbcValue instanceof Publisher) { - int indexFinal = i; - Publisher bindPublisher = - Mono.from((Publisher) jdbcValue) - .doOnSuccess(allocatedValue -> - setBind(indexFinal, allocatedValue, jdbcType)) - .then(); + Publisher bindPublisher = bind(i, binds[i]); + if (bindPublisher != null) { if (bindPublishers == null) bindPublishers = new LinkedList<>(); bindPublishers.add(bindPublisher); } - else { - setBind(i, jdbcValue, jdbcType); - } } return bindPublishers == null @@ -1044,6 +1051,72 @@ protected final Publisher bind(Object[] binds) { }); } + /** + * Binds a value to the {@link #preparedStatement}. This method may convert + * the given {@code value} into an object type that is accepted by JDBC. If + * value is materialized asynchronously, such as a Blob or Clob, then this + * method returns a publisher that completes when the materialized value is + * bound. + * @param index zero-based bind index + * @param value value to bind. May be null. + * @return A publisher that completes when the value is bound, or null if + * the value is bound synchronously. + * @implNote The decision to return a null publisher rather than an empty + * publisher is motivated by reducing object allocation and overhead from + * subscribe/onSubscribe/onComplete. It is thought that this overhead would + * be substantial if it were incurred for each bind, of each statement, of + * each connection. + */ + private Publisher bind(int index, Object value) { + + final Object jdbcValue = convertBind(value); + + final SQLType jdbcType; + final String typeName; + + if (value instanceof Parameter) { + // Convert the parameter's R2DBC type to a JDBC type. Get the type name + // by calling getName() on the R2DBC type, not the JDBC type; This + // ensures that a user defined name will be used, such as one from + // OracleR2dbcTypes.ArrayType.getName() + Type type = ((Parameter)value).getType(); + jdbcType = toJdbcType(type); + typeName = type.getName(); + } + else { + // JDBC will infer the type from the class of jdbcValue + jdbcType = null; + typeName = null; + } + + if (jdbcValue instanceof Publisher) { + return setPublishedBind(index, (Publisher) jdbcValue); + } + else { + setBind(index, jdbcValue, jdbcType, typeName); + return null; + } + } + + /** + * Binds a published value to the {@link #preparedStatement}. The binding + * happens asynchronously. The returned publisher that completes after the + * value is published and bound + * @param index zero based bind index + * @param publisher publisher that emits a bound value. + * @return A publisher that completes after the published value is bound. + */ + private Publisher setPublishedBind( + int index, Publisher publisher) { + return Mono.from(publisher) + .flatMap(value -> { + Publisher bindPublisher = bind(index, value); + return bindPublisher == null + ? Mono.empty() + : Mono.from(bindPublisher); + }); + } + /** * Executes the JDBC {@link #preparedStatement} and maps the * results into R2DBC {@link Result} objects. The base class implements @@ -1216,14 +1289,23 @@ private Publisher closeStatement() { * @param index 0-based parameter index * @param value Value. May be null. * @param type SQL type. May be null. + * @param typeName Name of a user defined type. May be null. */ - private void setBind(int index, Object value, SQLType type) { + private void setBind( + int index, Object value, SQLType type, String typeName) { runJdbc(() -> { int jdbcIndex = index + 1; - if (type != null) - preparedStatement.setObject(jdbcIndex, value, type); - else + + if (type == null) { preparedStatement.setObject(jdbcIndex, value); + } + else if (value == null) { + preparedStatement.setNull( + jdbcIndex, type.getVendorTypeNumber(), typeName); + } + else { + preparedStatement.setObject(jdbcIndex, value, type); + } }); } @@ -1253,7 +1335,7 @@ private Object convertBind(Object value) { return null; } else if (value instanceof Parameter) { - return convertBind(((Parameter) value).getValue()); + return convertParameterBind((Parameter) value); } else if (value instanceof io.r2dbc.spi.Blob) { return convertBlobBind((io.r2dbc.spi.Blob) value); @@ -1269,6 +1351,123 @@ else if (value instanceof ByteBuffer) { } } + /** Converts a {@code Parameter} bind value to a JDBC bind value */ + private Object convertParameterBind(Parameter parameter) { + Object value = parameter.getValue(); + + if (value == null) + return null; + + Type type = parameter.getType(); + + if (type instanceof OracleR2dbcTypes.ArrayType) { + return convertArrayBind((OracleR2dbcTypes.ArrayType) type, value); + } + else { + return value; + } + } + + /** + * Converts a given {@code value} to a JDBC {@link Array} of the given + * {@code type}. + */ + private Array convertArrayBind( + OracleR2dbcTypes.ArrayType type, Object value) { + + // TODO: createOracleArray executes a blocking database call the first + // time an OracleArray is created for a given type name. Subsequent + // creations of the same type avoid the database call using a cached type + // descriptor. If possible, rewrite this use a non-blocking call. + return fromJdbc(() -> + jdbcConnection.unwrap(OracleConnection.class) + .createOracleArray(type.getName(), convertJavaArray(value))); + } + + /** + * Converts a bind value for an ARRAY to a Java type that is supported by + * Oracle JDBC. This method handles cases for standard type mappings of + * R2DBC and extended type mapping Oracle R2DBC which are not supported by + * Oracle JDBC. + */ + private Object convertJavaArray(Object array) { + + if (array == null) + return null; + + // TODO: R2DBC drivers are only required to support ByteBuffer to + // VARBINARY (ie: RAW) conversions. However, a programmer might want to + // use byte[][] as a bind for an ARRAY of RAW. If that happens, they + // might hit this code by accident. Ideally, they can bind ByteBuffer[] + // instead. If absolutely necessary, Oracle R2DBC can do a type look up + // on the ARRAY and determine if the base type is RAW or NUMBER. + if (array instanceof byte[]) { + // Convert byte to NUMBER. Oracle JDBC does not support creating SQL + // arrays from a byte[], so convert the byte[] to an short[]. + byte[] bytes = (byte[])array; + short[] shorts = new short[bytes.length]; + for (int i = 0; i < bytes.length; i++) + shorts[i] = (short)(0xFF & bytes[i]); + + return shorts; + } + else if (array instanceof ByteBuffer[]) { + // Convert from R2DBC's ByteBuffer representation of binary data into + // JDBC's byte[] representation + ByteBuffer[] byteBuffers = (ByteBuffer[]) array; + byte[][] byteArrays = new byte[byteBuffers.length][]; + for (int i = 0; i < byteBuffers.length; i++) { + ByteBuffer byteBuffer = byteBuffers[i]; + byteArrays[i] = byteBuffer == null + ? null + : convertByteBufferBind(byteBuffers[i]); + } + + return byteArrays; + } + else if (array instanceof Period[]) { + // Convert from Oracle R2DBC's Period representation of INTERVAL YEAR TO + // MONTH to Oracle JDBC's INTERVALYM representation. + Period[] periods = (Period[]) array; + INTERVALYM[] intervalYearToMonths = new INTERVALYM[periods.length]; + for (int i = 0; i < periods.length; i++) { + Period period = periods[i]; + if (period == null) { + intervalYearToMonths[i] = null; + } + else { + // The binary representation is specified in the JavaDoc of + // oracle.sql.INTERVALYM. In 21.x, the JavaDoc has bug: It neglects + // to mention that the year value is offset by 0x80000000 + byte[] bytes = new byte[5]; + ByteBuffer.wrap(bytes) + .putInt(period.getYears() + 0x80000000) // 4 byte year + .put((byte)(period.getMonths() + 60)); // 1 byte month + intervalYearToMonths[i] = new INTERVALYM(bytes); + } + } + + return intervalYearToMonths; + } + else { + // Check if the bind value is a multi-dimensional array + Class componentType = array.getClass().getComponentType(); + + if (componentType == null || !componentType.isArray()) + return array; + + int length = java.lang.reflect.Array.getLength(array); + Object[] converted = new Object[length]; + + for (int i = 0; i < length; i++) { + converted[i] = + convertJavaArray(java.lang.reflect.Array.get(array, i)); + } + + return converted; + } + } + /** * Converts an R2DBC Blob to a JDBC Blob. The returned {@code Publisher} * asynchronously writes the {@code r2dbcBlob's} content to a JDBC Blob and @@ -1407,7 +1606,19 @@ private Publisher registerOutParameters() { for (int i : outBindIndexes) { Type type = ((Parameter) binds[i]).getType(); SQLType jdbcType = toJdbcType(type); - callableStatement.registerOutParameter(i + 1, jdbcType); + + if (type instanceof OracleR2dbcTypes.ArrayType) { + // Call registerOutParameter with the user defined type name + // returned by ArrayType.getName(). Oracle JDBC throws an exception + // if a name is provided for a built-in type, like VARCHAR, etc. So + // this branch should only be taken for user defined types, like + // ARRAY. + callableStatement.registerOutParameter( + i + 1, jdbcType, type.getName()); + } + else { + callableStatement.registerOutParameter(i + 1, jdbcType); + } } }); } @@ -1419,6 +1630,7 @@ protected Publisher executeJdbc() { Mono.just(createCallResult( dependentCounter, createOutParameters( + fromJdbc(preparedStatement::getConnection), dependentCounter, new JdbcOutParameters(), metadata, adapter), adapter))); diff --git a/src/main/java/oracle/r2dbc/impl/SqlTypeMap.java b/src/main/java/oracle/r2dbc/impl/SqlTypeMap.java index b2ad059..8bffe5c 100644 --- a/src/main/java/oracle/r2dbc/impl/SqlTypeMap.java +++ b/src/main/java/oracle/r2dbc/impl/SqlTypeMap.java @@ -133,6 +133,7 @@ final class SqlTypeMap { entry(OffsetDateTime.class, JDBCType.TIMESTAMP_WITH_TIMEZONE), entry(io.r2dbc.spi.Blob.class, JDBCType.BLOB), entry(io.r2dbc.spi.Clob.class, JDBCType.CLOB), + entry(Object[].class, JDBCType.ARRAY), // JDBC 4.3 mappings not included in R2DBC Specification. Types like // java.sql.Blob/Clob/NClob/Array can be accessed from Row.get(...) @@ -160,8 +161,19 @@ final class SqlTypeMap { // Extended mappings supported by Oracle entry(Duration.class, OracleType.INTERVAL_DAY_TO_SECOND), entry(Period.class, OracleType.INTERVAL_YEAR_TO_MONTH), - entry(OracleJsonObject.class, OracleType.JSON) + entry(OracleJsonObject.class, OracleType.JSON), + // Primitive array mappings supported by OracleArray. Primitive arrays are + // not subtypes of Object[], which is listed for SQLType.ARRAY above. The + // primitive array types must be explicitly listed here. + entry(boolean[].class, OracleType.VARRAY), + // byte[] is mapped to RAW by default + // entry(byte[].class, OracleType.VARRAY), + entry(short[].class, OracleType.VARRAY), + entry(int[].class, OracleType.VARRAY), + entry(long[].class, OracleType.VARRAY), + entry(float[].class, OracleType.VARRAY), + entry(double[].class, OracleType.VARRAY) ); /** @@ -216,9 +228,12 @@ static Type toR2dbcType(int jdbcTypeNumber) { * @return A JDBC SQL type */ static SQLType toJdbcType(Type r2dbcType) { - return r2dbcType instanceof Type.InferredType - ? toJdbcType(r2dbcType.getJavaType()) - : R2DBC_TO_JDBC_TYPE_MAP.get(r2dbcType); + if (r2dbcType instanceof Type.InferredType) + return toJdbcType(r2dbcType.getJavaType()); + else if (r2dbcType instanceof OracleR2dbcTypes.ArrayType) + return JDBCType.ARRAY; + else + return R2DBC_TO_JDBC_TYPE_MAP.get(r2dbcType); } /** diff --git a/src/test/java/oracle/r2dbc/impl/OracleReadableMetadataImplTest.java b/src/test/java/oracle/r2dbc/impl/OracleReadableMetadataImplTest.java index 55147ad..e3f82f5 100644 --- a/src/test/java/oracle/r2dbc/impl/OracleReadableMetadataImplTest.java +++ b/src/test/java/oracle/r2dbc/impl/OracleReadableMetadataImplTest.java @@ -24,6 +24,7 @@ import io.r2dbc.spi.ColumnMetadata; import io.r2dbc.spi.Connection; import io.r2dbc.spi.Nullability; +import io.r2dbc.spi.Parameters; import io.r2dbc.spi.R2dbcType; import io.r2dbc.spi.Type; import oracle.jdbc.OracleType; @@ -53,6 +54,7 @@ import static oracle.r2dbc.util.Awaits.awaitExecution; import static oracle.r2dbc.util.Awaits.awaitOne; import static oracle.r2dbc.util.Awaits.awaitUpdate; +import static oracle.r2dbc.util.Awaits.tryAwaitExecution; import static oracle.r2dbc.util.Awaits.tryAwaitNone; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -284,8 +286,6 @@ public void testRowIdTypes() { connection, "UROWID", JDBCType.ROWID, OracleR2dbcTypes.ROWID, null, null, RowId.class, rowId); - // Expect JSON and OracleJsonObject to map. - } finally { tryAwaitNone(connection.close()); @@ -325,6 +325,32 @@ public void testJsonType() { } } + /** + * Verifies the implementation of {@link OracleReadableMetadataImpl} for + * ARRAY type columns. + */ + @Test + public void testArrayTypes() { + Connection connection = + Mono.from(sharedConnection()).block(connectTimeout()); + try { + awaitExecution(connection.createStatement( + "CREATE TYPE TEST_ARRAY_TYPE AS ARRAY(3) OF NUMBER")); + + verifyColumnMetadata( + connection, "TEST_ARRAY_TYPE", JDBCType.ARRAY, R2dbcType.COLLECTION, + null, null, Object[].class, + Parameters.in( + OracleR2dbcTypes.arrayType("TEST_ARRAY_TYPE"), + new Integer[]{0, 1, 2})); + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TYPE TEST_ARRAY_TYPE")); + tryAwaitNone(connection.close()); + } + } + /** * Calls * {@link #verifyColumnMetadata(Connection, String, SQLType, Type, Integer, Integer, Nullability, Class, Object)} diff --git a/src/test/java/oracle/r2dbc/impl/OracleStatementImplTest.java b/src/test/java/oracle/r2dbc/impl/OracleStatementImplTest.java index ed63857..8812509 100644 --- a/src/test/java/oracle/r2dbc/impl/OracleStatementImplTest.java +++ b/src/test/java/oracle/r2dbc/impl/OracleStatementImplTest.java @@ -23,17 +23,13 @@ import io.r2dbc.spi.Connection; import io.r2dbc.spi.ConnectionFactories; -import io.r2dbc.spi.ConnectionFactoryOptions; -import io.r2dbc.spi.Parameter; import io.r2dbc.spi.Parameters; import io.r2dbc.spi.R2dbcException; import io.r2dbc.spi.R2dbcNonTransientException; import io.r2dbc.spi.R2dbcType; import io.r2dbc.spi.Result; -import io.r2dbc.spi.Result.Message; import io.r2dbc.spi.Result.UpdateCount; import io.r2dbc.spi.Statement; -import io.r2dbc.spi.Type; import oracle.r2dbc.OracleR2dbcOptions; import oracle.r2dbc.OracleR2dbcTypes; import oracle.r2dbc.OracleR2dbcWarning; @@ -47,11 +43,12 @@ import java.sql.RowId; import java.sql.SQLWarning; import java.util.ArrayList; -import java.util.Collection; +import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.NoSuchElementException; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -64,19 +61,13 @@ import java.util.stream.IntStream; import java.util.stream.Stream; -import static java.lang.String.format; import static java.util.Arrays.asList; import static oracle.r2dbc.test.DatabaseConfig.connectTimeout; import static oracle.r2dbc.test.DatabaseConfig.connectionFactoryOptions; -import static oracle.r2dbc.test.DatabaseConfig.host; import static oracle.r2dbc.test.DatabaseConfig.newConnection; -import static oracle.r2dbc.test.DatabaseConfig.password; -import static oracle.r2dbc.test.DatabaseConfig.port; -import static oracle.r2dbc.test.DatabaseConfig.serviceName; import static oracle.r2dbc.test.DatabaseConfig.sharedConnection; import static oracle.r2dbc.test.DatabaseConfig.showErrors; import static oracle.r2dbc.test.DatabaseConfig.sqlTimeout; -import static oracle.r2dbc.test.DatabaseConfig.user; import static oracle.r2dbc.util.Awaits.awaitError; import static oracle.r2dbc.util.Awaits.awaitExecution; import static oracle.r2dbc.util.Awaits.awaitMany; @@ -1286,7 +1277,7 @@ public void testOneInOutCall() { // inserted by the call. consumeOne(connection.createStatement( "BEGIN testOneInOutCallAdd(?); END;") - .bind(0, new InOutParameter(1, R2dbcType.NUMERIC)) + .bind(0, Parameters.inOut(R2dbcType.NUMERIC, 1)) .execute(), result -> { awaitNone(result.getRowsUpdated()); @@ -1300,7 +1291,7 @@ public void testOneInOutCall() { // parameter's default value to have been inserted by the call. consumeOne(connection.createStatement( "BEGIN testOneInOutCallAdd(:value); END;") - .bind("value", new InOutParameter(2, R2dbcType.NUMERIC)) + .bind("value", Parameters.inOut(R2dbcType.NUMERIC, 2)) .execute(), result -> awaitOne(1, result.map(row -> @@ -1314,7 +1305,7 @@ public void testOneInOutCall() { // parameter's value to have been inserted by the call. consumeOne(connection.createStatement( "BEGIN testOneInOutCallAdd(:value); END;") - .bind("value", new InOutParameter(3)) + .bind("value", Parameters.inOut(3)) .execute(), result -> awaitNone(result.getRowsUpdated())); @@ -1327,7 +1318,7 @@ public void testOneInOutCall() { // parameter's value to have been inserted by the call. consumeOne(connection.createStatement( "BEGIN testOneInOutCallAdd(?); END;") - .bind(0, new InOutParameter(4)) + .bind(0, Parameters.inOut(4)) .execute(), result -> awaitOne(3, result.map(row -> @@ -1381,8 +1372,8 @@ public void testMultiInOutCall() { // inserted by the call. consumeOne(connection.createStatement( "BEGIN testMultiInOutCallAdd(:value1, :value2); END;") - .bind("value1", new InOutParameter(1, R2dbcType.NUMERIC)) - .bind("value2", new InOutParameter(101, R2dbcType.NUMERIC)) + .bind("value1", Parameters.inOut(R2dbcType.NUMERIC, 1)) + .bind("value2", Parameters.inOut(R2dbcType.NUMERIC, 101)) .execute(), result -> awaitNone(result.getRowsUpdated())); @@ -1396,8 +1387,8 @@ public void testMultiInOutCall() { // parameter's default value to have been inserted by the call. consumeOne(connection.createStatement( "BEGIN testMultiInOutCallAdd(?, :value2); END;") - .bind(0, new InOutParameter(2, R2dbcType.NUMERIC)) - .bind("value2", new InOutParameter(102, R2dbcType.NUMERIC)) + .bind(0, Parameters.inOut(R2dbcType.NUMERIC, 2)) + .bind("value2", Parameters.inOut(R2dbcType.NUMERIC, 102)) .execute(), result -> awaitOne(asList(1, 101), result.map(row -> @@ -1413,8 +1404,8 @@ public void testMultiInOutCall() { // parameter's value to have been inserted by the call. consumeOne(connection.createStatement( "BEGIN testMultiInOutCallAdd(?, ?); END;") - .bind(0, new InOutParameter(3)) - .bind(1, new InOutParameter(103)) + .bind(0, Parameters.inOut(3)) + .bind(1, Parameters.inOut(103)) .execute(), result -> awaitNone(result.getRowsUpdated())); awaitQuery(asList(asList(3, 103)), @@ -1429,8 +1420,8 @@ public void testMultiInOutCall() { consumeOne(connection.createStatement( "BEGIN testMultiInOutCallAdd(" + "inout_value2 => :value2, inout_value1 => :value1); END;") - .bind("value1", new InOutParameter(4)) - .bind("value2", new InOutParameter(104)) + .bind("value1", Parameters.inOut(4)) + .bind("value2", Parameters.inOut(104)) .execute(), result -> awaitOne(asList(3, 103), result.map(row -> @@ -2431,39 +2422,306 @@ public void testMultipleRefCursorOut() { } } - // TODO: Repalce with Parameters.inOut when that's available - private static final class InOutParameter - implements Parameter, Parameter.In, Parameter.Out { - final Type type; - final Object value; + /** + * Verifies behavior for a PL/SQL call having {@code ARRAY} type IN bind + */ + @Test + public void testInArrayCall() { + Connection connection = awaitOne(sharedConnection()); + try { + awaitExecution(connection.createStatement( + "CREATE TYPE TEST_IN_ARRAY AS ARRAY(8) OF NUMBER")); + awaitExecution(connection.createStatement( + "CREATE TABLE testInArrayCall(id NUMBER, value TEST_IN_ARRAY)")); + awaitExecution(connection.createStatement( + "CREATE OR REPLACE PROCEDURE testInArrayProcedure (" + + " id IN NUMBER," + + " inArray IN TEST_IN_ARRAY)" + + " IS" + + " BEGIN" + + " INSERT INTO testInArrayCall VALUES(id, inArray);" + + " END;")); - InOutParameter(Object value) { - this(value, new Type.InferredType() { + class TestRow { + Long id; + int[] value; + TestRow(Long id, int[] value) { + this.id = id; + this.value = value; + } @Override - public Class getJavaType() { - return value.getClass(); + public boolean equals(Object other) { + return other instanceof TestRow + && Objects.equals(((TestRow) other).id, id) + && Objects.deepEquals(((TestRow)other).value, value); } @Override - public String getName() { - return "Inferred"; + public String toString() { + return id + ", " + Arrays.toString(value); } - }); - } + } - InOutParameter(Object value, Type type) { - this.value = value; - this.type = type; + TestRow row0 = new TestRow(0L, new int[]{1, 2, 3}); + OracleR2dbcTypes.ArrayType arrayType = + OracleR2dbcTypes.arrayType("TEST_IN_ARRAY"); + Statement callStatement = connection.createStatement( + "BEGIN testInArrayProcedure(:id, :value); END;"); + awaitExecution( + callStatement + .bind("id", row0.id) + .bind("value", Parameters.in(arrayType, row0.value))); + + awaitQuery( + List.of(row0), + row -> + new TestRow( + row.get("id", Long.class), + row.get("value", int[].class)), + connection.createStatement( + "SELECT id, value FROM testInArrayCall ORDER BY id")); + + TestRow row1 = new TestRow(1L, new int[]{4, 5, 6}); + awaitExecution( + callStatement + .bind("id", row1.id) + .bind("value", Parameters.in(arrayType, row1.value))); + + awaitQuery( + List.of(row0, row1), + row -> + new TestRow( + row.get("id", Long.class), + row.get("value", int[].class)), + connection.createStatement( + "SELECT id, value FROM testInArrayCall ORDER BY id")); + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TABLE testInArrayCall")); + tryAwaitExecution(connection.createStatement( + "DROP PROCEDURE testInArrayProcedure")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE TEST_IN_ARRAY")); + tryAwaitNone(connection.close()); } + } - @Override - public Type getType() { - return type; + /** + * Verifies behavior for a PL/SQL call having {@code ARRAY} type OUT bind + */ + @Test + public void testOutArrayCall() { + Connection connection = awaitOne(sharedConnection()); + try { + awaitExecution(connection.createStatement( + "CREATE TYPE TEST_OUT_ARRAY AS ARRAY(8) OF NUMBER")); + awaitExecution(connection.createStatement( + "CREATE TABLE testOutArrayCall(id NUMBER, value TEST_OUT_ARRAY)")); + awaitExecution(connection.createStatement( + "CREATE OR REPLACE PROCEDURE testOutArrayProcedure (" + + " inId IN NUMBER," + + " outArray OUT TEST_OUT_ARRAY)" + + " IS" + + " BEGIN" + + " SELECT value INTO outArray" + + " FROM testOutArrayCall" + + " WHERE id = inId;" + + " EXCEPTION" + + " WHEN NO_DATA_FOUND THEN" + + " outArray := NULL;" + + " END;")); + + class TestRow { + Long id; + Integer[] value; + TestRow(Long id, Integer[] value) { + this.id = id; + this.value = value; + } + + @Override + public boolean equals(Object other) { + return other instanceof TestRow + && Objects.equals(((TestRow) other).id, id) + && Objects.deepEquals(((TestRow)other).value, value); + } + + @Override + public String toString() { + return id + ", " + Arrays.toString(value); + } + } + + OracleR2dbcTypes.ArrayType arrayType = + OracleR2dbcTypes.arrayType("TEST_OUT_ARRAY"); + Statement callStatement = connection.createStatement( + "BEGIN testOutArrayProcedure(:id, :value); END;"); + + // Expect a NULL out parameter before any rows have been inserted + awaitQuery( + List.of(Optional.empty()), + outParameters -> { + assertNull(outParameters.get("value")); + assertNull(outParameters.get("value", int[].class)); + assertNull(outParameters.get("value", Integer[].class)); + return Optional.empty(); + }, + callStatement + .bind("id", -1) + .bind("value", Parameters.out(arrayType))); + + // Insert a row and expect an out parameter with the value + TestRow row0 = new TestRow(0L, new Integer[]{1, 2, 3}); + awaitUpdate(1, connection.createStatement( + "INSERT INTO testOutArrayCall VALUES (:id, :value)") + .bind("id", row0.id) + .bind("value", Parameters.in(arrayType, row0.value))); + awaitQuery( + List.of(row0), + outParameters -> + new TestRow(row0.id, outParameters.get("value", Integer[].class)), + callStatement + .bind("id", row0.id) + .bind("value", Parameters.out(arrayType))); + + // Insert another row and expect an out parameter with the value + TestRow row1 = new TestRow(1L, new Integer[]{4, 5, 6}); + awaitUpdate(1, connection.createStatement( + "INSERT INTO testOutArrayCall VALUES (:id, :value)") + .bind("id", row1.id) + .bind("value", Parameters.in(arrayType, row1.value))); + awaitQuery( + List.of(row1), + outParameters -> + new TestRow(row1.id, outParameters.get("value", Integer[].class)), + callStatement + .bind("id", row1.id) + .bind("value", Parameters.out(arrayType))); + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TABLE testOutArrayCall")); + tryAwaitExecution(connection.createStatement( + "DROP PROCEDURE testOutArrayProcedure")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE TEST_OUT_ARRAY")); + tryAwaitNone(connection.close()); } + } - @Override - public Object getValue() { - return value; + /** + * Verifies behavior for a PL/SQL call having {@code ARRAY} type OUT bind + */ + @Test + public void testInOutArrayCall() { + Connection connection = awaitOne(sharedConnection()); + try { + awaitExecution(connection.createStatement( + "CREATE TYPE TEST_IN_OUT_ARRAY AS ARRAY(8) OF NUMBER")); + awaitExecution(connection.createStatement( + "CREATE TABLE testInOutArrayCall(id NUMBER, value TEST_IN_OUT_ARRAY)")); + awaitExecution(connection.createStatement( + "CREATE OR REPLACE PROCEDURE testInOutArrayProcedure (" + + " inId IN NUMBER," + + " inOutArray IN OUT TEST_IN_OUT_ARRAY)" + + " IS" + + " newValue TEST_IN_OUT_ARRAY;" + + " BEGIN" + + "" + + " newValue := TEST_IN_OUT_ARRAY();" + + " newValue.extend(inOutArray.count);" + + " FOR i IN 1 .. inOutArray.count LOOP" + + " newValue(i) := inOutArray(i);" + + " END LOOP;" + + "" + + " BEGIN" + + " SELECT value INTO inOutArray" + + " FROM testInOutArrayCall" + + " WHERE id = inId;" + + " DELETE FROM testInOutArrayCall WHERE id = inId;" + + " EXCEPTION" + + " WHEN NO_DATA_FOUND THEN" + + " inOutArray := NULL;" + + " END;" + + "" + + " INSERT INTO testInOutArrayCall VALUES (inId, newValue);" + + "" + + " END;")); + + class TestRow { + Long id; + Integer[] value; + TestRow(Long id, Integer[] value) { + this.id = id; + this.value = value; + } + + @Override + public boolean equals(Object other) { + return other instanceof TestRow + && Objects.equals(((TestRow) other).id, id) + && Objects.deepEquals(((TestRow)other).value, value); + } + + @Override + public String toString() { + return id + ", " + Arrays.toString(value); + } + } + + OracleR2dbcTypes.ArrayType arrayType = + OracleR2dbcTypes.arrayType("TEST_IN_OUT_ARRAY"); + Statement callStatement = connection.createStatement( + "BEGIN testInOutArrayProcedure(:id, :value); END;"); + + // Expect a NULL out parameter the first time a row is inserted + TestRow row = new TestRow(0L, new Integer[]{1, 2, 3}); + awaitQuery( + List.of(Optional.empty()), + outParameters -> { + assertNull(outParameters.get("value")); + assertNull(outParameters.get("value", int[].class)); + assertNull(outParameters.get("value", Integer[].class)); + return Optional.empty(); + }, + callStatement + .bind("id", row.id) + .bind("value", Parameters.inOut(arrayType, row.value))); + + // Update the row and expect an out parameter with the previous value + TestRow row1 = new TestRow(row.id, new Integer[]{4, 5, 6}); + awaitQuery( + List.of(row), + outParameters -> + new TestRow( + row.id, + outParameters.get("value", Integer[].class)), + callStatement + .bind("id", row.id) + .bind("value", Parameters.inOut(arrayType, row1.value))); + + // Update the row again and expect an out parameter with the previous + // value + TestRow row2 = new TestRow(row.id, new Integer[]{7, 8, 9}); + awaitQuery( + List.of(row1), + outParameters -> + new TestRow( + row.id, + outParameters.get("value", Integer[].class)), + callStatement + .bind("id", row.id) + .bind("value", Parameters.inOut(arrayType, row2.value))); + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TABLE testInOutArrayCall")); + tryAwaitExecution(connection.createStatement( + "DROP PROCEDURE testInOutArrayProcedure")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE TEST_IN_OUT_ARRAY")); + tryAwaitNone(connection.close()); } } diff --git a/src/test/java/oracle/r2dbc/impl/TypeMappingTest.java b/src/test/java/oracle/r2dbc/impl/TypeMappingTest.java index 1f8907b..88fe2ef 100644 --- a/src/test/java/oracle/r2dbc/impl/TypeMappingTest.java +++ b/src/test/java/oracle/r2dbc/impl/TypeMappingTest.java @@ -24,9 +24,13 @@ import io.r2dbc.spi.Blob; import io.r2dbc.spi.Clob; import io.r2dbc.spi.Connection; +import io.r2dbc.spi.Parameter; import io.r2dbc.spi.Parameters; import io.r2dbc.spi.Row; import io.r2dbc.spi.Statement; +import io.r2dbc.spi.Type; +import oracle.r2dbc.OracleR2dbcTypes; +import oracle.r2dbc.OracleR2dbcTypes.ArrayType; import oracle.sql.json.OracleJsonFactory; import oracle.sql.json.OracleJsonObject; import org.junit.jupiter.api.Assertions; @@ -40,19 +44,21 @@ import java.sql.RowId; import java.time.Duration; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.LocalTime; import java.time.Month; import java.time.OffsetDateTime; import java.time.Period; import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; +import java.util.Arrays; import java.util.function.BiConsumer; import java.util.function.Function; +import java.util.function.IntFunction; import java.util.stream.Collectors; import java.util.stream.Stream; import static io.r2dbc.spi.R2dbcType.NCHAR; -import static io.r2dbc.spi.R2dbcType.NCLOB; import static io.r2dbc.spi.R2dbcType.NVARCHAR; import static java.util.Arrays.asList; import static oracle.r2dbc.test.DatabaseConfig.connectTimeout; @@ -62,10 +68,12 @@ import static oracle.r2dbc.util.Awaits.awaitExecution; import static oracle.r2dbc.util.Awaits.awaitOne; import static oracle.r2dbc.util.Awaits.awaitUpdate; +import static oracle.r2dbc.util.Awaits.tryAwaitExecution; import static oracle.r2dbc.util.Awaits.tryAwaitNone; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -482,6 +490,1034 @@ public void testByteArrayMapping() { } } + /** + *

+ * Verifies the implementation of Java to SQL and SQL to Java type mappings + * for ARRAY types. The Oracle R2DBC Driver is expected to implement the type + * mapping listed in + * + * Table 9 of Section 14 of the R2DBC 1.0.0 Specification. + * + *

+ * An ARRAY is expected to map to the Java type mapping of it's element type. + * An ARRAY of VARCHAR should map to a Java array of String. + *

+ */ + @Test + public void testArrayCharacterTypeMappings() { + Connection connection = + Mono.from(sharedConnection()).block(connectTimeout()); + try { + ArrayType arrayType1D = OracleR2dbcTypes.arrayType("CHARACTER_ARRAY"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE CHARACTER_ARRAY" + + " AS ARRAY(10) OF VARCHAR(100)") + .execute()); + ArrayType arrayType2D = OracleR2dbcTypes.arrayType("CHARACTER_ARRAY_2D"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE CHARACTER_ARRAY_2D" + + " AS ARRAY(10) OF CHARACTER_ARRAY") + .execute()); + ArrayType arrayType3D = OracleR2dbcTypes.arrayType("CHARACTER_ARRAY_3D"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE CHARACTER_ARRAY_3D" + + " AS ARRAY(10) OF CHARACTER_ARRAY_2D") + .execute()); + + // Expect ARRAY of VARCHAR and String[] to map + String[] strings = {"Hello", "Bonjour", "你好", null}; + verifyArrayTypeMapping( + connection, arrayType1D, arrayType2D, arrayType3D, String[].class, + i -> + Arrays.stream(strings) + .map(string -> string == null ? null : i + "-" + string) + .toArray(String[]::new), + true, + Assertions::assertArrayEquals); + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TYPE CHARACTER_ARRAY_3D")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE CHARACTER_ARRAY_2D")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE CHARACTER_ARRAY")); + tryAwaitNone(connection.close()); + } + } + + /** + *

+ * Verifies the implementation of Java to SQL and SQL to Java type mappings + * for ARRAY types. The Oracle R2DBC Driver is expected to implement the type + * mapping listed in + * + * Table 9 of Section 14 of the R2DBC 1.0.0 Specification. + * + *

+ * An ARRAY is expected to map to the Java type mapping of it's element type. + * An ARRAY of NUMBER should map to a Java array of BigDecimal, Byte, Short, + * Integer, Long, Float, and Double. + *

+ */ + @Test + public void testArrayNumericTypeMappings() { + Connection connection = + Mono.from(sharedConnection()).block(connectTimeout()); + try { + + ArrayType arrayType1D = + OracleR2dbcTypes.arrayType("NUMBER_ARRAY"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE NUMBER_ARRAY" + + " AS ARRAY(10) OF NUMBER") + .execute()); + + ArrayType arrayType2D = + OracleR2dbcTypes.arrayType("NUMBER_ARRAY_2D"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE NUMBER_ARRAY_2D" + + " AS ARRAY(10) OF NUMBER_ARRAY") + .execute()); + + ArrayType arrayType3D = + OracleR2dbcTypes.arrayType("NUMBER_ARRAY_3D"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE NUMBER_ARRAY_3D" + + " AS ARRAY(10) OF NUMBER_ARRAY_2D") + .execute()); + + // Expect ARRAY of NUMBER and BigDecimal to map + BigDecimal[] bigDecimals = new BigDecimal[] { + BigDecimal.ZERO, + new BigDecimal("1.23"), + new BigDecimal("4.56"), + new BigDecimal("7.89"), + null + }; + verifyArrayTypeMapping( + connection, arrayType1D, arrayType2D, arrayType3D, BigDecimal[].class, + i -> + Arrays.stream(bigDecimals) + .map(bigDecimal -> + bigDecimal == null + ? null + :bigDecimal.add(BigDecimal.valueOf(i))) + .toArray(BigDecimal[]::new), + true, + Assertions::assertArrayEquals); + + // Expect ARRAY of NUMBER and byte[] to map + byte[] bytes = new byte[]{1,2,3,4,5}; + verifyArrayTypeMapping( + connection, arrayType1D, arrayType2D, arrayType3D, byte[].class, + i -> { + byte[] moreBytes = new byte[bytes.length]; + for (int j = 0; j < bytes.length; j++) + moreBytes[j] = (byte)(bytes[j] + i); + return moreBytes; + }, + false, + Assertions::assertArrayEquals); + + // Expect ARRAY of NUMBER and short[] to map + short[] shorts = new short[]{1,2,3,4,5}; + verifyArrayTypeMapping( + connection, arrayType1D, arrayType2D, arrayType3D, short[].class, + i -> { + short[] moreShorts = new short[shorts.length]; + for (int j = 0; j < shorts.length; j++) + moreShorts[j] = (short)(shorts[j] + i); + return moreShorts; + }, + false, + Assertions::assertArrayEquals); + + // Expect ARRAY of NUMBER and int[] to map + int[] ints = {1,2,3,4,5}; + verifyArrayTypeMapping( + connection, arrayType1D, arrayType2D, arrayType3D, int[].class, + i -> { + int[] moreInts = new int[ints.length]; + for (int j = 0; j < ints.length; j++) + moreInts[j] = (ints[j] + i); + return moreInts; + }, + false, + Assertions::assertArrayEquals); + + // Expect ARRAY of NUMBER and long[] to map + long[] longs = {1,2,3,4,5}; + verifyArrayTypeMapping( + connection, arrayType1D, arrayType2D, arrayType3D, long[].class, + i -> { + long[] moreLongs = new long[longs.length]; + for (int j = 0; j < longs.length; j++) + moreLongs[j] = longs[j] + i; + return moreLongs; + }, + false, + Assertions::assertArrayEquals); + + // Expect ARRAY of NUMBER and float[] to map + float[] floats = {1.1f,2.2f,3.3f,4.4f,5.5f}; + verifyArrayTypeMapping( + connection, arrayType1D, arrayType2D, arrayType3D, float[].class, + i -> { + float[] moreFloats = new float[floats.length]; + for (int j = 0; j < floats.length; j++) + moreFloats[j] = (floats[j] + (float)i); + return moreFloats; + }, + false, + Assertions::assertArrayEquals); + + // Expect ARRAY of NUMBER and double[] to map + double[] doubles = {1.1,2.2,3.3,4.4,5.5}; + verifyArrayTypeMapping( + connection, arrayType1D, arrayType2D, arrayType3D, double[].class, + i -> { + double[] moreDoubles = new double[doubles.length]; + for (int j = 0; j < doubles.length; j++) + moreDoubles[j] = (doubles[j] + (double)i); + return moreDoubles; + }, + false, + Assertions::assertArrayEquals); + + // Expect ARRAY of NUMBER and Byte[] to map + Byte[] byteObjects = new Byte[]{1,2,3,4,5,null}; + verifyArrayTypeMapping( + connection, arrayType1D, arrayType2D, arrayType3D, Byte[].class, + i -> + Arrays.stream(byteObjects) + .map(byteObject -> + byteObject == null ? null : (byte)(byteObject + i)) + .toArray(Byte[]::new), + false, + Assertions::assertArrayEquals); + + // Expect ARRAY of NUMBER and Short[] to map + Short[] shortObjects = new Short[]{1,2,3,4,5,null}; + verifyArrayTypeMapping( + connection, arrayType1D, arrayType2D, arrayType3D, Short[].class, + i -> + Arrays.stream(shortObjects) + .map(shortObject -> + shortObject == null ? null : (short)(shortObject + i)) + .toArray(Short[]::new), + false, + Assertions::assertArrayEquals); + + // Expect ARRAY of NUMBER and Integer[] to map + Integer[] intObjects = {1,2,3,4,5,null}; + verifyArrayTypeMapping( + connection, arrayType1D, arrayType2D, arrayType3D, Integer[].class, + i -> + Arrays.stream(intObjects) + .map(intObject -> + intObject == null ? null : intObject + i) + .toArray(Integer[]::new), + false, + Assertions::assertArrayEquals); + + // Expect ARRAY of NUMBER and Long[] to map + Long[] longObjects = {1L,2L,3L,4L,5L,null}; + verifyArrayTypeMapping( + connection, arrayType1D, arrayType2D, arrayType3D, Long[].class, + i -> + Arrays.stream(longObjects) + .map(longObject -> + longObject == null ? null : longObject + i) + .toArray(Long[]::new), + false, + Assertions::assertArrayEquals); + + // Expect ARRAY of NUMBER and Float[] to map + Float[] floatObjects = {1.1f,2.2f,3.3f,4.4f,5.5f,null}; + verifyArrayTypeMapping( + connection, arrayType1D, arrayType2D, arrayType3D, Float[].class, + i -> + Arrays.stream(floatObjects) + .map(floatObject -> + floatObject == null ? null : floatObject + i) + .toArray(Float[]::new), + false, + Assertions::assertArrayEquals); + + // Expect ARRAY of NUMBER and Double[] to map + Double[] doubleObjects = {1.1,2.2,3.3,4.4,5.5,null}; + verifyArrayTypeMapping( + connection, arrayType1D, arrayType2D, arrayType3D, Double[].class, + i -> + Arrays.stream(doubleObjects) + .map(doubleObject -> + doubleObject == null ? null : doubleObject + i) + .toArray(Double[]::new), + false, + Assertions::assertArrayEquals); + } + finally { + tryAwaitExecution(connection.createStatement("DROP TYPE NUMBER_ARRAY_3D")); + tryAwaitExecution(connection.createStatement("DROP TYPE NUMBER_ARRAY_2D")); + tryAwaitExecution(connection.createStatement("DROP TYPE NUMBER_ARRAY")); + tryAwaitNone(connection.close()); + } + } + + /** + *

+ * Verifies the implementation of Java to SQL and SQL to Java type mappings + * for ARRAY types. The Oracle R2DBC Driver is expected to implement the type + * mapping listed in + * + * Table 9 of Section 14 of the R2DBC 1.0.0 Specification. + * + *

+ * An ARRAY is expected to map to the Java type mapping of it's element type. + * An ARRAY of NUMBER should map to a Java array of boolean. + *

+ */ + @Test + public void testArrayBooleanTypeMappings() { + Connection connection = + Mono.from(sharedConnection()).block(connectTimeout()); + try { + ArrayType arrayType1D = OracleR2dbcTypes.arrayType("BOOLEAN_ARRAY"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE BOOLEAN_ARRAY" + + " AS ARRAY(10) OF NUMBER") + .execute()); + ArrayType arrayType2D = OracleR2dbcTypes.arrayType("BOOLEAN_ARRAY_2D"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE BOOLEAN_ARRAY_2D" + + " AS ARRAY(10) OF BOOLEAN_ARRAY") + .execute()); + ArrayType arrayType3D = OracleR2dbcTypes.arrayType("BOOLEAN_ARRAY_3D"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE BOOLEAN_ARRAY_3D" + + " AS ARRAY(10) OF BOOLEAN_ARRAY_2D") + .execute()); + + // Expect ARRAY of NUMBER and boolean[] to map + boolean[] booleans = {true, false, false, true}; + verifyArrayTypeMapping( + connection, arrayType1D, arrayType2D, arrayType3D, + boolean[].class, + i -> { + boolean[] moreBooleans = new boolean[booleans.length]; + for (int j = 0; j < booleans.length; j++) + moreBooleans[j] = booleans[i % booleans.length]; + return moreBooleans; + }, + false, + Assertions::assertArrayEquals); + + // Expect ARRAY of NUMBER and Boolean[] to map + Boolean[] booleanObjects = {true, false, false, true, null}; + verifyArrayTypeMapping( + connection, arrayType1D, arrayType2D, arrayType3D, + Boolean[].class, + i -> { + Boolean[] moreBooleans = new Boolean[booleanObjects.length]; + for (int j = 0; j < booleanObjects.length; j++) + moreBooleans[j] = booleanObjects[i % booleanObjects.length]; + return moreBooleans; + }, + false, + Assertions::assertArrayEquals); + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TYPE BOOLEAN_ARRAY_3D")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE BOOLEAN_ARRAY_2D")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE BOOLEAN_ARRAY")); + tryAwaitNone(connection.close()); + } + } + + /** + *

+ * Verifies the implementation of Java to SQL and SQL to Java type mappings + * for ARRAY types. The Oracle R2DBC Driver is expected to implement the type + * mapping listed in + * + * Table 9 of Section 14 of the R2DBC 1.0.0 Specification. + * + *

+ * An ARRAY is expected to map to the Java type mapping of it's element type. + * An ARRAY of RAW should map to a Java array of ByteBuffer. + *

+ */ + @Test + public void testArrayBinaryTypeMappings() { + Connection connection = + Mono.from(sharedConnection()).block(connectTimeout()); + try { + ArrayType arrayType1D = OracleR2dbcTypes.arrayType("BINARY_ARRAY"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE BINARY_ARRAY" + + " AS ARRAY(10) OF RAW(100)") + .execute()); + ArrayType arrayType2D = OracleR2dbcTypes.arrayType("BINARY_ARRAY_2D"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE BINARY_ARRAY_2D" + + " AS ARRAY(10) OF BINARY_ARRAY") + .execute()); + ArrayType arrayType3D = OracleR2dbcTypes.arrayType("BINARY_ARRAY_3D"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE BINARY_ARRAY_3D" + + " AS ARRAY(10) OF BINARY_ARRAY_2D") + .execute()); + + // Expect ARRAY of RAW and ByteBuffer[] to map + ByteBuffer[] byteBuffers = { + ByteBuffer.allocate(3 * Integer.BYTES) + .putInt(0).putInt(1).putInt(2).clear(), + ByteBuffer.allocate(3 * Integer.BYTES) + .putInt(3).putInt(4).putInt(5).clear(), + ByteBuffer.allocate(3 * Integer.BYTES) + .putInt(6).putInt(7).putInt(8).clear(), + null + }; + verifyArrayTypeMapping(connection, + arrayType1D, arrayType2D, arrayType3D, + ByteBuffer[].class, + i -> Arrays.stream(byteBuffers) + .map(byteBuffer -> + byteBuffer == null + ? null + : ByteBuffer.allocate(3 * Integer.BYTES) + .putInt(byteBuffer.get(0) + i) + .putInt(byteBuffer.get(1) + i) + .putInt(byteBuffer.get(2) + i) + .clear()) + .toArray(ByteBuffer[]::new), + true, + Assertions::assertArrayEquals); + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TYPE BINARY_ARRAY_3D")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE BINARY_ARRAY_2D")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE BINARY_ARRAY")); + tryAwaitNone(connection.close()); + } + } + + /** + *

+ * Verifies the implementation of Java to SQL and SQL to Java type mappings + * for ARRAY types. The Oracle R2DBC Driver is expected to implement the type + * mapping listed in + * + * Table 9 of Section 14 of the R2DBC 1.0.0 Specification. + * + *

+ * An ARRAY is expected to map to the Java type mapping of it's element type. + * An ARRAY of DATE should map to a Java array of LocalDateTime (Oracle DATE + * values have a time component). + * An ARRAY of TIMESTAMP should map to a Java array of LocalDateTime + * An ARRAY of TIMESTAMP WITH TIME ZONE should map to a Java array of + * OffsetDateTime. + * An ARRAY of TIMESTAMP WITH LOCAL TIME ZONE should map to a Java array of + * LocalDateTime. + * An ARRAY of INTERVAL YEAR TO MONTH should map to a Java array of Period + * An ARRAY of INTERVAL DAY TO SECOND should map to a Java array of Duration + *

+ */ + @Test + public void testArrayDatetimeTypeMappings() { + Connection connection = + Mono.from(sharedConnection()).block(connectTimeout()); + try { + OffsetDateTime dateTimeValue = + OffsetDateTime.of(2038, 10, 23, 9, 42, 1, 1, ZoneOffset.ofHours(-5)); + + ArrayType dateArrayType1D = OracleR2dbcTypes.arrayType("DATE_ARRAY"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE DATE_ARRAY" + + " AS ARRAY(10) OF DATE") + .execute()); + ArrayType dateArrayType2D = OracleR2dbcTypes.arrayType("DATE_ARRAY_2D"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE DATE_ARRAY_2D" + + " AS ARRAY(10) OF DATE_ARRAY") + .execute()); + ArrayType dateArrayType3D = OracleR2dbcTypes.arrayType("DATE_ARRAY_3D"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE DATE_ARRAY_3D" + + " AS ARRAY(10) OF DATE_ARRAY_2D") + .execute()); + try { + + // Expect ARRAY of DATE and LocalDateTime[] to map + LocalDateTime[] localDateTimes = { + dateTimeValue.minus(Period.ofDays(7)) + .toLocalDateTime() + .truncatedTo(ChronoUnit.SECONDS), + dateTimeValue.toLocalDateTime() + .truncatedTo(ChronoUnit.SECONDS), + dateTimeValue.plus(Period.ofDays(7)) + .toLocalDateTime() + .truncatedTo(ChronoUnit.SECONDS), + null + }; + verifyArrayTypeMapping( + connection, dateArrayType1D, dateArrayType2D, dateArrayType3D, + LocalDateTime[].class, + i -> + Arrays.stream(localDateTimes) + .map(localDateTime -> + localDateTime == null + ? null + : localDateTime.plus(Period.ofDays(i))) + .toArray(LocalDateTime[]::new), + true, + Assertions::assertArrayEquals); + + // Expect ARRAY of DATE and LocalDate[] to map + LocalDate[] localDates = + Arrays.stream(localDateTimes) + .map(localDateTime -> + localDateTime == null ? null : localDateTime.toLocalDate()) + .toArray(LocalDate[]::new); + verifyArrayTypeMapping( + connection, dateArrayType1D, dateArrayType2D, dateArrayType3D, + LocalDate[].class, + i -> + Arrays.stream(localDates) + .map(localDate -> + localDate == null + ? null + : localDate.plus(Period.ofDays(i))) + .toArray(LocalDate[]::new), + false, + Assertions::assertArrayEquals); + + // Expect ARRAY of DATE and LocalTime[] to map + LocalTime[] localTimes = + Arrays.stream(localDateTimes) + .map(localDateTime -> + localDateTime == null ? null : localDateTime.toLocalTime()) + .toArray(LocalTime[]::new); + verifyArrayTypeMapping( + connection, dateArrayType1D, dateArrayType2D, dateArrayType3D, + LocalTime[].class, + i -> + Arrays.stream(localTimes) + .map(localTime -> + localTime == null + ? null + : localTime.plus(Duration.ofMinutes(i))) + .toArray(LocalTime[]::new), + false, + Assertions::assertArrayEquals); + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TYPE DATE_ARRAY_3D")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE DATE_ARRAY_2D")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE DATE_ARRAY")); + } + + + // Expect ARRAY of TIMESTAMP and LocalDateTime[] to map + ArrayType timestampArrayType1D = + OracleR2dbcTypes.arrayType("TIMESTAMP_ARRAY"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE TIMESTAMP_ARRAY" + + " AS ARRAY(10) OF TIMESTAMP") + .execute()); + ArrayType timestampArrayType2D = + OracleR2dbcTypes.arrayType("TIMESTAMP_ARRAY_2D"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE TIMESTAMP_ARRAY_2D" + + " AS ARRAY(10) OF TIMESTAMP_ARRAY") + .execute()); + ArrayType timestampArrayType3D = + OracleR2dbcTypes.arrayType("TIMESTAMP_ARRAY_3D"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE TIMESTAMP_ARRAY_3D" + + " AS ARRAY(10) OF TIMESTAMP_ARRAY_2D") + .execute()); + try { + + // Expect ARRAY of TIMESTAMP and LocalDateTime[] to map + LocalDateTime[] localDateTimes = { + dateTimeValue.minus(Duration.ofMillis(100)) + .toLocalDateTime(), + dateTimeValue.toLocalDateTime(), + dateTimeValue.plus(Duration.ofMillis(100)) + .toLocalDateTime(), + null + }; + verifyArrayTypeMapping( + connection, + timestampArrayType1D, + timestampArrayType2D, + timestampArrayType3D, + LocalDateTime[].class, + i -> + Arrays.stream(localDateTimes) + .map(localDateTime -> + localDateTime == null + ? null + : localDateTime.plus(Period.ofDays(i))) + .toArray(LocalDateTime[]::new), + true, + Assertions::assertArrayEquals); + + // Expect ARRAY of TIMESTAMP and LocalDate[] to map + LocalDate[] localDates = + Arrays.stream(localDateTimes) + .map(localDateTime -> + localDateTime == null ? null : localDateTime.toLocalDate()) + .toArray(LocalDate[]::new); + verifyArrayTypeMapping( + connection, timestampArrayType1D, timestampArrayType2D, timestampArrayType3D, + LocalDate[].class, + i -> + Arrays.stream(localDates) + .map(localDate -> + localDate == null + ? null + : localDate.plus(Period.ofDays(i))) + .toArray(LocalDate[]::new), + false, + Assertions::assertArrayEquals); + + // Expect ARRAY of TIMESTAMP and LocalTime[] to map + LocalTime[] localTimes = + Arrays.stream(localDateTimes) + .map(localDateTime -> + localDateTime == null ? null : localDateTime.toLocalTime()) + .toArray(LocalTime[]::new); + verifyArrayTypeMapping( + connection, timestampArrayType1D, timestampArrayType2D, timestampArrayType3D, + LocalTime[].class, + i -> + Arrays.stream(localTimes) + .map(localTime -> + localTime == null + ? null + : localTime.plus(Duration.ofMinutes(i))) + .toArray(LocalTime[]::new), + false, + Assertions::assertArrayEquals); + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TYPE TIMESTAMP_ARRAY_3D")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE TIMESTAMP_ARRAY_2D")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE TIMESTAMP_ARRAY")); + } + + ArrayType timestampWithTimeZoneArrayType1D = + OracleR2dbcTypes.arrayType("TIMESTAMP_WITH_TIME_ZONE_ARRAY"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE TIMESTAMP_WITH_TIME_ZONE_ARRAY" + + " AS ARRAY(10) OF TIMESTAMP WITH TIME ZONE") + .execute()); + ArrayType timestampWithTimeZoneArrayType2D = + OracleR2dbcTypes.arrayType("TIMESTAMP_WITH_TIME_ZONE_ARRAY_2D"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE TIMESTAMP_WITH_TIME_ZONE_ARRAY_2D" + + " AS ARRAY(10) OF TIMESTAMP_WITH_TIME_ZONE_ARRAY") + .execute()); + ArrayType timestampWithTimeZoneArrayType3D = + OracleR2dbcTypes.arrayType("TIMESTAMP_WITH_TIME_ZONE_ARRAY_3D"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE TIMESTAMP_WITH_TIME_ZONE_ARRAY_3D" + + " AS ARRAY(10) OF TIMESTAMP_WITH_TIME_ZONE_ARRAY_2D") + .execute()); + try { + // Expect ARRAY of TIMESTAMP WITH TIME ZONE and OffsetDateTime[] to map + OffsetDateTime[] offsetDateTimes = { + dateTimeValue.minus(Duration.ofMillis(100)) + .toLocalDateTime() + .atOffset(ZoneOffset.ofHours(-8)), + dateTimeValue.toLocalDateTime() + .atOffset(ZoneOffset.ofHours(0)), + dateTimeValue.plus(Duration.ofMillis(100)) + .toLocalDateTime() + .atOffset(ZoneOffset.ofHours(8)), + null + }; + verifyArrayTypeMapping( + connection, + timestampWithTimeZoneArrayType1D, + timestampWithTimeZoneArrayType2D, + timestampWithTimeZoneArrayType3D, + OffsetDateTime[].class, + i -> + Arrays.stream(offsetDateTimes) + .map(offsetDateTime -> + offsetDateTime == null + ? null + : offsetDateTime.plus(Duration.ofMinutes(i))) + .toArray(OffsetDateTime[]::new), + true, + Assertions::assertArrayEquals); + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TYPE TIMESTAMP_WITH_TIME_ZONE_ARRAY_3D")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE TIMESTAMP_WITH_TIME_ZONE_ARRAY_2D")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE TIMESTAMP_WITH_TIME_ZONE_ARRAY")); + } + + ArrayType timestampWithLocalTimeZoneArrayType1D = + OracleR2dbcTypes.arrayType("TIMESTAMP_WITH_LOCAL_TIME_ZONE_ARRAY"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE TIMESTAMP_WITH_LOCAL_TIME_ZONE_ARRAY" + + " AS ARRAY(10) OF TIMESTAMP WITH LOCAL TIME ZONE") + .execute()); + ArrayType timestampWithLocalTimeZoneArrayType2D = + OracleR2dbcTypes.arrayType("TIMESTAMP_WITH_LOCAL_TIME_ZONE_ARRAY_2D"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE TIMESTAMP_WITH_LOCAL_TIME_ZONE_ARRAY_2D" + + " AS ARRAY(10) OF TIMESTAMP_WITH_LOCAL_TIME_ZONE_ARRAY") + .execute()); + ArrayType timestampWithLocalTimeZoneArrayType3D = + OracleR2dbcTypes.arrayType("TIMESTAMP_WITH_LOCAL_TIME_ZONE_ARRAY_3D"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE TIMESTAMP_WITH_LOCAL_TIME_ZONE_ARRAY_3D" + + " AS ARRAY(10) OF TIMESTAMP_WITH_LOCAL_TIME_ZONE_ARRAY_2D") + .execute()); + try { + // Expect ARRAY of TIMESTAMP WITH LOCAL TIME ZONE and LocalDateTime[] to + // map + LocalDateTime[] localDateTimes = { + dateTimeValue.minus(Duration.ofMillis(100)) + .toLocalDateTime(), + dateTimeValue.toLocalDateTime(), + dateTimeValue.plus(Duration.ofMillis(100)) + .toLocalDateTime(), + null + }; + verifyArrayTypeMapping( + connection, + timestampWithLocalTimeZoneArrayType1D, + timestampWithLocalTimeZoneArrayType2D, + timestampWithLocalTimeZoneArrayType3D, + LocalDateTime[].class, + i -> + Arrays.stream(localDateTimes) + .map(localDateTime -> + localDateTime == null + ? null + : localDateTime.plus(Period.ofDays(i))) + .toArray(LocalDateTime[]::new), + true, + Assertions::assertArrayEquals); + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TYPE TIMESTAMP_WITH_LOCAL_TIME_ZONE_ARRAY_3D")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE TIMESTAMP_WITH_LOCAL_TIME_ZONE_ARRAY_2D")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE TIMESTAMP_WITH_LOCAL_TIME_ZONE_ARRAY")); + } + + ArrayType intervalYearToMonthArrayType1D = + OracleR2dbcTypes.arrayType("INTERVAL_YEAR_TO_MONTH_ARRAY"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE INTERVAL_YEAR_TO_MONTH_ARRAY" + + " AS ARRAY(10) OF INTERVAL YEAR TO MONTH") + .execute()); + ArrayType intervalYearToMonthArrayType2D = + OracleR2dbcTypes.arrayType("INTERVAL_YEAR_TO_MONTH_ARRAY_2D"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE INTERVAL_YEAR_TO_MONTH_ARRAY_2D" + + " AS ARRAY(10) OF INTERVAL_YEAR_TO_MONTH_ARRAY") + .execute()); + ArrayType intervalYearToMonthArrayType3D = + OracleR2dbcTypes.arrayType("INTERVAL_YEAR_TO_MONTH_ARRAY_3D"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE INTERVAL_YEAR_TO_MONTH_ARRAY_3D" + + " AS ARRAY(10) OF INTERVAL_YEAR_TO_MONTH_ARRAY_2D") + .execute()); + try { + + // Expect ARRAY of INTERVAL YEAR TO MONTH and Period[] to map + Period[] periods = { + Period.of(1, 2, 0), + Period.of(3, 4, 0), + Period.of(5, 6, 0), + null + }; + + verifyArrayTypeMapping( + connection, + intervalYearToMonthArrayType1D, + intervalYearToMonthArrayType2D, + intervalYearToMonthArrayType3D, + Period[].class, + i -> + Arrays.stream(periods) + .map(period -> + period == null + ? null + : period.plus(Period.ofYears(i))) + .toArray(Period[]::new), + true, + Assertions::assertArrayEquals); + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TYPE INTERVAL_YEAR_TO_MONTH_ARRAY_3D")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE INTERVAL_YEAR_TO_MONTH_ARRAY_2D")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE INTERVAL_YEAR_TO_MONTH_ARRAY")); + } + + ArrayType intervalDayToSecondArrayType1D = + OracleR2dbcTypes.arrayType("INTERVAL_DAY_TO_SECOND_ARRAY"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE INTERVAL_DAY_TO_SECOND_ARRAY" + + " AS ARRAY(10) OF INTERVAL DAY TO SECOND") + .execute()); + ArrayType intervalDayToSecondArrayType2D = + OracleR2dbcTypes.arrayType("INTERVAL_DAY_TO_SECOND_ARRAY_2D"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE INTERVAL_DAY_TO_SECOND_ARRAY_2D" + + " AS ARRAY(10) OF INTERVAL_DAY_TO_SECOND_ARRAY") + .execute()); + ArrayType intervalDayToSecondArrayType3D = + OracleR2dbcTypes.arrayType("INTERVAL_DAY_TO_SECOND_ARRAY_3D"); + awaitOne(connection.createStatement( + "CREATE OR REPLACE TYPE INTERVAL_DAY_TO_SECOND_ARRAY_3D" + + " AS ARRAY(10) OF INTERVAL_DAY_TO_SECOND_ARRAY_2D") + .execute()); + try { + Duration[] durations = { + Duration.ofDays(1), + Duration.ofHours(2), + Duration.ofMinutes(3), + Duration.ofSeconds(4), + null + }; + verifyArrayTypeMapping( + connection, + intervalDayToSecondArrayType1D, + intervalDayToSecondArrayType2D, + intervalDayToSecondArrayType3D, + Duration[].class, + i -> + Arrays.stream(durations) + .map(duration -> + duration == null + ? null + : duration.plus(Duration.ofSeconds(i))) + .toArray(Duration[]::new), + true, + Assertions::assertArrayEquals); + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TYPE INTERVAL_DAY_TO_SECOND_ARRAY_3D")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE INTERVAL_DAY_TO_SECOND_ARRAY_2D")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE INTERVAL_DAY_TO_SECOND_ARRAY")); + } + } + finally { + tryAwaitNone(connection.close()); + } + } + + /** + * For an ARRAY type of a given {@code typeName}, verifies the following + * cases: + *
    + *
  • 1 dimensional array mapping to an array of a {@code javaType}
  • + *
  • Mapping when array is empty.
  • + *
  • + * All cases listed above when the ARRAY type is the type of a a two + * dimensional and three dimensional array. + *
  • + *
+ */ + private static void verifyArrayTypeMapping( + Connection connection, + ArrayType arrayType1D, + ArrayType arrayType2D, + ArrayType arrayType3D, + Class javaArrayClass, IntFunction arrayGenerator, + boolean isDefaultMapping, + BiConsumer equalsAssertion) { + + // Verify mapping of 1-dimensional array with values + T javaArray1D = arrayGenerator.apply(0); + verifyTypeMapping( + connection, + Parameters.in(arrayType1D, javaArray1D), + arrayType1D.getName(), + isDefaultMapping + ? row -> row.get(0) + : row -> row.get(0, javaArray1D.getClass()), + (ignored, rowValue) -> + equalsAssertion.accept( + javaArray1D, + assertInstanceOf(javaArrayClass, rowValue))); + + // Verify mapping of an empty 1-dimensional array + @SuppressWarnings("unchecked") + T emptyJavaArray = (T)java.lang.reflect.Array.newInstance( + javaArrayClass.getComponentType(), 0); + verifyTypeMapping( + connection, + Parameters.in(arrayType1D, emptyJavaArray), + arrayType1D.getName(), + isDefaultMapping + ? row -> row.get(0) + : row -> row.get(0, emptyJavaArray.getClass()), + (ignored, rowValue) -> + assertEquals( + 0, + java.lang.reflect.Array.getLength( + assertInstanceOf(javaArrayClass, rowValue)))); + + // Create a 2D Java array + @SuppressWarnings("unchecked") + T[] javaArray2D = + (T[]) java.lang.reflect.Array.newInstance(javaArrayClass, 3); + for (int i = 0; i < javaArray2D.length; i++) { + javaArray2D[i] = arrayGenerator.apply(i); + } + + // Verify mapping of 2-dimensional array with values + verifyTypeMapping( + connection, + Parameters.in(arrayType2D, javaArray2D), + arrayType2D.getName(), + isDefaultMapping + ? row -> row.get(0) + : row -> row.get(0, javaArray2D.getClass()), + (ignored, rowValue) -> + assertArrayEquals( + javaArray2D, + assertInstanceOf(javaArray2D.getClass(), rowValue))); + + // Verify mapping of an empty 2-dimensional array + @SuppressWarnings("unchecked") + T[] emptyJavaArray2D = (T[]) java.lang.reflect.Array.newInstance( + javaArrayClass.getComponentType(), 0, 0); + verifyTypeMapping( + connection, + Parameters.in(arrayType2D, emptyJavaArray2D), + arrayType2D.getName(), + isDefaultMapping + ? row -> row.get(0) + : row -> row.get(0, emptyJavaArray2D.getClass()), + (ignored, rowValue) -> + assertArrayEquals( + emptyJavaArray2D, + assertInstanceOf(emptyJavaArray2D.getClass(), rowValue))); + + // Verify of a 2-dimensional array with empty 1-dimensional arrays + @SuppressWarnings("unchecked") + T[] empty1DJavaArray2D = (T[]) java.lang.reflect.Array.newInstance( + javaArrayClass.getComponentType(), 3, 0); + verifyTypeMapping( + connection, + Parameters.in(arrayType2D, empty1DJavaArray2D), + arrayType2D.getName(), + isDefaultMapping + ? row -> row.get(0) + : row -> row.get(0, empty1DJavaArray2D.getClass()), + (ignored, rowValue) -> + assertArrayEquals( + empty1DJavaArray2D, + assertInstanceOf(empty1DJavaArray2D.getClass(), rowValue))); + + // Create a 3D Java array + @SuppressWarnings("unchecked") + T[][] javaArray3D = + (T[][])java.lang.reflect.Array.newInstance(javaArrayClass, 3, 3); + for (int i = 0; i < javaArray3D.length; i++) { + for (int j = 0; j < javaArray3D[i].length; j++) { + javaArray3D[i][j] = + arrayGenerator.apply((i * javaArray3D[i].length) + j); + } + } + + // Verify mapping of 3-dimensional array with values + verifyTypeMapping( + connection, + Parameters.in(arrayType3D, javaArray3D), + arrayType3D.getName(), + isDefaultMapping + ? row -> row.get(0) + : row -> row.get(0, javaArray3D.getClass()), + (ignored, rowValue) -> + assertArrayEquals( + javaArray3D, + assertInstanceOf(javaArray3D.getClass(), rowValue))); + + // Verify mapping of an empty 2-dimensional array + @SuppressWarnings("unchecked") + T[][] emptyJavaArray3D = (T[][])java.lang.reflect.Array.newInstance( + javaArrayClass.getComponentType(), 0, 0, 0); + verifyTypeMapping( + connection, + Parameters.in(arrayType3D, emptyJavaArray3D), + arrayType3D.getName(), + isDefaultMapping + ? row -> row.get(0) + : row -> row.get(0, emptyJavaArray3D.getClass()), + (ignored, rowValue) -> + assertArrayEquals( + emptyJavaArray3D, + assertInstanceOf(emptyJavaArray3D.getClass(), rowValue))); + + // Verify of a 3-dimensional array with empty 2-dimensional arrays + @SuppressWarnings("unchecked") + T[][] empty2DJavaArray3D = (T[][])java.lang.reflect.Array.newInstance( + javaArrayClass.getComponentType(), 3, 0, 0); + verifyTypeMapping( + connection, + Parameters.in(arrayType3D, empty2DJavaArray3D), + arrayType3D.getName(), + isDefaultMapping + ? row -> row.get(0) + : row -> row.get(0, empty2DJavaArray3D.getClass()), + (ignored, rowValue) -> + assertArrayEquals( + empty2DJavaArray3D, + assertInstanceOf(empty2DJavaArray3D.getClass(), rowValue))); + + // Verify of a 3-dimensional array with empty 1-dimensional arrays + @SuppressWarnings("unchecked") + T[][] empty1DJavaArray3D = (T[][])java.lang.reflect.Array.newInstance( + javaArrayClass.getComponentType(), 3, 3, 0); + verifyTypeMapping( + connection, + Parameters.in(arrayType3D, empty1DJavaArray3D), + arrayType3D.getName(), + isDefaultMapping + ? row -> row.get(0) + : row -> row.get(0, empty1DJavaArray3D.getClass()), + (ignored, rowValue) -> + assertArrayEquals( + empty1DJavaArray3D, + assertInstanceOf(empty1DJavaArray3D.getClass(), rowValue))); + } + // TODO: More tests for JDBC 4.3 mappings like BigInteger to BIGINT, // java.sql.Date to DATE, java.sql.Blob to BLOB? Oracle R2DBC exposes all // type mappings supported by Oracle JDBC. @@ -565,10 +1601,20 @@ private static void verifyTypeMapping( awaitExecution(connection.createStatement(String.format( "CREATE TABLE "+table+" (javaValue %s)", sqlTypeDdl))); - awaitUpdate(asList(1,1), connection.createStatement( - "INSERT INTO "+table+"(javaValue) VALUES(:javaValue)") - .bind("javaValue", javaValue).add() - .bindNull("javaValue", javaValue.getClass())); + Statement insert = connection.createStatement( + "INSERT INTO "+table+"(javaValue) VALUES(:javaValue)") + .bind("javaValue", javaValue) + .add(); + + if (javaValue instanceof Parameter) { + Type type = ((Parameter) javaValue).getType(); + insert.bind("javaValue", Parameters.in(type)); + } + else { + insert.bindNull("javaValue", javaValue.getClass()); + } + + awaitUpdate(asList(1,1), insert); verifyEquals.accept(javaValue, awaitOne(Flux.from(connection.createStatement(