diff --git a/iotdb-client/jdbc/src/main/i18n/en/org/apache/iotdb/jdbc/i18n/JdbcMessages.java b/iotdb-client/jdbc/src/main/i18n/en/org/apache/iotdb/jdbc/i18n/JdbcMessages.java index 17f33cdf922d1..ea3eea76fb814 100644 --- a/iotdb-client/jdbc/src/main/i18n/en/org/apache/iotdb/jdbc/i18n/JdbcMessages.java +++ b/iotdb-client/jdbc/src/main/i18n/en/org/apache/iotdb/jdbc/i18n/JdbcMessages.java @@ -164,7 +164,6 @@ public final class JdbcMessages { "Does not support setTransactionIsolation"; public static final String NOT_SUPPORT_GET_TYPE_MAP = "Does not support getTypeMap"; public static final String NOT_SUPPORT_SET_TYPE_MAP = "Does not support setTypeMap"; - public static final String NOT_SUPPORT_READ_ONLY = "Does not support readOnly"; public static final String NOT_SUPPORT_NATIVE_SQL = "Does not support nativeSQL"; public static final String NOT_SUPPORT_RELEASE_SAVEPOINT = "Does not support releaseSavepoint"; @@ -225,5 +224,11 @@ private JdbcMessages() {} "Can not establish connection with %s : You may try to connect an old version IoTDB instance" + " using a client with new version: %s. "; public static final String EXCEPTION_CAN_NOT_ESTABLISH_CONNECTION_ARG_ARG_D7246055 = "Can not establish connection with %s : %s. "; + public static final String EXCEPTION_TIMESTAMP_ARG_OUT_RANGE_TIME_PRECISION_ARG_E25E3001 = "Timestamp %s is out of range for the time precision %s"; + public static final String EXCEPTION_CANNOT_CONVERT_ARG_COLUMN_ARG_FBFBC158 = "Cannot convert a %s column to %s"; + public static final String EXCEPTION_TARGET_TYPE_GETOBJECT_MUST_NOT_NULL_2E310BEC = "The target type of getObject must not be null"; + public static final String LOG_SHOW_TABLES_ERROR_ARG_068CF1BA = "Show tables error: {}"; + public static final String EXCEPTION_UNKNOWN_COLUMN_NAME_ARG_F8369278 = "Unknown column name: %s"; + public static final String EXCEPTION_CANNOT_BIND_ARG_IOTDB_SQL_HAS_NO_LITERAL_INFINITE_FLOATING_4068FF32 = "Cannot bind %s: IoTDB SQL has no literal for an infinite floating-point value"; } diff --git a/iotdb-client/jdbc/src/main/i18n/zh/org/apache/iotdb/jdbc/i18n/JdbcMessages.java b/iotdb-client/jdbc/src/main/i18n/zh/org/apache/iotdb/jdbc/i18n/JdbcMessages.java index a1cc83ea5e642..727a8d7809cb7 100644 --- a/iotdb-client/jdbc/src/main/i18n/zh/org/apache/iotdb/jdbc/i18n/JdbcMessages.java +++ b/iotdb-client/jdbc/src/main/i18n/zh/org/apache/iotdb/jdbc/i18n/JdbcMessages.java @@ -163,7 +163,6 @@ public final class JdbcMessages { "不支持 setTransactionIsolation"; public static final String NOT_SUPPORT_GET_TYPE_MAP = "不支持 getTypeMap"; public static final String NOT_SUPPORT_SET_TYPE_MAP = "不支持 setTypeMap"; - public static final String NOT_SUPPORT_READ_ONLY = "不支持 readOnly"; public static final String NOT_SUPPORT_NATIVE_SQL = "不支持 nativeSQL"; public static final String NOT_SUPPORT_RELEASE_SAVEPOINT = "不支持 releaseSavepoint"; @@ -224,5 +223,11 @@ private JdbcMessages() {} "无法与 %s 建立连接:可能正在使用新版本客户端连接旧版本 IoTDB 实例:" + "%s。"; public static final String EXCEPTION_CAN_NOT_ESTABLISH_CONNECTION_ARG_ARG_D7246055 = "无法与 %s 建立连接:%s。"; + public static final String EXCEPTION_TIMESTAMP_ARG_OUT_RANGE_TIME_PRECISION_ARG_E25E3001 = "时间戳 %s 超出了时间精度 %s 可表示的范围"; + public static final String EXCEPTION_CANNOT_CONVERT_ARG_COLUMN_ARG_FBFBC158 = "无法将 %s 类型的列转换为 %s"; + public static final String EXCEPTION_TARGET_TYPE_GETOBJECT_MUST_NOT_NULL_2E310BEC = "getObject 的目标类型不能为 null"; + public static final String LOG_SHOW_TABLES_ERROR_ARG_068CF1BA = "查询表信息出错:{}"; + public static final String EXCEPTION_UNKNOWN_COLUMN_NAME_ARG_F8369278 = "未知列名:%s"; + public static final String EXCEPTION_CANNOT_BIND_ARG_IOTDB_SQL_HAS_NO_LITERAL_INFINITE_FLOATING_4068FF32 = "无法绑定 %s:IoTDB SQL 没有表示无穷大浮点数的字面量"; } diff --git a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBAbstractDatabaseMetadata.java b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBAbstractDatabaseMetadata.java index 0c50512e80b0c..b52f3bba55e84 100644 --- a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBAbstractDatabaseMetadata.java +++ b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBAbstractDatabaseMetadata.java @@ -92,6 +92,7 @@ public abstract class IoTDBAbstractDatabaseMetadata implements DatabaseMetaData protected static final String BLOB = "BLOB"; protected static final String TIMESTAMP = "TIMESTAMP"; protected static final String DATE = "DATE"; + protected static final String OBJECT = "OBJECT"; protected static String sqlKeywordsThatArentSQL92; @@ -415,10 +416,15 @@ public static ByteBuffer convertTsBlock( for (List valuesInRow : valuesList) { tsBlockBuilder.getTimeColumnBuilder().writeLong(0); for (int j = 0; j < tsDataTypeList.size(); j++) { + Object value = valuesInRow.get(j); + if (value == null) { + tsBlockBuilder.getColumnBuilder(j).appendNull(); + continue; + } TSDataType columnType = tsDataTypeList.get(j); COLUMN_VALUE_WRITER_SERVICE .call(Type.fromTsDataType(columnType)) - .write(tsBlockBuilder.getColumnBuilder(j), valuesInRow.get(j)); + .write(tsBlockBuilder.getColumnBuilder(j), value); } tsBlockBuilder.declarePosition(); } @@ -474,6 +480,9 @@ protected int getSQLType(String columnType) { return Types.TIMESTAMP; case DATE: return Types.DATE; + case OBJECT: + // Database-specific: getObject() describes the value instead of returning it + return Types.OTHER; default: break; } @@ -495,6 +504,7 @@ protected int getTypePrecision(String columnType) { case "TEXT": case BLOB: case STRING: + case OBJECT: return Integer.MAX_VALUE; case TIMESTAMP: return 3; diff --git a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBConnection.java b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBConnection.java index cbd501aa88786..c207a12396471 100644 --- a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBConnection.java +++ b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBConnection.java @@ -98,7 +98,10 @@ public class IoTDBConnection implements Connection { private ZoneId zoneId; private Charset charset; - private boolean autoCommit; + // IoTDB has no transactions, so every statement takes effect at once as JDBC auto-commit does + private boolean autoCommit = true; + // Recorded as the hint JDBC defines it to be; IoTDB has no read-only connection mode + private boolean readOnly; private String url; public String getUserName() { @@ -143,7 +146,6 @@ public IoTDBConnection(String url, Properties info) throws SQLException, TTransp openSession(); // Wrap the client with a thread-safe proxy to serialize the RPC calls setClient(RpcUtils.newSynchronizedClient(getClient())); - autoCommit = false; } public String getUrl() { @@ -270,13 +272,14 @@ public void setAutoCommit(boolean arg0) { @Override public String getCatalog() { - return APACHE_IOTDB; + // The table model has no catalogs, matching the NULL TABLE_CAT of its DatabaseMetaData + return getSqlDialect().equals(Constant.TABLE_DIALECT) ? null : APACHE_IOTDB; } @Override public void setCatalog(String arg0) throws SQLException { if (getSqlDialect().equals(Constant.TABLE_DIALECT)) { - if (APACHE_IOTDB.equals(arg0)) { + if (arg0 == null || APACHE_IOTDB.equals(arg0)) { return; } for (String str : IoTDBRelationalDatabaseMetadata.allIotdbTableSQLKeywords) { @@ -400,14 +403,12 @@ public boolean isClosed() { @Override public boolean isReadOnly() { - return false; + return readOnly; } @Override public void setReadOnly(boolean readonly) throws SQLException { - if (readonly) { - throw new SQLException(JdbcMessages.NOT_SUPPORT_READ_ONLY); - } + this.readOnly = readonly; } @Override diff --git a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBDatabaseMetadata.java b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBDatabaseMetadata.java index 0e5d5b64a92a6..a9c03650e5077 100644 --- a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBDatabaseMetadata.java +++ b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBDatabaseMetadata.java @@ -387,7 +387,7 @@ public ResultSet getColumns( throws SQLException { try (Statement stmt = this.connection.createStatement()) { - if (this.connection.getCatalog().equals(catalog)) { + if (catalog != null && catalog.equals(this.connection.getCatalog())) { catalog = null; } diff --git a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBJDBCResultSet.java b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBJDBCResultSet.java index 3ea3c52320d1a..7857e624a8781 100644 --- a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBJDBCResultSet.java +++ b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBJDBCResultSet.java @@ -60,6 +60,7 @@ import java.sql.Statement; import java.sql.Time; import java.sql.Timestamp; +import java.time.LocalDate; import java.time.ZoneId; import java.util.Calendar; import java.util.List; @@ -67,8 +68,6 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; -import static org.apache.iotdb.rpc.RpcUtils.convertToTimestamp; - public class IoTDBJDBCResultSet implements ResultSet { public static final String OBJECT_ERR_MSG = "OBJECT Type only support getString"; @@ -645,13 +644,69 @@ public Object getObject(String arg0, Map> arg1) throws SQLExcep } @Override - public T getObject(int arg0, Class arg1) throws SQLException { - throw new SQLException(Constant.METHOD_NOT_SUPPORTED); + public T getObject(int columnIndex, Class type) throws SQLException { + if (type == null) { + throw new SQLException(JdbcMessages.EXCEPTION_TARGET_TYPE_GETOBJECT_MUST_NOT_NULL_2E310BEC); + } + try { + // Rejects a bad index before getDataType fails on it with an unchecked exception + ioTDBRpcDataSet.findColumnNameByIndex(columnIndex); + final TSDataType dataType = ioTDBRpcDataSet.getDataType(columnIndex); + final Object value; + if (type == String.class) { + value = ioTDBRpcDataSet.getString(columnIndex); + } else if (type == byte[].class) { + value = getBytes(columnIndex); + } else if (type == Timestamp.class && dataType == TSDataType.TIMESTAMP) { + value = ioTDBRpcDataSet.getTimestamp(columnIndex); + } else if (type == LocalDate.class && dataType == TSDataType.DATE) { + value = ioTDBRpcDataSet.getDate(columnIndex); + } else if (type == Boolean.class && dataType == TSDataType.BOOLEAN) { + value = nullIfLastReadWasNull(ioTDBRpcDataSet.getBoolean(columnIndex)); + } else if (type == Integer.class && dataType == TSDataType.INT32) { + value = nullIfLastReadWasNull(ioTDBRpcDataSet.getInt(columnIndex)); + } else if (type == Long.class + && (dataType == TSDataType.INT32 + || dataType == TSDataType.INT64 + || dataType == TSDataType.TIMESTAMP)) { + value = nullIfLastReadWasNull(ioTDBRpcDataSet.getLong(columnIndex)); + } else if (type == Float.class && dataType == TSDataType.FLOAT) { + value = nullIfLastReadWasNull(ioTDBRpcDataSet.getFloat(columnIndex)); + } else if (type == Double.class && dataType == TSDataType.FLOAT) { + value = nullIfLastReadWasNull((double) ioTDBRpcDataSet.getFloat(columnIndex)); + } else if (type == Double.class && dataType == TSDataType.DOUBLE) { + value = nullIfLastReadWasNull(ioTDBRpcDataSet.getDouble(columnIndex)); + } else { + throw new SQLException( + String.format( + JdbcMessages.EXCEPTION_CANNOT_CONVERT_ARG_COLUMN_ARG_FBFBC158, + dataType, + type.getName())); + } + return type.cast(value); + } catch (StatementExecutionException e) { + throw new SQLException(e.getMessage()); + } + } + + // The primitive getters return a default value such as 0 for SQL NULL + private Object nullIfLastReadWasNull(Object value) { + return ioTDBRpcDataSet.isLastReadWasNull() ? null : value; } @Override - public T getObject(String arg0, Class arg1) throws SQLException { - throw new SQLException(Constant.METHOD_NOT_SUPPORTED); + public T getObject(String columnLabel, Class type) throws SQLException { + return getObject(findColumnIndex(columnLabel), type); + } + + // findColumn() fails with an unchecked exception for an unknown label + private int findColumnIndex(String columnLabel) throws SQLException { + int index = ioTDBRpcDataSet.getColumnNameList().indexOf(columnLabel); + if (index < 0) { + throw new SQLException( + String.format(JdbcMessages.EXCEPTION_UNKNOWN_COLUMN_NAME_ARG_F8369278, columnLabel)); + } + return index + 1; } @Override @@ -741,12 +796,20 @@ public Time getTime(String arg0, Calendar arg1) throws SQLException { @Override public Timestamp getTimestamp(int columnIndex) throws SQLException { - return convertToTimestamp(getLong(columnIndex), statement.getTimeFactor()); + try { + return ioTDBRpcDataSet.getTimestamp(columnIndex); + } catch (StatementExecutionException e) { + throw new SQLException(e.getMessage()); + } } @Override public Timestamp getTimestamp(String columnName) throws SQLException { - return new Timestamp(getLong(columnName)); + try { + return ioTDBRpcDataSet.getTimestamp(columnName); + } catch (StatementExecutionException | IllegalArgumentException e) { + throw new SQLException(e.getMessage()); + } } @Override diff --git a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBResultMetadata.java b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBResultMetadata.java index 59279d88465c3..91a9c8cfbb2f2 100644 --- a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBResultMetadata.java +++ b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBResultMetadata.java @@ -51,6 +51,7 @@ public class IoTDBResultMetadata implements ResultSetMetaData { private static final String DATE = "DATE"; private static final String TIMESTAMP = "TIMESTAMP"; + private static final String OBJECT = "OBJECT"; /** Constructor of IoTDBResultMetadata. */ public IoTDBResultMetadata( @@ -165,6 +166,9 @@ public String getColumnClassName(int column) throws SQLException { return Date.class.getName(); case BLOB: return "byte[]"; + case OBJECT: + // getObject() describes an OBJECT value with a String such as "(Object) 1.2 MB" + return String.class.getName(); default: break; } @@ -232,6 +236,8 @@ public int getColumnType(int column) throws SQLException { return Types.TIMESTAMP; case BLOB: return Types.BLOB; + case OBJECT: + return Types.OTHER; default: break; } @@ -255,7 +261,8 @@ public String getColumnTypeName(int column) throws SQLException { || STRING.equals(typeString) || BLOB.equals(typeString) || DATE.equals(typeString) - || TIMESTAMP.equals(typeString)) { + || TIMESTAMP.equals(typeString) + || OBJECT.equals(typeString)) { return typeString; } return null; @@ -282,6 +289,7 @@ public int getPrecision(int column) throws SQLException { case TEXT: case BLOB: case STRING: + case OBJECT: return Integer.MAX_VALUE; case TIMESTAMP: return 3; @@ -309,6 +317,7 @@ public int getScale(int column) throws SQLException { case STRING: case TIMESTAMP: case DATE: + case OBJECT: return 0; case FLOAT: return 6; diff --git a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBStatement.java b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBStatement.java index 2af6e970a9255..260ef79724c92 100644 --- a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBStatement.java +++ b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBStatement.java @@ -357,11 +357,7 @@ private T callWithRetryAndReconnect(TFunction rpc, Function private boolean executeSQL(String sql) throws TException, SQLException { isCancelled = false; TSExecuteStatementReq execReq = new TSExecuteStatementReq(sessionId, sql, stmtId); - int rows = fetchSize; - if (maxRows != 0 && fetchSize > maxRows) { - rows = maxRows; - } - execReq.setFetchSize(rows); + execReq.setFetchSize(getRequestFetchSize()); execReq.setTimeout((long) queryTimeout * 1000); TSExecuteStatementResp execResp = callWithRetryAndReconnect( @@ -510,11 +506,7 @@ public ResultSet executeQuery(String sql, long timeoutInMS) throws SQLException private ResultSet executeQuerySQL(String sql, long timeoutInMS) throws TException, SQLException { isCancelled = false; TSExecuteStatementReq execReq = new TSExecuteStatementReq(sessionId, sql, stmtId); - int rows = fetchSize; - if (maxRows != 0 && fetchSize > maxRows) { - rows = maxRows; - } - execReq.setFetchSize(rows); + execReq.setFetchSize(getRequestFetchSize()); execReq.setTimeout(timeoutInMS); execReq.setJdbcQuery(true); TSExecuteStatementResp execResp = @@ -825,6 +817,21 @@ public long getStmtId() { return stmtId; } + /** The fetch size to send with an execution request, capped by maxRows when that is set. */ + int getRequestFetchSize() { + return maxRows != 0 && fetchSize > maxRows ? maxRows : fetchSize; + } + + /** Lets {@link #cancel()} reach the server again; called when a new execution starts. */ + void clearCancelled() { + isCancelled = false; + } + + /** Records the server-side query of the latest execution, which {@link #cancel()} cancels. */ + void setQueryId(long queryId) { + this.queryId = queryId; + } + public long getMilliSecond(long time) { return RpcUtils.getMilliSecond(time, connection.getTimeFactor()); } diff --git a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBTablePreparedStatement.java b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBTablePreparedStatement.java index 2fd0f382435fb..3c95afa107dd1 100644 --- a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBTablePreparedStatement.java +++ b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/IoTDBTablePreparedStatement.java @@ -60,6 +60,7 @@ import java.sql.Time; import java.sql.Timestamp; import java.sql.Types; +import java.time.LocalDate; import java.time.ZoneId; import java.util.ArrayList; import java.util.Calendar; @@ -199,12 +200,15 @@ private TSExecuteStatementResp executeInternal() throws SQLException { req.setParameters( PreparedParameterSerde.serialize(parameterValues, parameterTypes, parameterCount)); req.setStatementId(getStmtId()); + req.setFetchSize(getRequestFetchSize()); if (queryTimeout > 0) { req.setTimeout(queryTimeout * 1000L); } + clearCancelled(); try { TSExecuteStatementResp resp = client.executePreparedStatement(req); + setQueryId(resp.isSetQueryId() ? resp.getQueryId() : -1); RpcUtils.verifySuccess(resp.getStatus()); return resp; } catch (TException | StatementExecutionException e) { @@ -370,14 +374,32 @@ public void setLong(int parameterIndex, long x) throws SQLException { public void setFloat(int parameterIndex, float x) throws SQLException { checkParameterIndex(parameterIndex); setPreparedParameterValue(parameterIndex, x, Types.FLOAT); - this.parameters.put(parameterIndex, Float.toString(x)); + this.parameters.put(parameterIndex, floatingPointLiteral(x, Float.toString(x))); } @Override public void setDouble(int parameterIndex, double x) throws SQLException { checkParameterIndex(parameterIndex); setPreparedParameterValue(parameterIndex, x, Types.DOUBLE); - this.parameters.put(parameterIndex, Double.toString(x)); + this.parameters.put(parameterIndex, floatingPointLiteral(x, Double.toString(x))); + } + + /** + * The literal for a FLOAT or DOUBLE parameter of SQL completed on the client. IoTDB SQL has no + * NaN or Infinity literal, but stores the string 'NaN' as NaN and rejects Infinity in any form. + */ + private String floatingPointLiteral(double value, String text) throws SQLException { + if (serverSidePrepared || Double.isFinite(value)) { + return text; + } + if (Double.isNaN(value)) { + return "'NaN'"; + } + throw new SQLException( + String.format( + JdbcMessages + .EXCEPTION_CANNOT_BIND_ARG_IOTDB_SQL_HAS_NO_LITERAL_INFINITE_FLOATING_4068FF32, + text)); } @Override @@ -401,9 +423,13 @@ public void setBytes(int parameterIndex, byte[] x) throws SQLException { @Override public void setDate(int parameterIndex, Date x) throws SQLException { + setLocalDate(parameterIndex, x.toLocalDate()); + } + + private void setLocalDate(int parameterIndex, LocalDate x) throws SQLException { checkParameterIndex(parameterIndex); // Use ISO-8601 format YYYY-MM-DD for DATE columns - String dateStr = x.toLocalDate().toString(); + String dateStr = x.toString(); setPreparedParameterValue(parameterIndex, dateStr, Types.DATE); this.parameters.put(parameterIndex, "'" + dateStr + "'"); } @@ -446,10 +472,32 @@ public void setTime(int parameterIndex, Time x, Calendar cal) throws SQLExceptio @Override public void setTimestamp(int parameterIndex, Timestamp x) throws SQLException { checkParameterIndex(parameterIndex); - // Use millisecond value for SQL compatibility with TIMESTAMP columns - long timestampMs = x.getTime(); - setPreparedParameterValue(parameterIndex, timestampMs, Types.BIGINT); - this.parameters.put(parameterIndex, Long.toString(timestampMs)); + // The server reads time values in its own precision (ms, us or ns) + long time = toServerTime(x); + setPreparedParameterValue(parameterIndex, time, Types.BIGINT); + this.parameters.put(parameterIndex, Long.toString(time)); + } + + private long toServerTime(Timestamp x) throws SQLException { + int timeFactor = getTimeFactor(); + // getTime() carries the milliseconds of getNanos(), so only its whole seconds are used + long seconds = Math.floorDiv(x.getTime(), 1_000L); + long fraction = x.getNanos() / (1_000_000_000 / timeFactor); + try { + if (seconds < 0 && fraction > 0) { + // Borrow a second, as Instant.toEpochMilli() does, so that the product cannot underflow + // for a value whose positive fraction brings it back into range + return Math.addExact(Math.multiplyExact(seconds + 1, timeFactor), fraction - timeFactor); + } + return Math.addExact(Math.multiplyExact(seconds, timeFactor), fraction); + } catch (ArithmeticException e) { + throw new SQLException( + String.format( + JdbcMessages.EXCEPTION_TIMESTAMP_ARG_OUT_RANGE_TIME_PRECISION_ARG_E25E3001, + x, + RpcUtils.getTimePrecision(timeFactor)), + e); + } } @Override @@ -477,6 +525,8 @@ public void setObject(int parameterIndex, Object x) throws SQLException { setTimestamp(parameterIndex, (Timestamp) x); } else if (x instanceof Date) { setDate(parameterIndex, (Date) x); + } else if (x instanceof LocalDate) { + setLocalDate(parameterIndex, (LocalDate) x); } else if (x instanceof Time) { setTime(parameterIndex, (Time) x); } else if (x instanceof byte[]) { @@ -703,36 +753,54 @@ private String createCompleteSql(final String sql, Map paramete return newSql.toString(); } + /** + * Splits the SQL at its parameter markers, skipping string literals, quoted identifiers and + * comments as the table-model lexer does. Quotes are escaped only by doubling them; a backslash + * is an ordinary character. + */ private List splitSqlStatement(final String sql) { List parts = new ArrayList<>(); - int apCount = 0; int off = 0; - boolean skip = false; - - for (int i = 0; i < sql.length(); i++) { + int i = 0; + while (i < sql.length()) { char c = sql.charAt(i); - if (skip) { - skip = false; - continue; - } - switch (c) { - case '\'': - apCount++; - break; - case '\\': - skip = true; - break; - case '?': - if ((apCount & 1) == 0) { - parts.add(sql.substring(off, i)); - off = i + 1; - } - break; - default: - break; + if (c == '\'' || c == '"' || c == '`') { + i = skipQuoted(sql, i, c); + } else if (sql.startsWith("--", i)) { + // Like the lexer's SIMPLE_COMMENT, the comment ends at the first CR or LF + i += 2; + while (i < sql.length() && sql.charAt(i) != '\r' && sql.charAt(i) != '\n') { + i++; + } + } else if (sql.startsWith("/*", i)) { + int end = sql.indexOf("*/", i + 2); + i = end < 0 ? sql.length() : end + 2; + } else { + if (c == '?') { + parts.add(sql.substring(off, i)); + off = i + 1; + } + i++; } } parts.add(sql.substring(off)); return parts; } + + /** Returns the index just past the quoted text that starts at {@code start}. */ + private static int skipQuoted(String sql, int start, char quote) { + int i = start + 1; + while (i < sql.length()) { + if (sql.charAt(i) == quote) { + if (i + 1 < sql.length() && sql.charAt(i + 1) == quote) { + // A doubled quote stands for the quote character itself + i += 2; + continue; + } + return i + 1; + } + i++; + } + return sql.length(); + } } diff --git a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/relational/IoTDBRelationalDatabaseMetadata.java b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/relational/IoTDBRelationalDatabaseMetadata.java index 7ddfca1c01a9d..42d716f497fa5 100644 --- a/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/relational/IoTDBRelationalDatabaseMetadata.java +++ b/iotdb-client/jdbc/src/main/java/org/apache/iotdb/jdbc/relational/IoTDBRelationalDatabaseMetadata.java @@ -45,6 +45,7 @@ import java.util.List; import java.util.Map; import java.util.TreeMap; +import java.util.regex.Pattern; public class IoTDBRelationalDatabaseMetadata extends IoTDBAbstractDatabaseMetadata { @@ -56,7 +57,13 @@ public class IoTDBRelationalDatabaseMetadata extends IoTDBAbstractDatabaseMetada ? IoTDBRelationalDatabaseMetadata.class.getPackage().getImplementationVersion() : "UNKNOWN"; - public static final String SHOW_TABLES_ERROR_MSG = "Show tables error: {}"; + public static final String SHOW_TABLES_ERROR_MSG = + JdbcMessages.LOG_SHOW_TABLES_ERROR_ARG_068CF1BA; + + private static final String TABLE_TYPE_TABLE = "TABLE"; + private static final String TIME_CATEGORY = "TIME"; + // Same as getSearchStringEscape() + private static final char SEARCH_STRING_ESCAPE = '\\'; public static final String[] allIotdbTableSQLKeywords = { "ALTER", @@ -207,117 +214,52 @@ public ResultSet getTables( throws SQLException { Statement stmt = this.connection.createStatement(); - boolean legacyMode = true; - - ResultSet rs; + List tables; try { - String sql = - String.format( - "select * from information_schema.tables where database like '%s' escape '\\'", - schemaPattern); - rs = stmt.executeQuery(sql); - } catch (SQLException e) { - LOGGER.error(SHOW_TABLES_ERROR_MSG, e.getMessage()); - - try { - String sql = String.format("show tables details from %s", removeEscape(schemaPattern)); - rs = stmt.executeQuery(sql); - legacyMode = false; - } catch (SQLException e1) { - LOGGER.error(SHOW_TABLES_ERROR_MSG, e.getMessage()); - throw e; - } + // Every table is reported as TABLE, so a type filter without TABLE matches nothing + tables = + isTableTypeRequested(types) + ? listTables(stmt, schemaPattern, tableNamePattern) + : Collections.emptyList(); } finally { stmt.close(); } // Setup Fields - Field[] fields = new Field[6]; - fields[0] = new Field("", TABLE_SCHEM, "TEXT"); - fields[1] = new Field("", TABLE_NAME, "TEXT"); - fields[2] = new Field("", TABLE_TYPE, "TEXT"); - fields[3] = new Field("", REMARKS, "TEXT"); - fields[4] = new Field("", COLUMN_SIZE, INT32); - fields[5] = new Field("", DECIMAL_DIGITS, INT32); + Field[] fields = new Field[7]; + fields[0] = new Field("", TABLE_CAT, "TEXT"); + fields[1] = new Field("", TABLE_SCHEM, "TEXT"); + fields[2] = new Field("", TABLE_NAME, "TEXT"); + fields[3] = new Field("", TABLE_TYPE, "TEXT"); + fields[4] = new Field("", REMARKS, "TEXT"); + fields[5] = new Field("", COLUMN_SIZE, INT32); + fields[6] = new Field("", DECIMAL_DIGITS, INT32); List tsDataTypeList = Arrays.asList( TSDataType.TEXT, TSDataType.TEXT, TSDataType.TEXT, TSDataType.TEXT, + TSDataType.TEXT, TSDataType.INT32, TSDataType.INT32); - List columnNameList = new ArrayList<>(); - List columnTypeList = new ArrayList<>(); - Map columnNameIndex = new HashMap<>(); - List> valuesList = new ArrayList<>(); - for (int i = 0; i < fields.length; i++) { - columnNameList.add(fields[i].getName()); - columnTypeList.add(fields[i].getSqlType()); - columnNameIndex.put(fields[i].getName(), i); - } // Extract Values - boolean hasResultSet = false; - while (rs.next()) { - hasResultSet = true; + List> valuesList = new ArrayList<>(); + for (TableInfo table : tables) { List valueInRow = new ArrayList<>(); - for (int i = 0; i < fields.length; i++) { - if (i == 0) { - valueInRow.add(schemaPattern); - } else if (i == 1) { - // valueInRow.add(rs.getString(2)); - valueInRow.add(legacyMode ? rs.getString("table_name") : rs.getString("TableName")); - } else if (i == 2) { - valueInRow.add("TABLE"); - } else if (i == 3) { - // String tgtString = ""; - // String ttl = rs.getString("ttl(ms)"); - // tgtString += "TTL(ms): " + ttl; - String comment = legacyMode ? rs.getString("comment") : rs.getString("Comment"); - if (comment != null && !comment.isEmpty()) { - valueInRow.add(comment); - } else { - valueInRow.add(""); - } - } else if (i == 4) { - valueInRow.add(getTypePrecision(fields[i].getSqlType())); - } else if (i == 5) { - valueInRow.add(getTypeScale(fields[i].getSqlType())); - } else { - valueInRow.add("TABLE"); - } - } + // TABLE_CAT: the table model has no catalogs + valueInRow.add(null); + valueInRow.add(table.database()); + valueInRow.add(table.tableName()); + valueInRow.add(TABLE_TYPE_TABLE); + valueInRow.add(table.comment() == null ? "" : table.comment()); + valueInRow.add(getTypePrecision(fields[5].getSqlType())); + valueInRow.add(getTypeScale(fields[6].getSqlType())); valuesList.add(valueInRow); } - // Convert Values to ByteBuffer - ByteBuffer tsBlock = null; - try { - tsBlock = convertTsBlock(valuesList, tsDataTypeList); - } catch (IOException e) { - LOGGER.error(CONVERT_ERROR_MSG, e.getMessage()); - } finally { - close(rs, stmt); - } - - return hasResultSet - ? new IoTDBJDBCResultSet( - stmt, - columnNameList, - columnTypeList, - columnNameIndex, - true, - client, - null, - -1, - sessionId, - Collections.singletonList(tsBlock), - null, - (long) 60 * 1000, - false, - zoneId) - : null; + return toResultSet(stmt, fields, tsDataTypeList, valuesList); } /** @@ -328,7 +270,15 @@ public ResultSet getTables( * @return eg. information_schema */ public static String removeEscape(String pattern) { - return pattern.replaceAll("\\_", "_"); + StringBuilder name = new StringBuilder(pattern.length()); + for (int i = 0; i < pattern.length(); i++) { + char c = pattern.charAt(i); + if (c == SEARCH_STRING_ESCAPE && i + 1 < pattern.length()) { + c = pattern.charAt(++i); + } + name.append(c); + } + return name.toString(); } @Override @@ -337,30 +287,9 @@ public ResultSet getColumns( throws SQLException { Statement stmt = this.connection.createStatement(); - boolean legacyMode = true; - ResultSet rs; - - // Get Table Metadata + List columns; try { - String sql = - String.format( - "select * from information_schema.columns where database like '%s' escape '\\' and table_name like '%s' escape '\\'", - schemaPattern, tableNamePattern); - rs = stmt.executeQuery(sql); - } catch (SQLException e) { - LOGGER.error(SHOW_TABLES_ERROR_MSG, e.getMessage()); - - try { - String sql = - String.format( - "desc %s.%s details", removeEscape(schemaPattern), removeEscape(tableNamePattern)); - rs = stmt.executeQuery(sql); - legacyMode = false; - } catch (SQLException e1) { - LOGGER.error(SHOW_TABLES_ERROR_MSG, e.getMessage()); - throw e; - } - + columns = listColumns(stmt, schemaPattern, tableNamePattern, columnNamePattern); } finally { stmt.close(); } @@ -418,36 +347,27 @@ public ResultSet getColumns( TSDataType.TEXT, TSDataType.TEXT, TSDataType.TEXT); - List columnNameList = new ArrayList<>(); - List columnTypeList = new ArrayList<>(); - Map columnNameIndex = new HashMap<>(); List> valuesList = new ArrayList<>(); - for (int i = 0; i < fields.length; i++) { - columnNameList.add(fields[i].getName()); - columnTypeList.add(fields[i].getSqlType()); - columnNameIndex.put(fields[i].getName(), i); - } // Extract Metadata - int count = 1; - while (rs.next()) { - String columnName = - legacyMode ? rs.getString("column_name") : rs.getString("ColumnName"); // 3 - String type = legacyMode ? rs.getString("datatype") : rs.getString("DataType"); // 4 + for (ColumnInfo column : columns) { + // The time column can have any name, so it is recognized by its category + boolean isTimeColumn = TIME_CATEGORY.equals(column.category()); List valueInRow = new ArrayList<>(); for (int i = 0; i < fields.length; i++) { if (i == 0) { - valueInRow.add(""); + // TABLE_CAT: the table model has no catalogs + valueInRow.add(null); } else if (i == 1) { - valueInRow.add(schemaPattern); + valueInRow.add(column.database()); } else if (i == 2) { - valueInRow.add(tableNamePattern); + valueInRow.add(column.tableName()); } else if (i == 3) { - valueInRow.add(columnName); + valueInRow.add(column.columnName()); } else if (i == 4) { - valueInRow.add(getSQLType(type)); + valueInRow.add(getSQLType(column.dataType())); } else if (i == 5) { - valueInRow.add(type); + valueInRow.add(column.dataType()); } else if (i == 6) { valueInRow.add(0); } else if (i == 7) { @@ -457,13 +377,13 @@ public ResultSet getColumns( } else if (i == 9) { valueInRow.add(0); } else if (i == 10) { - if (!columnName.equals("time")) { + if (!isTimeColumn) { valueInRow.add(ResultSetMetaData.columnNullableUnknown); } else { valueInRow.add(ResultSetMetaData.columnNoNulls); } } else if (i == 11) { - String comment = legacyMode ? rs.getString("comment") : rs.getString("Comment"); + String comment = column.comment(); if (comment != null && !comment.isEmpty()) { valueInRow.add(comment); } else { @@ -478,9 +398,9 @@ public ResultSet getColumns( } else if (i == 15) { valueInRow.add(65535); } else if (i == 16) { - valueInRow.add(count++); + valueInRow.add(column.ordinalPosition()); } else if (i == 17) { - if (!columnName.equals("time")) { + if (!isTimeColumn) { valueInRow.add("YES"); } else { valueInRow.add("NO"); @@ -504,14 +424,167 @@ public ResultSet getColumns( valuesList.add(valueInRow); } - // Convert Values to ByteBuffer - ByteBuffer tsBlock = null; + return toResultSet(stmt, fields, tsDataTypeList, valuesList); + } + + /** Lists the tables matching the patterns, with their real database and table names. */ + private List listTables(Statement stmt, String schemaPattern, String tableNamePattern) + throws SQLException { + String sql = informationSchemaQuery("tables", schemaPattern, tableNamePattern); + try (ResultSet rs = stmt.executeQuery(sql)) { + List tables = new ArrayList<>(); + while (rs.next()) { + tables.add( + new TableInfo( + rs.getString("database"), rs.getString("table_name"), rs.getString("comment"))); + } + return tables; + } catch (SQLException e) { + LOGGER.error(SHOW_TABLES_ERROR_MSG, e.getMessage()); + try { + return listTablesWithShowStatements(stmt, schemaPattern, tableNamePattern); + } catch (SQLException fallbackException) { + e.addSuppressed(fallbackException); + throw e; + } + } + } + + /** + * Lists tables with SHOW statements, for users who may not query information_schema. SHOW TABLES + * cannot filter by pattern, so the patterns are matched here. + */ + private List listTablesWithShowStatements( + Statement stmt, String schemaPattern, String tableNamePattern) throws SQLException { + List databases = new ArrayList<>(); + if (isLiteral(schemaPattern)) { + databases.add(removeEscape(schemaPattern)); + } else { + try (ResultSet rs = stmt.executeQuery(SHOW_DATABASES_SQL)) { + while (rs.next()) { + String database = rs.getString(1); + if (matchesPattern(database, schemaPattern)) { + databases.add(database); + } + } + } + } + + List tables = new ArrayList<>(); + for (String database : databases) { + try (ResultSet rs = + stmt.executeQuery("show tables details from " + quoteIdentifier(database))) { + while (rs.next()) { + String tableName = rs.getString("TableName"); + if (matchesPattern(tableName, tableNamePattern)) { + tables.add(new TableInfo(database, tableName, rs.getString("Comment"))); + } + } + } + } + return tables; + } + + /** + * Lists the columns matching the patterns. ORDINAL_POSITION counts every column of the table, so + * the column name pattern is matched here rather than by the server. + */ + private List listColumns( + Statement stmt, String schemaPattern, String tableNamePattern, String columnNamePattern) + throws SQLException { + String sql = informationSchemaQuery("columns", schemaPattern, tableNamePattern); + try (ResultSet rs = stmt.executeQuery(sql)) { + List columns = new ArrayList<>(); + // information_schema lists the columns of each table in their defined order + Map, Integer> ordinalPositions = new HashMap<>(); + while (rs.next()) { + String database = rs.getString("database"); + String tableName = rs.getString("table_name"); + int ordinalPosition = + ordinalPositions.merge(Arrays.asList(database, tableName), 1, Integer::sum); + String columnName = rs.getString("column_name"); + if (matchesPattern(columnName, columnNamePattern)) { + columns.add( + new ColumnInfo( + database, + tableName, + columnName, + rs.getString("datatype"), + rs.getString("category"), + rs.getString("comment"), + ordinalPosition)); + } + } + return columns; + } catch (SQLException e) { + LOGGER.error(SHOW_TABLES_ERROR_MSG, e.getMessage()); + try { + return listColumnsWithDescribe(stmt, schemaPattern, tableNamePattern, columnNamePattern); + } catch (SQLException fallbackException) { + e.addSuppressed(fallbackException); + throw e; + } + } + } + + /** Lists columns with DESC statements, for users who may not query information_schema. */ + private List listColumnsWithDescribe( + Statement stmt, String schemaPattern, String tableNamePattern, String columnNamePattern) + throws SQLException { + List columns = new ArrayList<>(); + for (TableInfo table : listTablesWithShowStatements(stmt, schemaPattern, tableNamePattern)) { + String sql = + "desc " + + quoteIdentifier(table.database()) + + "." + + quoteIdentifier(table.tableName()) + + " details"; + try (ResultSet rs = stmt.executeQuery(sql)) { + int ordinalPosition = 0; + while (rs.next()) { + ordinalPosition++; + String columnName = rs.getString("ColumnName"); + if (matchesPattern(columnName, columnNamePattern)) { + columns.add( + new ColumnInfo( + table.database(), + table.tableName(), + columnName, + rs.getString("DataType"), + rs.getString("Category"), + rs.getString("Comment"), + ordinalPosition)); + } + } + } + } + return columns; + } + + private ResultSet toResultSet( + Statement stmt, + Field[] fields, + List tsDataTypeList, + List> valuesList) + throws SQLException { + List columnNameList = new ArrayList<>(); + List columnTypeList = new ArrayList<>(); + Map columnNameIndex = new HashMap<>(); + for (int i = 0; i < fields.length; i++) { + columnNameList.add(fields[i].getName()); + columnTypeList.add(fields[i].getSqlType()); + columnNameIndex.put(fields[i].getName(), i); + } + + // Convert Values to ByteBuffer. No rows must mean no TsBlock: an empty one reads as one row. + List tsBlocks; try { - tsBlock = convertTsBlock(valuesList, tsDataTypeList); + tsBlocks = + valuesList.isEmpty() + ? Collections.emptyList() + : Collections.singletonList(convertTsBlock(valuesList, tsDataTypeList)); } catch (IOException e) { - LOGGER.error(CONVERT_ERROR_MSG, e.getMessage()); - } finally { - close(rs, stmt); + throw new SQLException(e); } return new IoTDBJDBCResultSet( @@ -524,41 +597,98 @@ public ResultSet getColumns( null, -1, sessionId, - Collections.singletonList(tsBlock), + tsBlocks, null, (long) 60 * 1000, false, zoneId); } + /** + * getTableTypes() reports the type as "table", so the requested types are compared ignoring case. + */ + private static boolean isTableTypeRequested(String[] types) { + if (types == null) { + return true; + } + for (String type : types) { + if (TABLE_TYPE_TABLE.equalsIgnoreCase(type)) { + return true; + } + } + return false; + } + + /** A query on information_schema.{@code table}, narrowed by the non-null name patterns. */ + private static String informationSchemaQuery( + String table, String schemaPattern, String tableNamePattern) { + StringBuilder sql = new StringBuilder("select * from information_schema.").append(table); + String keyword = " where "; + if (schemaPattern != null) { + sql.append(keyword).append(likePredicate("database", schemaPattern)); + keyword = " and "; + } + if (tableNamePattern != null) { + sql.append(keyword).append(likePredicate("table_name", tableNamePattern)); + } + return sql.toString(); + } + + private static String likePredicate(String column, String pattern) { + return column + " like '" + pattern.replace("'", "''") + "' escape '\\'"; + } + + /** Whether the pattern names a single object, i.e. it has no unescaped wildcard. */ + private static boolean isLiteral(String pattern) { + if (pattern == null) { + return false; + } + for (int i = 0; i < pattern.length(); i++) { + char c = pattern.charAt(i); + if (c == SEARCH_STRING_ESCAPE) { + i++; + } else if (c == '%' || c == '_') { + return false; + } + } + return true; + } + + /** Matches a name the way LIKE matches it; a null pattern matches every name. */ + private static boolean matchesPattern(String name, String pattern) { + if (pattern == null) { + return true; + } + StringBuilder regex = new StringBuilder(); + for (int i = 0; i < pattern.length(); i++) { + char c = pattern.charAt(i); + if (c == SEARCH_STRING_ESCAPE && i + 1 < pattern.length()) { + regex.append(Pattern.quote(String.valueOf(pattern.charAt(++i)))); + } else if (c == '%') { + regex.append(".*"); + } else if (c == '_') { + regex.append('.'); + } else { + regex.append(Pattern.quote(String.valueOf(c))); + } + } + return Pattern.compile(regex.toString(), Pattern.DOTALL).matcher(name).matches(); + } + + private static String quoteIdentifier(String name) { + return "\"" + name.replace("\"", "\"\"") + "\""; + } + @Override public ResultSet getPrimaryKeys(String catalog, String schemaPattern, String tableNamePattern) throws SQLException { Statement stmt = connection.createStatement(); - boolean legacyMode = true; - ResultSet rs; - + List columns; try { - String sql = - String.format( - "select * from information_schema.columns where database like '%s' escape '\\' and table_name like '%s' escape '\\' and (category='TAG' or category='TIME')", - schemaPattern, tableNamePattern); - rs = stmt.executeQuery(sql); - } catch (SQLException e) { - LOGGER.error(SHOW_TABLES_ERROR_MSG, e.getMessage()); - - try { - String sql = - String.format( - "desc %s.%s", removeEscape(schemaPattern), removeEscape(tableNamePattern)); - rs = stmt.executeQuery(sql); - legacyMode = false; - } catch (SQLException e1) { - LOGGER.error(SHOW_TABLES_ERROR_MSG, e.getMessage()); - throw e; - } - + // The arguments are names rather than patterns, but some tools escape them anyway + columns = + listColumns(stmt, toExactPattern(schemaPattern), toExactPattern(tableNamePattern), null); } finally { stmt.close(); } @@ -578,65 +708,41 @@ public ResultSet getPrimaryKeys(String catalog, String schemaPattern, String tab TSDataType.TEXT, TSDataType.INT32, TSDataType.TEXT); - List columnNameList = new ArrayList<>(); - List columnTypeList = new ArrayList<>(); - Map columnNameIndex = new HashMap<>(); List> valuesList = new ArrayList<>(); - for (int i = 0; i < fields.length; i++) { - columnNameList.add(fields[i].getName()); - columnTypeList.add(fields[i].getSqlType()); - columnNameIndex.put(fields[i].getName(), i); - } - int count = 1; - while (rs.next()) { - String columnName = legacyMode ? rs.getString("column_name") : rs.getString("ColumnName"); - String category = legacyMode ? rs.getString("category") : rs.getString("Category"); + Map, Integer> keySeqs = new HashMap<>(); + for (ColumnInfo column : columns) { + String category = column.category(); if (category.equals("TAG") || category.equals("TIME")) { List valueInRow = new ArrayList<>(); - for (int i = 0; i < fields.length; ++i) { - if (i == 0) { - valueInRow.add(schemaPattern); - } else if (i == 1) { - valueInRow.add(schemaPattern); - } else if (i == 2) { - valueInRow.add(tableNamePattern); - } else if (i == 3) { - valueInRow.add(columnName); - } else if (i == 4) { - valueInRow.add(count++); - } else { - valueInRow.add(PRIMARY); - } - } + // TABLE_CAT: the table model has no catalogs + valueInRow.add(null); + valueInRow.add(column.database()); + valueInRow.add(column.tableName()); + valueInRow.add(column.columnName()); + valueInRow.add( + keySeqs.merge(Arrays.asList(column.database(), column.tableName()), 1, Integer::sum)); + valueInRow.add(PRIMARY); valuesList.add(valueInRow); } } - ByteBuffer tsBlock = null; - try { - tsBlock = convertTsBlock(valuesList, tsDataTypeList); - } catch (IOException e) { - LOGGER.error(JdbcMessages.RELATIONAL_GET_PRIMARY_KEYS_ERROR, e.getMessage()); - } finally { - close(null, stmt); - } + return toResultSet(stmt, fields, tsDataTypeList, valuesList); + } - return new IoTDBJDBCResultSet( - stmt, - columnNameList, - columnTypeList, - columnNameIndex, - true, - client, - null, - -1, - sessionId, - Collections.singletonList(tsBlock), - null, - (long) 60 * 1000, - false, - zoneId); + /** A pattern matching exactly the given name, which may already be escaped like a pattern. */ + private static String toExactPattern(String name) { + if (name == null) { + return null; + } + StringBuilder pattern = new StringBuilder(); + for (char c : removeEscape(name).toCharArray()) { + if (c == '%' || c == '_' || c == SEARCH_STRING_ESCAPE) { + pattern.append(SEARCH_STRING_ESCAPE); + } + pattern.append(c); + } + return pattern.toString(); } @Override @@ -653,4 +759,17 @@ public String getIdentifierQuoteString() throws SQLException { public String getSchemaTerm() throws SQLException { return ""; } + + /** A table as listed by the server, with its real (unescaped) names. */ + private record TableInfo(String database, String tableName, String comment) {} + + /** A column as listed by the server, with its real (unescaped) names. */ + private record ColumnInfo( + String database, + String tableName, + String columnName, + String dataType, + String category, + String comment, + int ordinalPosition) {} } diff --git a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBConnectionTest.java b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBConnectionTest.java index 2919e51d265a5..be3c7de929e1c 100644 --- a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBConnectionTest.java +++ b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBConnectionTest.java @@ -43,6 +43,11 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class IoTDBConnectionTest { @@ -119,4 +124,38 @@ public void setTimeoutTest() throws SQLException { connection.setQueryTimeout(60); Assert.assertEquals(60, connection.getQueryTimeout()); } + + @Test + public void testReadOnlyIsAcceptedAsHint() throws SQLException { + Assert.assertFalse(connection.isReadOnly()); + connection.setReadOnly(true); + Assert.assertTrue(connection.isReadOnly()); + connection.setReadOnly(false); + Assert.assertFalse(connection.isReadOnly()); + } + + @Test + public void testNewConnectionIsInAutoCommitMode() { + Assert.assertTrue(new IoTDBConnection().getAutoCommit()); + } + + @Test + public void testCatalogIsNullInTableDialect() throws SQLException { + IoTDBConnection tableConnection = spy(new IoTDBConnection()); + doReturn(Constant.TABLE_DIALECT).when(tableConnection).getSqlDialect(); + Assert.assertNull(tableConnection.getCatalog()); + + // The tree model keeps its catalog, which its DatabaseMetaData compares against + Assert.assertEquals("Apache IoTDB", connection.getCatalog()); + } + + @Test + public void testSetCatalogToNullIsIgnoredInTableDialect() throws SQLException { + IoTDBConnection tableConnection = spy(new IoTDBConnection()); + doReturn(Constant.TABLE_DIALECT).when(tableConnection).getSqlDialect(); + + tableConnection.setCatalog(null); + + verify(tableConnection, never()).prepareStatement(anyString()); + } } diff --git a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBDatabaseMetadataTest.java b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBDatabaseMetadataTest.java index ab29a14139986..a4f319796869c 100644 --- a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBDatabaseMetadataTest.java +++ b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBDatabaseMetadataTest.java @@ -159,6 +159,26 @@ public void testGetImportedKeys() throws SQLException { Assert.assertEquals("PKTABLE_SCHEM", resultSet.getMetaData().getColumnName(3)); } + @Test + public void testGetColumnsToleratesConnectionWithoutCatalog() throws Exception { + // A connection that switched to the table dialect reports no catalog + when(connection.getCatalog()).thenReturn(null); + TSExecuteStatementResp showTimeseriesResp = + new TSExecuteStatementResp(RpcUtils.getStatus(TSStatusCode.SUCCESS_STATUS)); + showTimeseriesResp.setQueryId(1L); + showTimeseriesResp.setColumns(Collections.singletonList("Timeseries")); + showTimeseriesResp.setDataTypeList(Collections.singletonList("TEXT")); + showTimeseriesResp.setIgnoreTimeStamp(true); + showTimeseriesResp.setColumnIndex2TsBlockColumnIndexList( + new ArrayList<>(Collections.singletonList(0))); + showTimeseriesResp.setQueryResult(Collections.emptyList()); + when(client.executeQueryStatementV2(any(TSExecuteStatementReq.class))) + .thenReturn(showTimeseriesResp); + when(client.closeOperation(any())).thenReturn(RpcUtils.getStatus(TSStatusCode.SUCCESS_STATUS)); + + Assert.assertNotNull(databaseMetaData.getColumns(null, "root.sg", null, null)); + } + @Test public void testGetIndexInfo() throws SQLException { ResultSet resultSet = databaseMetaData.getIndexInfo(null, null, null, false, false); diff --git a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBJDBCResultSetTest.java b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBJDBCResultSetTest.java index 5805fb103309e..0446e6473f339 100644 --- a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBJDBCResultSetTest.java +++ b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBJDBCResultSetTest.java @@ -30,9 +30,12 @@ import org.apache.iotdb.service.rpc.thrift.TSFetchResultsReq; import org.apache.iotdb.service.rpc.thrift.TSFetchResultsResp; +import org.apache.tsfile.block.column.ColumnBuilder; +import org.apache.tsfile.common.conf.TSFileConfig; import org.apache.tsfile.enums.TSDataType; import org.apache.tsfile.read.common.block.TsBlockBuilder; import org.apache.tsfile.read.common.block.column.TsBlockSerde; +import org.apache.tsfile.utils.Binary; import org.junit.Assert; import org.junit.Before; import org.junit.Test; @@ -47,11 +50,14 @@ import java.sql.Statement; import java.sql.Timestamp; import java.sql.Types; +import java.time.LocalDate; import java.time.ZoneId; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; @@ -107,6 +113,18 @@ public class IoTDBJDBCResultSetTest { + private static final List ALL_TYPES = + Arrays.asList( + TSDataType.BOOLEAN, + TSDataType.INT32, + TSDataType.INT64, + TSDataType.FLOAT, + TSDataType.DOUBLE, + TSDataType.STRING, + TSDataType.BLOB, + TSDataType.TIMESTAMP, + TSDataType.DATE); + @Mock TSExecuteStatementResp execResp; private long queryId; private long sessionId; @@ -279,6 +297,7 @@ public void testTimeColumnRejectedByStrictlyTypedGetters() throws Exception { Assert.assertEquals("2", resultSet.getString(1)); Assert.assertEquals(new Timestamp(2), resultSet.getObject(1)); Assert.assertEquals(new Timestamp(2), resultSet.getTimestamp(1)); + Assert.assertEquals(new Timestamp(2), resultSet.getTimestamp("Time")); // The strictly-typed ones must report a SQLException, not an unchecked exception. assertTimeColumnRejected(() -> resultSet.getBoolean(1)); @@ -292,6 +311,196 @@ public void testTimeColumnRejectedByStrictlyTypedGetters() throws Exception { } } + @SuppressWarnings("resource") + @Test + public void testGetObjectConvertsToRequestedClass() throws Exception { + try (ResultSet resultSet = executeTableModelQueryWithAllTypes(1_000_000, allTypesRow())) { + Assert.assertTrue(resultSet.next()); + Assert.assertEquals(Boolean.TRUE, resultSet.getObject(1, Boolean.class)); + Assert.assertEquals(Integer.valueOf(42), resultSet.getObject(2, Integer.class)); + Assert.assertEquals(Long.valueOf(42L), resultSet.getObject(2, Long.class)); + Assert.assertEquals(Long.valueOf(7L), resultSet.getObject(3, Long.class)); + Assert.assertEquals(Float.valueOf(1.5F), resultSet.getObject(4, Float.class)); + Assert.assertEquals(Double.valueOf(1.5D), resultSet.getObject(4, Double.class)); + Assert.assertEquals(Double.valueOf(2.25D), resultSet.getObject(5, Double.class)); + Assert.assertEquals("hello", resultSet.getObject(6, String.class)); + Assert.assertArrayEquals(new byte[] {1, 2, 3}, resultSet.getObject(7, byte[].class)); + Timestamp expectedTimestamp = new Timestamp(1_700_000_000_123L); + expectedTimestamp.setNanos(123_456_000); + Assert.assertEquals(expectedTimestamp, resultSet.getObject(8, Timestamp.class)); + Assert.assertEquals(Long.valueOf(1_700_000_000_123_456L), resultSet.getObject(8, Long.class)); + Assert.assertEquals(LocalDate.of(2024, 1, 15), resultSet.getObject(9, LocalDate.class)); + Assert.assertEquals("42", resultSet.getObject(2, String.class)); + } + } + + @SuppressWarnings("resource") + @Test + public void testGetObjectReturnsNullForSqlNull() throws Exception { + try (ResultSet resultSet = + executeTableModelQueryWithAllTypes(1_000, new Object[ALL_TYPES.size()])) { + Assert.assertTrue(resultSet.next()); + Assert.assertNull(resultSet.getObject(1, Boolean.class)); + Assert.assertNull(resultSet.getObject(2, Integer.class)); + Assert.assertNull(resultSet.getObject(3, Long.class)); + Assert.assertNull(resultSet.getObject(4, Float.class)); + Assert.assertNull(resultSet.getObject(5, Double.class)); + Assert.assertNull(resultSet.getObject(6, String.class)); + Assert.assertNull(resultSet.getObject(7, byte[].class)); + Assert.assertNull(resultSet.getObject(8, Timestamp.class)); + Assert.assertNull(resultSet.getObject(9, LocalDate.class)); + Assert.assertTrue(resultSet.wasNull()); + Assert.assertNull(resultSet.getObject("i", Integer.class)); + } + } + + @SuppressWarnings("resource") + @Test + public void testGetObjectRejectsUnsupportedConversion() throws Exception { + try (ResultSet resultSet = executeTableModelQueryWithAllTypes(1_000, allTypesRow())) { + Assert.assertTrue(resultSet.next()); + assertRejectedWithSqlException(() -> resultSet.getObject(6, Integer.class)); + assertRejectedWithSqlException(() -> resultSet.getObject(1, Long.class)); + assertRejectedWithSqlException(() -> resultSet.getObject(9, Timestamp.class)); + assertRejectedWithSqlException(() -> resultSet.getObject(2, StringBuilder.class)); + assertRejectedWithSqlException(() -> resultSet.getObject(2, (Class) null)); + } + } + + @SuppressWarnings("resource") + @Test + public void testGetObjectByLabelConvertsToRequestedClass() throws Exception { + try (ResultSet resultSet = executeTableModelQueryWithAllTypes(1_000_000, allTypesRow())) { + Assert.assertTrue(resultSet.next()); + Assert.assertEquals(Integer.valueOf(42), resultSet.getObject("i", Integer.class)); + Assert.assertEquals(Long.valueOf(7L), resultSet.getObject("l", Long.class)); + Assert.assertEquals("hello", resultSet.getObject("s", String.class)); + Timestamp expectedTimestamp = new Timestamp(1_700_000_000_123L); + expectedTimestamp.setNanos(123_456_000); + Assert.assertEquals(expectedTimestamp, resultSet.getObject("ts", Timestamp.class)); + Assert.assertEquals(LocalDate.of(2024, 1, 15), resultSet.getObject("dt", LocalDate.class)); + assertRejectedWithSqlException(() -> resultSet.getObject("s", Integer.class)); + assertRejectedWithSqlException(() -> resultSet.getObject("no_such_column", Integer.class)); + } + } + + @SuppressWarnings("resource") + @Test + public void testGetTimestampHonorsTimePrecisionByIndexAndLabel() throws Exception { + try (ResultSet resultSet = executeTableModelQueryWithAllTypes(1_000_000, allTypesRow())) { + Assert.assertTrue(resultSet.next()); + Timestamp expectedTimestamp = new Timestamp(1_700_000_000_123L); + expectedTimestamp.setNanos(123_456_000); + Assert.assertEquals(expectedTimestamp, resultSet.getTimestamp(8)); + Assert.assertEquals(expectedTimestamp, resultSet.getTimestamp("ts")); + assertRejectedWithSqlException(() -> resultSet.getTimestamp("no_such_column")); + } + } + + @SuppressWarnings("resource") + @Test + public void testGetTimestampKeepsPreEpochValues() throws Exception { + Object[] row = allTypesRow(); + // 1.5 seconds before the epoch, in milliseconds + row[7] = -1_500L; + try (ResultSet resultSet = executeTableModelQueryWithAllTypes(1_000, row)) { + Assert.assertTrue(resultSet.next()); + Assert.assertEquals(new Timestamp(-1_500L), resultSet.getTimestamp("ts")); + Assert.assertEquals(new Timestamp(-1_500L), resultSet.getTimestamp(8)); + Assert.assertEquals(new Timestamp(-1_500L), resultSet.getObject(8, Timestamp.class)); + } + } + + @SuppressWarnings("resource") + @Test + public void testGetTimestampReturnsNullForSqlNull() throws Exception { + try (ResultSet resultSet = + executeTableModelQueryWithAllTypes(1_000, new Object[ALL_TYPES.size()])) { + Assert.assertTrue(resultSet.next()); + Assert.assertNull(resultSet.getTimestamp(8)); + Assert.assertTrue(resultSet.wasNull()); + Assert.assertNull(resultSet.getTimestamp("ts")); + Assert.assertTrue(resultSet.wasNull()); + } + } + + private void assertRejectedWithSqlException(ResultSetRead read) { + try { + read.run(); + Assert.fail("the read should have been rejected"); + } catch (SQLException e) { + // expected + } catch (RuntimeException e) { + Assert.fail( + "expected a SQLException but got " + e.getClass().getName() + ": " + e.getMessage()); + } + } + + /** One value of each type in ALL_TYPES; the TIMESTAMP is in microseconds. */ + private static Object[] allTypesRow() { + return new Object[] { + true, 42, 7L, 1.5F, 2.25D, "hello", new byte[] {1, 2, 3}, 1_700_000_000_123_456L, 20240115 + }; + } + + /** Runs a table-model query whose single row holds one column of each type in ALL_TYPES. */ + private ResultSet executeTableModelQueryWithAllTypes(int timeFactor, Object[] row) + throws Exception { + TsBlockBuilder tsBlockBuilder = new TsBlockBuilder(ALL_TYPES); + tsBlockBuilder.getTimeColumnBuilder().writeLong(0); + for (int i = 0; i < ALL_TYPES.size(); i++) { + ColumnBuilder columnBuilder = tsBlockBuilder.getColumnBuilder(i); + Object value = row[i]; + if (value == null) { + columnBuilder.appendNull(); + continue; + } + switch (ALL_TYPES.get(i)) { + case BOOLEAN: + columnBuilder.writeBoolean((Boolean) value); + break; + case INT32: + case DATE: + columnBuilder.writeInt((Integer) value); + break; + case INT64: + case TIMESTAMP: + columnBuilder.writeLong((Long) value); + break; + case FLOAT: + columnBuilder.writeFloat((Float) value); + break; + case DOUBLE: + columnBuilder.writeDouble((Double) value); + break; + case STRING: + columnBuilder.writeBinary(new Binary((String) value, TSFileConfig.STRING_CHARSET)); + break; + default: + columnBuilder.writeBinary(new Binary((byte[]) value)); + break; + } + } + tsBlockBuilder.declarePosition(); + + List columns = Arrays.asList("b", "i", "l", "f", "d", "s", "blob", "ts", "dt"); + TSExecuteStatementResp resp = new TSExecuteStatementResp(successStatus); + resp.setQueryId(queryId); + resp.setColumns(columns); + resp.setDataTypeList(ALL_TYPES.stream().map(TSDataType::name).collect(Collectors.toList())); + resp.setIgnoreTimeStamp(true); + resp.setTableModel(true); + resp.setMoreData(false); + resp.setColumnIndex2TsBlockColumnIndexList( + IntStream.range(0, columns.size()).boxed().collect(Collectors.toList())); + resp.setQueryResult( + Collections.singletonList(new TsBlockSerde().serialize(tsBlockBuilder.build()))); + + when(connection.getTimeFactor()).thenReturn(timeFactor); + when(client.executeQueryStatementV2(any(TSExecuteStatementReq.class))).thenReturn(resp); + return statement.executeQuery("select * from all_types"); + } + @FunctionalInterface private interface ResultSetRead { void run() throws SQLException; diff --git a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBRelationalDatabaseMetadataTest.java b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBRelationalDatabaseMetadataTest.java new file mode 100644 index 0000000000000..6517e7dbf2b78 --- /dev/null +++ b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBRelationalDatabaseMetadataTest.java @@ -0,0 +1,634 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.iotdb.jdbc; + +import org.apache.iotdb.jdbc.relational.IoTDBRelationalDatabaseMetadata; +import org.apache.iotdb.rpc.RpcUtils; +import org.apache.iotdb.rpc.TSStatusCode; +import org.apache.iotdb.service.rpc.thrift.IClientRPCService; +import org.apache.iotdb.service.rpc.thrift.TSCloseOperationReq; +import org.apache.iotdb.service.rpc.thrift.TSExecuteStatementReq; +import org.apache.iotdb.service.rpc.thrift.TSExecuteStatementResp; + +import org.apache.tsfile.block.column.ColumnBuilder; +import org.apache.tsfile.common.conf.TSFileConfig; +import org.apache.tsfile.enums.TSDataType; +import org.apache.tsfile.read.common.block.TsBlockBuilder; +import org.apache.tsfile.read.common.block.column.TsBlockSerde; +import org.apache.tsfile.utils.Binary; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.Types; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +public class IoTDBRelationalDatabaseMetadataTest { + + private static final String TABLES_QUERY = "select * from information_schema.tables"; + private static final String COLUMNS_QUERY = "select * from information_schema.columns"; + + // Result headers as the server produces them for each metadata source. + private static final List INFORMATION_SCHEMA_TABLES_HEADER = + Arrays.asList( + "database", + "table_name", + "ttl(ms)", + "status", + "comment", + "table_type", + "need_last_cache"); + private static final List INFORMATION_SCHEMA_TABLES_TYPES = + Arrays.asList( + TSDataType.STRING, + TSDataType.STRING, + TSDataType.STRING, + TSDataType.STRING, + TSDataType.STRING, + TSDataType.STRING, + TSDataType.BOOLEAN); + private static final List INFORMATION_SCHEMA_COLUMNS_HEADER = + Arrays.asList( + "database", "table_name", "column_name", "datatype", "category", "status", "comment"); + private static final List SHOW_DATABASES_HEADER = + Arrays.asList( + "Database", + "TTL(ms)", + "SchemaReplicationFactor", + "DataReplicationFactor", + "TimePartitionInterval"); + private static final List SHOW_DATABASES_TYPES = + Arrays.asList( + TSDataType.TEXT, TSDataType.TEXT, TSDataType.INT32, TSDataType.INT32, TSDataType.INT64); + private static final List SHOW_TABLES_DETAILS_HEADER = + Arrays.asList("TableName", "TTL(ms)", "Status", "Comment", "TableType", "NeedLastCache"); + private static final List SHOW_TABLES_DETAILS_TYPES = + Arrays.asList( + TSDataType.TEXT, + TSDataType.TEXT, + TSDataType.TEXT, + TSDataType.TEXT, + TSDataType.TEXT, + TSDataType.BOOLEAN); + private static final List DESC_DETAILS_HEADER = + Arrays.asList("ColumnName", "DataType", "Category", "Status", "Comment"); + + @Mock private IoTDBConnection connection; + @Mock private IClientRPCService.Iface client; + + private final long sessionId = 1L; + private final ZoneId zoneId = ZoneId.systemDefault(); + private DatabaseMetaData metadata; + + /** Canned server responses, picked by the prefix of the statement the driver sends. */ + private final Map responses = new LinkedHashMap<>(); + + private final List executedStatements = new ArrayList<>(); + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + when(connection.isClosed()).thenReturn(false); + when(connection.getTimeFactor()).thenReturn(1_000); + when(connection.createStatement()) + .thenAnswer(invocation -> new IoTDBStatement(connection, client, sessionId, zoneId, 0, 1L)); + when(client.closeOperation(any(TSCloseOperationReq.class))).thenReturn(RpcUtils.SUCCESS_STATUS); + when(client.executeQueryStatementV2(any(TSExecuteStatementReq.class))) + .thenAnswer( + invocation -> { + String statement = ((TSExecuteStatementReq) invocation.getArgument(0)).getStatement(); + executedStatements.add(statement); + return responses.entrySet().stream() + .filter(entry -> statement.startsWith(entry.getKey())) + .map(entry -> withUnreadBuffers(entry.getValue())) + .findFirst() + .orElseGet(() -> failure("unexpected statement: " + statement)); + }); + metadata = new IoTDBRelationalDatabaseMetadata(connection, client, sessionId, zoneId); + } + + // ========== getTables ========== + + @Test + public void testGetTablesReportsRealNamesForEscapedPatterns() throws Exception { + responses.put( + TABLES_QUERY, + informationSchemaTables(informationSchemaTable("db_1", "sensor_data", "sensor table"))); + + try (ResultSet rs = metadata.getTables("Apache IoTDB", "db\\_1", "sensor\\_data", null)) { + assertTrue(rs.next()); + assertNull(rs.getString("TABLE_CAT")); + assertEquals("db_1", rs.getString("TABLE_SCHEM")); + assertEquals("sensor_data", rs.getString("TABLE_NAME")); + assertEquals("TABLE", rs.getString("TABLE_TYPE")); + assertEquals("sensor table", rs.getString("REMARKS")); + assertFalse(rs.next()); + } + } + + @Test + public void testGetTablesStartsWithStandardColumns() throws Exception { + responses.put( + TABLES_QUERY, informationSchemaTables(informationSchemaTable("db_1", "sensor_data", null))); + + try (ResultSet rs = metadata.getTables(null, "db_1", null, null)) { + assertEquals(1, rs.findColumn("TABLE_CAT")); + assertEquals(2, rs.findColumn("TABLE_SCHEM")); + assertEquals(3, rs.findColumn("TABLE_NAME")); + assertEquals(4, rs.findColumn("TABLE_TYPE")); + assertEquals(5, rs.findColumn("REMARKS")); + assertTrue(rs.next()); + assertEquals("db_1", rs.getString(2)); + assertEquals("sensor_data", rs.getString(3)); + } + } + + @Test + public void testGetTablesFiltersByTableNamePatternAndEscapesQuotes() throws Exception { + responses.put(TABLES_QUERY, informationSchemaTables()); + + metadata.getTables(null, "db\\_1", "it's%", null).close(); + + assertEquals( + Collections.singletonList( + "select * from information_schema.tables" + + " where database like 'db\\_1' escape '\\'" + + " and table_name like 'it''s%' escape '\\'"), + executedStatements); + } + + @Test + public void testGetTablesWithNullPatternsListsEveryDatabase() throws Exception { + responses.put( + TABLES_QUERY, + informationSchemaTables( + informationSchemaTable("db_1", "t1", null), + informationSchemaTable("db_2", "t2", null))); + + try (ResultSet rs = metadata.getTables(null, null, null, null)) { + assertTrue(rs.next()); + assertEquals("db_1", rs.getString("TABLE_SCHEM")); + assertEquals("t1", rs.getString("TABLE_NAME")); + assertTrue(rs.next()); + assertEquals("db_2", rs.getString("TABLE_SCHEM")); + assertEquals("t2", rs.getString("TABLE_NAME")); + assertFalse(rs.next()); + } + assertEquals( + Collections.singletonList("select * from information_schema.tables"), executedStatements); + } + + @Test + public void testGetTablesReturnsEmptyResultSetWhenNothingMatches() throws Exception { + responses.put(TABLES_QUERY, informationSchemaTables()); + + try (ResultSet rs = metadata.getTables(null, "no_such_db", null, null)) { + assertNotNull(rs); + assertEquals(3, rs.findColumn("TABLE_NAME")); + assertFalse(rs.next()); + } + } + + @Test + public void testGetTablesHonorsTableTypes() throws Exception { + responses.put( + TABLES_QUERY, informationSchemaTables(informationSchemaTable("db_1", "sensor_data", null))); + + try (ResultSet rs = metadata.getTables(null, "db_1", null, new String[] {"VIEW"})) { + assertFalse(rs.next()); + } + // getTableTypes() reports the type in lower case, so the match must ignore case. + try (ResultSet rs = metadata.getTables(null, "db_1", null, new String[] {"table"})) { + assertTrue(rs.next()); + assertEquals("sensor_data", rs.getString("TABLE_NAME")); + } + try (ResultSet rs = metadata.getTables(null, "db_1", null, new String[] {"TABLE", "VIEW"})) { + assertTrue(rs.next()); + assertEquals("sensor_data", rs.getString("TABLE_NAME")); + } + } + + @Test + public void testGetTablesFallsBackToShowTablesForLiteralSchema() throws Exception { + responses.put(TABLES_QUERY, failure("no permission on information_schema")); + responses.put( + "show tables details from \"db_1\"", + showTablesDetails( + showTablesRow("sensor_data", "sensor table"), showTablesRow("other", ""))); + + try (ResultSet rs = metadata.getTables(null, "db\\_1", "sensor%", null)) { + assertTrue(rs.next()); + assertNull(rs.getString("TABLE_CAT")); + assertEquals("db_1", rs.getString("TABLE_SCHEM")); + assertEquals("sensor_data", rs.getString("TABLE_NAME")); + assertEquals("sensor table", rs.getString("REMARKS")); + assertFalse(rs.next()); + } + } + + @Test + public void testGetTablesFallbackListsEveryDatabaseForNullSchema() throws Exception { + responses.put(TABLES_QUERY, failure("no permission on information_schema")); + responses.put("SHOW DATABASES", showDatabases("db_1", "db_2")); + responses.put("show tables details from \"db_1\"", showTablesDetails(showTablesRow("t1", ""))); + responses.put("show tables details from \"db_2\"", showTablesDetails(showTablesRow("t2", ""))); + + try (ResultSet rs = metadata.getTables(null, null, null, null)) { + assertTrue(rs.next()); + assertEquals("db_1", rs.getString("TABLE_SCHEM")); + assertEquals("t1", rs.getString("TABLE_NAME")); + assertTrue(rs.next()); + assertEquals("db_2", rs.getString("TABLE_SCHEM")); + assertEquals("t2", rs.getString("TABLE_NAME")); + assertFalse(rs.next()); + } + } + + @Test + public void testRemoveEscapeDropsSearchStringEscapes() { + assertEquals( + "information_schema", IoTDBRelationalDatabaseMetadata.removeEscape("information\\_schema")); + assertEquals("a%b\\c", IoTDBRelationalDatabaseMetadata.removeEscape("a\\%b\\\\c")); + assertEquals("plain", IoTDBRelationalDatabaseMetadata.removeEscape("plain")); + } + + // ========== getColumns ========== + + @Test + public void testGetColumnsReportsRealNamesForEscapedPatterns() throws Exception { + responses.put( + COLUMNS_QUERY, + informationSchemaColumns( + informationSchemaColumn("db_1", "sensor_data", "ts", "TIMESTAMP", "TIME"), + informationSchemaColumn("db_1", "sensor_data", "region", "STRING", "TAG"))); + + try (ResultSet rs = metadata.getColumns(null, "db\\_1", "sensor\\_data", null)) { + assertTrue(rs.next()); + assertNull(rs.getString("TABLE_CAT")); + assertEquals("db_1", rs.getString("TABLE_SCHEM")); + assertEquals("sensor_data", rs.getString("TABLE_NAME")); + assertEquals("ts", rs.getString("COLUMN_NAME")); + assertEquals(Types.TIMESTAMP, rs.getInt("DATA_TYPE")); + assertEquals("TIMESTAMP", rs.getString("TYPE_NAME")); + assertTrue(rs.next()); + assertNull(rs.getString("TABLE_CAT")); + assertEquals("db_1", rs.getString("TABLE_SCHEM")); + assertEquals("sensor_data", rs.getString("TABLE_NAME")); + assertEquals("region", rs.getString("COLUMN_NAME")); + assertEquals(Types.VARCHAR, rs.getInt("DATA_TYPE")); + assertFalse(rs.next()); + } + } + + @Test + public void testGetColumnsReportsObjectAsDatabaseSpecificType() throws Exception { + responses.put( + COLUMNS_QUERY, + informationSchemaColumns( + informationSchemaColumn("db_1", "files", "content", "OBJECT", "FIELD"))); + + try (ResultSet rs = metadata.getColumns(null, "db_1", "files", null)) { + assertTrue(rs.next()); + assertEquals("OBJECT", rs.getString("TYPE_NAME")); + assertEquals(Types.OTHER, rs.getInt("DATA_TYPE")); + } + } + + @Test + public void testGetColumnsDerivesNullabilityFromTimeCategory() throws Exception { + responses.put( + COLUMNS_QUERY, + informationSchemaColumns( + informationSchemaColumn("db_1", "sensor_data", "ts", "TIMESTAMP", "TIME"), + informationSchemaColumn("db_1", "sensor_data", "temperature", "FLOAT", "FIELD"))); + + try (ResultSet rs = metadata.getColumns(null, "db_1", "sensor_data", null)) { + assertTrue(rs.next()); + assertEquals("ts", rs.getString("COLUMN_NAME")); + assertEquals(ResultSetMetaData.columnNoNulls, rs.getInt("NULLABLE")); + assertEquals("NO", rs.getString("IS_NULLABLE")); + assertTrue(rs.next()); + assertEquals("temperature", rs.getString("COLUMN_NAME")); + assertEquals(ResultSetMetaData.columnNullableUnknown, rs.getInt("NULLABLE")); + assertEquals("YES", rs.getString("IS_NULLABLE")); + assertFalse(rs.next()); + } + } + + @Test + public void testGetColumnsNumbersColumnsWithinEachTable() throws Exception { + responses.put( + COLUMNS_QUERY, + informationSchemaColumns( + informationSchemaColumn("db_1", "t1", "time", "TIMESTAMP", "TIME"), + informationSchemaColumn("db_1", "t1", "a", "INT32", "FIELD"), + informationSchemaColumn("db_1", "t1", "b", "INT64", "FIELD"), + informationSchemaColumn("db_1", "t2", "time", "TIMESTAMP", "TIME"), + informationSchemaColumn("db_1", "t2", "c", "DOUBLE", "FIELD"))); + + List columns = new ArrayList<>(); + try (ResultSet rs = metadata.getColumns(null, "db_1", "t%", null)) { + while (rs.next()) { + columns.add( + rs.getString("TABLE_NAME") + + "." + + rs.getString("COLUMN_NAME") + + "#" + + rs.getInt("ORDINAL_POSITION")); + } + } + assertEquals(Arrays.asList("t1.time#1", "t1.a#2", "t1.b#3", "t2.time#1", "t2.c#2"), columns); + } + + @Test + public void testGetColumnsFiltersByColumnNamePatternKeepingOrdinalPosition() throws Exception { + responses.put( + COLUMNS_QUERY, + informationSchemaColumns( + informationSchemaColumn("db_1", "t1", "time", "TIMESTAMP", "TIME"), + informationSchemaColumn("db_1", "t1", "a", "INT32", "FIELD"), + informationSchemaColumn("db_1", "t1", "b", "INT64", "FIELD"))); + + try (ResultSet rs = metadata.getColumns(null, "db_1", "t1", "b")) { + assertTrue(rs.next()); + assertEquals("b", rs.getString("COLUMN_NAME")); + assertEquals(3, rs.getInt("ORDINAL_POSITION")); + assertFalse(rs.next()); + } + } + + @Test + public void testGetColumnsBuildsNullSafeQuotedQuery() throws Exception { + responses.put(COLUMNS_QUERY, informationSchemaColumns()); + + metadata.getColumns(null, "db\\_1", "it's", "c%").close(); + metadata.getColumns(null, null, null, null).close(); + + assertEquals( + Arrays.asList( + "select * from information_schema.columns" + + " where database like 'db\\_1' escape '\\'" + + " and table_name like 'it''s' escape '\\'", + "select * from information_schema.columns"), + executedStatements); + } + + @Test + public void testGetColumnsReturnsEmptyResultSetForUnknownTable() throws Exception { + responses.put(COLUMNS_QUERY, informationSchemaColumns()); + + try (ResultSet rs = metadata.getColumns(null, "db_1", "no_such_table", null)) { + assertFalse(rs.next()); + } + } + + @Test + public void testGetColumnsFallsBackToDescribeTable() throws Exception { + responses.put(COLUMNS_QUERY, failure("no permission on information_schema")); + responses.put( + "show tables details from \"db_1\"", + showTablesDetails(showTablesRow("sensor_data", ""), showTablesRow("sensorXdata", ""))); + responses.put( + "desc \"db_1\".\"sensor_data\" details", + describeTableDetails( + new Object[] {"ts", "TIMESTAMP", "TIME", "USING", null}, + new Object[] {"temperature", "FLOAT", "FIELD", "USING", "celsius"})); + + try (ResultSet rs = metadata.getColumns(null, "db\\_1", "sensor\\_data", null)) { + assertTrue(rs.next()); + assertNull(rs.getString("TABLE_CAT")); + assertEquals("db_1", rs.getString("TABLE_SCHEM")); + assertEquals("sensor_data", rs.getString("TABLE_NAME")); + assertEquals("ts", rs.getString("COLUMN_NAME")); + assertEquals(ResultSetMetaData.columnNoNulls, rs.getInt("NULLABLE")); + assertEquals(1, rs.getInt("ORDINAL_POSITION")); + assertTrue(rs.next()); + assertEquals("temperature", rs.getString("COLUMN_NAME")); + assertEquals("celsius", rs.getString("REMARKS")); + assertEquals("YES", rs.getString("IS_NULLABLE")); + assertEquals(2, rs.getInt("ORDINAL_POSITION")); + assertFalse(rs.next()); + } + } + + // ========== getPrimaryKeys ========== + + @Test + public void testGetPrimaryKeysReportsRealNamesOfTimeAndTagColumns() throws Exception { + responses.put( + COLUMNS_QUERY, + informationSchemaColumns( + informationSchemaColumn("db_1", "sensor_data", "ts", "TIMESTAMP", "TIME"), + informationSchemaColumn("db_1", "sensor_data", "region", "STRING", "TAG"), + informationSchemaColumn("db_1", "sensor_data", "model", "STRING", "ATTRIBUTE"), + informationSchemaColumn("db_1", "sensor_data", "plant", "STRING", "TAG"), + informationSchemaColumn("db_1", "sensor_data", "temperature", "FLOAT", "FIELD"))); + + List keys = new ArrayList<>(); + // Some tools escape the names as if they were patterns + try (ResultSet rs = metadata.getPrimaryKeys(null, "db\\_1", "sensor\\_data")) { + while (rs.next()) { + assertNull(rs.getString("TABLE_CAT")); + assertEquals("db_1", rs.getString("TABLE_SCHEM")); + assertEquals("sensor_data", rs.getString("TABLE_NAME")); + assertEquals("PRIMARY", rs.getString("PK_NAME")); + keys.add(rs.getString("COLUMN_NAME") + "#" + rs.getInt("KEY_SEQ")); + } + } + assertEquals(Arrays.asList("ts#1", "region#2", "plant#3"), keys); + } + + @Test + public void testGetPrimaryKeysMatchesExactNamesWhetherEscapedOrNot() throws Exception { + responses.put(COLUMNS_QUERY, informationSchemaColumns()); + + metadata.getPrimaryKeys(null, "db_1", "it's").close(); + metadata.getPrimaryKeys(null, "db\\_1", "sensor\\_data").close(); + metadata.getPrimaryKeys(null, null, "t1").close(); + + assertEquals( + Arrays.asList( + "select * from information_schema.columns" + + " where database like 'db\\_1' escape '\\'" + + " and table_name like 'it''s' escape '\\'", + "select * from information_schema.columns" + + " where database like 'db\\_1' escape '\\'" + + " and table_name like 'sensor\\_data' escape '\\'", + "select * from information_schema.columns where table_name like 't1' escape '\\'"), + executedStatements); + } + + @Test + public void testGetPrimaryKeysReturnsEmptyResultSetForUnknownTable() throws Exception { + responses.put(COLUMNS_QUERY, informationSchemaColumns()); + + try (ResultSet rs = metadata.getPrimaryKeys(null, "db_1", "no_such_table")) { + assertFalse(rs.next()); + } + } + + @Test + public void testGetPrimaryKeysFallsBackToDescribeTable() throws Exception { + responses.put(COLUMNS_QUERY, failure("no permission on information_schema")); + responses.put( + "show tables details from \"db_1\"", + showTablesDetails(showTablesRow("sensor_data", ""), showTablesRow("sensorXdata", ""))); + responses.put( + "desc \"db_1\".\"sensor_data\" details", + describeTableDetails( + new Object[] {"ts", "TIMESTAMP", "TIME", "USING", null}, + new Object[] {"region", "STRING", "TAG", "USING", null}, + new Object[] {"temperature", "FLOAT", "FIELD", "USING", null})); + + List keys = new ArrayList<>(); + try (ResultSet rs = metadata.getPrimaryKeys(null, "db_1", "sensor_data")) { + while (rs.next()) { + assertNull(rs.getString("TABLE_CAT")); + assertEquals("db_1", rs.getString("TABLE_SCHEM")); + assertEquals("sensor_data", rs.getString("TABLE_NAME")); + keys.add(rs.getString("COLUMN_NAME") + "#" + rs.getInt("KEY_SEQ")); + } + } + assertEquals(Arrays.asList("ts#1", "region#2"), keys); + } + + // ========== fake server responses ========== + + private static Object[] informationSchemaTable(String database, String table, String comment) { + return new Object[] {database, table, "INF", "USING", comment, "BASE TABLE", false}; + } + + private static TSExecuteStatementResp informationSchemaTables(Object[]... rows) + throws IOException { + return queryResult(INFORMATION_SCHEMA_TABLES_HEADER, INFORMATION_SCHEMA_TABLES_TYPES, rows); + } + + private static Object[] informationSchemaColumn( + String database, String table, String column, String dataType, String category) { + return new Object[] {database, table, column, dataType, category, "USING", null}; + } + + private static TSExecuteStatementResp informationSchemaColumns(Object[]... rows) + throws IOException { + return queryResult( + INFORMATION_SCHEMA_COLUMNS_HEADER, + Collections.nCopies(INFORMATION_SCHEMA_COLUMNS_HEADER.size(), TSDataType.STRING), + rows); + } + + private static TSExecuteStatementResp showDatabases(String... databases) throws IOException { + Object[][] rows = new Object[databases.length][]; + for (int i = 0; i < databases.length; i++) { + rows[i] = new Object[] {databases[i], "INF", 1, 1, 604_800_000L}; + } + return queryResult(SHOW_DATABASES_HEADER, SHOW_DATABASES_TYPES, rows); + } + + private static Object[] showTablesRow(String table, String comment) { + return new Object[] {table, "INF", "USING", comment, "BASE TABLE", false}; + } + + private static TSExecuteStatementResp showTablesDetails(Object[]... rows) throws IOException { + return queryResult(SHOW_TABLES_DETAILS_HEADER, SHOW_TABLES_DETAILS_TYPES, rows); + } + + private static TSExecuteStatementResp describeTableDetails(Object[]... rows) throws IOException { + return queryResult( + DESC_DETAILS_HEADER, + Collections.nCopies(DESC_DETAILS_HEADER.size(), TSDataType.TEXT), + rows); + } + + /** A successful table-model query response carrying the given rows in one TsBlock. */ + private static TSExecuteStatementResp queryResult( + List header, List types, Object[]... rows) throws IOException { + TsBlockBuilder builder = new TsBlockBuilder(types); + for (Object[] row : rows) { + builder.getTimeColumnBuilder().writeLong(0); + for (int i = 0; i < row.length; i++) { + ColumnBuilder columnBuilder = builder.getColumnBuilder(i); + Object value = row[i]; + if (value == null) { + columnBuilder.appendNull(); + } else if (value instanceof Boolean) { + columnBuilder.writeBoolean((Boolean) value); + } else if (value instanceof Integer) { + columnBuilder.writeInt((Integer) value); + } else if (value instanceof Long) { + columnBuilder.writeLong((Long) value); + } else { + columnBuilder.writeBinary(new Binary((String) value, TSFileConfig.STRING_CHARSET)); + } + } + builder.declarePosition(); + } + + TSExecuteStatementResp resp = new TSExecuteStatementResp(RpcUtils.SUCCESS_STATUS); + resp.setQueryId(100L); + resp.setColumns(header); + resp.setDataTypeList(types.stream().map(TSDataType::name).collect(Collectors.toList())); + resp.setIgnoreTimeStamp(true); + resp.setTableModel(true); + resp.setMoreData(false); + resp.setColumnIndex2TsBlockColumnIndexList( + IntStream.range(0, header.size()).boxed().collect(Collectors.toList())); + resp.setQueryResult( + rows.length == 0 + ? Collections.emptyList() + : Collections.singletonList(new TsBlockSerde().serialize(builder.build()))); + return resp; + } + + /** Reading a result consumes its TsBlock buffers, so every execution gets its own view. */ + private static TSExecuteStatementResp withUnreadBuffers(TSExecuteStatementResp resp) { + TSExecuteStatementResp copy = resp.deepCopy(); + if (copy.isSetQueryResult()) { + copy.setQueryResult( + copy.getQueryResult().stream().map(ByteBuffer::duplicate).collect(Collectors.toList())); + } + return copy; + } + + private static TSExecuteStatementResp failure(String message) { + return RpcUtils.getTSExecuteStatementResp(TSStatusCode.NO_PERMISSION, message); + } +} diff --git a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBResultMetadataTest.java b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBResultMetadataTest.java index dc9869bddda8e..13d84c6d76ac6 100644 --- a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBResultMetadataTest.java +++ b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBResultMetadataTest.java @@ -173,4 +173,18 @@ public void testGetColumnType() throws SQLException { assertEquals(metadata.getColumnType(i + 1), types[i - 1]); } } + + @Test + public void testObjectColumnIsDescribed() throws SQLException { + metadata = + new IoTDBResultMetadata( + false, null, "QUERY", Arrays.asList("file"), Arrays.asList("OBJECT"), true); + + assertEquals("OBJECT", metadata.getColumnTypeName(1)); + assertEquals(Types.OTHER, metadata.getColumnType(1)); + // getObject() renders an OBJECT value as a String such as "(Object) 1.2 MB" + assertEquals(String.class.getName(), metadata.getColumnClassName(1)); + assertEquals(Integer.MAX_VALUE, metadata.getPrecision(1)); + assertEquals(0, metadata.getScale(1)); + } } diff --git a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBTablePreparedStatementTest.java b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBTablePreparedStatementTest.java index dd1c7ecbcb209..d60cd8afdcf75 100644 --- a/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBTablePreparedStatementTest.java +++ b/iotdb-client/jdbc/src/test/java/org/apache/iotdb/jdbc/IoTDBTablePreparedStatementTest.java @@ -20,9 +20,13 @@ package org.apache.iotdb.jdbc; import org.apache.iotdb.common.rpc.thrift.TSStatus; +import org.apache.iotdb.rpc.RpcUtils; import org.apache.iotdb.rpc.TSStatusCode; +import org.apache.iotdb.rpc.stmt.PreparedParameterSerde; import org.apache.iotdb.service.rpc.thrift.IClientRPCService.Iface; +import org.apache.iotdb.service.rpc.thrift.TSCancelOperationReq; import org.apache.iotdb.service.rpc.thrift.TSExecutePreparedReq; +import org.apache.iotdb.service.rpc.thrift.TSExecuteStatementReq; import org.apache.iotdb.service.rpc.thrift.TSExecuteStatementResp; import org.apache.iotdb.service.rpc.thrift.TSPrepareReq; import org.apache.iotdb.service.rpc.thrift.TSPrepareResp; @@ -35,10 +39,23 @@ import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; +import java.nio.ByteBuffer; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.Instant; +import java.time.LocalDate; import java.time.ZoneId; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -237,4 +254,300 @@ public void testTableModelStringWithNull() throws Exception { verify(client).executePreparedStatement(argument.capture()); assertTrue(argument.getValue().getParameters() != null); } + + // ========== Cancel, fetch size and timestamp precision ========== + + @SuppressWarnings("resource") + @Test + public void testCancelTargetsQueryOfLatestPreparedExecution() throws Exception { + when(client.executePreparedStatement(any(TSExecutePreparedReq.class))) + .thenReturn(preparedQueryResp(42L), preparedQueryResp(43L)); + when(client.cancelOperation(any(TSCancelOperationReq.class))) + .thenReturn(RpcUtils.SUCCESS_STATUS); + IoTDBTablePreparedStatement ps = + new IoTDBTablePreparedStatement( + connection, client, sessionId, "SELECT * FROM t WHERE s1 = ?", zoneId); + ps.setInt(1, 1); + + ps.execute(); + ps.cancel(); + ps.execute(); + ps.cancel(); + + ArgumentCaptor argument = + ArgumentCaptor.forClass(TSCancelOperationReq.class); + verify(client, times(2)).cancelOperation(argument.capture()); + assertEquals( + Arrays.asList(42L, 43L), + argument.getAllValues().stream() + .map(TSCancelOperationReq::getQueryId) + .collect(Collectors.toList())); + assertEquals(sessionId, argument.getValue().getSessionId()); + } + + @SuppressWarnings("resource") + @Test + public void testPreparedExecutionSendsFetchSize() throws Exception { + IoTDBTablePreparedStatement ps = + new IoTDBTablePreparedStatement( + connection, client, sessionId, "SELECT * FROM t WHERE s1 = ?", zoneId); + ps.setInt(1, 1); + ps.setFetchSize(123); + + ps.execute(); + + TSExecutePreparedReq req = lastPreparedExecution(); + assertTrue(req.isSetFetchSize()); + assertEquals(123, req.getFetchSize()); + } + + @SuppressWarnings("resource") + @Test + public void testPreparedExecutionFetchSizeIsCappedByMaxRows() throws Exception { + IoTDBTablePreparedStatement ps = + new IoTDBTablePreparedStatement( + connection, client, sessionId, "SELECT * FROM t WHERE s1 = ?", zoneId); + ps.setInt(1, 1); + ps.setFetchSize(1000); + ps.setMaxRows(10); + + ps.execute(); + + assertEquals(10, lastPreparedExecution().getFetchSize()); + } + + @Test + public void testSetTimestampUsesServerTimePrecision() throws Exception { + Timestamp timestamp = new Timestamp(1_700_000_000_123L); + timestamp.setNanos(123_456_789); + assertEquals(1_700_000_000_123L, preparedTimestampValue(1_000, timestamp)); + assertEquals(1_700_000_000_123_456L, preparedTimestampValue(1_000_000, timestamp)); + assertEquals(1_700_000_000_123_456_789L, preparedTimestampValue(1_000_000_000, timestamp)); + + // 1.5 seconds before the epoch: the whole-second part must be floored, not truncated. + Timestamp beforeEpoch = new Timestamp(-1_500L); + assertEquals(-1_500L, preparedTimestampValue(1_000, beforeEpoch)); + assertEquals(-1_500_000L, preparedTimestampValue(1_000_000, beforeEpoch)); + assertEquals(-1_500_000_000L, preparedTimestampValue(1_000_000_000, beforeEpoch)); + } + + @SuppressWarnings("resource") + @Test + public void testSetTimestampRejectsValueBeyondServerPrecisionRange() throws Exception { + when(connection.getTimeFactor()).thenReturn(1_000_000_000); + IoTDBTablePreparedStatement ps = + new IoTDBTablePreparedStatement( + connection, client, sessionId, "SELECT * FROM t WHERE time = ?", zoneId); + + // Nanosecond precision can only represent instants up to the year 2262. + Timestamp farFuture = Timestamp.valueOf("2300-01-01 00:00:00"); + assertThrows(SQLException.class, () -> ps.setTimestamp(1, farFuture)); + + // ...and down to 1677-09-21T00:12:43.145224192Z, which is Long.MIN_VALUE nanoseconds. + Timestamp beforeLowerBound = Timestamp.from(Instant.parse("1677-09-21T00:12:43.145224191Z")); + assertThrows(SQLException.class, () -> ps.setTimestamp(1, beforeLowerBound)); + } + + @Test + public void testSetTimestampAcceptsNanosecondLowerBound() throws Exception { + assertEquals( + Long.MIN_VALUE + 1, + preparedTimestampValue( + 1_000_000_000, Timestamp.from(Instant.parse("1677-09-21T00:12:43.145224193Z")))); + assertEquals( + Long.MIN_VALUE, + preparedTimestampValue( + 1_000_000_000, Timestamp.from(Instant.parse("1677-09-21T00:12:43.145224192Z")))); + } + + @SuppressWarnings("resource") + @Test + public void testSetTimestampInNonQueryStatementUsesServerTimePrecision() throws Exception { + when(connection.getTimeFactor()).thenReturn(1_000_000); + when(client.executeUpdateStatement(any(TSExecuteStatementReq.class))) + .thenReturn(new TSExecuteStatementResp(RpcUtils.SUCCESS_STATUS)); + IoTDBTablePreparedStatement ps = + new IoTDBTablePreparedStatement( + connection, client, sessionId, "INSERT INTO t(time, s1) VALUES(?, 1)", zoneId); + Timestamp timestamp = new Timestamp(1_700_000_000_123L); + timestamp.setNanos(123_456_789); + ps.setTimestamp(1, timestamp); + + ps.executeUpdate(); + + ArgumentCaptor argument = + ArgumentCaptor.forClass(TSExecuteStatementReq.class); + verify(client).executeUpdateStatement(argument.capture()); + assertEquals( + "INSERT INTO t(time, s1) VALUES(1700000000123456, 1)", argument.getValue().getStatement()); + } + + // ========== LocalDate, client-side SQL completion and NaN/Infinity ========== + + @Test + public void testSetObjectAcceptsLocalDateInClientCompletedSql() throws Exception { + LocalDate date = LocalDate.of(2024, 1, 15); + String expected = "INSERT INTO t(time, d) VALUES(1, '2024-01-15')"; + String sql = "INSERT INTO t(time, d) VALUES(1, ?)"; + assertEquals(expected, clientCompletedSql(sql, ps -> ps.setObject(1, date))); + assertEquals(expected, clientCompletedSql(sql, ps -> ps.setObject(1, date, Types.DATE))); + assertEquals(expected, clientCompletedSql(sql, ps -> ps.setObject(1, date, Types.DATE, 0))); + } + + @SuppressWarnings("resource") + @Test + public void testSetObjectBindsLocalDateLikeSetDate() throws Exception { + IoTDBTablePreparedStatement ps = + new IoTDBTablePreparedStatement( + connection, client, sessionId, "SELECT * FROM t WHERE d = ? AND e = ?", zoneId); + ps.setObject(1, LocalDate.of(2024, 1, 15)); + ps.setDate(2, java.sql.Date.valueOf("2024-01-15")); + ps.execute(); + + List parameters = lastPreparedParameters(); + assertEquals("2024-01-15", parameters.get(0).value); + assertEquals(parameters.get(1).type, parameters.get(0).type); + assertEquals(parameters.get(1).value, parameters.get(0).value); + } + + @Test + public void testQuotedIdentifiersHideQuestionMarksAndQuotes() throws Exception { + assertEquals( + "INSERT INTO \"my?table\"(time, s1) VALUES(1, 'a')", + clientCompletedSql("INSERT INTO \"my?table\"(time, s1) VALUES(?, ?)", this::bindRow)); + assertEquals( + "INSERT INTO \"it's\"(time, s1) VALUES(1, 'a')", + clientCompletedSql("INSERT INTO \"it's\"(time, s1) VALUES(?, ?)", this::bindRow)); + assertEquals( + "INSERT INTO `a?b`(time, s1) VALUES(1, 'a')", + clientCompletedSql("INSERT INTO `a?b`(time, s1) VALUES(?, ?)", this::bindRow)); + } + + @Test + public void testStringLiteralsEscapeQuotesOnlyByDoublingThem() throws Exception { + // A backslash is an ordinary character, so 'C:\' ends at its second quote + assertEquals( + "INSERT INTO t(time, s1, s2) VALUES(1, 'C:\\', 'a')", + clientCompletedSql("INSERT INTO t(time, s1, s2) VALUES(?, 'C:\\', ?)", this::bindRow)); + assertEquals( + "INSERT INTO t(time, s1, s2) VALUES(1, 'it''s ?', 'a')", + clientCompletedSql("INSERT INTO t(time, s1, s2) VALUES(?, 'it''s ?', ?)", this::bindRow)); + } + + @Test + public void testCommentsHideQuestionMarks() throws Exception { + assertEquals( + "INSERT INTO t(time, s1) VALUES(1, 'a') -- why?\n", + clientCompletedSql("INSERT INTO t(time, s1) VALUES(?, ?) -- why?\n", this::bindRow)); + assertEquals( + "INSERT /* ? */ INTO t(time, s1) VALUES(1, 'a')", + clientCompletedSql("INSERT /* ? */ INTO t(time, s1) VALUES(?, ?)", this::bindRow)); + } + + @Test + public void testLineCommentEndsAtCarriageReturnOrLineFeed() throws Exception { + // As in the lexer's SIMPLE_COMMENT rule, a lone CR ends the comment just like LF or CRLF + assertEquals( + "INSERT -- comment\rINTO t(time, s1) VALUES(1, 'a')\n", + clientCompletedSql("INSERT -- comment\rINTO t(time, s1) VALUES(?, ?)\n", this::bindRow)); + assertEquals( + "INSERT -- comment\r\nINTO t(time, s1) VALUES(1, 'a')", + clientCompletedSql("INSERT -- comment\r\nINTO t(time, s1) VALUES(?, ?)", this::bindRow)); + } + + @Test + public void testNaNIsSentAsStringInClientCompletedSql() throws Exception { + // The server has no NaN literal but stores the string 'NaN' as NaN in FLOAT/DOUBLE columns + assertEquals( + "INSERT INTO t(time, d, f) VALUES(1, 'NaN', 'NaN')", + clientCompletedSql( + "INSERT INTO t(time, d, f) VALUES(1, ?, ?)", + ps -> { + ps.setDouble(1, Double.NaN); + ps.setFloat(2, Float.NaN); + })); + } + + @SuppressWarnings("resource") + @Test + public void testInfinityIsRejectedInClientCompletedSql() throws Exception { + IoTDBTablePreparedStatement ps = + new IoTDBTablePreparedStatement( + connection, client, sessionId, "INSERT INTO t(time, d, f) VALUES(1, ?, ?)", zoneId); + assertThrows(SQLException.class, () -> ps.setDouble(1, Double.POSITIVE_INFINITY)); + assertThrows(SQLException.class, () -> ps.setFloat(2, Float.NEGATIVE_INFINITY)); + } + + @SuppressWarnings("resource") + @Test + public void testInfinityIsBoundAsBinaryInServerPreparedQuery() throws Exception { + IoTDBTablePreparedStatement ps = + new IoTDBTablePreparedStatement( + connection, client, sessionId, "SELECT * FROM t WHERE d < ?", zoneId); + ps.setDouble(1, Double.POSITIVE_INFINITY); + ps.execute(); + + assertEquals(Double.POSITIVE_INFINITY, lastPreparedParameters().get(0).value); + } + + @FunctionalInterface + private interface ParameterBinder { + void bind(IoTDBTablePreparedStatement ps) throws SQLException; + } + + private void bindRow(IoTDBTablePreparedStatement ps) throws SQLException { + ps.setLong(1, 1); + ps.setString(2, "a"); + } + + /** Runs a statement that the driver completes on the client and returns the SQL it sends. */ + @SuppressWarnings("resource") + private String clientCompletedSql(String sql, ParameterBinder binder) throws Exception { + when(client.executeUpdateStatement(any(TSExecuteStatementReq.class))) + .thenReturn(new TSExecuteStatementResp(RpcUtils.SUCCESS_STATUS)); + IoTDBTablePreparedStatement ps = + new IoTDBTablePreparedStatement(connection, client, sessionId, sql, zoneId); + binder.bind(ps); + ps.executeUpdate(); + + ArgumentCaptor argument = + ArgumentCaptor.forClass(TSExecuteStatementReq.class); + verify(client, atLeastOnce()).executeUpdateStatement(argument.capture()); + return argument.getValue().getStatement(); + } + + private List lastPreparedParameters() throws Exception { + return PreparedParameterSerde.deserialize( + ByteBuffer.wrap(lastPreparedExecution().getParameters())); + } + + private static TSExecuteStatementResp preparedQueryResp(long queryId) { + TSExecuteStatementResp resp = new TSExecuteStatementResp(RpcUtils.SUCCESS_STATUS); + resp.setQueryId(queryId); + return resp; + } + + private TSExecutePreparedReq lastPreparedExecution() throws Exception { + ArgumentCaptor argument = + ArgumentCaptor.forClass(TSExecutePreparedReq.class); + verify(client, atLeastOnce()).executePreparedStatement(argument.capture()); + return argument.getValue(); + } + + /** Binds {@code timestamp} under the given server time factor and returns the value sent. */ + @SuppressWarnings("resource") + private long preparedTimestampValue(int timeFactor, Timestamp timestamp) throws Exception { + when(connection.getTimeFactor()).thenReturn(timeFactor); + IoTDBTablePreparedStatement ps = + new IoTDBTablePreparedStatement( + connection, client, sessionId, "SELECT * FROM t WHERE time = ?", zoneId); + ps.setTimestamp(1, timestamp); + ps.execute(); + + List parameters = + PreparedParameterSerde.deserialize( + ByteBuffer.wrap(lastPreparedExecution().getParameters())); + assertEquals(1, parameters.size()); + return (Long) parameters.get(0).value; + } } diff --git a/iotdb-client/service-rpc/src/main/java/org/apache/iotdb/rpc/RpcUtils.java b/iotdb-client/service-rpc/src/main/java/org/apache/iotdb/rpc/RpcUtils.java index 9c88bf3f8b7d0..4c4c3c20989a6 100644 --- a/iotdb-client/service-rpc/src/main/java/org/apache/iotdb/rpc/RpcUtils.java +++ b/iotdb-client/service-rpc/src/main/java/org/apache/iotdb/rpc/RpcUtils.java @@ -434,12 +434,13 @@ public static boolean isSetSqlDialect(String sql) { return sql.substring(0, 11).equalsIgnoreCase("sql_dialect"); } + // Seconds are floored so that a pre-epoch time keeps a nanos part in [0, 1e9), as Timestamp needs public static long getMilliSecond(long time, int timeFactor) { - return time / timeFactor * 1_000; + return Math.floorDiv(time, timeFactor) * 1_000; } public static int getNanoSecond(long time, int timeFactor) { - return (int) (time % timeFactor * (1_000_000_000 / timeFactor)); + return Math.floorMod(time, timeFactor) * (1_000_000_000 / timeFactor); } public static Timestamp convertToTimestamp(long time, int timeFactor) { diff --git a/iotdb-client/service-rpc/src/test/java/org/apache/iotdb/rpc/RpcUtilsTest.java b/iotdb-client/service-rpc/src/test/java/org/apache/iotdb/rpc/RpcUtilsTest.java index 54da8cb1e70a1..a061bc1506c4d 100644 --- a/iotdb-client/service-rpc/src/test/java/org/apache/iotdb/rpc/RpcUtilsTest.java +++ b/iotdb-client/service-rpc/src/test/java/org/apache/iotdb/rpc/RpcUtilsTest.java @@ -24,6 +24,7 @@ import org.junit.Assert; import org.junit.Test; +import java.sql.Timestamp; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Arrays; @@ -120,4 +121,31 @@ public void testSslProtocolResolution() { RpcSslUtils.configure(originProtocol); } } + + @Test + public void convertToTimestampKeepsPreEpochFractions() { + // 1.5 seconds before the epoch in each precision + Assert.assertEquals(new Timestamp(-1_500L), RpcUtils.convertToTimestamp(-1_500L, 1_000)); + Assert.assertEquals( + new Timestamp(-1_500L), RpcUtils.convertToTimestamp(-1_500_000L, 1_000_000)); + Assert.assertEquals( + new Timestamp(-1_500L), RpcUtils.convertToTimestamp(-1_500_000_000L, 1_000_000_000)); + + // 1 ns before the epoch: the second before it, plus 999,999,999 ns + Timestamp oneNanoBeforeEpoch = new Timestamp(-1_000L); + oneNanoBeforeEpoch.setNanos(999_999_999); + Assert.assertEquals(oneNanoBeforeEpoch, RpcUtils.convertToTimestamp(-1L, 1_000_000_000)); + + // Whole seconds and post-epoch values are unaffected + Assert.assertEquals(new Timestamp(-2_000L), RpcUtils.convertToTimestamp(-2_000L, 1_000)); + Assert.assertEquals(new Timestamp(1_500L), RpcUtils.convertToTimestamp(1_500L, 1_000)); + } + + @Test + public void splitTimeIntoFlooredSecondsAndNonNegativeNanos() { + Assert.assertEquals(-2_000L, RpcUtils.getMilliSecond(-1_500L, 1_000)); + Assert.assertEquals(500_000_000, RpcUtils.getNanoSecond(-1_500L, 1_000)); + Assert.assertEquals(1_000L, RpcUtils.getMilliSecond(1_500L, 1_000)); + Assert.assertEquals(500_000_000, RpcUtils.getNanoSecond(1_500L, 1_000)); + } }