Skip to content

Commit be95c45

Browse files
[SPARK-57778][SQL] Handle Oracle JDBC objects that are not selectable as tables
### What changes were proposed in this pull request? When using the built-in JDBC `TableCatalog` over Oracle, the driver's table listing returns objects that cannot be read as tables, e.g. a synonym that resolves to a PL/SQL procedure/function/package, or an invalid view. Probing such an object (`tableExists` via `SELECT 1 FROM <obj> WHERE 1=0`, or schema resolution via `SELECT * FROM <obj> WHERE 1=0`) raises `ORA-04044` ("procedure, function, package, or type is not allowed here") or `ORA-04063` ("... has errors"). These were unclassified, so `tableExists` threw and table resolution surfaced a raw `FAILED_JDBC` failure. This adds a `JdbcDialect.isNotSelectableObjectException` predicate (Oracle recognizes `ORA-04044`/`ORA-04063`) so: - `JdbcUtils.tableExists` returns `false` for such objects instead of throwing; - `JDBCRDD.resolveTable` throws a dedicated, clear error (`JDBC_OBJECT_NOT_SELECTABLE`) instead of a generic failure. ### Why are the changes needed? A schema legitimately contains synonyms/views that are not selectable tables; probing them should not surface a raw external-engine error or make existence checks throw. ### Does this PR introduce any user-facing change? Yes. Querying such an object now returns `JDBC_OBJECT_NOT_SELECTABLE` instead of a raw `FAILED_JDBC` error, and `tableExists` returns `false` for it. ### How was this patch tested? New cases in `OracleIntegrationSuite` (docker-integration-tests): synonym to a procedure, synonym to a broken function, and an invalid view.
1 parent aa784b0 commit be95c45

7 files changed

Lines changed: 90 additions & 1 deletion

File tree

common/utils/src/main/resources/error/error-conditions.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5292,6 +5292,12 @@
52925292
},
52935293
"sqlState" : "42000"
52945294
},
5295+
"JDBC_OBJECT_NOT_SELECTABLE" : {
5296+
"message" : [
5297+
"The JDBC object <objectName> cannot be read as a table or view."
5298+
],
5299+
"sqlState" : "42000"
5300+
},
52955301
"JOIN_CONDITION_IS_NOT_BOOLEAN_TYPE" : {
52965302
"message" : [
52975303
"The join condition <joinCondition> has the invalid type <conditionType>, expected \"BOOLEAN\"."

connector/docker-integration-tests/src/test/scala/org/apache/spark/sql/jdbc/v2/OracleIntegrationSuite.scala

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@ package org.apache.spark.sql.jdbc.v2
2020
import java.sql.Connection
2121
import java.util.Locale
2222

23+
import scala.util.Using
24+
2325
import org.apache.spark.{SparkConf, SparkRuntimeException}
2426
import org.apache.spark.sql.{AnalysisException, Row}
2527
import org.apache.spark.sql.catalyst.util.CharVarcharUtils.CHAR_VARCHAR_TYPE_STRING_METADATA_KEY
28+
import org.apache.spark.sql.connector.catalog.{Identifier, TableCatalog}
2629
import org.apache.spark.sql.execution.datasources.v2.jdbc.JDBCTableCatalog
2730
import org.apache.spark.sql.jdbc.OracleDatabaseOnDocker
2831
import org.apache.spark.sql.types._
@@ -203,6 +206,60 @@ class OracleIntegrationSuite extends DockerJDBCIntegrationV2Suite with V2JDBCTes
203206
}
204207
}
205208

209+
// An object that Oracle's table listing surfaces but that cannot be read as a table.
210+
// `setup`/`teardown` are the DDL to create/drop it; `objectName` is its name in SYSTEM.
211+
case class NonSelectableObjectCase(setup: Seq[String], objectName: String, teardown: Seq[String])
212+
213+
private val nonSelectableObjectCases = Map(
214+
"synonym to a procedure (ORA-04044)" -> NonSelectableObjectCase(
215+
setup = Seq(
216+
"CREATE PROCEDURE test_proc AS BEGIN NULL; END;",
217+
"CREATE SYNONYM proc_synonym FOR test_proc"),
218+
objectName = "PROC_SYNONYM",
219+
teardown = Seq("DROP SYNONYM proc_synonym", "DROP PROCEDURE test_proc")),
220+
"synonym to a broken function (ORA-04044)" -> NonSelectableObjectCase(
221+
// Function referencing a missing object -> created INVALID.
222+
setup = Seq(
223+
"""CREATE FUNCTION broken_func RETURN NUMBER AS
224+
| x NUMBER;
225+
|BEGIN
226+
| SELECT col INTO x FROM non_existent_table_xyz;
227+
| RETURN x;
228+
|END;""".stripMargin,
229+
"CREATE SYNONYM func_synonym FOR broken_func"),
230+
objectName = "FUNC_SYNONYM",
231+
teardown = Seq("DROP SYNONYM func_synonym", "DROP FUNCTION broken_func")),
232+
"invalid view (ORA-04063)" -> NonSelectableObjectCase(
233+
// FORCE-create a view over a missing table -> view is INVALID; SELECT raises ORA-04063.
234+
setup = Seq("CREATE FORCE VIEW invalid_view AS SELECT * FROM non_existent_table_xyz"),
235+
objectName = "INVALID_VIEW",
236+
teardown = Seq("DROP VIEW invalid_view")))
237+
238+
namedGridTest("SPARK-57778: non-selectable object is handled gracefully")(
239+
nonSelectableObjectCases) { testCase =>
240+
Using.resource(getConnection()) { conn =>
241+
testCase.setup.foreach(conn.prepareStatement(_).executeUpdate())
242+
}
243+
try {
244+
val tableCatalog =
245+
spark.sessionState.catalogManager.catalog(catalogName).asInstanceOf[TableCatalog]
246+
247+
// tableExists treats a non-selectable object as non-existent instead of throwing.
248+
assert(!tableCatalog.tableExists(Identifier.of(Array("SYSTEM"), testCase.objectName)))
249+
250+
// Reading it surfaces a dedicated, clear error instead of a raw JDBC failure.
251+
val e = intercept[AnalysisException] {
252+
sql(s"SELECT * FROM $catalogName.SYSTEM.${testCase.objectName}").collect()
253+
}
254+
assert(e.getCondition == "JDBC_OBJECT_NOT_SELECTABLE")
255+
assert(e.getMessageParameters.get("objectName").contains(testCase.objectName))
256+
} finally {
257+
Using.resource(getConnection()) { conn =>
258+
testCase.teardown.foreach(conn.prepareStatement(_).executeUpdate())
259+
}
260+
}
261+
}
262+
206263
override def testDatetime(tbl: String): Unit = {
207264
val df1 = sql(s"SELECT name FROM $tbl WHERE " +
208265
"dayofyear(date1) > 100 AND dayofmonth(date1) > 10 ")

sql/catalyst/src/main/scala/org/apache/spark/sql/errors/QueryCompilationErrors.scala

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1703,6 +1703,17 @@ private[sql] object QueryCompilationErrors extends QueryErrorsBase with Compilat
17031703
new NoSuchTableException(catalogName +: ident.asMultipartIdentifier)
17041704
}
17051705

1706+
def objectNotSelectableError(
1707+
catalogName: String,
1708+
ident: Identifier,
1709+
cause: Throwable): Throwable = {
1710+
new AnalysisException(
1711+
errorClass = "JDBC_OBJECT_NOT_SELECTABLE",
1712+
messageParameters = Map(
1713+
"objectName" -> toSQLId(catalogName +: ident.asMultipartIdentifier)),
1714+
cause = Some(cause))
1715+
}
1716+
17061717
/**
17071718
* Table or view not found (TABLE_OR_VIEW_NOT_FOUND). The `searchPath` segment uses
17081719
* `nameParts.dropRight(1)` when `nameParts` has more than one part (catalog plus namespace);

sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/jdbc/JDBCRDD.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ object JDBCRDD extends Logging {
7979
case e: SQLException if ident.isDefined &&
8080
dialect.isObjectNotFoundException(e) =>
8181
throw QueryCompilationErrors.noSuchTableError(catalogName.get, ident.get)
82+
case e: SQLException if ident.isDefined &&
83+
dialect.isNotSelectableObjectException(e) =>
84+
throw QueryCompilationErrors.objectNotSelectableError(catalogName.get, ident.get, e)
8285
case e: SQLException if dialect.isSyntaxErrorBestEffort(e) =>
8386
throw new SparkException(
8487
errorClass = "JDBC_EXTERNAL_ENGINE_SYNTAX_ERROR.DURING_OUTPUT_SCHEMA_RESOLUTION",

sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/jdbc/JdbcUtils.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ object JdbcUtils extends Logging with SQLConfHelper {
7777

7878
executionResult match {
7979
case Success(_) => true
80-
case Failure(e: SQLException) if dialect.isObjectNotFoundException(e) => false
80+
case Failure(e: SQLException)
81+
if dialect.isObjectNotFoundException(e) || dialect.isNotSelectableObjectException(e) =>
82+
false
8183
case Failure(e) => throw e // Re-throw unexpected exceptions
8284
}
8385
}

sql/core/src/main/scala/org/apache/spark/sql/jdbc/JdbcDialects.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,9 @@ abstract class JdbcDialect extends Serializable with Logging {
808808
Option(e.getSQLState).exists(_.startsWith("42"))
809809
}
810810

811+
@Since("4.3.0")
812+
def isNotSelectableObjectException(e: SQLException): Boolean = false
813+
811814
/**
812815
* Gets a dialect exception, classifies it and wraps it by `AnalysisException`.
813816
* @param e The dialect specific exception.

sql/core/src/main/scala/org/apache/spark/sql/jdbc/OracleDialect.scala

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ private case class OracleDialect() extends JdbcDialect with SQLConfHelper with N
5454
e.getMessage.contains("ORA-39165")
5555
}
5656

57+
override def isNotSelectableObjectException(e: SQLException): Boolean = {
58+
// ORA-04044: object is not a table (e.g. a synonym to a procedure/function/package).
59+
e.getMessage.contains("ORA-04044") ||
60+
// ORA-04063: object is invalid (e.g. a view over a dropped base table).
61+
e.getMessage.contains("ORA-04063")
62+
}
63+
5764
class OracleSQLBuilder extends JDBCSQLBuilder {
5865

5966
override def visitExtract(extract: Extract): String = {

0 commit comments

Comments
 (0)