This is an automated email from the ASF dual-hosted git repository.
Wei-hao-Li pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iotdb.git
The following commit(s) were added to refs/heads/master by this push:
new d9040c037bd Return one NULL row for last_by over a never-written device
d9040c037bd is described below
commit d9040c037bd1d33275706d372ca49d6da434a67a
Author: ZIHAN DAI <[email protected]>
AuthorDate: Fri Jul 24 11:22:09 2026 +1000
Return one NULL row for last_by over a never-written device
---
.../relational/it/db/it/IoTDBDeletionTableIT.java | 114 +++++++++++++++++++++
.../planner/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 82b6c1a8ff6..b31b853f9b8 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 java.util.stream.Stream;
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 @@ public class IoTDBDeletionTableIT {
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 e09d05066a3..2e5e8cba292 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 class DataNodeTableOperatorGenerator
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);