diff --git a/integration-test/src/test/java/org/apache/iotdb/relational/it/db/it/IoTDBDeletionTableIT.java b/integration-test/src/test/java/org/apache/iotdb/relational/it/db/it/IoTDBDeletionTableIT.java index 82b6c1a8ff6b6..b31b853f9b82e 100644 --- a/integration-test/src/test/java/org/apache/iotdb/relational/it/db/it/IoTDBDeletionTableIT.java +++ b/integration-test/src/test/java/org/apache/iotdb/relational/it/db/it/IoTDBDeletionTableIT.java @@ -81,6 +81,7 @@ import static org.apache.iotdb.relational.it.session.IoTDBSessionRelationalIT.genValue; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -3199,4 +3200,117 @@ private void cleanData(int testNum) throws SQLException { statement.execute(String.format(deleteAllTemplate, testNum)); } } + + // A global aggregation without GROUP BY over zero matching rows must return exactly one row + // whose value is NULL (SQL standard, matching e.g. Trino), not zero rows. These cover the two + // empty-input shapes: a device that was never written, and a device whose data was deleted. + + @Test + public void testLastByOverNeverWrittenDeviceReturnsSingleNullRow() throws SQLException { + try (Connection connection = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT); + Statement statement = connection.createStatement()) { + statement.execute("use test"); + statement.execute("create table last_empty0(deviceId string tag, s0 int32 field)"); + statement.execute("insert into last_empty0(time, deviceId, s0) values (1, 'd0', 1)"); + statement.execute("flush"); + + // 'nope' was never written, so its device set resolves to zero devices. + try (ResultSet resultSet = + statement.executeQuery( + "select last_by(s0, time) from last_empty0 where deviceId = 'nope'")) { + assertSingleAllNullRow(resultSet); + } + } + } + + @Test + public void testThreeArgLastByOverNeverWrittenDeviceReturnsSingleNullRow() throws SQLException { + try (Connection connection = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT); + Statement statement = connection.createStatement()) { + statement.execute("use test"); + statement.execute( + "create table last_empty4(deviceId string tag, s0 int32 field, s1 int32 field)"); + statement.execute("insert into last_empty4(time, deviceId, s0, s1) values (1, 'd0', 1, 2)"); + statement.execute("flush"); + + // 3-arg last_by(target, ordering, time) over a never-written device -> one NULL row. + try (ResultSet resultSet = + statement.executeQuery( + "select last_by(s0, s1, time) from last_empty4 where deviceId = 'nope'")) { + assertSingleAllNullRow(resultSet); + } + } + } + + @Test + public void testMultipleLastAggregatesOverNeverWrittenDeviceReturnSingleAllNullRow() + throws SQLException { + try (Connection connection = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT); + Statement statement = connection.createStatement()) { + statement.execute("use test"); + statement.execute( + "create table last_empty1(deviceId string tag, s0 int32 field, s1 int64 field)"); + statement.execute("insert into last_empty1(time, deviceId, s0, s1) values (1, 'd0', 1, 2)"); + statement.execute("flush"); + + try (ResultSet resultSet = + statement.executeQuery( + "select last_by(s0, time), last_by(s1, time) from last_empty1 where deviceId = 'nope'")) { + assertSingleAllNullRow(resultSet); + } + } + } + + @Test + public void testLastByOverNeverWrittenDeviceWithGroupByReturnsNoRows() throws SQLException { + try (Connection connection = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT); + Statement statement = connection.createStatement()) { + statement.execute("use test"); + statement.execute("create table last_empty2(deviceId string tag, s0 int32 field)"); + statement.execute("insert into last_empty2(time, deviceId, s0) values (1, 'd0', 1)"); + statement.execute("flush"); + + // The one-NULL-row rule is for no-GROUP-BY only; an empty group produces no rows. + try (ResultSet resultSet = + statement.executeQuery( + "select deviceId, last_by(s0, time) from last_empty2 where deviceId = 'nope' group by deviceId")) { + assertFalse("GROUP BY over an empty group must return no rows", resultSet.next()); + } + } + } + + @Test + public void testLastByOverDeletedDeviceStillReturnsSingleNullRow() throws SQLException { + try (Connection connection = EnvFactory.getEnv().getConnection(BaseEnv.TABLE_SQL_DIALECT); + Statement statement = connection.createStatement()) { + statement.execute("use test"); + statement.execute("create table last_empty3(deviceId string tag, s0 int32 field)"); + statement.execute("insert into last_empty3(time, deviceId, s0) values (1, 'd0', 1)"); + statement.execute("flush"); + statement.execute("delete from last_empty3 where deviceId = 'd0'"); + + // The deleted device still exists in the schema; last_by must return one NULL row. Query + // twice to cover both the last-value-cache path and the recomputed path. + try (ResultSet resultSet = + statement.executeQuery( + "select last_by(s0, time) from last_empty3 where deviceId = 'd0'")) { + assertSingleAllNullRow(resultSet); + } + try (ResultSet resultSet = + statement.executeQuery( + "select last_by(s0, time) from last_empty3 where deviceId = 'd0'")) { + assertSingleAllNullRow(resultSet); + } + } + } + + private void assertSingleAllNullRow(ResultSet resultSet) throws SQLException { + assertTrue("Expected exactly one result row, but got none", resultSet.next()); + int columnCount = resultSet.getMetaData().getColumnCount(); + for (int i = 1; i <= columnCount; i++) { + Object value = resultSet.getObject(i); + assertNull("Expected column " + i + " to be NULL, but got: " + value, value); + } + assertFalse("Expected exactly one result row, but got more than one", resultSet.next()); + } } diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/DataNodeTableOperatorGenerator.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/DataNodeTableOperatorGenerator.java index e09d05066a3bf..2e5e8cba292b9 100644 --- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/DataNodeTableOperatorGenerator.java +++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/queryengine/plan/planner/DataNodeTableOperatorGenerator.java @@ -1755,9 +1755,18 @@ public Operator visitAggregationTableScan( AbstractAggTableScanOperator.AbstractAggTableScanOperatorParameter parameter = constructAbstractAggTableScanOperatorParameter(node, context); + // When the device set resolves to zero devices AND there are no grouping + // keys (e.g. a global last/last_by over a device that was never written), + // fall back to the default aggregation operator, which already emits the + // single all-NULL row required for a global aggregation over empty input. + // The last-cache optimization has no device to read and would otherwise + // emit zero rows. A GROUP BY over an empty device set is intentionally left + // on the last-cache path, which already (correctly) returns zero rows. OptimizeType optimizeType = - canUseLastCacheOptimize( - parameter.getTableAggregators(), node, parameter.getTimeColumnName()); + node.getDeviceEntries().isEmpty() && node.getGroupingKeys().isEmpty() + ? OptimizeType.NOOP + : canUseLastCacheOptimize( + parameter.getTableAggregators(), node, parameter.getTimeColumnName()); if (optimizeType != OptimizeType.NOOP) { return constructLastQueryAggTableScanOperator( node, parameter, optimizeType == OptimizeType.LAST_ROW, context);