diff --git a/.github/workflows/test.sh b/.github/workflows/test.sh index 4ba9e9b..fed4029 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=240" >> src/test/resources/config.properties +echo "SQL_TIMEOUT=240" >> src/test/resources/config.properties mvn clean compile test diff --git a/README.md b/README.md index 3352062..4cc64e9 100644 --- a/README.md +++ b/README.md @@ -570,7 +570,169 @@ 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)); +} +``` + +### OBJECT +Oracle Database supports `OBJECT` as a user defined type. A `CREATE TYPE` +command is used to define an `OBJECT` type: +```sql +CREATE TYPE PET AS OBJECT( + name VARCHAR(128), + species VARCHAR(128), + weight NUMBER, + birthday DATE) +``` +Oracle R2DBC defines `oracle.r2dbc.OracleR2dbcType.ObjectType` as a `Type` for +representing user defined `OBJECT` types. A `Parameter` with a type of +`ObjectType` may be used to bind `OBJECT` values to a `Statement`. + +Use an `Object[]` to bind the attribute values of an `OBJECT` by index: +```java +Publisher objectArrayBindExample(Connection connection) { + Statement statement = + connection.createStatement("INSERT INTO petTable VALUES (:petObject)"); + + // Bind the attributes of the PET OBJECT defined above + ObjectType objectType = OracleR2dbcTypes.objectType("PET"); + Object[] attributeValues = { + "Derby", + "Dog", + 22.8, + LocalDate.of(2015, 11, 07) + }; + statement.bind("petObject", Parameters.in(objectType, attributeValues)); + + return statement.execute(); +} +``` + +Use a `Map` to bind the attribute values of an `OBJECT` by name: +```java +Publisher objectMapBindExample(Connection connection) { + Statement statement = + connection.createStatement("INSERT INTO petTable VALUES (:petObject)"); + + // Bind the attributes of the PET OBJECT defined above + ObjectType objectType = OracleR2dbcTypes.objectType("PET"); + Map attributeValues = Map.of( + "name", "Derby", + "species", "Dog", + "weight", 22.8, + "birthday", LocalDate.of(2015, 11, 07)); + statement.bind("petObject", Parameters.in(objectType, attributeValues)); + + return statement.execute(); +} +``` +A `Parameter` with a type of `ObjectType` must be used when binding OUT +parameters of `OBJECT` types for a PL/SQL call: +```java +Publisher objectOutBindExample(Connection connection) { + Statement statement = + connection.createStatement("BEGIN; getPet(:petObject); END;"); + + ObjectType objectType = OracleR2dbcTypes.objectType("PET"); + statement.bind("petObject", Parameters.out(objectType)); + + return statement.execute(); +} +``` +`OBJECT` values may be consumed from a `Row` or `OutParameter` as an +`oracle.r2dbc.OracleR2dbcObject`. The `OracleR2dbcObject` interface is a subtype +of `io.r2dbc.spi.Readable`. Attribute values may be accessed using the standard +`get` methods of `Readable`. The `get` methods of `OracleR2dbcObject` support +alll SQL to Java type mappings defined by the +[R2DBC Specification](https://r2dbc.io/spec/1.0.0.RELEASE/spec/html/#datatypes.mapping): +```java +Publisher objectMapExample(Result result) { + return result.map(row -> { + OracleR2dbcObject oracleObject = row.get(0, OracleR2dbcObject.class); + return new Pet( + oracleObject.get("name", String.class), + oracleObject.get("species", String.class), + oracleObject.get("weight", Float.class), + oracleObject.get("birthday", LocalDate.class)); + }); +} +``` + +Instances of `OracleR2dbcObject` may be passed directly to `Statement` bind +methods: +```java +Publisher objectBindExample( + OracleR2dbcObject oracleObject, Connection connection) { + Statement statement = + connection.createStatement("INSERT INTO petTable VALUES (:petObject)"); + + statement.bind("petObject", oracleObject); + + return statement.execute(); +} +``` +Attribute metadata is exposed by the `getMetadata` method of +`OracleR2dbcObject`: +```java +void printObjectMetadata(OracleR2dbcObject oracleObject) { + OracleR2dbcObjectMetadata metadata = oracleObject.getMetadata(); + OracleR2dbcTypes.ObjectType objectType = metadata.getObjectType(); + + System.out.println("Object Type: " + objectType); + metadata.getAttributeMetadatas() + .stream() + .forEach(attributeMetadata -> { + System.out.println("\tAttribute Name: " + attributeMetadata.getName())); + System.out.println("\tAttribute Type: " + attributeMetadata.getType())); + }); +} +``` + +### 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/OracleR2dbcObject.java b/src/main/java/oracle/r2dbc/OracleR2dbcObject.java new file mode 100644 index 0000000..388c56e --- /dev/null +++ b/src/main/java/oracle/r2dbc/OracleR2dbcObject.java @@ -0,0 +1,7 @@ +package oracle.r2dbc; + +public interface OracleR2dbcObject extends io.r2dbc.spi.Readable { + + OracleR2dbcObjectMetadata getMetadata(); + +} diff --git a/src/main/java/oracle/r2dbc/OracleR2dbcObjectMetadata.java b/src/main/java/oracle/r2dbc/OracleR2dbcObjectMetadata.java new file mode 100644 index 0000000..ee8535c --- /dev/null +++ b/src/main/java/oracle/r2dbc/OracleR2dbcObjectMetadata.java @@ -0,0 +1,49 @@ +package oracle.r2dbc; + +import io.r2dbc.spi.ReadableMetadata; + +import java.util.List; +import java.util.NoSuchElementException; + +/** + * Represents the metadata for attributes of an OBJECT. Metadata for attributes + * can either be retrieved by index or by name. Attribute indexes are + * {@code 0}-based. Retrieval by attribute name is case-insensitive. + */ +public interface OracleR2dbcObjectMetadata { + + /** + * Returns the type of the OBJECT which metadata is provided for. + * @return The type of the OBJECT. Not null. + */ + OracleR2dbcTypes.ObjectType getObjectType(); + + /** + * Returns the {@link ReadableMetadata} for one attribute. + * + * @param index the attribute index starting at 0 + * @return the {@link ReadableMetadata} for one attribute. Not null. + * @throws IndexOutOfBoundsException if {@code index} is out of range + * (negative or equals/exceeds {@code getParameterMetadatas().size()}) + */ + ReadableMetadata getAttributeMetadata(int index); + + /** + * Returns the {@link ReadableMetadata} for one attribute. + * + * @param name the name of the attribute. Not null. Parameter names are + * case-insensitive. + * @return the {@link ReadableMetadata} for one attribute. Not null. + * @throws IllegalArgumentException if {@code name} is {@code null} + * @throws NoSuchElementException if there is no attribute with the + * {@code name} + */ + ReadableMetadata getAttributeMetadata(String name); + + /** + * Returns the {@link ReadableMetadata} for all attributes. + * + * @return the {@link ReadableMetadata} for all attributes. Not null. + */ + List getAttributeMetadatas(); +} diff --git a/src/main/java/oracle/r2dbc/OracleR2dbcTypes.java b/src/main/java/oracle/r2dbc/OracleR2dbcTypes.java index 08c6c56..2639f42 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,144 @@ 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")); + } + + public static ObjectType objectType(String name) { + return new ObjectTypeImpl(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(); + } + + public interface ObjectType 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}. {@code Object[]} is + * the default mapping of the constructed type. 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); + } + } + + /** Concrete implementation of the {@code ObjectType} interface */ + private static final class ObjectTypeImpl + extends TypeImpl implements ObjectType { + + /** + * Constructs an OBJECT type with the given {@code name}. + * {@code OracleR2dbcObject} is the default mapping of the constructed type. + * @param name User defined name of the type. Not null. + */ + ObjectTypeImpl(String name) { + super(OracleR2dbcObject.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. @@ -163,6 +301,19 @@ public String getName() { public String toString() { return getName(); } + + @Override + public boolean equals(Object other) { + if (! (other instanceof Type)) + return false; + + return sqlName.equals(((Type)other).getName()); + } + + @Override + public int hashCode() { + return sqlName.hashCode(); + } } } diff --git a/src/main/java/oracle/r2dbc/impl/OracleR2dbcExceptions.java b/src/main/java/oracle/r2dbc/impl/OracleR2dbcExceptions.java index f638ac9..dd4b7bb 100755 --- a/src/main/java/oracle/r2dbc/impl/OracleR2dbcExceptions.java +++ b/src/main/java/oracle/r2dbc/impl/OracleR2dbcExceptions.java @@ -81,6 +81,7 @@ static T requireNonNull(T obj, String message) { return obj; } + /** * Checks if a {@code jdbcConnection} is open, and throws an exception if the * check fails. diff --git a/src/main/java/oracle/r2dbc/impl/OracleReadableImpl.java b/src/main/java/oracle/r2dbc/impl/OracleReadableImpl.java index 4d4b56c..02490cd 100755 --- a/src/main/java/oracle/r2dbc/impl/OracleReadableImpl.java +++ b/src/main/java/oracle/r2dbc/impl/OracleReadableImpl.java @@ -26,24 +26,58 @@ import io.r2dbc.spi.OutParameters; import io.r2dbc.spi.OutParametersMetadata; import io.r2dbc.spi.R2dbcException; - import io.r2dbc.spi.R2dbcType; +import io.r2dbc.spi.ReadableMetadata; 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.jdbc.OracleStruct; +import oracle.r2dbc.OracleR2dbcObject; +import oracle.r2dbc.OracleR2dbcObjectMetadata; import oracle.r2dbc.OracleR2dbcTypes; import oracle.r2dbc.impl.ReactiveJdbcAdapter.JdbcReadable; +import oracle.r2dbc.impl.ReadablesMetadata.OracleR2dbcObjectMetadataImpl; import oracle.r2dbc.impl.ReadablesMetadata.OutParametersMetadataImpl; import oracle.r2dbc.impl.ReadablesMetadata.RowMetadataImpl; +import oracle.sql.INTERVALDS; +import oracle.sql.INTERVALYM; +import oracle.sql.TIMESTAMPLTZ; +import oracle.sql.TIMESTAMPTZ; +import java.math.BigDecimal; import java.nio.ByteBuffer; +import java.sql.Array; import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Struct; 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.OffsetTime; +import java.time.Period; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.List; +import java.util.Map; import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.TreeMap; +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 +92,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 +102,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 +117,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 +140,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 +149,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 +171,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); } /** @@ -232,7 +281,18 @@ else if (Object.class.equals(type)) { : convert(index, defaultType); } else { - value = jdbcReadable.getObject(index, type); + Type sqlType = readablesMetadata.get(index).getType(); + + if (sqlType instanceof OracleR2dbcTypes.ArrayType) { + value = getOracleArray(index, type); + } + else if (sqlType instanceof OracleR2dbcTypes.ObjectType) { + value = getOracleObject(index, type); + } + else { + // Fallback to a built-in conversion supported by Oracle JDBC + value = jdbcReadable.getObject(index, type); + } } return type.cast(value); @@ -330,6 +390,8 @@ private Clob getClob(int index) { * value is NULL. */ private LocalDateTime getLocalDateTime(int index) { + return jdbcReadable.getObject(index, LocalDateTime.class); + /* if (OracleR2dbcTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE .equals(readablesMetadata.get(index).getType())) { // TODO: Remove this when Oracle JDBC implements a correct conversion @@ -339,6 +401,528 @@ private LocalDateTime getLocalDateTime(int index) { else { return jdbcReadable.getObject(index, LocalDateTime.class); } + */ + } + + /** + * Returns an ARRAY at the given {@code index} as a specified {@code type}. + * This method supports conversions to Java arrays of the default Java type + * mapping for the ARRAY's element type. This conversion is the standard type + * mapping for a COLLECTION, as defined by the R2DBC Specification. + */ + private T getOracleArray(int index, Class type) { + if (type.isArray()) { + return type.cast(getJavaArray(index, type.getComponentType())); + } + else { + // Fallback to a built-in conversion supported by Oracle JDBC + return jdbcReadable.getObject(index, type); + } + } + + /** + *

+ * 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 unsupportedConversion(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); + } + + return mapArray( + array, + length -> (T[]) java.lang.reflect.Array.newInstance(desiredType, length), + (Function)getMappingFunction(elementType, desiredType)); + } + + /** + * Converts the OBJECT column at a given {@code index} to the specified + * {@code type}. For symmetry with the object types supported by Statement + * bind methods, this method supports conversions to + * {@code OracleR2dbcObject}, {@code Object[]}, or + * {@code Map}. + */ + private T getOracleObject(int index, Class type) { + + if (type.isAssignableFrom(OracleR2dbcObject.class)) { + // Support conversion to OracleR2dbcObject by default + return type.cast(getOracleR2dbcObject(index)); + } + else if (type.isAssignableFrom(Object[].class)) { + // Support conversion to Object[] for symmetry with bind types + return type.cast(toArray(getOracleR2dbcObject(index))); + } + else if (type.isAssignableFrom(Map.class)) { + // Support conversion to Map for symmetry with bind + // types + return type.cast(toMap(getOracleR2dbcObject(index))); + } + else { + // Fallback to a built-in conversion of Oracle JDBC + return jdbcReadable.getObject(index, type); + } + } + + /** + * Returns the OBJECT at a given {@code index} as an {@code OracleR2dbcObject} + */ + private OracleR2dbcObjectImpl getOracleR2dbcObject(int index) { + + OracleStruct oracleStruct = + jdbcReadable.getObject(index, OracleStruct.class); + + if (oracleStruct == null) + return null; + + return new OracleR2dbcObjectImpl( + jdbcConnection, + dependentCounter, + new StructJdbcReadable(oracleStruct), + ReadablesMetadata.createAttributeMetadata(oracleStruct), + adapter); + } + + /** + * Returns of an array with the default mapping of each value in a + * {@code readable} + */ + private static Object[] toArray(OracleReadableImpl readable) { + if (readable == null) + return null; + + Object[] array = + new Object[readable.readablesMetadata.getList().size()]; + + for (int i = 0; i < array.length; i++) + array[i] = readable.get(i); + + return array; + } + + /** + * Returns a map containing the default mapping of each value in an + * {@code object}, keyed to the value's name. + */ + private static Map toMap(OracleReadableImpl readable) { + if (readable == null) + return null; + + List metadataList = + readable.readablesMetadata.getList(); + + Map map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + + for (int i = 0; i < metadataList.size(); i++) + map.put(metadataList.get(i).getName(), readable.get(i)); + + return map; + } + + private Function getMappingFunction( + Class fromType, Class toType) { + + Function mappingFunction = null; + + if (toType.isAssignableFrom(fromType)) { + return toType::cast; + } + else if (fromType.equals(BigDecimal.class)) { + if (toType.equals(Boolean.class)) { + mappingFunction = (BigDecimal bigDecimal) -> + bigDecimal.shortValue() != 0; + } + else if (toType.equals(Byte.class)) { + mappingFunction = (BigDecimal bigDecimal) -> bigDecimal.byteValue(); + } + else if (toType.equals(Short.class)) { + mappingFunction = (BigDecimal bigDecimal) -> bigDecimal.shortValue(); + } + else if (toType.equals(Integer.class)) { + mappingFunction = (BigDecimal bigDecimal) -> bigDecimal.intValue(); + } + else if (toType.equals(Long.class)) { + mappingFunction = (BigDecimal bigDecimal) -> bigDecimal.longValue(); + } + else if (toType.equals(Float.class)) { + mappingFunction = (BigDecimal bigDecimal) -> bigDecimal.floatValue(); + } + else if (toType.equals(Double.class)) { + mappingFunction = (BigDecimal bigDecimal) -> bigDecimal.doubleValue(); + } + } + else if (byte[].class.equals(fromType) + && toType.isAssignableFrom(ByteBuffer.class)) { + mappingFunction = (byte[] byteArray) -> ByteBuffer.wrap(byteArray); + } + else if (Timestamp.class.isAssignableFrom(fromType)) { + // 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 (toType.isAssignableFrom(LocalDateTime.class)) { + mappingFunction = (Timestamp timeStamp) -> timeStamp.toLocalDateTime(); + } + else if (toType.isAssignableFrom(LocalDate.class)) { + mappingFunction = (Timestamp timeStamp) -> + timeStamp.toLocalDateTime().toLocalDate(); + } + else if (toType.isAssignableFrom(LocalTime.class)) { + mappingFunction = (Timestamp timeStamp) -> + timeStamp.toLocalDateTime().toLocalTime(); + } + } + else if (OffsetDateTime.class.isAssignableFrom(fromType)) { + // This branch handles mapping from TIMESTAMP WITH LOCAL TIME ZONE values. + // Oracle JDBC maps these to OffsetDateTime. Oracle R2DBC defines + // LocalDateTime as their default mapping. + if (toType.isAssignableFrom(OffsetTime.class)) { + mappingFunction = (OffsetDateTime offsetDateTime) -> + offsetDateTime.toOffsetTime(); + } + else if (toType.isAssignableFrom(LocalDateTime.class)) { + mappingFunction = (OffsetDateTime offsetDateTime) -> + offsetDateTime.toLocalDateTime(); + } + else if (toType.isAssignableFrom(LocalDate.class)) { + mappingFunction = (OffsetDateTime offsetDateTime) -> + offsetDateTime.toLocalDate(); + } + else if (toType.isAssignableFrom(LocalTime.class)) { + mappingFunction = (OffsetDateTime offsetDateTime) -> + offsetDateTime.toLocalTime(); + } + } + else if (TIMESTAMPTZ.class.isAssignableFrom(fromType)) { + if (toType.isAssignableFrom(OffsetDateTime.class)) { + mappingFunction = (TIMESTAMPTZ timestampWithTimeZone) -> + fromJdbc(() -> timestampWithTimeZone.toOffsetDateTime()); + } + else if (toType.isAssignableFrom(OffsetTime.class)) { + mappingFunction = (TIMESTAMPTZ timestampWithTimeZone) -> + fromJdbc(() -> timestampWithTimeZone.toOffsetTime()); + } + else if (toType.isAssignableFrom(LocalDateTime.class)) { + mappingFunction = (TIMESTAMPTZ timestampWithTimeZone) -> + fromJdbc(() -> timestampWithTimeZone.toLocalDateTime()); + } + } + else if (TIMESTAMPLTZ.class.isAssignableFrom(fromType)) { + if (toType.isAssignableFrom(OffsetDateTime.class)) { + mappingFunction = (TIMESTAMPLTZ timestampWithLocalTimeZone) -> + fromJdbc(() -> + timestampWithLocalTimeZone.offsetDateTimeValue(jdbcConnection)); + } + else if (toType.isAssignableFrom(OffsetTime.class)) { + mappingFunction = (TIMESTAMPLTZ timestampWithLocalTimeZone) -> + fromJdbc(() -> + timestampWithLocalTimeZone.offsetTimeValue(jdbcConnection)); + } + else if (toType.isAssignableFrom(LocalDateTime.class)) { + mappingFunction = (TIMESTAMPLTZ timestampWithLocalTimeZone) -> + fromJdbc(() -> + timestampWithLocalTimeZone.localDateTimeValue(jdbcConnection)); + } + } + else if (INTERVALYM.class.isAssignableFrom(fromType) + && toType.isAssignableFrom(Period.class)) { + mappingFunction = (INTERVALYM 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 (INTERVALDS.class.isAssignableFrom(fromType) + && toType.isAssignableFrom(Duration.class)) { + mappingFunction = (INTERVALDS 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 (java.sql.Blob.class.isAssignableFrom(fromType) + && byte[].class.equals(toType)) { + mappingFunction = (java.sql.Blob blob) -> + fromJdbc(() -> blob.getBytes(1L, Math.toIntExact(blob.length()))); + } + else if (java.sql.Clob.class.isAssignableFrom(fromType) + && String.class.isAssignableFrom(toType)) { + mappingFunction = (java.sql.Clob clob) -> + fromJdbc(() -> clob.getSubString(1L, Math.toIntExact(clob.length()))); + } + else if (OracleArray.class.isAssignableFrom(fromType) + && toType.isArray()) { + // Recursively convert a multi-dimensional array. + mappingFunction = (OracleArray oracleArray) -> + convertOracleArray(oracleArray, toType.getComponentType()); + } + + if (mappingFunction == null) + throw unsupportedConversion(fromType, toType); + + @SuppressWarnings("unchecked") + Function typedMappingFunction = (Function) mappingFunction; + return typedMappingFunction; + } + + /** + * Returns an exception indicating that conversion between from one Java type + * to another is not supported. + */ + private static IllegalArgumentException unsupportedConversion( + Class fromType, Class toType) { + return new IllegalArgumentException(format( + "Conversion from %s to %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; } /** @@ -406,15 +990,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 +1033,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; } @@ -465,4 +1053,73 @@ public OutParametersMetadata getMetadata() { } } + private final class OracleR2dbcObjectImpl + extends OracleReadableImpl implements OracleR2dbcObject { + + private final OracleR2dbcObjectMetadata metadata; + + /** + *

+ * Constructs a new set of out parameters that supplies values of a + * {@code jdbcReadable} and obtains metadata of the values from + * {@code outParametersMetaData}. + *

+ * @param jdbcConnection JDBC connection that created the + * {@code jdbcReadable}. Not null. + * @param structJdbcReadable 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 OracleR2dbcObjectImpl( + java.sql.Connection jdbcConnection, + DependentCounter dependentCounter, + StructJdbcReadable structJdbcReadable, + OracleR2dbcObjectMetadataImpl metadata, + ReactiveJdbcAdapter adapter) { + super( + jdbcConnection, dependentCounter, structJdbcReadable, metadata, adapter); + this.metadata = metadata; + } + + @Override + public OracleR2dbcObjectMetadata getMetadata() { + return metadata; + } + + @Override + public String toString() { + return format( + "%s = %s", + metadata.getObjectType().getName(), + toMap(this)); + } + } + + /** A {@code JdbcReadable} backed by a java.sql.Struct */ + private final class StructJdbcReadable implements JdbcReadable { + + /** Attributes of the Struct, mapped to their default Java type for JDBC */ + private final Object[] attributes; + + private StructJdbcReadable(Struct struct) { + attributes = fromJdbc(struct::getAttributes); + } + + @Override + public T getObject(int index, Class type) { + Object attribute = attributes[index]; + + if (attribute == null) + return null; + + if (type.isInstance(attribute)) + return type.cast(attribute); + + @SuppressWarnings("unchecked") + Function mappingFunction = + (Function) getMappingFunction(attribute.getClass(), type); + return mappingFunction.apply(attribute); + } + } + } diff --git a/src/main/java/oracle/r2dbc/impl/OracleReadableMetadataImpl.java b/src/main/java/oracle/r2dbc/impl/OracleReadableMetadataImpl.java index a5b9996..ecd3e06 100755 --- a/src/main/java/oracle/r2dbc/impl/OracleReadableMetadataImpl.java +++ b/src/main/java/oracle/r2dbc/impl/OracleReadableMetadataImpl.java @@ -23,6 +23,7 @@ import java.sql.ResultSetMetaData; import java.sql.SQLType; +import java.sql.Types; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.OffsetDateTime; @@ -34,6 +35,7 @@ import io.r2dbc.spi.R2dbcType; import io.r2dbc.spi.ReadableMetadata; import io.r2dbc.spi.Type; +import oracle.jdbc.OracleTypes; import oracle.r2dbc.OracleR2dbcTypes; import static oracle.r2dbc.impl.OracleR2dbcExceptions.fromJdbc; @@ -249,6 +251,28 @@ public Type getType() { return type; } + @Override + public boolean equals(Object other) { + if (!(other instanceof ReadableMetadata)) + return false; + + ReadableMetadata otherMetadata = (ReadableMetadata) other; + return Objects.equals(type, otherMetadata.getType()) + && Objects.equals(name, otherMetadata.getName()) + && Objects.equals(nullability, otherMetadata.getNullability()) + && Objects.equals(precision, otherMetadata.getPrecision()) + && Objects.equals(scale, otherMetadata.getScale()); + } + + @Override + public int hashCode() { + return Objects.hash( + type, + name, + nullability, + precision, + scale); + } /** * Creates {@code ColumnMetadata} for an out parameter. The returned @@ -261,8 +285,7 @@ public Type getType() { * @param type Column type * @return Column metadata having {@code name} and {@code type} */ - static OutParameterMetadata createParameterMetadata( - String name, Type type) { + static OutParameterMetadata createParameterMetadata(String name, Type type) { return new OracleOutParameterMetadataImpl( type, name, Nullability.NULLABLE, null, null); } @@ -286,8 +309,7 @@ static ColumnMetadata createColumnMetadata( int jdbcIndex = index + 1; - Type type = toR2dbcType(fromJdbc(() -> - resultSetMetaData.getColumnType(jdbcIndex))); + Type type = getR2dbcType(jdbcIndex, resultSetMetaData); String name = fromJdbc(() -> resultSetMetaData.getColumnName(jdbcIndex)); @@ -319,18 +341,25 @@ else if (type == R2dbcType.TIMESTAMP || type == OracleR2dbcTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE) { // For the TIMESTAMP types, use the length of LocalDateTime.toString() as // the precision. Use the scale from JDBC, even if it's 0 because a - // TIMESTAMP may 0 decimal digits. + // TIMESTAMP may 0 decimal digits. Oracle JDBC does not support getScale + // for struct metadata, so ignore the value it returns (which is 0). return new OracleColumnMetadataImpl(type, name, nullability, LOCAL_DATE_TIME_PRECISION, - fromJdbc(() -> resultSetMetaData.getScale(jdbcIndex))); + resultSetMetaData instanceof oracle.jdbc.StructMetaData + ? null + : fromJdbc(() -> resultSetMetaData.getScale(jdbcIndex))); } else if (type == R2dbcType.TIMESTAMP_WITH_TIME_ZONE) { // For the TIMESTAMP WITH TIMEZONE types, use the length of // OffsetDateTime.toString() as the precision. Use the scale from JDBC, - // even if it's 0 because a TIMESTAMP may have 0 decimal digits. + // even if it's 0 because a TIMESTAMP may have 0 decimal digits. Oracle + // JDBC does not support getScale for struct metadata, so ignore the value + // it returns (which is 0). return new OracleColumnMetadataImpl(type, name, nullability, OFFSET_DATE_TIME_PRECISION, - fromJdbc(() -> resultSetMetaData.getScale(jdbcIndex))); + resultSetMetaData instanceof oracle.jdbc.StructMetaData + ? null + : fromJdbc(() -> resultSetMetaData.getScale(jdbcIndex))); } else if (type == R2dbcType.VARBINARY) { // Oracle JDBC implements getColumnDisplaySize to return the @@ -366,6 +395,37 @@ else if (type == R2dbcType.VARBINARY) { } } + /** + * Returns the type of the column at a 1-based {@code index}. This method + * handles the case where the column is a named type, like ARRAY or OBJECT. + */ + private static Type getR2dbcType( + int index, ResultSetMetaData resultSetMetaData) { + int jdbcType = fromJdbc(() -> resultSetMetaData.getColumnType(index)); + switch (jdbcType) { + case java.sql.Types.ARRAY: + return OracleR2dbcTypes.arrayType(fromJdbc(() -> + resultSetMetaData.getColumnTypeName(index))); + case java.sql.Types.STRUCT: + return OracleR2dbcTypes.objectType(fromJdbc(() -> + resultSetMetaData.getColumnTypeName(index))); + case Types.BINARY: + // Oracle JDBC's struct metadata returns BINARY. VARBINARY is the + // correct type to describe Oracle's RAW type. + return R2dbcType.VARBINARY; + case Types.DATE: + // Oracle JDBC's struct metadata returns DATE. TIMESTAMP is the correct + // type to describe Oracle's DATE type. + return R2dbcType.TIMESTAMP; + case OracleTypes.TIMESTAMPTZ: + // Oracle JDBC's struct metadata returns a proprietary type code. Use + // the standard type. + return R2dbcType.TIMESTAMP_WITH_TIME_ZONE; + default: + return SqlTypeMap.toR2dbcType(jdbcType); + } + } + /** * Maps the {@code int} constant returned by * {@link ResultSetMetaData#isNullable(int)} to the equivalent 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..42e575e 100755 --- a/src/main/java/oracle/r2dbc/impl/OracleStatementImpl.java +++ b/src/main/java/oracle/r2dbc/impl/OracleStatementImpl.java @@ -21,19 +21,34 @@ package oracle.r2dbc.impl; +import io.r2dbc.spi.Blob; +import io.r2dbc.spi.Clob; import io.r2dbc.spi.OutParameterMetadata; import io.r2dbc.spi.Parameter; import io.r2dbc.spi.R2dbcException; +import io.r2dbc.spi.R2dbcType; +import io.r2dbc.spi.ReadableMetadata; import io.r2dbc.spi.Result; import io.r2dbc.spi.Statement; import io.r2dbc.spi.Type; +import oracle.jdbc.OracleConnection; +import oracle.jdbc.OracleStruct; +import oracle.r2dbc.OracleR2dbcObject; +import oracle.r2dbc.OracleR2dbcObjectMetadata; +import oracle.r2dbc.OracleR2dbcTypes; +import oracle.r2dbc.OracleR2dbcTypes.ObjectType; import oracle.r2dbc.impl.ReactiveJdbcAdapter.JdbcReadable; import oracle.r2dbc.impl.ReadablesMetadata.OutParametersMetadataImpl; +import oracle.sql.DATE; +import oracle.sql.INTERVALDS; +import oracle.sql.INTERVALYM; +import oracle.sql.TIMESTAMP; 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; @@ -42,20 +57,30 @@ import java.sql.SQLException; import java.sql.SQLType; import java.sql.SQLWarning; +import java.sql.Struct; import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.Period; import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.NoSuchElementException; import java.util.Queue; +import java.util.Set; +import java.util.TreeMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; +import java.util.stream.Collectors; import java.util.stream.IntStream; +import static java.lang.String.format; import static java.sql.Statement.CLOSE_ALL_RESULTS; import static java.sql.Statement.KEEP_CURRENT_RESULT; import static java.sql.Statement.RETURN_GENERATED_KEYS; @@ -321,7 +346,7 @@ public Statement bind(String identifier, Object value) { @Override public Statement bindNull(int index, Class type) { requireOpenConnection(jdbcConnection); - requireNonNull(type, "type is null"); + requireSupportedNullClass(type); requireValidIndex(index); bindObject(index, null); return this; @@ -367,7 +392,7 @@ public Statement bindNull(int index, Class type) { public Statement bindNull(String identifier, Class type) { requireOpenConnection(jdbcConnection); requireNonNull(identifier, "identifier is null"); - requireNonNull(type, "type is null"); + requireSupportedNullClass(type); bindNamedParameter(identifier, null); return this; } @@ -727,18 +752,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 +865,44 @@ 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); + } + + /** + * Checks that a class is supported as a null bind type. Oracle JDBC + * requires a type name when binding a null value for a user defined type, so + * this method checks for that. + * @param type Class type to check. May be null. + * @throws IllegalArgumentException If the class type is null or is the + * default mapping for a named type. + */ + private static void requireSupportedNullClass(Class type) { + requireNonNull(type, "type is null"); + + if (OracleR2dbcObject.class.isAssignableFrom(type)) { + throw new IllegalArgumentException( + "A type name is required for NULL OBJECT binds. Use: " + + "bind(Parameters.in(OracleR2dbcTypes.objectType(typeName)))"); + } + + // For backwards compatibility, allow Class for NULL RAW binds + if (type.isArray() && !byte[].class.equals(type)) { + throw new IllegalArgumentException( + "A type name is required for NULL ARRAY binds. Use: " + + "bind(Parameters.in(OracleR2dbcTypes.arrayType(typeName)))"); + } + } + /** * Returns an exception indicating that it is not possible to execute a * statement that returns both out-parameters and generated values. There @@ -989,7 +1042,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 +1067,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 +1096,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 +1334,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); + } }); } @@ -1252,9 +1379,6 @@ private Object convertBind(Object value) { if (value == null || value == NULL_BIND) { return null; } - else if (value instanceof Parameter) { - return convertBind(((Parameter) value).getValue()); - } else if (value instanceof io.r2dbc.spi.Blob) { return convertBlobBind((io.r2dbc.spi.Blob) value); } @@ -1264,11 +1388,467 @@ else if (value instanceof io.r2dbc.spi.Clob) { else if (value instanceof ByteBuffer) { return convertByteBufferBind((ByteBuffer) value); } + else if (value instanceof Parameter) { + return convertParameterBind((Parameter) value); + } + else if (value instanceof OracleR2dbcObject) { + return convertObjectBind((OracleR2dbcObject) value); + } else { return value; } } + /** 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 if (type instanceof ObjectType) { + return convertObjectBind((ObjectType) type, value); + } + else { + return convertBind(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 multidimensional 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 a given {@code object} to a JDBC {@link Struct} of OBJECT type. + */ + private Object convertObjectBind(OracleR2dbcObject object) { + return convertObjectBind( + object.getMetadata().getObjectType(), + object); + } + + /** + * Converts a given {@code value} to a JDBC {@link Struct} of the given + * OBJECT {@code type}. + */ + private Object convertObjectBind(ObjectType type, Object value) { + + if (value == null) + return null; + + final Object[] attributes; + + OracleR2dbcObjectMetadata metadata = + ReadablesMetadata.createAttributeMetadata(fromJdbc(() -> + (OracleStruct)jdbcConnection.createStruct(type.getName(), null))); + + if (value instanceof Object[]) { + attributes = toAttributeArray((Object[])value, metadata); + } + else if (value instanceof Map) { + @SuppressWarnings("unchecked") + Map valueMap = (Map)value; + attributes = toAttributeArray(valueMap, metadata); + } + else if (value instanceof io.r2dbc.spi.Readable) { + attributes = toAttributeArray((io.r2dbc.spi.Readable)value, metadata); + } + else { + // Fallback to a built-in conversion supported by Oracle JDBC + return value; + } + + Publisher conversionPublisher = + convertUdtValues(attributes, metadata); + + OracleR2dbcExceptions.JdbcSupplier structSupplier = () -> + jdbcConnection.unwrap(OracleConnection.class) + .createStruct(type.getName(), attributes); + + if (conversionPublisher == null) { + return structSupplier.get(); + } + else { + return Mono.from(conversionPublisher) + .then(Mono.fromSupplier(structSupplier)); + } + } + + /** + * Copies values of an {@code Object[]} from user code into an + * {@code Object[]} of a length equal to the number of attributes in an + * OBJECT described by {@code metadata}. + * @throws IllegalArgumentException If the length of the array from user + * code is not equal to the number of attributes in the OBJECT type. + */ + private Object[] toAttributeArray( + Object[] values, OracleR2dbcObjectMetadata metadata) { + + List metadatas = + metadata.getAttributeMetadatas(); + + if (values.length != metadatas.size()) { + throw new IllegalArgumentException(format( + "Length of Object[]: %d, does not match the number of attributes" + + " in OBJECT %s: %d", + values.length, + metadata.getObjectType().getName(), + metadatas.size())); + } + + return values.clone(); + } + + /** + * Copies values of a {@code Map} from user code into an {@code Object[]} + * having a length equal to the number of attributes in an OBJECT described + * by {@code metadata}. + * @throws IllegalArgumentException If the keys of the Map do not match the + * attribute names of the OBJECT. + */ + private Object[] toAttributeArray( + Map values, OracleR2dbcObjectMetadata metadata) { + + TreeMap treeMap = + new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + treeMap.putAll(values); + + List metadatas = + metadata.getAttributeMetadatas(); + + Set remainingNames = treeMap.keySet(); + Object[] attributes = new Object[metadatas.size()]; + for (int i = 0; i < attributes.length; i++) { + String attributeName = metadatas.get(i).getName(); + Object attribute = treeMap.get(attributeName); + + if (attribute == null && !treeMap.containsKey(attributeName)) { + throw new IllegalArgumentException(format( + "Map contains no key for attribute %s of OBJECT %s", + attributeName, + metadata.getObjectType().getName())); + } + else { + remainingNames.remove(attributeName); + } + + attributes[i] = attribute; + } + + if (!remainingNames.isEmpty()) { + throw new IllegalArgumentException(format( + "Map contains keys: [%s], which do not match any attribute" + + " names of OBJECT %s: [%s]", + String.join(",", remainingNames), + metadata.getObjectType().getName(), + metadatas.stream() + .map(ReadableMetadata::getName) + .collect(Collectors.joining(",")))); + } + + return attributes; + } + + /** + * Copies values of an {@code io.r2dbc.spi.Readable} from user code into an + * {@code Object[]} of a length equal to the number of attributes in an + * OBJECT described by {@code metadata}. + * @implNote This method does not require the Readable to be an + * {@code OracleR2dbcObject}. This is done to allow mapping of row or out + * parameter values into OBJECT values. + * @throws IllegalArgumentException If the number of values in the Readable + * is not equal to the number of attributes in the OBJECT type. + */ + private Object[] toAttributeArray( + io.r2dbc.spi.Readable readable, OracleR2dbcObjectMetadata metadata) { + + Object[] attributes = new Object[metadata.getAttributeMetadatas().size()]; + + for (int i = 0; i < attributes.length; i++) { + try { + attributes[i] = readable.get(i); + } + catch (IndexOutOfBoundsException indexOutOfBoundsException) { + throw new IllegalArgumentException(format( + "Readable contains less values than the number of attributes, %d," + + " in OBJECT %s", + attributes.length, + metadata.getObjectType().getName())); + } + } + + try { + readable.get(attributes.length); + throw new IllegalArgumentException(format( + "Readable contains more values than the number of attributes, %d," + + " in OBJECT %s", + attributes.length, + metadata.getObjectType().getName())); + } + catch (IndexOutOfBoundsException indexOutOfBoundsException) { + // An out-of-bound index is expected if the number of values in the + // Readable matches the number of attributes in the OBJECT. + } + + return attributes; + } + + private Publisher convertUdtValues( + Object[] values, OracleR2dbcObjectMetadata metadata) { + + LinkedList> publishers = null; + + for (int i = 0; i < values.length; i++) { + + // Apply any conversion for objects not supported by the setObject + // methods of OraclePreparedStatement. + values[i] = convertBind(values[i]); + + // Apply any conversion for objects not supported by the + // createOracleArray or createStruct methods of OracleConnection + if (values[i] instanceof Period) { + values[i] = convertPeriodUdtValue((Period) values[i]); + } + else if (values[i] instanceof LocalDateTime) { + values[i] = convertLocalDateTimeUdtValue((LocalDateTime) values[i]); + } + else if (values[i] instanceof LocalDate) { + values[i] = convertLocalDateUdtValue((LocalDate) values[i]); + } + else if (values[i] instanceof LocalTime) { + values[i] = convertLocalTimeUdtValue((LocalTime) values[i]); + } + else if (values[i] instanceof Duration) { + values[i] = convertDurationUdtValue((Duration) values[i]); + } + else if (values[i] instanceof byte[] + && R2dbcType.BLOB.equals( + metadata.getAttributeMetadata(i).getType())) { + values[i] = convertBlobUdtValue((byte[]) values[i]); + } + else if (values[i] instanceof CharSequence + && R2dbcType.CLOB.equals( + metadata.getAttributeMetadata(i).getType())) { + values[i] = convertClobUdtValue((CharSequence) values[i]); + } + + // Check if the value is published asynchronously (ie: a BLOB or CLOB) + if (values[i] instanceof Publisher) { + if (publishers == null) + publishers = new LinkedList<>(); + + final int valueIndex = i; + publishers.add( + Mono.from((Publisher)values[i]) + .doOnSuccess(value -> values[valueIndex] = value) + .then()); + } + } + + return publishers == null + ? null + : Flux.concat(publishers); + } + /** + * Converts a {@code LocalDateTime} to a {@code TIMESTAMP} object. This + * conversion is only required when passing an {@code Object[]} to the + * {@code createOracleArray} or {@code createStruct} methods of + * {@link OracleConnection}. A built-in conversion is implemented by Oracle + * JDBC for the {@code setObject} methods of {@link PreparedStatement}. + */ + private TIMESTAMP convertLocalDateTimeUdtValue(LocalDateTime localDateTime) { + return fromJdbc(() -> TIMESTAMP.of(localDateTime)); + } + + /** + * Converts a {@code LocalDate} to a {@code DATE} object. This + * conversion is only required when passing an {@code Object[]} to the + * {@code createOracleArray} or {@code createStruct} methods of + * {@link OracleConnection}. A built-in conversion is implemented by Oracle + * JDBC for the {@code setObject} methods of {@link PreparedStatement}. + */ + private DATE convertLocalDateUdtValue(LocalDate localDate) { + return fromJdbc(() -> DATE.of(localDate)); + } + + /** + * Converts a {@code LocalTime} to a {@code TIMESTAMP} object. This + * conversion is only required when passing an {@code Object[]} to the + * {@code createOracleArray} or {@code createStruct} methods of + * {@link OracleConnection}. A built-in conversion is implemented by Oracle + * JDBC for the {@code setObject} methods of {@link PreparedStatement}. + * @implNote Mapping this to TIMESTAMP to avoid loss of precision. Other + * object types like DATE or java.sql.Time do not have nanosecond precision. + */ + private TIMESTAMP convertLocalTimeUdtValue(LocalTime localTime) { + return fromJdbc(() -> + TIMESTAMP.of(LocalDateTime.of(LocalDate.EPOCH, localTime))); + } + + /** + * Converts a {@code Duration} to an {@code INTERVALDS} object. This + * conversion is only required when passing an {@code Object[]} to the + * {@code createOracleArray} or {@code createStruct} methods of + * {@link OracleConnection}. A built-in conversion is implemented by Oracle + * JDBC for the {@code setObject} methods of {@link PreparedStatement}. + */ + private INTERVALDS convertDurationUdtValue(Duration duration) { + // 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 + byte[] bytes = new byte[11]; + ByteBuffer.wrap(bytes) + .putInt((int)(duration.toDaysPart()) + 0x80000000)// 4 byte day + .put((byte)(duration.toHoursPart() + 60))// 1 byte hour + .put((byte)(duration.toMinutesPart() + 60))// 1 byte minute + .put((byte)(duration.toSecondsPart() + 60))// 1 byte second + .putInt(duration.toNanosPart() + 0x80000000);// 4 byte fractional second + return new INTERVALDS(bytes); + } + + /** + * Converts a {@code Period} to an {@code INTERVALYM} object. This + * conversion is only required when passing an {@code Object[]} to the + * {@code createOracleArray} or {@code createStruct} methods of + * {@link OracleConnection}. A built-in conversion is implemented by Oracle + * JDBC for the {@code setObject} methods of {@link PreparedStatement}. + */ + private INTERVALYM convertPeriodUdtValue(Period period) { + // 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 + return new INTERVALYM(bytes); + } + + /** + * Converts a {@code byte[]} to a {@code java.sql.Blob} object. This + * conversion is only required when passing an {@code Object[]} to the + * {@code createOracleArray} or {@code createStruct} methods of + * {@link OracleConnection}. A built-in conversion is implemented by Oracle + * JDBC for the {@code setObject} methods of {@link PreparedStatement}. + */ + private Publisher convertBlobUdtValue(byte[] bytes) { + return convertBlobBind(Blob.from(Mono.just(ByteBuffer.wrap(bytes)))); + } + + /** + * Converts a {@code String} to a {@code java.sql.Clob} object. This + * conversion is only required when passing an {@code Object[]} to the + * {@code createOracleArray} or {@code createStruct} methods of + * {@link OracleConnection}. A built-in conversion is implemented by Oracle + * JDBC for the {@code setObject} methods of {@link PreparedStatement}. + */ + private Publisher convertClobUdtValue( + CharSequence charSequence) { + return convertClobBind(Clob.from(Mono.just(charSequence))); + } + /** * 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 +1987,20 @@ 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 + || type instanceof OracleR2dbcTypes.ObjectType) { + // Call registerOutParameter with the user defined type name + // returned by Type.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 or OBJECT. + callableStatement.registerOutParameter( + i + 1, jdbcType, type.getName()); + } + else { + callableStatement.registerOutParameter(i + 1, jdbcType); + } } }); } @@ -1419,6 +2012,7 @@ protected Publisher executeJdbc() { Mono.just(createCallResult( dependentCounter, createOutParameters( + fromJdbc(preparedStatement::getConnection), dependentCounter, new JdbcOutParameters(), metadata, adapter), adapter))); @@ -1486,7 +2080,7 @@ private JdbcBatch( */ @Override protected Publisher bind() { - Publisher[] bindPublishers = new Publisher[batchSize]; + Publisher[] bindPublishers = new Publisher[batchSize]; for (int i = 0; i < batchSize; i++) { bindPublishers[i] = Flux.concat( bind(batch.remove()), diff --git a/src/main/java/oracle/r2dbc/impl/ReadablesMetadata.java b/src/main/java/oracle/r2dbc/impl/ReadablesMetadata.java index aa69e3e..ae37498 100755 --- a/src/main/java/oracle/r2dbc/impl/ReadablesMetadata.java +++ b/src/main/java/oracle/r2dbc/impl/ReadablesMetadata.java @@ -26,9 +26,12 @@ import io.r2dbc.spi.OutParametersMetadata; import io.r2dbc.spi.ReadableMetadata; import io.r2dbc.spi.RowMetadata; +import oracle.jdbc.OracleStruct; +import oracle.jdbc.OracleTypeMetaData; +import oracle.r2dbc.OracleR2dbcObjectMetadata; +import oracle.r2dbc.OracleR2dbcTypes; import java.sql.ResultSetMetaData; -import java.util.Collection; import java.util.List; import java.util.Map; import java.util.NoSuchElementException; @@ -95,15 +98,22 @@ private ReadablesMetadata(T[] metadatas) { */ static RowMetadataImpl createRowMetadata( ResultSetMetaData resultSetMetaData) { - int columnCount = fromJdbc(resultSetMetaData::getColumnCount); - ColumnMetadata[] columnMetadataArray = new ColumnMetadata[columnCount]; - - for (int i = 0; i < columnCount; i++) { - columnMetadataArray[i] = - OracleReadableMetadataImpl.createColumnMetadata(resultSetMetaData, i); - } + return new RowMetadataImpl( + ReadablesMetadata.toColumnMetadata(resultSetMetaData)); + } - return new RowMetadataImpl(columnMetadataArray); + /** + * Creates {@code OracleR2dbcObjectMetadata} that supplies attribute metadata + * from a JDBC {@code OracleStruct} object. + */ + static OracleR2dbcObjectMetadataImpl createAttributeMetadata( + OracleStruct oracleStruct) { + return fromJdbc(() -> + new OracleR2dbcObjectMetadataImpl( + OracleR2dbcTypes.objectType(oracleStruct.getSQLTypeName()), + ReadablesMetadata.toColumnMetadata( + ((OracleTypeMetaData.Struct)oracleStruct.getOracleMetaData()) + .getMetaData()))); } /** @@ -117,6 +127,24 @@ static OutParametersMetadataImpl createOutParametersMetadata( return new OutParametersMetadataImpl(metadata); } + /** + * Converts JDBC {@code ResultSetMetaData} into an array of + * {@code ColumnMetadata} + */ + private static ColumnMetadata[] toColumnMetadata( + ResultSetMetaData resultSetMetaData) { + + int columnCount = fromJdbc(resultSetMetaData::getColumnCount); + ColumnMetadata[] columnMetadataArray = new ColumnMetadata[columnCount]; + + for (int i = 0; i < columnCount; i++) { + columnMetadataArray[i] = + OracleReadableMetadataImpl.createColumnMetadata(resultSetMetaData, i); + } + + return columnMetadataArray; + } + /** * Returns the {@link ReadableMetadata} for one value in this result. * @@ -196,6 +224,20 @@ final int getColumnIndex(String name) { return index == null ? -1 : index; } + @Override + public boolean equals(Object other) { + if (!(other instanceof ReadablesMetadata)) + return super.equals(other); + + ReadablesMetadata otherMetadata = (ReadablesMetadata)other; + return metadataList.equals(otherMetadata.metadataList); + } + + @Override + public int hashCode() { + return metadataList.hashCode(); + } + static final class RowMetadataImpl extends ReadablesMetadata implements RowMetadata { @@ -271,4 +313,60 @@ public List getParameterMetadatas() { } } + static final class OracleR2dbcObjectMetadataImpl + extends ReadablesMetadata + implements OracleR2dbcObjectMetadata { + + /** Type of the OBJECT which metadata is provided for */ + private final OracleR2dbcTypes.ObjectType objectType; + + /** + * Constructs a new instance which supplies metadata from an array of {@code + * columnMetadata}. + * + * @param attributeMetadata Metadata from each column in a row. Not null. + * Retained. Not modified. + */ + private OracleR2dbcObjectMetadataImpl( + OracleR2dbcTypes.ObjectType objectType, + ReadableMetadata[] attributeMetadata) { + super(attributeMetadata); + this.objectType = objectType; + } + + @Override + public OracleR2dbcTypes.ObjectType getObjectType() { + return objectType; + } + + @Override + public ReadableMetadata getAttributeMetadata(int index) { + return get(index); + } + + @Override + public ReadableMetadata getAttributeMetadata(String name) { + return get(name); + } + + @Override + public List getAttributeMetadatas() { + return getList(); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof OracleR2dbcObjectMetadata)) + return false; + + OracleR2dbcObjectMetadata otherMetadata = + (OracleR2dbcObjectMetadata) other; + + if (!objectType.equals(otherMetadata.getObjectType())) + return false; + + return super.equals(other); + } + } + } \ No newline at end of file diff --git a/src/main/java/oracle/r2dbc/impl/SqlTypeMap.java b/src/main/java/oracle/r2dbc/impl/SqlTypeMap.java index b2ad059..75afdd1 100644 --- a/src/main/java/oracle/r2dbc/impl/SqlTypeMap.java +++ b/src/main/java/oracle/r2dbc/impl/SqlTypeMap.java @@ -23,6 +23,7 @@ import io.r2dbc.spi.R2dbcType; import io.r2dbc.spi.Type; import oracle.jdbc.OracleType; +import oracle.r2dbc.OracleR2dbcObject; import oracle.r2dbc.OracleR2dbcTypes; import oracle.sql.json.OracleJsonObject; @@ -33,6 +34,7 @@ import java.sql.RowId; import java.sql.SQLException; import java.sql.SQLType; +import java.sql.Types; import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; @@ -133,6 +135,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 +163,24 @@ 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, JDBCType.ARRAY), + // byte[] is mapped to RAW by default + // entry(byte[].class, JDBCType.ARRAY), + entry(short[].class, JDBCType.ARRAY), + entry(int[].class, JDBCType.ARRAY), + entry(long[].class, JDBCType.ARRAY), + entry(float[].class, JDBCType.ARRAY), + entry(double[].class, JDBCType.ARRAY), + + // Support binding OracleR2dbcReadable, Object[], and Map + // to OBJECT (ie: STRUCT) + entry(Map.class, JDBCType.STRUCT), + entry(OracleR2dbcObject.class, JDBCType.STRUCT) ); /** @@ -216,9 +235,14 @@ 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 if (r2dbcType instanceof OracleR2dbcTypes.ObjectType) + return JDBCType.STRUCT; + else + return R2DBC_TO_JDBC_TYPE_MAP.get(r2dbcType); } /** diff --git a/src/test/java/oracle/r2dbc/impl/OracleLargeObjectsTest.java b/src/test/java/oracle/r2dbc/impl/OracleLargeObjectsTest.java index a8c1d11..a9ed093 100644 --- a/src/test/java/oracle/r2dbc/impl/OracleLargeObjectsTest.java +++ b/src/test/java/oracle/r2dbc/impl/OracleLargeObjectsTest.java @@ -24,8 +24,11 @@ import io.r2dbc.spi.Blob; import io.r2dbc.spi.Clob; import io.r2dbc.spi.Connection; +import io.r2dbc.spi.Parameters; import io.r2dbc.spi.Row; import io.r2dbc.spi.Statement; +import oracle.r2dbc.OracleR2dbcObject; +import oracle.r2dbc.OracleR2dbcTypes; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -34,6 +37,7 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import static java.util.Arrays.asList; @@ -372,4 +376,128 @@ public void testClobBatchInsert() { } } + @Test + public void testBlobObject() { + Connection connection = + Mono.from(sharedConnection()).block(connectTimeout()); + OracleR2dbcTypes.ObjectType objectType = + OracleR2dbcTypes.objectType("BLOB_OBJECT"); + try { + awaitExecution(connection.createStatement( + "CREATE TYPE BLOB_OBJECT AS OBJECT(x BLOB, y BLOB)")); + awaitExecution(connection.createStatement( + "CREATE TABLE testBlobObject(id NUMBER, blobs BLOB_OBJECT)")); + byte[] xBytes0 = getBytes(64 * 1024); + byte[] yBytes0 = getBytes(64 * 1024); + byte[] xBytes1 = getBytes(64 * 1024); + byte[] yBytes1 = getBytes(64 * 1024); + + // Verify asynchronous materialization of Blob binds. The Blob lengths + // are each large enough to require multiple Blob writing network calls + // by the Oracle JDBC Driver. + awaitUpdate(asList(1, 1), connection.createStatement( + "INSERT INTO testBlobObject (id, blobs) VALUES (:id, :blobs)") + .bind("id", 0) + .bind("blobs", Parameters.in(objectType, new Object[]{ + createBlob(xBytes0), + createBlob(yBytes0), + })) + .add() + .bind("id", 1) + .bind("blobs", Parameters.in(objectType, Map.of( + "x", createBlob(xBytes1), + "y", createBlob(yBytes1))))); + + // Expect OracleR2dbcObject.get(int/String) to support Blob as a Java type + // mapping for BLOB type attributes. + List> blobs = + awaitMany(Flux.from(connection.createStatement( + "SELECT blobs FROM testBlobObject ORDER BY id") + .execute()) + .flatMap(result -> + result.map(row -> + row.get("blobs", OracleR2dbcObject.class))) + .map(blobObject -> List.of( + blobObject.get("x", Blob.class), + blobObject.get("y", Blob.class)))); + + // Expect bytes written to INSERTed Blobs to match the bytes read from + // SELECTed Blobs + awaitBytes(xBytes0, blobs.get(0).get(0)); + awaitBytes(yBytes0, blobs.get(0).get(1)); + awaitBytes(xBytes1, blobs.get(1).get(0)); + awaitBytes(yBytes1, blobs.get(1).get(1)); + + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TABLE testBlobObject")); + tryAwaitExecution(connection.createStatement( + "DROP type " + objectType.getName())); + tryAwaitNone(connection.close()); + } + } + + @Test + public void testClobObject() { + Connection connection = + Mono.from(sharedConnection()).block(connectTimeout()); + OracleR2dbcTypes.ObjectType objectType = + OracleR2dbcTypes.objectType("CLOB_OBJECT"); + try { + awaitExecution(connection.createStatement( + "CREATE TYPE CLOB_OBJECT AS OBJECT(x CLOB, y CLOB)")); + awaitExecution(connection.createStatement( + "CREATE TABLE testClobObject(id NUMBER, clobs CLOB_OBJECT)")); + byte[] xBytes0 = getBytes(64 * 1024); + byte[] yBytes0 = getBytes(64 * 1024); + byte[] xBytes1 = getBytes(64 * 1024); + byte[] yBytes1 = getBytes(64 * 1024); + + // Verify asynchronous materialization of Clob binds. The Clob lengths + // are each large enough to require multiple Clob writing network calls + // by the Oracle JDBC Driver. + awaitUpdate(asList(1, 1), connection.createStatement( + "INSERT INTO testClobObject (id, clobs) VALUES (:id, :clobs)") + .bind("id", 0) + .bind("clobs", Parameters.in(objectType, new Object[]{ + createClob(xBytes0), + createClob(yBytes0), + })) + .add() + .bind("id", 1) + .bind("clobs", Parameters.in(objectType, Map.of( + "x", createClob(xBytes1), + "y", createClob(yBytes1))))); + + // Expect OracleR2dbcObject.get(int/String) to support Clob as a Java type + // mapping for CLOB type attributes. + List> clobs = + awaitMany(Flux.from(connection.createStatement( + "SELECT clobs FROM testClobObject ORDER BY id") + .execute()) + .flatMap(result -> + result.map(row -> + row.get("clobs", OracleR2dbcObject.class))) + .map(clobObject -> List.of( + clobObject.get("x", Clob.class), + clobObject.get("y", Clob.class)))); + + // Expect bytes written to INSERTed Clobs to match the bytes read from + // SELECTed Clobs + awaitBytes(xBytes0, clobs.get(0).get(0)); + awaitBytes(yBytes0, clobs.get(0).get(1)); + awaitBytes(xBytes1, clobs.get(1).get(0)); + awaitBytes(yBytes1, clobs.get(1).get(1)); + + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TABLE testClobObject")); + tryAwaitExecution(connection.createStatement( + "DROP type " + objectType.getName())); + tryAwaitNone(connection.close()); + } + } + } diff --git a/src/test/java/oracle/r2dbc/impl/OracleReadableMetadataImplTest.java b/src/test/java/oracle/r2dbc/impl/OracleReadableMetadataImplTest.java index 55147ad..043e55e 100644 --- a/src/test/java/oracle/r2dbc/impl/OracleReadableMetadataImplTest.java +++ b/src/test/java/oracle/r2dbc/impl/OracleReadableMetadataImplTest.java @@ -24,9 +24,14 @@ 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.ReadableMetadata; +import io.r2dbc.spi.Statement; import io.r2dbc.spi.Type; import oracle.jdbc.OracleType; +import oracle.r2dbc.OracleR2dbcObject; +import oracle.r2dbc.OracleR2dbcObjectMetadata; import oracle.r2dbc.OracleR2dbcTypes; import oracle.sql.json.OracleJsonFactory; import oracle.sql.json.OracleJsonObject; @@ -46,13 +51,21 @@ import java.time.OffsetDateTime; import java.time.Period; import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import static java.lang.String.format; import static oracle.r2dbc.test.DatabaseConfig.connectTimeout; import static oracle.r2dbc.test.DatabaseConfig.databaseVersion; import static oracle.r2dbc.test.DatabaseConfig.sharedConnection; +import static oracle.r2dbc.test.DatabaseConfig.user; +import static oracle.r2dbc.test.TestUtils.constructObject; import static oracle.r2dbc.util.Awaits.awaitExecution; import static oracle.r2dbc.util.Awaits.awaitOne; +import static oracle.r2dbc.util.Awaits.awaitQuery; 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 +297,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 +336,199 @@ 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")); + OracleR2dbcTypes.ArrayType arrayType = + OracleR2dbcTypes.arrayType(user().toUpperCase() + ".TEST_ARRAY_TYPE"); + + verifyColumnMetadata( + connection, arrayType.getName(), JDBCType.ARRAY, arrayType, + null, null, Object[].class, + Parameters.in(arrayType, new Integer[]{0, 1, 2})); + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TYPE TEST_ARRAY_TYPE")); + tryAwaitNone(connection.close()); + } + } + + /** + * Verifies the implementation of {@link OracleReadableMetadataImpl} for + * OBJECT type columns. + */ + @Test + public void testObjectTypes() { + Connection connection = + Mono.from(sharedConnection()).block(connectTimeout()); + try { + // A fully qualified type names to match the names returned by RowMetadata + OracleR2dbcTypes.ObjectType objectType = OracleR2dbcTypes.objectType( + user().toUpperCase() + ".TEST_OBJECT_TYPE"); + OracleR2dbcTypes.ObjectType objectType2D = OracleR2dbcTypes.objectType( + user().toUpperCase() + ".TEST_OBJECT_TYPE_2D"); + OracleR2dbcTypes.ArrayType arrayType2D = OracleR2dbcTypes.arrayType( + user().toUpperCase() + ".TEST_OBJECT_TYPE_ARRAY_TYPE"); + + Type[] attributeTypes = new Type[] { + R2dbcType.CHAR, + R2dbcType.VARCHAR, + R2dbcType.NCHAR, + R2dbcType.NVARCHAR, + R2dbcType.CLOB, + R2dbcType.NCLOB, + R2dbcType.VARBINARY, + R2dbcType.BLOB, + R2dbcType.NUMERIC, + OracleR2dbcTypes.BINARY_FLOAT, + OracleR2dbcTypes.BINARY_DOUBLE, + R2dbcType.TIMESTAMP, + R2dbcType.TIMESTAMP, + R2dbcType.TIMESTAMP_WITH_TIME_ZONE, + OracleR2dbcTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE, + objectType2D, + arrayType2D, + }; + + awaitExecution(connection.createStatement( + "CREATE OR REPLACE TYPE TEST_OBJECT_TYPE_2D" + + " AS OBJECT(value0_2D NUMBER(2, 2))")); + awaitExecution(connection.createStatement( + "CREATE OR REPLACE TYPE TEST_OBJECT_TYPE_ARRAY_TYPE" + + " AS ARRAY(1) OF VARCHAR(10)")); + + String[] attributeNames = IntStream.range(0, attributeTypes.length) + .mapToObj(i -> "value" + i) + .toArray(String[]::new); + + awaitExecution(connection.createStatement( + "CREATE OR REPLACE TYPE TEST_OBJECT_TYPE AS OBJECT(" + + "value0 CHAR(10)," + + "value1 VARCHAR(10)," + + "value2 NCHAR(10)," + + "value3 NVARCHAR2(10)," + + "value4 CLOB," + + "value5 NCLOB," + + "value6 RAW(10)," + + "value7 BLOB," + + "value8 NUMBER(9)," + + "value9 BINARY_FLOAT," + + "value10 BINARY_DOUBLE," + + "value11 DATE," + + "value12 TIMESTAMP(9)," + + "value13 TIMESTAMP(8) WITH TIME ZONE," + + "value14 TIMESTAMP(7) WITH LOCAL TIME ZONE," + + "value15 TEST_OBJECT_TYPE_2D," + + "value16 TEST_OBJECT_TYPE_ARRAY_TYPE)")); + + OracleR2dbcObject object = constructObject( + connection, objectType, + Arrays.stream(attributeTypes) + .map(Parameters::in) + .toArray()); + + verifyColumnMetadata( + connection, "TEST_OBJECT_TYPE", JDBCType.STRUCT, objectType, + null, null, OracleR2dbcObject.class, object); + + // Verify the attribute metadata too. + OracleR2dbcObjectMetadata objectMetadata = object.getMetadata(); + assertEquals(objectType, objectMetadata.getObjectType()); + // Oracle JDBC does not support returning the precision of character type + // attributes. Expect a null precision. + verifyMetadata( + objectMetadata.getAttributeMetadata(attributeNames[0]), + attributeNames[0], JDBCType.CHAR, attributeTypes[0], + null, null, Nullability.NULLABLE, String.class); + verifyMetadata( + objectMetadata.getAttributeMetadata(attributeNames[1]), + attributeNames[1], JDBCType.VARCHAR, attributeTypes[1], + null, null, Nullability.NULLABLE, String.class); + verifyMetadata( + objectMetadata.getAttributeMetadata(attributeNames[2]), + attributeNames[2], JDBCType.NCHAR, attributeTypes[2], + null, null, Nullability.NULLABLE, String.class); + verifyMetadata( + objectMetadata.getAttributeMetadata(attributeNames[3]), + attributeNames[3], JDBCType.NVARCHAR, attributeTypes[3], + null, null, Nullability.NULLABLE, String.class); + verifyMetadata( + objectMetadata.getAttributeMetadata(attributeNames[4]), + attributeNames[4], JDBCType.CLOB, attributeTypes[4], + null, null, Nullability.NULLABLE, String.class); + verifyMetadata( + objectMetadata.getAttributeMetadata(attributeNames[5]), + attributeNames[5], JDBCType.NCLOB, attributeTypes[5], + null, null, Nullability.NULLABLE, String.class); + verifyMetadata( + objectMetadata.getAttributeMetadata(attributeNames[6]), + attributeNames[6], JDBCType.VARBINARY, attributeTypes[6], + 10, null, Nullability.NULLABLE, ByteBuffer.class); + verifyMetadata( + objectMetadata.getAttributeMetadata(attributeNames[7]), + attributeNames[7], JDBCType.BLOB, attributeTypes[7], + null, null, Nullability.NULLABLE, ByteBuffer.class); + verifyMetadata( + objectMetadata.getAttributeMetadata(attributeNames[8]), + attributeNames[8], JDBCType.NUMERIC, attributeTypes[8], + 9, 0, Nullability.NULLABLE, BigDecimal.class); + verifyMetadata( + objectMetadata.getAttributeMetadata(attributeNames[9]), + attributeNames[9], OracleType.BINARY_FLOAT, attributeTypes[9], + null, null, Nullability.NULLABLE, Float.class); + verifyMetadata( + objectMetadata.getAttributeMetadata(attributeNames[10]), + attributeNames[10], OracleType.BINARY_DOUBLE, attributeTypes[10], + null, null, Nullability.NULLABLE, Double.class); + // Oracle JDBC does not support returning the scale for data time types. + // Expect null. + verifyMetadata( + objectMetadata.getAttributeMetadata(attributeNames[11]), + attributeNames[11], JDBCType.TIMESTAMP, attributeTypes[11], + 29, null, Nullability.NULLABLE, LocalDateTime.class); + verifyMetadata( + objectMetadata.getAttributeMetadata(attributeNames[12]), + attributeNames[12], JDBCType.TIMESTAMP, attributeTypes[12], + 29, null, Nullability.NULLABLE, LocalDateTime.class); + verifyMetadata( + objectMetadata.getAttributeMetadata(attributeNames[13]), + attributeNames[13], OracleType.TIMESTAMP_WITH_TIME_ZONE, + attributeTypes[13], + 35, null, Nullability.NULLABLE, OffsetDateTime.class); + verifyMetadata( + objectMetadata.getAttributeMetadata(attributeNames[14]), + attributeNames[14], OracleType.TIMESTAMP_WITH_LOCAL_TIME_ZONE, + attributeTypes[14], + 29, null, Nullability.NULLABLE, LocalDateTime.class); + verifyMetadata( + objectMetadata.getAttributeMetadata(attributeNames[15]), + attributeNames[15], JDBCType.STRUCT, attributeTypes[15], + null, null, Nullability.NULLABLE, OracleR2dbcObject.class); + verifyMetadata( + objectMetadata.getAttributeMetadata(attributeNames[16]), + attributeNames[16], JDBCType.ARRAY, attributeTypes[16], + null, null, Nullability.NULLABLE, Object[].class); + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TYPE TEST_OBJECT_TYPE")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE TEST_OBJECT_TYPE_2D")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE TEST_OBJECT_TYPE_ARRAY_TYPE")); + tryAwaitNone(connection.close()); + } + } + /** * Calls * {@link #verifyColumnMetadata(Connection, String, SQLType, Type, Integer, Integer, Nullability, Class, Object)} @@ -370,7 +574,7 @@ private void verifyColumnMetadata( String columnName = "test_123"; String tableName = "verify_" + columnDdl.replaceAll("[^\\p{Alnum}]", "_"); awaitExecution(connection.createStatement( - String.format("CREATE TABLE "+tableName+"(%s %s)", + format("CREATE TABLE "+tableName+"(%s %s)", columnName, columnDdl) )); try { @@ -385,18 +589,27 @@ private void verifyColumnMetadata( result.map((row, rowMetadata) -> rowMetadata.getColumnMetadata(0)) )); - assertEquals(javaType, metadata.getJavaType()); - // Don't expect Oracle R2DBC to match the column name's case. - assertEquals(columnName.toUpperCase(), metadata.getName().toUpperCase()); - assertEquals(jdbcType, metadata.getNativeTypeMetadata()); - assertEquals(r2dbcType, metadata.getType()); - assertEquals(nullability, metadata.getNullability()); - assertEquals(precision, metadata.getPrecision()); - assertEquals(scale, metadata.getScale()); + verifyMetadata( + metadata, columnName, jdbcType, r2dbcType, precision, scale, + nullability, javaType); } finally { awaitExecution(connection.createStatement("DROP TABLE "+tableName)); } } + private void verifyMetadata( + ReadableMetadata metadata, String name, SQLType jdbcType, + Type r2dbcType, Integer precision, Integer scale, Nullability nullability, + Class javaType) { + assertEquals(javaType, metadata.getJavaType()); + // Don't expect Oracle R2DBC to match the column name's case. + assertEquals(name.toUpperCase(), metadata.getName().toUpperCase()); + assertEquals(jdbcType, metadata.getNativeTypeMetadata()); + assertEquals(r2dbcType, metadata.getType()); + assertEquals(nullability, metadata.getNullability()); + assertEquals(precision, metadata.getPrecision()); + assertEquals(scale, metadata.getScale()); + } + } diff --git a/src/test/java/oracle/r2dbc/impl/OracleStatementImplTest.java b/src/test/java/oracle/r2dbc/impl/OracleStatementImplTest.java index ed63857..7e056fe 100644 --- a/src/test/java/oracle/r2dbc/impl/OracleStatementImplTest.java +++ b/src/test/java/oracle/r2dbc/impl/OracleStatementImplTest.java @@ -23,17 +23,14 @@ 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.OracleR2dbcObject; import oracle.r2dbc.OracleR2dbcOptions; import oracle.r2dbc.OracleR2dbcTypes; import oracle.r2dbc.OracleR2dbcWarning; @@ -47,11 +44,13 @@ 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.Map; 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 +63,14 @@ 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.TestUtils.constructObject; +import static oracle.r2dbc.test.TestUtils.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 +1280,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 +1294,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 +1308,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 +1321,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 +1375,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 +1390,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 +1407,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 +1423,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 +2425,555 @@ 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); } - }); + } + + 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()); } + } - InOutParameter(Object value, Type type) { - this.value = value; - this.type = 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 Type getType() { - return type; + /** + * 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()); + } + } - @Override - public Object getValue() { - return value; + /** + * Verifies behavior for a PL/SQL call having {@code OBJECT} type IN bind + */ + @Test + public void testInObjectCall() { + Connection connection = awaitOne(sharedConnection()); + try { + awaitExecution(connection.createStatement( + "CREATE TYPE TEST_IN_OBJECT AS OBJECT(x NUMBER, y NUMBER, z NUMBER)")); + awaitExecution(connection.createStatement( + "CREATE TABLE testInObjectCall(id NUMBER, value TEST_IN_OBJECT)")); + awaitExecution(connection.createStatement( + "CREATE OR REPLACE PROCEDURE testInObjectProcedure (" + + " id IN NUMBER," + + " inObject IN TEST_IN_OBJECT)" + + " IS" + + " BEGIN" + + " INSERT INTO testInObjectCall VALUES(id, inObject);" + + " END;")); + + TestObjectRow row0 = new TestObjectRow(0L, new Integer[]{1, 2, 3}); + OracleR2dbcTypes.ObjectType objectType = + OracleR2dbcTypes.objectType("TEST_IN_OBJECT"); + Statement callStatement = connection.createStatement( + "BEGIN testInObjectProcedure(:id, :value); END;"); + awaitExecution( + callStatement + .bind("id", row0.id) + .bind("value", Parameters.in(objectType, row0.value))); + + awaitQuery( + List.of(row0), + TestObjectRow::fromReadable, + connection.createStatement( + "SELECT id, value FROM testInObjectCall ORDER BY id")); + + TestObjectRow row1 = new TestObjectRow(1L, new Integer[]{4, 5, 6}); + awaitExecution( + callStatement + .bind("id", row1.id) + .bind("value", constructObject(connection, objectType, row1.value))); + + awaitQuery( + List.of(row0, row1), + TestObjectRow::fromReadable, + connection.createStatement( + "SELECT id, value FROM testInObjectCall ORDER BY id")); + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TABLE testInObjectCall")); + tryAwaitExecution(connection.createStatement( + "DROP PROCEDURE testInObjectProcedure")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE TEST_IN_OBJECT")); + tryAwaitNone(connection.close()); + } + } + + /** + * Verifies behavior for a PL/SQL call having {@code OBJECT} type OUT bind + */ + @Test + public void testOutObjectCall() { + Connection connection = awaitOne(sharedConnection()); + try { + awaitExecution(connection.createStatement( + "CREATE TYPE TEST_OUT_OBJECT AS OBJECT(x NUMBER, y NUMBER, z NUMBER)")); + awaitExecution(connection.createStatement( + "CREATE TABLE testOutObjectCall(id NUMBER, value TEST_OUT_OBJECT)")); + awaitExecution(connection.createStatement( + "CREATE OR REPLACE PROCEDURE testOutObjectProcedure (" + + " inId IN NUMBER," + + " outObject OUT TEST_OUT_OBJECT)" + + " IS" + + " BEGIN" + + " SELECT value INTO outObject" + + " FROM testOutObjectCall" + + " WHERE id = inId;" + + " EXCEPTION" + + " WHEN NO_DATA_FOUND THEN" + + " outObject := NULL;" + + " END;")); + + + OracleR2dbcTypes.ObjectType objectType = + OracleR2dbcTypes.objectType("TEST_OUT_OBJECT"); + Statement callStatement = connection.createStatement( + "BEGIN testOutObjectProcedure(: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", Object[].class)); + assertNull(outParameters.get("value", Map.class)); + assertNull(outParameters.get("value", OracleR2dbcObject.class)); + return Optional.empty(); + }, + callStatement + .bind("id", -1) + .bind("value", Parameters.out(objectType))); + + // Insert a row and expect an out parameter with the value + TestObjectRow row0 = new TestObjectRow(0L, new Integer[]{1, 2, 3}); + awaitUpdate(1, connection.createStatement( + "INSERT INTO testOutObjectCall VALUES (:id, :value)") + .bind("id", row0.id) + .bind("value", Parameters.in(objectType, row0.value))); + awaitQuery( + List.of(row0), + outParameters -> + new TestObjectRow( + row0.id, + outParameters.get("value", OracleR2dbcObject.class)), + callStatement + .bind("id", row0.id) + .bind("value", Parameters.out(objectType))); + + // Insert another row and expect an out parameter with the value + TestObjectRow row1 = new TestObjectRow(1L, new Integer[]{4, 5, 6}); + awaitUpdate(1, connection.createStatement( + "INSERT INTO testOutObjectCall VALUES (:id, :value)") + .bind("id", row1.id) + .bind("value", constructObject(connection, objectType, row1.value))); + awaitQuery( + List.of(row1), + outParameters -> + new TestObjectRow( + row1.id, + outParameters.get("value", OracleR2dbcObject.class)), + callStatement + .bind("id", row1.id) + .bind("value", Parameters.out(objectType))); + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TABLE testOutObjectCall")); + tryAwaitExecution(connection.createStatement( + "DROP PROCEDURE testOutObjectProcedure")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE TEST_OUT_OBJECT")); + tryAwaitNone(connection.close()); + } + } + + /** + * Verifies behavior for a PL/SQL call having {@code OBJECT} type OUT bind + */ + @Test + public void testInOutObjectCall() { + Connection connection = awaitOne(sharedConnection()); + try { + awaitExecution(connection.createStatement( + "CREATE TYPE TEST_IN_OUT_OBJECT AS OBJECT(x NUMBER, y NUMBER, z NUMBER)")); + awaitExecution(connection.createStatement( + "CREATE TABLE testInOutObjectCall(id NUMBER, value TEST_IN_OUT_OBJECT)")); + awaitExecution(connection.createStatement( + "CREATE OR REPLACE PROCEDURE testInOutObjectProcedure (" + + " inId IN NUMBER," + + " inOutObject IN OUT TEST_IN_OUT_OBJECT)" + + " IS" + + " newValue TEST_IN_OUT_OBJECT;" + + " BEGIN" + + "" + + /* + " newValue := TEST_IN_OUT_OBJECT();" + + " newValue.extend(inOutObject.count);" + + " FOR i IN 1 .. inOutObject.count LOOP" + + " newValue(i) := inOutObject(i);" + + " END LOOP;" + + "" + + */ + " newValue := inOutObject;" + + " BEGIN" + + " SELECT value INTO inOutObject" + + " FROM testInOutObjectCall" + + " WHERE id = inId;" + + " DELETE FROM testInOutObjectCall WHERE id = inId;" + + " EXCEPTION" + + " WHEN NO_DATA_FOUND THEN" + + " inOutObject := NULL;" + + " END;" + + "" + + " INSERT INTO testInOutObjectCall VALUES (inId, newValue);" + + "" + + " END;")); + + OracleR2dbcTypes.ObjectType objectType = + OracleR2dbcTypes.objectType("TEST_IN_OUT_OBJECT"); + Statement callStatement = connection.createStatement( + "BEGIN testInOutObjectProcedure(:id, :value); END;"); + + // Expect a NULL out parameter the first time a row is inserted + TestObjectRow row = new TestObjectRow(0L, new Integer[]{1, 2, 3}); + awaitQuery( + List.of(Optional.empty()), + outParameters -> { + assertNull(outParameters.get("value")); + assertNull(outParameters.get("value", Object[].class)); + assertNull(outParameters.get("value", Map.class)); + assertNull(outParameters.get("value", OracleR2dbcObject.class)); + return Optional.empty(); + }, + callStatement + .bind("id", row.id) + .bind("value", Parameters.inOut(objectType, row.value))); + + // Update the row and expect an out parameter with the previous value + TestObjectRow row1 = new TestObjectRow(row.id, new Integer[]{4, 5, 6}); + awaitQuery( + List.of(row), + outParameters -> + new TestObjectRow( + row.id, + outParameters.get("value", OracleR2dbcObject.class)), + callStatement + .bind("id", row.id) + .bind("value", Parameters.inOut(objectType, constructObject( + connection, objectType, row1.value)))); + + // Update the row again and expect an out parameter with the previous + // value + TestObjectRow row2 = new TestObjectRow(row.id, new Integer[]{7, 8, 9}); + awaitQuery( + List.of(row1), + outParameters -> + new TestObjectRow( + row.id, + outParameters.get("value", OracleR2dbcObject.class)), + callStatement + .bind("id", row.id) + .bind("value", Parameters.inOut(objectType, Map.of( + "x", row2.value[0], + "y", row2.value[1], + "z", row2.value[2])))); + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TABLE testInOutObjectCall")); + tryAwaitExecution(connection.createStatement( + "DROP PROCEDURE testInOutObjectProcedure")); + tryAwaitExecution(connection.createStatement( + "DROP TYPE TEST_IN_OUT_OBJECT")); + tryAwaitNone(connection.close()); } } @@ -2635,9 +3145,54 @@ public boolean equals(Object other) { && Objects.equals(value, ((TestRow)other).value); } + @Override + public int hashCode() { + return Objects.hash(id, value); + } + @Override public String toString() { return "[id=" + id + ", value=" + value + "]"; } } + + private static class TestObjectRow { + Long id; + Object[] value; + TestObjectRow(Long id, OracleR2dbcObject object) { + this(id, new Integer[] { + object.get("x", Integer.class), + object.get("y", Integer.class), + object.get("z", Integer.class) + }); + } + + TestObjectRow(Long id, Object[] value) { + this.id = id; + this.value = value; + } + + static TestObjectRow fromReadable(io.r2dbc.spi.Readable row) { + return new TestObjectRow( + row.get("id", Long.class), + row.get("value", OracleR2dbcObject.class)); + } + + @Override + public boolean equals(Object other) { + return other instanceof TestObjectRow + && Objects.equals(((TestObjectRow) other).id, id) + && Arrays.equals(((TestObjectRow)other).value, value); + } + + @Override + public int hashCode() { + return Objects.hash(id, value); + } + + @Override + public String toString() { + return id + ", " + Arrays.toString(value); + } + } } diff --git a/src/test/java/oracle/r2dbc/impl/TypeMappingTest.java b/src/test/java/oracle/r2dbc/impl/TypeMappingTest.java index 1f8907b..5c28f30 100644 --- a/src/test/java/oracle/r2dbc/impl/TypeMappingTest.java +++ b/src/test/java/oracle/r2dbc/impl/TypeMappingTest.java @@ -24,9 +24,16 @@ 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.OracleR2dbcObject; +import oracle.r2dbc.OracleR2dbcTypes; +import oracle.r2dbc.OracleR2dbcTypes.ArrayType; +import oracle.r2dbc.OracleR2dbcTypes.ObjectType; +import oracle.r2dbc.test.TestUtils; import oracle.sql.json.OracleJsonFactory; import oracle.sql.json.OracleJsonObject; import org.junit.jupiter.api.Assertions; @@ -40,20 +47,28 @@ 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.OffsetTime; import java.time.Period; import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; import java.util.function.BiConsumer; import java.util.function.Function; +import java.util.function.IntFunction; import java.util.stream.Collectors; +import java.util.stream.IntStream; 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.lang.String.format; import static java.util.Arrays.asList; import static oracle.r2dbc.test.DatabaseConfig.connectTimeout; import static oracle.r2dbc.test.DatabaseConfig.databaseVersion; @@ -62,10 +77,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; @@ -119,14 +136,14 @@ public void testCharacterTypeMappings() { // Expect CHAR and String to map verifyTypeMapping(connection, - String.format("%100s", "Hello, Oracle"), "CHAR(100)"); + format("%100s", "Hello, Oracle"), "CHAR(100)"); // Expect VARCHAR and String to map verifyTypeMapping(connection, "Bonjour, Oracle", "VARCHAR(100)"); // Expect NCHAR and String to map verifyTypeMapping(connection, - Parameters.in(NCHAR, String.format("%100s", "你好, Oracle")), + Parameters.in(NCHAR, format("%100s", "你好, Oracle")), "NCHAR(100)", (expected, actual) -> assertEquals(expected.getValue(), actual)); @@ -482,6 +499,1459 @@ 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()); + } + } + + /** + *

+ * Verifies the implementation of Java to SQL and SQL to Java type mappings + * for OBJECT types. The current R2DBC SPI does not specify a standard mapping + * for database OBJECT types. Oracle R2DBC is expected to support Object[] + * mappings. + *

+ */ + @Test + public void testObjectTypeMappings() { + Connection connection = awaitOne(sharedConnection()); + try { + + // Verify default mappings of all SQL types + verifyObjectTypeMapping( + "OBJECT_TEST", new String[] { + "VARCHAR(100)", + "NUMBER", + "RAW(100)", + "DATE", + "TIMESTAMP(9)", + "TIMESTAMP(9) WITH TIME ZONE", + "TIMESTAMP(9) WITH LOCAL TIME ZONE", + "INTERVAL YEAR TO MONTH", + "INTERVAL DAY TO SECOND", + "BLOB", + "CLOB" + }, + i -> new Object[]{ + // Expect VARCHAR and String to map + "你好-" + i, + // Expect NUMBER and BigDecimal to map + BigDecimal.valueOf(i), + // Expect RAW and ByteBuffer to map + ByteBuffer.allocate(5 * Integer.BYTES) + .putInt(i) + .putInt(i + 1) + .putInt(i + 2) + .putInt(i + 3) + .putInt(i + 4) + .flip(), + // Expect DATE and LocalDateTime to map + LocalDateTime.of(2038 + i, 10, 23, 9, 42, 1), + // Expect TIMESTAMP and LocalDateTime to map + LocalDateTime.of(2038, 10, 23, 9, 42, 1, 1 + i), + // Expect TIMESTAMP WITH TIME ZONE and OffsetDateTime to map + OffsetDateTime.of( + 2038, 10, 23, 9, 42, 1, 1 + i, ZoneOffset.ofHours(-5)), + // Expect TIMESTAMP WITH LOCAL TIME ZONE and LocalDateTime to map + LocalDateTime.of(2038, 10, 23, 9, 42, 1, 1 + i), + // Expect INTERVAL YEAR TO MONTH and Period to map + Period.of(1 + i, 2, 0), + // Expect INTERVAL DAY TO SECOND and Duration to map + Duration.ofDays(1 + i) + .plus(Duration.ofHours(2)) + .plus(Duration.ofMinutes(3)) + .plus(Duration.ofSeconds(4)), + // Expect BLOB and ByteBuffer to map. + IntStream.range(0, 16_000) + .map(j -> j + i) + .collect( + () -> ByteBuffer.allocate(16_000 * Integer.BYTES), + ByteBuffer::putInt, + ByteBuffer::put) + .flip(), + // Expect CLOB and String to map + IntStream.range(0, 64_000) + .map(j -> 'a' + ((j + i) % 26)) + .collect( + () -> CharBuffer.allocate(64_000), + (charBuffer, intChar) -> charBuffer.put((char)intChar), + CharBuffer::put) + .flip() + .toString() + }, + true, + connection); + + // Verify all SQL types with null values + verifyObjectTypeMapping( + "OBJECT_TEST_NULL", new String[] { + "VARCHAR(100)", + "NUMBER", + "RAW(100)", + "DATE", + "TIMESTAMP(9)", + "TIMESTAMP(9) WITH TIME ZONE", + "TIMESTAMP(9) WITH LOCAL TIME ZONE", + "INTERVAL YEAR TO MONTH", + "INTERVAL DAY TO SECOND", + "BLOB", + "CLOB" + }, + i -> new Object[]{ + // Expect VARCHAR and null to map + null, + // Expect NUMBER and null to map + null, + // Expect RAW and null to map + null, + // Expect DATE and null to map + null, + // Expect TIMESTAMP and null to map + null, + // Expect TIMESTAMP WITH TIME ZONE and null to map + null, + // Expect TIMESTAMP WITH LOCAL TIME ZONE and null to map + null, + // Expect INTERVAL YEAR TO MONTH and null to map + null, + // Expect INTERVAL DAY TO SECOND and null to map + null, + // Expect BLOB and io.r2dbc.spi.Blob to map + null, + // Expect CLOB and io.r2dbc.spi.Clob to map + null + }, + true, + connection); + + // Verify non-default mappings of all SQL types + verifyObjectTypeMapping( + "OBJECT_TEST_NON_DEFAULT", new String[] { + "NUMBER", + "NUMBER", + "NUMBER", + "NUMBER", + "NUMBER", + "NUMBER", + "NUMBER", + "DATE", + "DATE", + "TIMESTAMP(9)", + "TIMESTAMP(9)", + "TIMESTAMP(9) WITH TIME ZONE", + }, + i -> new Object[]{ + // Expect NUMBER and Boolean to map + true, + // Expect NUMBER and Integer to map + i, + // Expect NUMBER and Byte to map + (byte)i, + // Expect NUMBER and Short to map + (short)i, + // Expect NUMBER and Long to map + (long)i, + // Expect NUMBER and Float to map + (float)i, + // Expect NUMBER and Double to map + (double) i, + // Expect DATE and LocalDate to map + LocalDate.of(2038 + i, 10, 23), + // Expect DATE and LocalTime to map + LocalTime.of(9 + i, 42, 1), + // Expect TIMESTAMP and LocalDate to map + LocalDate.of(2038 + i, 10, 23), + // Expect TIMESTAMP and LocalTime to map + LocalTime.of(9 + i, 42, 1), + // Expect TIMESTAMP WITH TIME ZONE and OffsetTime to map + OffsetTime.of(9 + i, 42, 1, 1, ZoneOffset.ofHours(-5)) + }, + false, + connection); + } + 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))); + } + + + private static void verifyObjectTypeMapping( + String typeName, String[] attributeTypes, + IntFunction valueArrayGenerator, boolean isDefaultMapping, + Connection connection) { + + ObjectType objectType1D = OracleR2dbcTypes.objectType(typeName); + ObjectType objectType2D = OracleR2dbcTypes.objectType(typeName + "_2D"); + ObjectType objectType3D = OracleR2dbcTypes.objectType(typeName + "_3D"); + try { + String[] attributeNames1D = + IntStream.range(0, attributeTypes.length) + .mapToObj(i -> format("value%d", i)) + .toArray(String[]::new); + createObjectType( + objectType1D.getName(), attributeNames1D, attributeTypes, connection); + + String[] attributeNames2D = + IntStream.range(0, 3) + .mapToObj(i -> format("value2D%d", i)) + .toArray(String[]::new); + createObjectType( + objectType2D.getName(), + attributeNames2D, + new String[] {typeName, typeName, typeName}, + connection); + + String[] attributeNames3D = + IntStream.range(0, 3) + .mapToObj(i -> format("value3D%d", i)) + .toArray(String[]::new); + createObjectType( + objectType3D.getName(), + attributeNames3D, + new String[] { + objectType2D.getName(), + objectType2D.getName(), + objectType2D.getName() + }, + connection); + + verifyObjectTypeMapping( + objectType1D, attributeNames1D, valueArrayGenerator.apply(0), + isDefaultMapping, connection); + + verifyObjectTypeMapping( + objectType2D, attributeNames2D, new Object[] { + Parameters.in(objectType1D, valueArrayGenerator.apply(1)), + Parameters.in(objectType1D, + toMap(attributeNames1D, valueArrayGenerator.apply(2))), + TestUtils.constructObject( + connection, objectType1D, valueArrayGenerator.apply(3)) + }, + false, connection); + + verifyObjectTypeMapping( + objectType3D, attributeNames3D, new Object[] { + Parameters.in( + objectType2D, + new Object[] { + Parameters.in(objectType1D, valueArrayGenerator.apply(4)), + Parameters.in(objectType1D, + toMap(attributeNames1D, valueArrayGenerator.apply(5))), + TestUtils.constructObject( + connection, objectType1D, valueArrayGenerator.apply(6)) + }), + Parameters.in( + objectType2D, + toMap(attributeNames2D, new Object[] { + Parameters.in(objectType1D, valueArrayGenerator.apply(7)), + Parameters.in(objectType1D, + toMap(attributeNames1D, valueArrayGenerator.apply(8))), + TestUtils.constructObject( + connection, objectType1D, valueArrayGenerator.apply(9)) + })), + TestUtils.constructObject( + connection, objectType2D, + Parameters.in(objectType1D, valueArrayGenerator.apply(10)), + Parameters.in(objectType1D, + toMap(attributeNames1D, valueArrayGenerator.apply(11))), + TestUtils.constructObject( + connection, objectType1D, valueArrayGenerator.apply(12))) + }, + false, connection); + } + finally { + tryAwaitExecution(connection.createStatement( + "DROP TYPE " + objectType3D.getName())); + tryAwaitExecution(connection.createStatement( + "DROP TYPE " + objectType2D.getName())); + tryAwaitExecution(connection.createStatement( + "DROP TYPE " + objectType1D.getName())); + } + + } + + static void verifyObjectTypeMapping( + ObjectType objectType, String[] attributeNames, Object[] attributeValues, + boolean isDefaultMapping, Connection connection) { + + // Bind the attributes as an Object[] + verifyTypeMapping( + connection, + Parameters.in(objectType, attributeValues), + objectType.getName(), + row -> row.get(0, OracleR2dbcObject.class), + (ignored, object) -> + assertObjectEquals(object, attributeValues, isDefaultMapping)); + + // Bind the attributes as a Map + verifyTypeMapping( + connection, + Parameters.in(objectType, toMap(attributeNames, attributeValues)), + objectType.getName(), + row -> row.get(0, OracleR2dbcObject.class), + (ignored, object) -> + assertObjectEquals(object, attributeValues, isDefaultMapping)); + + // Bind the attributes as an OracleR2dbcObject + OracleR2dbcObject objectValue = + TestUtils.constructObject(connection, objectType, attributeValues); + verifyTypeMapping( + connection, + objectValue, + objectType.getName(), + row -> row.get(0, OracleR2dbcObject.class), + (ignored, object) -> + assertObjectEquals(object, attributeValues, isDefaultMapping)); + } + + static ObjectType createObjectType( + String typeName, String[] attributeNames, String[] attributeTypes, + Connection connection) { + + awaitExecution(connection.createStatement(format( + "CREATE OR REPLACE TYPE %s AS OBJECT(%s)", + typeName, + IntStream.range(0, attributeNames.length) + .mapToObj(i -> attributeNames[i] + " " + attributeTypes[i]) + .collect(Collectors.joining(","))))); + + return OracleR2dbcTypes.objectType(typeName); + } + + private static Map toMap(String[] names, Object[] values) { + Map map = new HashMap<>(values.length); + for (int i = 0; i < names.length; i++) + map.put(names[i], values[i]); + return map; + } + + /** + * Asserts that the attributes of an {@code object} are the same as a set + * of {@code values} provided to bind the object in an INSERT. + */ + private static void assertObjectEquals( + OracleR2dbcObject object, Object[] values, boolean isDefaultMapping) { + + for (int i = 0; i < values.length; i++) { + final Object expected; + final Object actual; + + if (values[i] instanceof Parameter) { + expected = ((Parameter)values[i]).getValue(); + } + else { + expected = values[i]; + } + + + if (isDefaultMapping || expected == null) { + actual = object.get(i); + } + else if (values[i] instanceof Parameter + && ((Parameter)values[i]).getType() instanceof ObjectType) { + + // Recursively compare Object[] and Map binds with OBJECT attributes + OracleR2dbcObject objectAttribute = + object.get(i, OracleR2dbcObject.class); + + if (expected instanceof Object[]) { + assertObjectEquals(objectAttribute, (Object[]) expected, false); + continue; + } + else if (expected instanceof Map) { + @SuppressWarnings("unchecked") + Map expectedMap = (Map) expected; + TreeMap treeMap = + new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + treeMap.putAll(expectedMap); + assertObjectEquals( + objectAttribute, + objectAttribute.getMetadata() + .getAttributeMetadatas() + .stream() + .map(metadata -> treeMap.get(metadata.getName())) + .toArray(), + false); + continue; + } + else { + actual = objectAttribute; + } + } + else { + final Class expectedClass; + + // Request a supported super class type, rather than a specific + // subclass of the expected value: ByteBuffer rather than + // HeapByteBuffer, for instance. + if (expected instanceof ByteBuffer) + expectedClass = ByteBuffer.class; + else if (expected instanceof OracleR2dbcObject) + expectedClass = OracleR2dbcObject.class; + else + expectedClass = expected.getClass(); + + actual = object.get(i, expectedClass); + } + + String message = "Mismatch at attribute index " + i; + + if (expected instanceof OracleR2dbcObject + && actual instanceof OracleR2dbcObject) { + // Need to compare default mappings as OracleR2dbcObject does not + // implement equals. + assertArrayEquals( + toArray((OracleR2dbcObject) expected), + toArray((OracleR2dbcObject) actual), + message); + } + else if (expected instanceof Object[] && actual instanceof Object[]) + assertArrayEquals((Object[]) expected, (Object[]) actual, message); + else + assertEquals(expected, actual, message); + } + } + + /** Converts an OBJECT to an Object[] of each attribute's default Java type */ + private static Object[] toArray(OracleR2dbcObject object) { + Object[] array = new Object[ + object.getMetadata().getAttributeMetadatas().size()]; + + for (int i = 0; i < array.length; i++) { + array[i] = object.get(i); + + if (array[i] instanceof OracleR2dbcObject) + array[i] = toArray((OracleR2dbcObject) array[i]); + } + + return array; + } + // 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. @@ -562,13 +2032,28 @@ private static void verifyTypeMapping( Function rowMapper, BiConsumer verifyEquals) { String table = "verify_" + sqlTypeDdl.replaceAll("[^\\p{Alnum}]", "_"); try { - awaitExecution(connection.createStatement(String.format( + awaitExecution(connection.createStatement(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 if (javaValue instanceof OracleR2dbcObject) { + Type type = + ((OracleR2dbcObject)javaValue).getMetadata().getObjectType(); + 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( diff --git a/src/test/java/oracle/r2dbc/test/DatabaseConfig.java b/src/test/java/oracle/r2dbc/test/DatabaseConfig.java index 133833e..c8d3d75 100644 --- a/src/test/java/oracle/r2dbc/test/DatabaseConfig.java +++ b/src/test/java/oracle/r2dbc/test/DatabaseConfig.java @@ -21,7 +21,6 @@ package oracle.r2dbc.test; -import io.r2dbc.spi.ColumnMetadata; import io.r2dbc.spi.Connection; import io.r2dbc.spi.ConnectionFactories; import io.r2dbc.spi.ConnectionFactory; @@ -30,7 +29,6 @@ import oracle.jdbc.OracleConnection; import oracle.r2dbc.util.SharedConnectionFactory; import org.reactivestreams.Publisher; -import reactor.core.publisher.Flux; import java.io.FileNotFoundException; import java.io.InputStream; @@ -38,7 +36,6 @@ import java.sql.SQLException; import java.time.Duration; import java.util.Properties; -import java.util.stream.Collectors; /** * Stores configuration used by integration tests that connect to a database. @@ -191,28 +188,6 @@ public static int databaseVersion() { } } - /** - * Queries the {@code user_errors} data dictionary view and prints all rows. - * When writing new tests that declare a PL/SQL procedure or function, - * "ORA-17110: executed completed with a warning" results if the PL/SQL has - * a syntax error. The error details will be printed by calling this method. - */ - public static void showErrors(Connection connection) { - Flux.from(connection.createStatement( - "SELECT * FROM user_errors ORDER BY sequence") - .execute()) - .flatMap(result -> - result.map((row, metadata) -> - metadata.getColumnMetadatas() - .stream() - .map(ColumnMetadata::getName) - .map(name -> name + ": " + row.get(name)) - .collect(Collectors.joining("\n")))) - .toStream() - .map(errorText -> "\n" + errorText) - .forEach(System.err::println); - } - /** * Returns the options parsed from the "config.properties" resource. */ diff --git a/src/test/java/oracle/r2dbc/test/TestUtils.java b/src/test/java/oracle/r2dbc/test/TestUtils.java new file mode 100644 index 0000000..0c9948b --- /dev/null +++ b/src/test/java/oracle/r2dbc/test/TestUtils.java @@ -0,0 +1,70 @@ +package oracle.r2dbc.test; + +import io.r2dbc.spi.ColumnMetadata; +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.Parameters; +import io.r2dbc.spi.Statement; +import oracle.r2dbc.OracleR2dbcObject; +import oracle.r2dbc.OracleR2dbcTypes; +import reactor.core.publisher.Flux; + +import java.util.Arrays; +import java.util.stream.Collectors; + +import static java.lang.String.format; +import static oracle.r2dbc.util.Awaits.awaitOne; + +public class TestUtils { + + /** + * Queries the {@code user_errors} data dictionary view and prints all rows. + * When writing new tests that declare a PL/SQL procedure or function, + * "ORA-17110: executed completed with a warning" results if the PL/SQL has + * a syntax error. The error details will be printed by calling this method. + */ + public static void showErrors(Connection connection) { + Flux.from(connection.createStatement( + "SELECT * FROM user_errors ORDER BY sequence") + .execute()) + .flatMap(result -> + result.map((row, metadata) -> + metadata.getColumnMetadatas() + .stream() + .map(ColumnMetadata::getName) + .map(name -> name + ": " + row.get(name)) + .collect(Collectors.joining("\n")))) + .toStream() + .map(errorText -> "\n" + errorText) + .forEach(System.err::println); + } + + /** + * Constructs an OBJECT of a given {@code objectType} with the given attribute + * {@code attributeValues}. + */ + public static OracleR2dbcObject constructObject( + Connection connection, OracleR2dbcTypes.ObjectType objectType, + Object... attributeValues) { + + Statement constructor = connection.createStatement(format( + "{? = call %s(%s)}", + objectType.getName(), + Arrays.stream(attributeValues) + .map(value -> + // Bind the NULL literal, as SQL type of the bind value can not be + // inferred from a null value + value == null ? "NULL" : "?") + .collect(Collectors.joining(",")))); + + constructor.bind(0, Parameters.out(objectType)); + + for (int i = 0; i < attributeValues.length; i++) { + if (attributeValues[i] != null) + constructor.bind(i + 1, attributeValues[i]); + } + + return awaitOne(Flux.from(constructor.execute()) + .flatMap(result -> + result.map(row -> row.get(0, OracleR2dbcObject.class)))); + } +}