diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index c80b47e..37c4627 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -31,7 +31,7 @@ jobs: run: mvn -B package --file pom.xml - name: Archive artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: OFHSqlDriver path: | diff --git a/out/artifacts/OracleBICatalog/OracleCloudSQLQuery.catalog b/out/artifacts/OracleBICatalog/OracleCloudSQLQuery.catalog index 340643a..9e0ac70 100644 Binary files a/out/artifacts/OracleBICatalog/OracleCloudSQLQuery.catalog and b/out/artifacts/OracleBICatalog/OracleCloudSQLQuery.catalog differ diff --git a/src/main/java/com/oraclefusionhub/jdbc/OFHConnection.java b/src/main/java/com/oraclefusionhub/jdbc/OFHConnection.java index bfb01c6..07ad9c1 100644 --- a/src/main/java/com/oraclefusionhub/jdbc/OFHConnection.java +++ b/src/main/java/com/oraclefusionhub/jdbc/OFHConnection.java @@ -16,10 +16,12 @@ public class OFHConnection implements java.sql.Connection { private String fullURL; private String basicAuth; private Boolean closed; + private final boolean debugEnabled; public OFHConnection(String url, Properties properties) throws SQLException { this.closed = false; + this.debugEnabled = isDebugEnabled(properties); try { String user; @@ -71,12 +73,12 @@ public Statement createStatement() throws SQLException { conn.setRequestProperty("Content-Type", "application/soap+xml"); conn.setRequestProperty("SOAPAction", "runReport"); conn.setRequestProperty("Authorization", this.basicAuth); - return new OFHStatement(conn, reportPath); + return new OFHStatement(conn, reportPath, debugEnabled); } @Override public PreparedStatement prepareStatement(String s) throws SQLException { - System.out.println("PrepareStatement: " + s); + debug("PrepareStatement: " + s); return null; } @@ -1063,7 +1065,7 @@ public void clearWarnings() throws SQLException { @Override public Statement createStatement(int i, int i1) throws SQLException { - return null; + return createStatement(); } @Override @@ -1117,7 +1119,7 @@ public void releaseSavepoint(Savepoint savepoint) throws SQLException { @Override public Statement createStatement(int i, int i1, int i2) throws SQLException { - return null; + return createStatement(); } @Override @@ -1167,10 +1169,25 @@ public SQLXML createSQLXML() throws SQLException { @Override public boolean isValid(int i) throws SQLException { - System.out.println("isvalid: " + i); + debug("isvalid: " + i); return true; } + private boolean isDebugEnabled(Properties properties) { + Object debugProperty = properties.get("debug"); + if (debugProperty != null) { + return Boolean.parseBoolean(String.valueOf(debugProperty)); + } + + return Boolean.parseBoolean(System.getProperty("ofh.debug", "false")); + } + + private void debug(String message) { + if (debugEnabled) { + System.out.println(message); + } + } + @Override public void setClientInfo(String s, String s1) throws SQLClientInfoException { diff --git a/src/main/java/com/oraclefusionhub/jdbc/OFHDriver.java b/src/main/java/com/oraclefusionhub/jdbc/OFHDriver.java index 93d5993..c6952f1 100644 --- a/src/main/java/com/oraclefusionhub/jdbc/OFHDriver.java +++ b/src/main/java/com/oraclefusionhub/jdbc/OFHDriver.java @@ -29,7 +29,6 @@ public OFHDriver() {} @Override public Connection connect(String url, Properties properties) throws SQLException { - System.out.println("Props: " + properties); url = getURL(url); return new OFHConnection(url, properties); @@ -49,8 +48,12 @@ private String getURL(String fullJDBCPath) { @Override public DriverPropertyInfo[] getPropertyInfo(String s, Properties properties) throws SQLException { DriverPropertyInfo reportPathProp = new DriverPropertyInfo("reportPath", "/Custom/OracleCloudSQLQuery/SQLQuery.xdo"); - DriverPropertyInfo[] dpi = new DriverPropertyInfo[1]; + DriverPropertyInfo debugProp = new DriverPropertyInfo("debug", "false"); + debugProp.description = "Enable verbose driver debug logging"; + debugProp.choices = new String[] { "true", "false" }; + DriverPropertyInfo[] dpi = new DriverPropertyInfo[2]; dpi[0] = reportPathProp; + dpi[1] = debugProp; return dpi; } diff --git a/src/main/java/com/oraclefusionhub/jdbc/OFHResultSet.java b/src/main/java/com/oraclefusionhub/jdbc/OFHResultSet.java index 8028641..4f2d01d 100644 --- a/src/main/java/com/oraclefusionhub/jdbc/OFHResultSet.java +++ b/src/main/java/com/oraclefusionhub/jdbc/OFHResultSet.java @@ -21,40 +21,78 @@ import java.sql.Statement; import java.sql.Time; import java.sql.Timestamp; +import java.sql.Types; import java.util.*; public class OFHResultSet implements ResultSet { - private final Iterator iterator; - private CSVRecord record = null; - private final CSVRecord header; + private final List header; + private final List> rows; + private final Map columnIndexByName; + private int currentRowIndex = -1; + private List record = null; + private boolean closed = false; + private boolean lastWasNull = false; public OFHResultSet(Iterable s) { - this.iterator = s.iterator(); - this.header = this.iterator.next(); + Iterator iterator = s.iterator(); + this.header = new ArrayList<>(); + this.rows = new ArrayList<>(); + if (iterator.hasNext()) { + CSVRecord headerRecord = iterator.next(); + for (String value : headerRecord) { + this.header.add(value); + } + + while (iterator.hasNext()) { + CSVRecord csvRecord = iterator.next(); + List row = new ArrayList<>(); + for (String value : csvRecord) { + row.add(value); + } + this.rows.add(row); + } + } + + this.columnIndexByName = createColumnIndex(this.header); + } + + public OFHResultSet(List header, List> rows) { + this.header = new ArrayList<>(header); + this.rows = new ArrayList<>(); + for (List row : rows) { + this.rows.add(new ArrayList<>(row)); + } + this.columnIndexByName = createColumnIndex(this.header); } @Override public boolean next() throws SQLException { - boolean retVal = iterator.hasNext(); - - if (iterator.hasNext()) { - //record = List.of(iterator.next().split(FIELD_SEPARATOR, -1)); - record = iterator.next(); + ensureOpen(); + if (currentRowIndex + 1 < rows.size()) { + currentRowIndex++; + record = rows.get(currentRowIndex); + return true; } - return retVal; + record = null; + currentRowIndex = rows.size(); + lastWasNull = false; + return false; } @Override public void close() throws SQLException { + closed = true; + record = null; } @Override public boolean wasNull() throws SQLException { - return false; + ensureOpen(); + return lastWasNull; } @Override public String getString(int i) throws SQLException { - return record.get(i-1); + return getValue(i); } @Override @@ -134,7 +172,7 @@ public InputStream getBinaryStream(int i) throws SQLException { @Override public String getString(String s) throws SQLException { - return null; + return getString(findColumn(s)); } @Override @@ -229,6 +267,7 @@ public String getCursorName() throws SQLException { @Override public ResultSetMetaData getMetaData() throws SQLException { + ensureOpen(); return new ResultSetMetaData() { @Override public int getColumnCount() throws SQLException { @@ -272,6 +311,7 @@ public int getColumnDisplaySize(int column) throws SQLException { @Override public String getColumnLabel(int column) throws SQLException { + validateColumnIndex(column); return header.get(column - 1); } @@ -307,12 +347,14 @@ public String getCatalogName(int column) throws SQLException { @Override public int getColumnType(int column) throws SQLException { - return 0; + validateColumnIndex(column); + return Types.VARCHAR; } @Override public String getColumnTypeName(int column) throws SQLException { - return null; + validateColumnIndex(column); + return "VARCHAR"; } @Override @@ -332,7 +374,8 @@ public boolean isDefinitelyWritable(int column) throws SQLException { @Override public String getColumnClassName(int column) throws SQLException { - return null; + validateColumnIndex(column); + return String.class.getName(); } @Override @@ -349,17 +392,22 @@ public boolean isWrapperFor(Class iface) throws SQLException { @Override public Object getObject(int i) throws SQLException { - return null; + return getString(i); } @Override public Object getObject(String s) throws SQLException { - return null; + return getString(s); } @Override public int findColumn(String s) throws SQLException { - return 2; + ensureOpen(); + Integer index = columnIndexByName.get(s); + if (index == null) { + throw new SQLException("Column not found: " + s); + } + return index + 1; } @Override @@ -384,47 +432,76 @@ public BigDecimal getBigDecimal(String s) throws SQLException { @Override public boolean isBeforeFirst() throws SQLException { - return false; + ensureOpen(); + return currentRowIndex < 0 && !rows.isEmpty(); } @Override public boolean isAfterLast() throws SQLException { - return false; + ensureOpen(); + return currentRowIndex >= rows.size() && !rows.isEmpty(); } @Override public boolean isFirst() throws SQLException { - return false; + ensureOpen(); + return currentRowIndex == 0 && record != null; } @Override public boolean isLast() throws SQLException { - return false; + ensureOpen(); + return currentRowIndex == rows.size() - 1 && record != null; } @Override public void beforeFirst() throws SQLException { - + ensureOpen(); + currentRowIndex = -1; + record = null; + lastWasNull = false; } @Override public void afterLast() throws SQLException { - + ensureOpen(); + currentRowIndex = rows.size(); + record = null; + lastWasNull = false; } @Override public boolean first() throws SQLException { - return false; + ensureOpen(); + if (rows.isEmpty()) { + record = null; + currentRowIndex = -1; + return false; + } + currentRowIndex = 0; + record = rows.get(currentRowIndex); + lastWasNull = false; + return true; } @Override public boolean last() throws SQLException { - return false; + ensureOpen(); + if (rows.isEmpty()) { + record = null; + currentRowIndex = -1; + return false; + } + currentRowIndex = rows.size() - 1; + record = rows.get(currentRowIndex); + lastWasNull = false; + return true; } @Override public int getRow() throws SQLException { - return 0; + ensureOpen(); + return record == null ? 0 : currentRowIndex + 1; } @Override @@ -442,6 +519,45 @@ public boolean previous() throws SQLException { return false; } + private Map createColumnIndex(List columns) { + Map indexByName = new LinkedHashMap<>(); + for (int i = 0; i < columns.size(); i++) { + indexByName.put(columns.get(i), i); + } + return indexByName; + } + + private String getValue(int columnIndex) throws SQLException { + ensureOpen(); + if (record == null) { + throw new SQLException("Cursor not positioned on a row"); + } + + validateColumnIndex(columnIndex); + int zeroBasedIndex = columnIndex - 1; + + if (zeroBasedIndex >= record.size()) { + lastWasNull = true; + return null; + } + + String value = record.get(zeroBasedIndex); + lastWasNull = value == null; + return value; + } + + private void validateColumnIndex(int columnIndex) throws SQLException { + if (columnIndex < 1 || columnIndex > header.size()) { + throw new SQLException("Invalid column index: " + columnIndex); + } + } + + private void ensureOpen() throws SQLException { + if (closed) { + throw new SQLException("ResultSet is closed"); + } + } + @Override public void setFetchDirection(int i) throws SQLException { @@ -869,13 +985,12 @@ public void updateRowId(String s, RowId rowId) throws SQLException { @Override public int getHoldability() throws SQLException { - return 0; + return ResultSet.HOLD_CURSORS_OVER_COMMIT; } @Override public boolean isClosed() throws SQLException { - System.out.println("isClosed::"); - return false; + return closed; } @Override @@ -1090,12 +1205,12 @@ public void updateNClob(String s, Reader reader) throws SQLException { @Override public T getObject(int i, Class aClass) throws SQLException { - return null; + return aClass.cast(getObject(i)); } @Override public T getObject(String s, Class aClass) throws SQLException { - return null; + return aClass.cast(getObject(s)); } @Override diff --git a/src/main/java/com/oraclefusionhub/jdbc/OFHStatement.java b/src/main/java/com/oraclefusionhub/jdbc/OFHStatement.java index 734b4a0..67d9046 100644 --- a/src/main/java/com/oraclefusionhub/jdbc/OFHStatement.java +++ b/src/main/java/com/oraclefusionhub/jdbc/OFHStatement.java @@ -15,21 +15,30 @@ import java.nio.charset.StandardCharsets; import java.sql.*; import java.text.MessageFormat; +import java.util.ArrayList; import java.util.Base64; import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import java.util.NoSuchElementException; +import java.util.zip.GZIPInputStream; import org.apache.commons.csv.CSVRecord; public class OFHStatement implements Statement { private final HttpURLConnection httpURLConnection; private final String payload; + private final boolean debugEnabled; private ResultSet rs; private final String reportPath; + private int maxRows = 0; - OFHStatement(HttpURLConnection connection, String reportPath) { + OFHStatement(HttpURLConnection connection, String reportPath, boolean debugEnabled) { this.httpURLConnection = connection; this.reportPath = reportPath; + this.debugEnabled = debugEnabled; this.payload = "\n" + " \n" + @@ -55,6 +64,7 @@ public class OFHStatement implements Statement { @Override public ResultSet executeQuery(String s) throws SQLException { + debug("OFHStatement.executeQuery SQL: " + s); String query = encodeXML(s); Object[] params = new Object[] { query, this.reportPath }; String finalPayload = MessageFormat.format(this.payload, params); @@ -70,7 +80,7 @@ public ResultSet executeQuery(String s) throws SQLException { os.close(); responseCode = conn.getResponseCode(); } catch (IOException e) { - System.out.println("error while getting outstream: " + e.getStackTrace()); + debug("error while getting outstream: " + e.getStackTrace()); } StringBuffer response = new StringBuffer(); @@ -78,8 +88,9 @@ public ResultSet executeQuery(String s) throws SQLException { if (responseCode == HttpURLConnection.HTTP_OK) { try { getOutput(conn, response, responseCode); + debug("OFHStatement HTTP 200 response length: " + response.length()); } catch (IOException e) { - System.out.println("error while getting instream response: " + e.getStackTrace()); + debug("error while getting instream response: " + e.getStackTrace()); } } else { @@ -87,7 +98,7 @@ public ResultSet executeQuery(String s) throws SQLException { String errorResponseReason = null; try { getOutput(conn, errorResponse, responseCode); - System.out.println("Error Response: " + errorResponse.toString()); + debug("Error Response: " + errorResponse.toString()); DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); try { @@ -111,13 +122,15 @@ public ResultSet executeQuery(String s) throws SQLException { } conn.disconnect(); - String responseCsv = null; + String responseContent = null; DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); try { DocumentBuilder builder = factory.newDocumentBuilder(); Document doc = builder.parse(new InputSource(new StringReader(response.toString()))); - responseCsv = getResponseCSVString(doc); + responseContent = getResponseContentString(doc); + } catch (RuntimeException e) { + throw new SQLException("Parsing Error: " + e.getMessage(), e); } catch (ParserConfigurationException e) { throw new SQLException("Parsing Error: " + e.getMessage()); } catch (IOException e) { @@ -126,110 +139,354 @@ public ResultSet executeQuery(String s) throws SQLException { throw new SQLException("Parsing Error: " + e.getMessage()); } - Iterable records; + return buildResultSet(responseContent); + } + + private String getResponseContentString(Document doc) { + String base64Content = getContentDataFromDoc(doc); + debug("OFHStatement reportBytes base64 length: " + base64Content.length()); + + byte[] decodedBytes = Base64.getDecoder().decode(base64Content); + debug("OFHStatement decoded payload bytes: " + decodedBytes.length); + + String decodedContent = new String(decodedBytes, StandardCharsets.UTF_8); + debug("OFHStatement decoded payload preview: " + preview(decodedContent)); + return decodedContent; + } + + private ResultSet buildResultSet(String responseContent) throws SQLException { + String trimmedContent = stripBom(responseContent == null ? "" : responseContent).trim(); + debug("OFHStatement trimmed payload preview: " + preview(trimmedContent)); + if (trimmedContent.startsWith("<")) { + return buildXmlPayloadResultSet(trimmedContent); + } + + return buildCsvResultSet(responseContent); + } + + private ResultSet buildXmlPayloadResultSet(String xmlContent) throws SQLException { try { - records = CSVFormat.DEFAULT.parse(new StringReader(responseCsv)); + String embeddedRowset = extractEmbeddedRowsetFromPayload(xmlContent); + if (embeddedRowset != null) { + debug("OFHStatement parser branch: embedded XML ROWSET"); + return buildXmlResultSet(parseXmlDocument(embeddedRowset)); + } + + Document xmlResult = parseXmlDocument(xmlContent); + Node rootNode = xmlResult.getDocumentElement(); + String rootName = rootNode == null ? "null" : rootNode.getNodeName(); + debug("OFHStatement payload XML root: " + rootName); + + if (matchesNodeName(rootNode, "ROWSET")) { + debug("OFHStatement parser branch: XML ROWSET"); + return buildXmlResultSet(xmlResult); + } + + if (matchesNodeName(rootNode, "DATA_DS")) { + embeddedRowset = extractEmbeddedRowset(rootNode); + if (embeddedRowset != null) { + debug("OFHStatement parser branch: embedded XML ROWSET"); + return buildXmlResultSet(parseXmlDocument(embeddedRowset)); + } + + if (isEmptyDataSet(rootNode)) { + debug("OFHStatement parser branch: empty DATA_DS result"); + return emptyResultSet(); + } + } + + throw new SQLException("Unsupported XML payload root: " + rootName); + } catch (ParserConfigurationException | SAXException | IOException e) { + throw new SQLException("XML Result Parsing Error: " + e.getMessage(), e); + } + } + + private String extractEmbeddedRowsetFromPayload(String xmlContent) { + String normalizedContent = stripBom(xmlContent); + if (normalizedContent == null || !normalizedContent.contains("")) { + return null; + } + + int resultStart = normalizedContent.indexOf(""); + int resultEnd = normalizedContent.indexOf("", resultStart); + if (resultStart < 0 || resultEnd < 0) { + return null; + } + + String escapedRowset = normalizedContent.substring(resultStart + "".length(), resultEnd); + String unescapedRowset = unescapeXml(escapedRowset).trim(); + if (unescapedRowset.startsWith("")) { + debug("OFHStatement embedded ROWSET preview: " + preview(unescapedRowset)); + return unescapedRowset; + } + + return null; + } + + private ResultSet buildCsvResultSet(String responseContent) throws SQLException { + try { + Iterable parsedRecords = CSVFormat.DEFAULT.parse(new StringReader(responseContent)); + List records = new ArrayList<>(); + for (CSVRecord record : parsedRecords) { + records.add(record); + } + + if (records.isEmpty()) { + debug("OFHStatement CSV parser found no records"); + return emptyResultSet(); + } + + CSVRecord header = records.get(0); + debug("OFHStatement parser branch: CSV. Header columns: " + header.size() + " -> " + header); + if (records.size() > 1) { + CSVRecord firstRow = records.get(1); + debug("OFHStatement CSV first row columns: " + firstRow.size() + " preview: " + preview(firstRow.toString())); + } else { + debug("OFHStatement CSV has header only"); + } + + if (maxRows > 0 && records.size() > maxRows + 1) { + debug("OFHStatement applying maxRows to CSV result: " + maxRows); + records = new ArrayList<>(records.subList(0, maxRows + 1)); + } + + return new OFHResultSet(records); } catch (IOException e) { throw new SQLException("CSV Parsing Error: " + e.getMessage()); } - return new OFHResultSet(records); + } + private Document parseXmlDocument(String xmlContent) throws ParserConfigurationException, IOException, SAXException { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + DocumentBuilder builder = factory.newDocumentBuilder(); + return builder.parse(new InputSource(new StringReader(xmlContent))); } - private String getResponseCSVString(Document doc) { - String base64Content = getContentDataFromDoc(doc); + private ResultSet buildXmlResultSet(Document xmlResult) { + NodeList rowNodes = xmlResult.getDocumentElement().getChildNodes(); + List headers = new ArrayList<>(); + LinkedHashSet headerNames = new LinkedHashSet<>(); + List> rows = new ArrayList<>(); - byte[] decodedBytes = Base64.getDecoder().decode(base64Content); + for (Node rowNode : iterable(rowNodes)) { + if (!matchesNodeName(rowNode, "ROW")) { + continue; + } - String responseCsv = new String(decodedBytes); + Map rowValues = new LinkedHashMap<>(); + for (Node columnNode : iterable(rowNode.getChildNodes())) { + if (columnNode.getNodeType() != Node.ELEMENT_NODE) { + continue; + } - return responseCsv; + String columnName = columnNode.getNodeName(); + if (headerNames.add(columnName)) { + headers.add(columnName); + } + rowValues.put(columnName, columnNode.getTextContent()); + } + + List row = new ArrayList<>(); + for (String header : headers) { + row.add(rowValues.get(header)); + } + rows.add(row); + } + + debug("OFHStatement XML ROWSET parsed rows: " + rows.size() + " columns: " + headers.size() + " headers: " + headers); + + if (maxRows > 0 && rows.size() > maxRows) { + debug("OFHStatement applying maxRows to XML result: " + maxRows); + rows = new ArrayList<>(rows.subList(0, maxRows)); + } + + return new OFHResultSet(headers, rows); } - private String getContentDataFromDoc(Document doc) { + private String extractEmbeddedRowset(Node node) { + if (node == null) { + return null; + } - NodeList envelopeNodeList = doc.getElementsByTagName("env:Envelope"); + String textContent = stripBom(node.getTextContent()); + if (textContent != null) { + String unescapedText = unescapeXml(textContent).trim(); + if (unescapedText.startsWith("")) { + debug("OFHStatement embedded ROWSET preview: " + preview(unescapedText)); + return unescapedText; + } + } - if (envelopeNodeList.getLength() == 0) { - throw new RuntimeException("No Envelope found"); + for (Node childNode : iterable(node.getChildNodes())) { + String embeddedRowset = extractEmbeddedRowset(childNode); + if (embeddedRowset != null) { + return embeddedRowset; + } } - Node envelope = envelopeNodeList.item(0); + return null; + } - NodeList envelopeChildNodeList = envelope.getChildNodes(); + private String unescapeXml(String value) { + return value + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&"); + } - if (envelopeChildNodeList.getLength() == 0) { - throw new RuntimeException("No Envelope child nodes found"); + private String stripBom(String value) { + if (value == null) { + return null; + } + if (!value.isEmpty() && value.charAt(0) == '\ufeff') { + return value.substring(1); } + return value; + } - Node body = null; + private boolean isEmptyDataSet(Node node) { + if (node == null || !matchesNodeName(node, "DATA_DS")) { + return false; + } - for (Node node : iterable(envelopeChildNodeList)) { - if (node.getNodeName().equals("env:Body")) { - body = node; - break; + return !hasNestedElementWithText(node); + } + + private boolean hasNestedElementWithText(Node node) { + for (Node childNode : iterable(node.getChildNodes())) { + if (childNode.getNodeType() != Node.ELEMENT_NODE) { + continue; + } + + String textContent = stripBom(childNode.getTextContent()); + if (textContent != null && !textContent.trim().isEmpty()) { + return true; + } + + if (hasNestedElementWithText(childNode)) { + return true; } } - if (body == null) { - throw new RuntimeException("No Body found"); + return false; + } + + private ResultSet emptyResultSet() { + return new OFHResultSet(new ArrayList<>(), new ArrayList<>()); + } + + private String preview(String value) { + if (value == null) { + return "null"; } - if (body.getChildNodes().getLength() == 0) { - throw new RuntimeException("No Body child nodes found"); + String normalized = value + .replace("\ufeff", "\\uFEFF") + .replace("\r", "\\r") + .replace("\n", "\\n"); + + int maxLength = 240; + if (normalized.length() <= maxLength) { + return normalized; } - Node runReportResponse = null; + return normalized.substring(0, maxLength) + "..."; + } - for (Node node : iterable(body.getChildNodes())) { - if (node.getNodeName().equals("runReportResponse")) { - runReportResponse = node; - break; - } + private void debug(String message) { + if (debugEnabled) { + System.out.println(message); } + } - if (runReportResponse == null) { - throw new RuntimeException("No runReportResponse found"); + private String getContentDataFromDoc(Document doc) { + + Node envelope = findFirstChild(doc, "Envelope"); + + if (envelope == null) { + throw new RuntimeException("No Envelope found"); } - if (runReportResponse.getChildNodes().getLength() == 0) { - throw new RuntimeException("No runReportResponse child nodes found"); + Node body = findFirstChild(envelope, "Body"); + + if (body == null) { + throw new RuntimeException("No Body found"); } - Node runReportReturn = null; + Node fault = findFirstChild(body, "Fault"); - for (Node node : iterable(runReportResponse.getChildNodes())) { - if (node.getNodeName().equals("runReportReturn")) { - runReportReturn = node; - break; - } + if (fault != null) { + String faultReason = extractSoapFaultReason(fault); + throw new RuntimeException("SOAP Fault: " + faultReason); + } + + Node runReportResponse = findFirstChild(body, "runReportResponse"); + + if (runReportResponse == null) { + throw new RuntimeException("No runReportResponse found"); } + Node runReportReturn = findFirstChild(runReportResponse, "runReportReturn"); + if (runReportReturn == null) { throw new RuntimeException("No runReportReturn found"); } - if (runReportReturn.getChildNodes().getLength() == 0) { - throw new RuntimeException("No runReportReturn child nodes found"); + Node reportBytes = findFirstChild(runReportReturn, "reportBytes"); + + if (reportBytes == null) { + throw new RuntimeException("No reportBytes found"); } - Node reportBytes = null; + return reportBytes.getTextContent(); + } - for (Node node : iterable(runReportReturn.getChildNodes())) { - if (node.getNodeName().equals("reportBytes")) { - reportBytes = node; - break; + private Node findFirstChild(Node parent, String nodeName) { + NodeList childNodes = parent.getChildNodes(); + for (Node node : iterable(childNodes)) { + if (matchesNodeName(node, nodeName)) { + return node; } } + return null; + } - if (reportBytes == null) { - throw new RuntimeException("No reportBytes found"); + private boolean matchesNodeName(Node node, String expectedName) { + if (node == null) { + return false; } - String base64Content = reportBytes.getTextContent(); + String localName = node.getLocalName(); + if (expectedName.equals(localName)) { + return true; + } - return base64Content; + String nodeName = node.getNodeName(); + return expectedName.equals(nodeName) || nodeName.endsWith(":" + expectedName); + } + + private String extractSoapFaultReason(Node fault) { + Node reason = findFirstChild(fault, "Reason"); + if (reason != null) { + Node text = findFirstChild(reason, "Text"); + if (text != null && text.getTextContent() != null && !text.getTextContent().isEmpty()) { + return text.getTextContent(); + } + } + + Node faultString = findFirstChild(fault, "faultstring"); + if (faultString != null && faultString.getTextContent() != null && !faultString.getTextContent().isEmpty()) { + return faultString.getTextContent(); + } + + String fallback = fault.getTextContent(); + if (fallback == null || fallback.isEmpty()) { + return "Unknown SOAP fault"; + } + return fallback.trim(); } public static Iterable iterable(final NodeList nodeList) { @@ -259,14 +516,42 @@ private void getOutput(HttpURLConnection conn, StringBuffer response, int respon } else { is = conn.getInputStream(); } - BufferedReader in = new BufferedReader(new InputStreamReader(is)); - String inputLine; - while ((inputLine = in.readLine()) != null) { - response.append(inputLine); + if (is == null) { + return; + } + + byte[] responseBytes = readAllBytes(is); + byte[] decodedBytes = maybeDecompressResponse(conn, responseBytes); + response.append(new String(decodedBytes, StandardCharsets.UTF_8)); + is.close(); + } + + private byte[] maybeDecompressResponse(HttpURLConnection conn, byte[] responseBytes) throws IOException { + String contentEncoding = conn.getContentEncoding(); + boolean gzipEncoded = contentEncoding != null && contentEncoding.toLowerCase().contains("gzip"); + boolean gzipMagic = responseBytes.length >= 2 + && (responseBytes[0] & 0xff) == 0x1f + && (responseBytes[1] & 0xff) == 0x8b; + + if (!gzipEncoded && !gzipMagic) { + return responseBytes; + } + + debug("OFHStatement decompressing gzip response body"); + try (GZIPInputStream gzipInputStream = new GZIPInputStream(new ByteArrayInputStream(responseBytes))) { + return readAllBytes(gzipInputStream); } + } - in.close(); + private byte[] readAllBytes(InputStream inputStream) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + return outputStream.toByteArray(); } private static String encodeXML(CharSequence s) { @@ -343,12 +628,15 @@ public void setMaxFieldSize(int i) throws SQLException { @Override public int getMaxRows() throws SQLException { - return 0; + return maxRows; } @Override public void setMaxRows(int i) throws SQLException { - + if (i < 0) { + throw new SQLException("maxRows cannot be negative"); + } + this.maxRows = i; } @Override diff --git a/src/test/java/com/oraclefusionhub/jdbc/OFHDriverTest.java b/src/test/java/com/oraclefusionhub/jdbc/OFHDriverTest.java index 76919b0..12afef1 100644 --- a/src/test/java/com/oraclefusionhub/jdbc/OFHDriverTest.java +++ b/src/test/java/com/oraclefusionhub/jdbc/OFHDriverTest.java @@ -1,6 +1,8 @@ package com.oraclefusionhub.jdbc; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; import java.sql.Connection; import java.sql.Driver; @@ -64,4 +66,99 @@ public void test() throws SQLException { assertTrue("connection should be closed", connection.isClosed()); } -} \ No newline at end of file + @Test + public void testSoapFaultMessage() { + + mockXMLPServer.stubFor(WireMock.post(WireMock.urlEqualTo("/xmlpserver/services/ExternalReportWSSService")) + .willReturn(WireMock.aResponse().withStatus(200).withHeader("Content-Type", "text/xml") + .withBodyFile("E2ETestFault.xml"))); + + try (Connection connection = DriverManager.getConnection("jdbc:ofh://http://localhost:8089", "testUser", + "testPassword"); + Statement statement = connection.createStatement()) { + statement.executeQuery("select sysdate from dual"); + fail("Expected SQLException"); + } catch (SQLException e) { + assertEquals("Parsing Error: SOAP Fault: Invalid column type XMLTYPE for report output", e.getMessage()); + } + } + + @Test + public void testXmlRowsetResult() throws SQLException { + + mockXMLPServer.stubFor(WireMock.post(WireMock.urlEqualTo("/xmlpserver/services/ExternalReportWSSService")) + .willReturn(WireMock.aResponse().withStatus(200).withHeader("Content-Type", "text/xml") + .withBodyFile("E2ETestXmlResult.xml"))); + + try (Connection connection = DriverManager.getConnection("jdbc:ofh://http://localhost:8089", "testUser", + "testPassword"); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("select sysdate from dual")) { + assertTrue("resultset should contain xml-derived row", resultSet.next()); + assertEquals("SYSDATE", resultSet.getMetaData().getColumnLabel(1)); + assertEquals("2026-04-12", resultSet.getString(1)); + assertEquals("2026-04-12", resultSet.getString("SYSDATE")); + } + } + + @Test + public void testWrappedXmlRowsetResult() throws SQLException { + + mockXMLPServer.stubFor(WireMock.post(WireMock.urlEqualTo("/xmlpserver/services/ExternalReportWSSService")) + .willReturn(WireMock.aResponse().withStatus(200).withHeader("Content-Type", "text/xml") + .withBodyFile("E2ETestWrappedXmlResult.xml"))); + + try (Connection connection = DriverManager.getConnection("jdbc:ofh://http://localhost:8089", "testUser", + "testPassword"); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("select sysdate from dual")) { + assertTrue("resultset should contain wrapped xml-derived row", resultSet.next()); + assertEquals("SYSDATE", resultSet.getMetaData().getColumnLabel(1)); + assertEquals("2026-04-12", resultSet.getString(1)); + } + } + + @Test + public void testWrappedXmlRowsetRespectsMaxRows() throws SQLException { + + mockXMLPServer.stubFor(WireMock.post(WireMock.urlEqualTo("/xmlpserver/services/ExternalReportWSSService")) + .willReturn(WireMock.aResponse().withStatus(200).withHeader("Content-Type", "text/xml") + .withBodyFile("E2ETestWrappedXmlResult.xml"))); + + try (Connection connection = DriverManager.getConnection("jdbc:ofh://http://localhost:8089", "testUser", + "testPassword")) { + Statement fullStatement = connection.createStatement(); + fullStatement.setMaxRows(0); + ResultSet fullResultSet = fullStatement.executeQuery("select sysdate from dual"); + assertTrue(fullResultSet.next()); + assertEquals(false, fullResultSet.next()); + fullResultSet.close(); + fullStatement.close(); + + Statement limitedStatement = connection.createStatement(); + limitedStatement.setMaxRows(1); + ResultSet limitedResultSet = limitedStatement.executeQuery("select sysdate from dual"); + assertTrue(limitedResultSet.next()); + assertEquals(false, limitedResultSet.next()); + limitedResultSet.close(); + limitedStatement.close(); + } + } + + @Test + public void testEmptyWrappedXmlResult() throws SQLException { + + mockXMLPServer.stubFor(WireMock.post(WireMock.urlEqualTo("/xmlpserver/services/ExternalReportWSSService")) + .willReturn(WireMock.aResponse().withStatus(200).withHeader("Content-Type", "text/xml") + .withBodyFile("E2ETestEmptyWrappedResult.xml"))); + + try (Connection connection = DriverManager.getConnection("jdbc:ofh://http://localhost:8089", "testUser", + "testPassword"); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("select sysdate from dual")) { + assertEquals(0, resultSet.getMetaData().getColumnCount()); + assertEquals(false, resultSet.next()); + } + } + +} diff --git a/src/test/java/com/oraclefusionhub/jdbc/OFHResultSetTest.java b/src/test/java/com/oraclefusionhub/jdbc/OFHResultSetTest.java index e77b042..92951d2 100644 --- a/src/test/java/com/oraclefusionhub/jdbc/OFHResultSetTest.java +++ b/src/test/java/com/oraclefusionhub/jdbc/OFHResultSetTest.java @@ -5,12 +5,17 @@ import org.junit.Test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import java.io.IOException; import java.io.Reader; import java.io.StringReader; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Types; +import java.util.Arrays; +import java.util.Collections; public class OFHResultSetTest { @@ -36,4 +41,66 @@ public void testResultSet() throws IOException, SQLException { rs.close(); } -} \ No newline at end of file + @Test + public void testResultSetFromRows() throws SQLException { + ResultSet rs = new OFHResultSet( + Arrays.asList("SYSDATE"), + Collections.singletonList(Collections.singletonList("2026-04-12")) + ); + + assertEquals("SYSDATE", rs.getMetaData().getColumnLabel(1)); + assertEquals(1, rs.findColumn("SYSDATE")); + + rs.next(); + assertEquals("2026-04-12", rs.getString(1)); + assertEquals("2026-04-12", rs.getString("SYSDATE")); + assertEquals("2026-04-12", rs.getObject(1)); + } + + @Test + public void testEmptyResultSet() throws SQLException { + ResultSet rs = new OFHResultSet(Collections.emptyList(), Collections.emptyList()); + + assertEquals(0, rs.getMetaData().getColumnCount()); + assertFalse(rs.next()); + assertFalse(rs.first()); + assertFalse(rs.last()); + assertFalse(rs.isBeforeFirst()); + assertFalse(rs.isAfterLast()); + } + + @Test + public void testCursorNavigationAndWasNull() throws SQLException { + ResultSet rs = new OFHResultSet( + Arrays.asList("A", "B"), + Collections.singletonList(Collections.singletonList("a1")) + ); + + assertTrue(rs.next()); + assertEquals("a1", rs.getString(1)); + assertFalse(rs.wasNull()); + assertEquals(null, rs.getString(2)); + assertTrue(rs.wasNull()); + assertTrue(rs.isFirst()); + assertTrue(rs.isLast()); + assertEquals(1, rs.getRow()); + assertTrue(rs.first()); + rs.afterLast(); + assertTrue(rs.isAfterLast()); + rs.beforeFirst(); + assertTrue(rs.isBeforeFirst()); + } + + @Test + public void testMetadataTypeDefaults() throws SQLException { + ResultSet rs = new OFHResultSet( + Collections.singletonList("A"), + Collections.singletonList(Collections.singletonList("a1")) + ); + + assertEquals(Types.VARCHAR, rs.getMetaData().getColumnType(1)); + assertEquals("VARCHAR", rs.getMetaData().getColumnTypeName(1)); + assertEquals(String.class.getName(), rs.getMetaData().getColumnClassName(1)); + } + +} diff --git a/src/test/resources/__files/E2ETestEmptyWrappedResult.xml b/src/test/resources/__files/E2ETestEmptyWrappedResult.xml new file mode 100644 index 0000000..76a6fd4 --- /dev/null +++ b/src/test/resources/__files/E2ETestEmptyWrappedResult.xml @@ -0,0 +1,11 @@ + + + + + + PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCEtLUdlbmVyYXRlZCBieSBPcmFjbGUgQW5hbHl0aWNzIFB1Ymxpc2hlciAtRGF0YWVuZ2luZS0tPgo8REFUQV9EUz48R18xPgo8L0dfMT48L0RBVEFfRFM+ + text/xml;charset=UTF-8 + + + + diff --git a/src/test/resources/__files/E2ETestFault.xml b/src/test/resources/__files/E2ETestFault.xml new file mode 100644 index 0000000..e806fa9 --- /dev/null +++ b/src/test/resources/__files/E2ETestFault.xml @@ -0,0 +1,13 @@ + + + + + + env:Receiver + + + Invalid column type XMLTYPE for report output + + + + diff --git a/src/test/resources/__files/E2ETestWrappedXmlResult.xml b/src/test/resources/__files/E2ETestWrappedXmlResult.xml new file mode 100644 index 0000000..bc14d08 --- /dev/null +++ b/src/test/resources/__files/E2ETestWrappedXmlResult.xml @@ -0,0 +1,11 @@ + + + + + + PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPERBVEFfRFM+PEdfMT4KPFJFU1VMVD4mbHQ7Uk9XU0VUIHhtbG5zOnhzaSA9ICZxdW90O2h0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlJnF1b3Q7Jmd0OwogJmx0O1JPVyZndDsKICAmbHQ7U1lTREFURSZndDsyMDI2LTA0LTEyJmx0Oy9TWVNEQVRFJmd0OwogJmx0Oy9ST1cmZ3Q7CiZsdDsvUk9XU0VUJmd0OzwvUkVTVUxUPjwvR18xPjwvREFUQV9EUz4= + text/xml;charset=UTF-8 + + + + diff --git a/src/test/resources/__files/E2ETestXmlResult.xml b/src/test/resources/__files/E2ETestXmlResult.xml new file mode 100644 index 0000000..ad87061 --- /dev/null +++ b/src/test/resources/__files/E2ETestXmlResult.xml @@ -0,0 +1,11 @@ + + + + + + PFJPV1NFVCB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIj48Uk9XPjxTWVNEQVRFPjIwMjYtMDQtMTI8L1NZU0RBVEU+PC9ST1c+PC9ST1dTRVQ+ + text/plain;charset=UTF-8 + + + +