From d2ad54125b556c818fc4d8abf979b2b4d6ec576e Mon Sep 17 00:00:00 2001 From: Zihan Dai <99155080+PDGGK@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:37:12 +1000 Subject: [PATCH] Return one NULL row for last_by over a never-written device (#16985) A global aggregation without GROUP BY always produces exactly one row; over empty input every aggregate except count() evaluates to NULL. On the last-cache-optimized last/last_by path, a device set that resolves to zero devices (e.g. a filter on a device that was never written) left the operator with nothing to iterate, so it returned zero rows instead of the single NULL row required by SQL semantics (consistent with engines such as Trino). Route the empty-device case to the default aggregation operator, which already emits the single all-NULL row for a global aggregation over empty input: in DataNodeTableOperatorGenerator.visitAggregationTableScan, use OptimizeType.NOOP when the device set is empty so the last-cache optimization (which has no device to read) is skipped. The deleted-device case (the device still exists, so the operator iterates and already emits the NULL row) is unchanged; GROUP BY over an empty group still returns no rows. Signed-off-by: Zihan Dai <99155080+PDGGK@users.noreply.github.com> --- .../it/db/it/IoTDBDeletionTableIT.java | 114 ++++++++++++++++++ .../DataNodeTableOperatorGenerator.java | 13 +- 2 files changed, 125 insertions(+), 2 deletions(-) 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);