Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Read an ARRAY from a STRUCT returned by a stored procedure

Tags:

java

oracle

jdbc

In the database are three Oracle custom types (simplified) as follows:

create or replace TYPE T_ENCLOSURE AS OBJECT(
  ENCLOSURE_ID      NUMBER(32,0),
  ENCLOSURE_NAME    VARCHAR2(255 BYTE),
  ANIMALS           T_ARRAY_ANIMALS,

  MEMBER FUNCTION   CHECK_IF_RED RETURN BOOLEAN
);


create or replace TYPE T_ARRAY_ANIMALS is TABLE OF T_ANIMAL;


create or replace TYPE T_ANIMAL AS OBJECT(
  ANIMAL_ID NUMBER(32,0),
  NUMBER_OF_HAIRS NUMBER(32,0)
);

and a function, that build the object tree

FUNCTION GET_ENCLOSURE ( f_enclosure_id zoo_schema.ENCLOSURE_TABLE.ENCLOSURE_ID%TYPE ) RETURN T_ENCLOSURE
AS
    v_ENC T_ENCLOSURE;
    v_idx pls_integer;

BEGIN

    v_ENC := T_ENCLOSURE(
        f_enclosure_id,
        NULL,
        T_ARRAY_ANIMALS(T_ANIMAL(NULL,NULL))
    );

    SELECT ENCLOSURE_NAME
    INTO   v_ENC.ENCLOSURE_NAME
    FROM   ENCLOSURE_TABLE WHERE ENCLOSURE_ID = f_ENCLOSURE_ID;

    SELECT
        CAST(MULTISET(
            SELECT ANIMAL_ID, NUMBER_OF_HAIRS
            FROM   ANIMAL_TABLE
            WHERE  ENCLOSURE_ID = f_ENCLOSURE_ID
        ) AS T_ARRAY_ANIMALS
    )
    INTO v_ENC.ANIMALS
    FROM dual;

RETURN v_ENC;

END;

Now I want to call the GET_ENCLOSURE function and work with its result T_ENCLOSURE object in my Java code.

// prepare the call
Connection connection = MyConnectionFactory.getConnection(SOME_CONNECTION_CONFIG);
CallableStatement stmt = connection.prepareCall("{? = call zoo_schema.zoo_utils.GET_ENCLOSURE( ? )}");
stmt.registerOutParameter(1, OracleTypes.STRUCT, "zoo_schema.T_ENCLOSURE");
stmt.setInt(2, 6);  // fetch data for ENCLOSURE#6

// execute function
stmt.executeQuery();

// extract the result
Struct resultStruct = (Struct)stmt.getObject(1); // java.sql.Struct

I can access ID and NAME via

Integer id = ((BigInteger)resultStruct.getAttributes()[0]).intValue(); // works for me
String name = (String)resultStruct.getAttributes()[1]); // works for me

However, I cannot seem to get the list of animals

resultStruct.getAttributes()[2].getClass().getCanonicalName(); // oracle.sql.ARRAY
ARRAY arrayAnimals = (ARRAY)jdbcStruct.getAttributes()[2];
arrayAnimals.getArray(); // throws a java.sql.SQLException("Internal Error: Unable to resolve name")

I had a bit of trial and error here including

OracleConnection oracleConnection = connection.unwrap(OracleConnection.class);
STRUCT resultOracleStruct = (STRUCT) stmt.getObject(1); // oracle.sql.STRUCT
oracleConnection.createARRAY("zoo_schema.T_ARRAY_ANIMALS", resultOracleStruct.getAttributes()[2]) // throws an SQLException("Fail to convert to internal representation: oracle.sql.ARRAY@8de7cfc4")

But no luck either.

How can I get the list of animals into a List<TAnimal>?

like image 409
DerMike Avatar asked Mar 17 '17 09:03

DerMike


2 Answers

Create objects that implement java.sql.SQLData. In this scenario, create TEnclosure and TAnimal classes, which both implement SQLData.

Just FYI, in newer Oracle JDBC versions, types such as oracle.sql.ARRAY are deprecated in favor of java.sql types. Although I'm not sure how to write an array (described bellow) using only java.sql API.

When you implement readSQL() you read fields in order. You obtain a java.sql.Array with sqlInput.readArray(). So TEnclosure.readSQL() would look something like this.

@Override
public void readSQL(SQLInput sqlInput, String s) throws SQLException {
    id = sqlInput.readBigDecimal();
    name = sqlInput.readString();
    Array animals = sqlInput.readArray();
    // what to do here...
}

Note: readInt() also exists, but Oracle JDBC seems to always provide BigDecimal for NUMBER

You will notice that some APIs such as java.sql.Array have methods that take a type map Map<String, Class<?>> This is a mapping of Oracle type names to their corresponding Java class implementing SQLData (ORAData may work too?).

If you just call Array.getArray(), you will get Struct objects unless the JDBC driver knows about your type mappings via Connection.setTypeMap(typeMap). However, setting typeMap on the connection didn't work for me, so I use getArray(typeMap)

Create your Map<String, Class<?>> typeMap somewhere and add entries for your types:

typeMap.put("T_ENCLOSURE", TEnclosure.class);
typeMap.put("T_ANIMAL", TAnimal.class);

Within a SQLData.readSQL() implementation, call sqlInput.readArray().getArray(typeMap), which returns Object[] where the Object entries or of type TAnimal.

Of course the code to convert to a List<TAnimal> gets tedious, so just use this utility function and adjust it for your needs as far as null vs empty list policy:

/**
 * Constructs a list from the given SQL Array
 * Note: this needs to be static because it's called from SQLData classes.
 *
 * @param <T> SQLData implementing class
 * @param array Array containing objects of type T
 * @param typeClass Class reference used to cast T type
 * @return List<T> (empty if array=null)
 * @throws SQLException
 */
public static <T> List<T> listFromArray(Array array, Class<T> typeClass) throws SQLException {
    if (array == null) {
        return Collections.emptyList();
    }
    // Java does not allow casting Object[] to T[]
    final Object[] objectArray = (Object[]) array.getArray(getTypeMap());
    List<T> list = new ArrayList<>(objectArray.length);
    for (Object o : objectArray) {
        list.add(typeClass.cast(o));
    }
    return list;
}

Writing Arrays

Figuring out how to write an array was frustrating, Oracle APIs require a Connection to create an Array, but you don't have an obvious Connection in the context of writeSQL(SQLOutput sqlOutput). Fortunately, this blog has a trick/hack to get the OracleConnection, which I've used here.

When you create an array with createOracleArray() you specify the list type (T_ARRAY_ANIMALS) for the type name, NOT the singular object type.

Here's a generic function for writing arrays. In your case, listType would be "T_ARRAY_ANIMALS" and you would pass in List<TAnimal>

/**
 * Write the list out as an Array
 *
 * @param sqlOutput SQLOutput to write array to
 * @param listType array type name (table of type)
 * @param list List of objects to write as an array
 * @param <T> Class implementing SQLData that corresponds to the type listType is a list of.
 * @throws SQLException
 * @throws ClassCastException if SQLOutput is not an OracleSQLOutput
 */
public static <T> void writeArrayFromList(SQLOutput sqlOutput, String listType, @Nullable List<T> list) throws SQLException {
    final OracleSQLOutput out = (OracleSQLOutput) sqlOutput;
    OracleConnection conn = (OracleConnection) out.getSTRUCT().getJavaSqlConnection();
    conn.setTypeMap(getTypeMap());  // not needed?
    if (list == null) {
        list = Collections.emptyList();
    }
    final Array array = conn.createOracleArray(listType, list.toArray());
    out.writeArray(array);
}

Notes:

  • At one point I thought setTypeMap was required, but now when I remove that line my code still works, so I'm not sure if it's necessary.
  • I'm not certain if you should write null or an empty array, but I assumed the empty array is more correct.

Tips on Oracle types

  • Oracle uppercases everything, so all type names should be uppercase.
  • You may need to specify SCHEMA.TYPE_NAME if the type isn't in your default schema.
  • Remember to grant execute on types if the user you are connecting with is not the owner.
    If you have execute on the package, but not the type, getArray() will throw an exception when it tries to look for type metadata.

Spring

For developers using Spring, you may want to look at Spring Data JDBC Extensions, which provides SqlArrayValue and SqlReturnArray, which are useful for creating a SimpleJdbcCall for a procedure that takes an array as an argument or returns an array.

Chapter 7.2.1 Setting ARRAY values using SqlArrayValue for an IN parameter explains how to call procedures with array parameters.

like image 189
Arlo Avatar answered Sep 21 '22 13:09

Arlo


As long as a Oracle specific solution is sufficient, the key lies within the DTOs. All of them have to implement ORAData and ORADataFactory

public class TAnimal implements ORAData, ORADataFactory {
    Integer animal_id, number_of_hairs;

    public TAnimal() { }

    // [ Getter and Setter omitted here ]

    @Override
    public Datum toDatum(Connection connection) throws SQLException {
        OracleConnection oracleConnection = connection.unwrap(OracleConnection.class);
        StructDescriptor structDescriptor = StructDescriptor.createDescriptor("zoo_schema.T_ANIMAL", oracleConnection);
        Object[] attributes = {
                this.animal_id,
                this.number_of_hairs
        };
        return new STRUCT(structDescriptor, oracleConnection, attributes);
    }

    @Override
    public TAnimal create(Datum datum, int sqlTypeCode) throws SQLException {
        if (datum == null) {
            return null;
        }
        Datum[] attributes = ((STRUCT) datum).getOracleAttributes();
        TAnimal result = new TAnimal();
        result.animal_id = asInteger(attributes[0]); // see TEnclosure#asInteger(Datum)
        result.number_of_hairs = asInteger(attributes[1]); // see TEnclosure#asInteger(Datum)
        return result;
    }

}

and

public class TEnclosure implements ORAData, ORADataFactory {

    Integer enclosureId;
    String enclosureName;
    List<Animal> animals;

    public TEnclosure() {
        this.animals = new ArrayList<>();
    }

    // [ Getter and Setter omitted here ]

    @Override
    public Datum toDatum(Connection connection) throws SQLException {
        OracleConnection oracleConnection = connection.unwrap(OracleConnection.class);
        StructDescriptor structDescriptor = StructDescriptor.createDescriptor("zoo_schema.T_ENCLOSURE", oracleConnection);
        Object[] attributes = {
                this.enclosureId,
                this.enclosureName,
                null // TODO: solve this; however, retrieving data works without this
        };
        return new STRUCT(structDescriptor, oracleConnection, attributes);
    }

    @Override
    public TEnclosure create(Datum datum, int sqlTypeCode) throws SQLException {
        if (datum == null) {
            return null;
        }
        Datum[] attributes = ((STRUCT) datum).getOracleAttributes();
        TEnclosure result = new TEnclosure();
        result.enclosureId = asInteger(attributes[0]);
        result.enclosureName = asString(attributes[1]);
        result.animals = asListOfAnimals(attributes[2]);
        return result;
    }

    // Utility methods

    Integer asInteger(Datum datum) throws SQLException {
        if (datum == null)
            return null;
        else
            return ((NUMBER) datum).intValue(); // oracle.sql.NUMBER
    }

    String asString(Datum datum) throws SQLException {
        if (datum = null)
            return null;
        else
            return ((CHAR) datum).getString(); // oracle.sql.CHAR
    }

    List<TAnimal> asListOfAnimals(Datum datum) throws SQLException {
        if (datum == null)
            return null;
        else {
            TAnimal factory = new TAnimal();

            List<TAnimal> result = new ArrayList<>();

            ARRAY array = (ARRAY) datum; // oracle.sql.ARRAY
            Datum[] elements = array.getOracleArray();
            for (int i = 0; i < elements.length; i++) {
                result.add(factory.create(elements[i], 0));
            }
            return result;
        }
    }
}

then fetching the data works like so:

    TEnclosure factory = new TEnclosure();

    Connection connection = null;
    OracleConnection oracleConnection = null;
    OracleCallableStatement oracleCallableStatement = null;

    try {
        connection = MyConnectionFactory.getConnection(SOME_CONNECTION_CONFIG);
        oracleConnection = connection.unwrap(OracleConnection.class);
        oracleCallableStatement = (OracleCallableStatement) oracleConnection.prepareCall("{? = call zoo_schema.zoo_utils.GET_ENCLOSURE( ? )}");

        oracleCallableStatement.registerOutParameter(1, OracleTypes.STRUCT, "zoo_schema.T_ENCLOSURE");
        oracleCallableStatement.setInt(2, 6);  // fetch data for ENCLOSURE#6

        // Execute query
        oracleCallableStatement.executeQuery();

        // Check result
        Object oraData = oracleCallableStatement.getORAData(1, factory);
        LOGGER.info("oraData is a {}", oraData.getClass().getName()); // acme.zoo.TEnclosure

    } finally {
        ResourceUtils.closeQuietly(oracleCallableStatement);
        ResourceUtils.closeQuietly(oracleConnection);
        ResourceUtils.closeQuietly(connection); // probably not necessary...
    }
like image 24
DerMike Avatar answered Sep 21 '22 13:09

DerMike