Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ public class LastQueryAggTableScanOperator extends AbstractAggTableScanOperator
private final List<DeviceEntry> cachedDeviceEntries;
private final int allDeviceCount;

// A no-GROUP-BY global aggregation whose device set resolves to zero devices
// (e.g. last_by on a device that was never written) has nothing for the device
// loop to iterate, so without this it would emit zero rows. Per SQL semantics a
// global aggregate over empty input must return exactly one row whose value is
// NULL (except count()); this one-shot flag drives that single row.
private boolean emptyGlobalResultEmitted = false;

private final boolean needUpdateCache;
private final boolean needUpdateNullEntry;
private final List<Integer> hitCachesIndexes;
Expand Down Expand Up @@ -129,8 +136,10 @@ public boolean hasNext() throws Exception {
if (retainedTsBlock != null) {
return true;
}

return outputDeviceIndex < allDeviceCount;
if (outputDeviceIndex < allDeviceCount) {
return true;
}
return shouldEmitEmptyGlobalResult() && !emptyGlobalResultEmitted;
}

@Override
Expand All @@ -147,6 +156,13 @@ public TsBlock next() throws Exception {
processCurrentDevice();
}

if (resultTsBlockBuilder.isEmpty()
&& !emptyGlobalResultEmitted
&& shouldEmitEmptyGlobalResult()) {
appendEmptyGlobalAggregationResult();
emptyGlobalResultEmitted = true;
}

if (resultTsBlockBuilder.isEmpty()) {
return null;
}
Expand All @@ -155,6 +171,36 @@ public TsBlock next() throws Exception {
return checkTsBlockSizeAndGetResult();
}

/**
* A no-GROUP-BY global aggregation whose device set resolves to zero devices must still return
* exactly one row (each aggregate NULL, except count()) — the same result the deleted-device case
* already produces once its device is iterated. Limited to the final/complete stage: a partial
* stage must not emit an intermediate row, and a GROUP BY stays empty (empty groups produce no
* rows).
*/
private boolean shouldEmitEmptyGlobalResult() {
return allDeviceCount == 0
&& groupingKeySize == 0
&& !tableAggregators.isEmpty()
&& !tableAggregators.get(0).getStep().isOutputPartial();
}

private void appendEmptyGlobalAggregationResult() {
// One row, each aggregator's no-input final value. shouldEmitEmptyGlobalResult()
// guarantees groupingKeySize == 0, and a last-cache-optimized scan never carries a
// date-bin window (dateBinSize == 0), so the value columns begin at
// groupingKeySize + dateBinSize; using the full offset mirrors appendAggregationResult.
// The accumulators were never fed input, so evaluate() (final step) writes NULL for
// last / last_by.
for (int i = 0; i < tableAggregators.size(); i++) {
tableAggregators
.get(i)
.evaluate(
resultTsBlockBuilder.getValueColumnBuilders()[groupingKeySize + dateBinSize + i]);
}
resultTsBlockBuilder.declarePosition();
}

/** Main process logic, calc the last aggregation results of current device. */
private void processCurrentDevice() throws Exception {
if (currentHitCacheIndex < hitCachesIndexes.size()
Expand Down