Description
In jdbc-v2, most ResultSet getters that take a column label treat an unknown label as a SQL NULL value instead of reporting an error: they set wasNull() == true and return null / 0 / false.
JDBC requires an SQLException when the label does not identify a column in the result set (ResultSet.getString(String): "throws SQLException - if the columnLabel is not valid"). As it is, a typo in a column name is indistinguishable from a genuine NULL, so an application reads silent zeros/nulls instead of failing.
The getter family is also inconsistent: getDate, getTime, getTimestamp, getBytes, getBinaryStream, getObject and findColumn all do fail on the same unknown label.
Steps to reproduce
- Open a JDBC connection with the
jdbc-v2 driver.
SELECT 'abc' AS txt, 42 AS num, CAST(NULL AS Nullable(Int32)) AS nul
- Call the label getters with a label that is not in the result set, e.g.
rs.getInt("no_such_column").
Error Log or Exception StackTrace
=== sanity: known labels ===
getString("txt") -> "abc", wasNull=false
getInt("num") -> 42, wasNull=false
getInt("nul") [real SQL NULL] -> 0, wasNull=true
=== unknown label "no_such_column" ===
getString -> NO THROW, value=null, wasNull=true <-- indistinguishable from a real NULL
getBoolean -> NO THROW, value=false, wasNull=true
getByte -> NO THROW, value=0, wasNull=true
getShort -> NO THROW, value=0, wasNull=true
getInt -> NO THROW, value=0, wasNull=true
getLong -> NO THROW, value=0, wasNull=true
getFloat -> NO THROW, value=0.0, wasNull=true
getDouble -> NO THROW, value=0.0, wasNull=true
getBigDecimal -> NO THROW, value=null, wasNull=true
=== same unknown label, getters that DO fail ===
getDate -> NoSuchColumnException: Result has no column with name 'no_such_column'
getTime -> NoSuchColumnException: Result has no column with name 'no_such_column'
getTimestamp -> NoSuchColumnException: Result has no column with name 'no_such_column'
getBytes -> NoSuchColumnException: Result has no column with name 'no_such_column'
getBinaryStream -> NoSuchColumnException: Result has no column with name 'no_such_column'
getTimestamp(label, cal) -> NoSuchColumnException: Result has no column with name 'no_such_column'
getObject -> SQLException: Method: getObject("no_such_column", null) encountered an exception.
findColumn -> SQLException: Method: findColumn("no_such_column") encountered an exception.
Expected Behaviour
Every label getter reports an unknown column label as an SQLException. Only a column that exists and holds SQL NULL should return null / 0 with wasNull() == true.
Note that the getters that currently fail do so with com.clickhouse.client.api.metadata.NoSuchColumnException, which extends ClientException -> ClickHouseException -> RuntimeException. That is an unchecked exception crossing the JDBC boundary, so it does not satisfy the JDBC contract either, although at least it is not silent.
Root cause
jdbc-v2 ResultSetImpl label getters guard the read with reader.hasValue(columnLabel) (for example ResultSetImpl.java:314 in getString(String), :378 in getInt(String), :394 in getLong(String)) and fall into the "no value" branch when it returns false:
if (reader.hasValue(columnLabel)) {
wasNull = false;
return reader.getString(columnLabel);
} else {
wasNull = true;
return null;
}
AbstractBinaryFormatReader.hasValue(String) (client-v2 .../data_formats/internal/AbstractBinaryFormatReader.java:607) resolves the label with TableSchema.findColumnIndex, which returns -1 for an unknown column (client-v2 .../metadata/TableSchema.java:136); hasValue(int) then rejects -1 as out of range and returns false.
So hasValue(String) == false means either "the column is absent" or "the column is present and its value is null", and the JDBC layer maps both onto SQL NULL.
The leniency in client-v2 is deliberate — it is the behaviour requested in #2755 for the hasValue predicate, and it is reasonable for a predicate. The defect is in jdbc-v2 using that predicate as its label-resolution step, where "absent" must be an error.
The getters that do fail take a different route: they resolve the label through TableSchema.nameToColumnIndex (TableSchema.java:113), which throws NoSuchColumnException. Hence the inconsistency inside the same class.
Suggested fix
Resolve the label once in the label getters and make an unresolvable label an SQLException, then look the value up by index. Concretely: a single private helper that resolves label -> 1-based index and throws SQLException when the column does not exist, used by all label getters, with the null check done on the resolved index (hasValue(int)).
This also makes the getter family consistent and converts the current unchecked NoSuchColumnException leaks on the getDate / getTime / getTimestamp / getBytes / getBinaryStream paths into proper SQLExceptions.
Cases that must keep their current behaviour:
Related observation, same code path: a case-mismatched label for an existing column (rs.getString("TXT") for column txt) is also silently read as NULL today, because the lookup is an exact-match map. After this fix it would raise an SQLException. Whether jdbc-v2 should additionally match labels case-insensitively (the JDBC javadoc states column names used as input to getter methods are case insensitive) is a separate decision, and is not covered by this report.
Code Example
try (Connection conn = DriverManager.getConnection(url, props);
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT 'abc' AS txt, 42 AS num")) {
rs.next();
// Expected: SQLException. Actual: returns 0 and wasNull() == true.
int v = rs.getInt("no_such_column");
boolean wasNull = rs.wasNull();
// Expected: SQLException. Actual: returns null and wasNull() == true.
String s = rs.getString("no_such_column");
}
Configuration
Environment
Notes
Found by automated analysis of jdbc-v2 while working on the indexed-getter read path (#2516 / PR #3124). It is not introduced by that PR — it reproduces on plain main at the commit above, and the pre-#3124 code reaches the identical findColumnIndex -> -1 path. Verified by running the getters against a live ClickHouse server, not by inspection alone.
Description
In
jdbc-v2, mostResultSetgetters that take a column label treat an unknown label as a SQLNULLvalue instead of reporting an error: they setwasNull() == trueand returnnull/0/false.JDBC requires an
SQLExceptionwhen the label does not identify a column in the result set (ResultSet.getString(String): "throws SQLException - if the columnLabel is not valid"). As it is, a typo in a column name is indistinguishable from a genuine NULL, so an application reads silent zeros/nulls instead of failing.The getter family is also inconsistent:
getDate,getTime,getTimestamp,getBytes,getBinaryStream,getObjectandfindColumnall do fail on the same unknown label.Steps to reproduce
jdbc-v2driver.SELECT 'abc' AS txt, 42 AS num, CAST(NULL AS Nullable(Int32)) AS nulrs.getInt("no_such_column").Error Log or Exception StackTrace
Expected Behaviour
Every label getter reports an unknown column label as an
SQLException. Only a column that exists and holds SQLNULLshould returnnull/0withwasNull() == true.Note that the getters that currently fail do so with
com.clickhouse.client.api.metadata.NoSuchColumnException, which extendsClientException->ClickHouseException->RuntimeException. That is an unchecked exception crossing the JDBC boundary, so it does not satisfy the JDBC contract either, although at least it is not silent.Root cause
jdbc-v2ResultSetImpllabel getters guard the read withreader.hasValue(columnLabel)(for exampleResultSetImpl.java:314ingetString(String),:378ingetInt(String),:394ingetLong(String)) and fall into the "no value" branch when it returnsfalse:AbstractBinaryFormatReader.hasValue(String)(client-v2 .../data_formats/internal/AbstractBinaryFormatReader.java:607) resolves the label withTableSchema.findColumnIndex, which returns-1for an unknown column (client-v2 .../metadata/TableSchema.java:136);hasValue(int)then rejects-1as out of range and returnsfalse.So
hasValue(String) == falsemeans either "the column is absent" or "the column is present and its value is null", and the JDBC layer maps both onto SQL NULL.The leniency in
client-v2is deliberate — it is the behaviour requested in #2755 for thehasValuepredicate, and it is reasonable for a predicate. The defect is injdbc-v2using that predicate as its label-resolution step, where "absent" must be an error.The getters that do fail take a different route: they resolve the label through
TableSchema.nameToColumnIndex(TableSchema.java:113), which throwsNoSuchColumnException. Hence the inconsistency inside the same class.Suggested fix
Resolve the label once in the label getters and make an unresolvable label an
SQLException, then look the value up by index. Concretely: a single private helper that resolves label -> 1-based index and throwsSQLExceptionwhen the column does not exist, used by all label getters, with the null check done on the resolved index (hasValue(int)).This also makes the getter family consistent and converts the current unchecked
NoSuchColumnExceptionleaks on thegetDate/getTime/getTimestamp/getBytes/getBinaryStreampaths into properSQLExceptions.Cases that must keep their current behaviour:
NULLstill returnsnull/0withwasNull() == true(verified above withCAST(NULL AS Nullable(Int32)) AS nul);hasValue(String)inclient-v2keeps returningfalsefor a missing column ([client-v2] hasValue on GenericRecord does not work as intended #2755) — the change belongs injdbc-v2, not inclient-v2.Related observation, same code path: a case-mismatched label for an existing column (
rs.getString("TXT")for columntxt) is also silently read as NULL today, because the lookup is an exact-match map. After this fix it would raise anSQLException. Whetherjdbc-v2should additionally match labels case-insensitively (the JDBC javadoc states column names used as input to getter methods are case insensitive) is a separate decision, and is not covered by this report.Code Example
Configuration
Environment
mainat91ec4d326(VERSION0.11.0-rc1), modulejdbc-v2Notes
Found by automated analysis of
jdbc-v2while working on the indexed-getter read path (#2516 / PR #3124). It is not introduced by that PR — it reproduces on plainmainat the commit above, and the pre-#3124 code reaches the identicalfindColumnIndex->-1path. Verified by running the getters against a live ClickHouse server, not by inspection alone.