Skip to content

#3585 fix: SQL aggregating projection with count(*) returns nothing on empty type#3587

Merged
robfrank merged 2 commits intomainfrom
fix/3585-aggregate-projection-empty-result
Mar 6, 2026
Merged

#3585 fix: SQL aggregating projection with count(*) returns nothing on empty type#3587
robfrank merged 2 commits intomainfrom
fix/3585-aggregate-projection-empty-result

Conversation

@robfrank
Copy link
Collaborator

@robfrank robfrank commented Mar 6, 2026

Summary

Fixes #3585

  • SELECT count(*), 2 FROM empty_type and SELECT count(*), $x FROM empty_type LET $x = 2 now correctly return 1 row with count(*) = 0 and the non-aggregate values
  • Added findCountStarItem() to properly identify count(*) as the sole aggregate even when aggregateProjection contains pass-through alias refs for non-aggregate items
  • Extended GuaranteeEmptyCountStep to evaluate preAggregateProjection items (literals, LET variables) in the zero-count synthetic result

Test plan

  • Added countStarWithLiteralProjectionOnEmptyType — covers SELECT count(*), 2 FROM empty
  • Added countStarWithLetVariableProjectionOnEmptyType — covers SELECT count(*), $x FROM empty LET $x = 2
  • All 150 tests in SelectStatementExecutionTest pass
  • All related test classes pass (SelectStatementExecutionTestIT, SelectExecutionTest, SelectStatementTest)

🤖 Generated with Claude Code

…y type (#3585)

When count(*) was combined with non-aggregate projections (e.g. SELECT count(*), 2 FROM empty_type),
the query returned no results instead of a row with count=0. The fix properly identifies count(*)
as the sole aggregate even when aggregateProjection contains pass-through alias refs for non-aggregate
items, and evaluates those non-aggregate expressions in the zero-count synthetic result.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request resolves a bug where SQL queries using count(*) in conjunction with other projected items (like literals or LET variables) against an empty data type would incorrectly yield no results. The fix ensures that such queries now consistently return a single row, correctly showing a count(*) of zero along with the evaluated values for the non-aggregate projections, thereby improving the robustness and predictability of aggregate query behavior.

Highlights

  • SQL count(*) behavior on empty types: SQL queries combining count(*) with non-aggregate projections (like literals or LET variables) on empty types now correctly return a single row containing count(*)=0 and the evaluated non-aggregate values, resolving an issue where no results were returned.
  • findCountStarItem() utility: A new helper method, findCountStarItem(), was introduced to accurately identify count(*) as the sole aggregate function within a projection, even when other non-aggregate pass-through items are present.
  • GuaranteeEmptyCountStep enhancement: The GuaranteeEmptyCountStep was extended to properly evaluate and include preAggregateProjection items (such as literals and LET variables) in the synthetic result generated when a count(*) query on an empty type produces zero rows.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • engine/src/main/java/com/arcadedb/query/sql/executor/GuaranteeEmptyCountStep.java
    • Modified the constructor to accept a preAggregateProjection parameter, allowing the step to process non-aggregate items.
    • Updated the next() method to evaluate and set properties for items from the preAggregateProjection when generating a zero-count result.
    • Adjusted the copy() method to correctly clone the preAggregateProjection if it exists.
  • engine/src/main/java/com/arcadedb/query/sql/executor/SelectExecutionPlanner.java
    • Added a new static method findCountStarItem() to identify if a projection contains exactly one count(*) aggregate, ignoring non-aggregate pass-through items.
    • Modified the handleProjections() method to utilize findCountStarItem() and pass the preAggregateProjection to the GuaranteeEmptyCountStep when applicable.
  • engine/src/test/java/com/arcadedb/query/sql/executor/SelectStatementExecutionTest.java
    • Added countStarWithLiteralProjectionOnEmptyType test to verify count(*) with a literal projection on an empty type.
    • Added countStarWithLetVariableProjectionOnEmptyType test to verify count(*) with a LET variable projection on an empty type.
Activity
  • Added a new test case countStarWithLiteralProjectionOnEmptyType to validate the fix for literal projections.
  • Added a new test case countStarWithLetVariableProjectionOnEmptyType to validate the fix for LET variable projections.
  • Confirmed that all 150 existing tests in SelectStatementExecutionTest passed successfully.
  • Verified that all related test classes, including SelectStatementExecutionTestIT, SelectExecutionTest, and SelectStatementTest, passed.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@claude
Copy link

claude bot commented Mar 6, 2026

Code Review

Good fix for a clear regression — the logic is sound and the approach of broadening the GuaranteeEmptyCountStep trigger condition is the right direction. A few things worth addressing:


Bug: isCountOnly() is now dead code

isCountOnly() (line 484 in SelectExecutionPlanner.java) was the only call site replaced by findCountStarItem(). It is now unreferenced and should be removed to avoid confusion and future maintenance burden.


Minor: Redundant null check + double call in findCountStarItem()

Current code calls getMathExpression() twice with an unnecessary null guard:

if (exp.getMathExpression() != null && exp.getMathExpression() instanceof final BaseExpression base
    && base.isCount() && base.getModifier() == null)

Since instanceof already returns false for null, simplify to:

if (exp.getMathExpression() instanceof final BaseExpression base
    && base.isCount() && base.getModifier() == null)

Missing test: non-empty type with mixed projections

The PR only tests the empty-type path. A test verifying SELECT count(*), 2 FROM non_empty_type (with actual records inserted) would guard against the pre-aggregate items being double-applied or overwritten in the non-empty path.


Minor: Test style inconsistency

The two new tests do not use the try-catch pattern used by the adjacent tests (countStarEmptyNoIndex, countStarEmptyNoIndexWithAlias). JUnit 5 makes try-catch unnecessary for exception propagation, so the newer style is actually cleaner — worth noting for a follow-up cleanup of the older tests.


Behaviour note (not a bug, worth a comment)

In GuaranteeEmptyCountStep.next():

preAggItem.execute((Result) null, context)

This works correctly for literals and LET/global variables (resolved via context), but a field reference like SELECT count(*), someField FROM empty_type would silently produce null for someField. That is the correct semantic for an empty result set, but a short inline comment explaining the null-Result contract would help future readers.


Summary

The core fix is correct and the regression tests cover the reported cases. The main actionable items are:

  1. Remove the now-unused isCountOnly() method.
  2. Simplify the redundant double getMathExpression() call.
  3. Add a non-empty-type test for the mixed count(*) + literal projection.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request effectively addresses the issue where an aggregating projection with count(*) on an empty type would incorrectly return no results. The changes are well-implemented: findCountStarItem correctly identifies count(*) as the sole aggregate function, and GuaranteeEmptyCountStep is extended to handle non-aggregate projection items, ensuring a synthetic row is produced. The addition of specific regression tests for literal and LET variable projections is a great way to verify the fix. I have one suggestion to improve code readability by refactoring a complex condition.

Comment on lines +474 to +482
final Expression exp = item.getExpression();
if (exp.getMathExpression() != null && exp.getMathExpression() instanceof final BaseExpression base
&& base.isCount() && base.getModifier() == null)
countItem = item;
else
return null; // aggregate but not count(*)
}
return countItem;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic to identify a count(*) expression is a bit complex and hurts readability. Extracting it into a dedicated private helper method isCountStarExpression would make the findCountStarItem method cleaner and more maintainable.

      final Expression exp = item.getExpression();
      if (isCountStarExpression(exp)) {
        countItem = item;
      } else {
        return null; // aggregate but not count(*)
      }
    }
    return countItem;
  }

  private static boolean isCountStarExpression(final Expression exp) {
    return exp.getMathExpression() != null && exp.getMathExpression() instanceof final BaseExpression base
        && base.isCount() && base.getModifier() == null;
  }

- Remove dead isCountOnly() method (replaced by findCountStarItem)
- Simplify redundant null check before instanceof in findCountStarItem
- Add non-empty-type test for count(*) + literal projection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@robfrank
Copy link
Collaborator Author

robfrank commented Mar 6, 2026

Addressed the review feedback in c78fea6:

  • Removed dead isCountOnly() — no callers remain after replacing it with findCountStarItem().
  • Simplified getMathExpression() check — dropped the redundant != null guard since instanceof already handles null.
  • Added non-empty-type testcountStarWithLiteralProjectionOnNonEmptyType verifies SELECT count(*), 2 FROM type_with_records returns {count(*): 5, 2: 2}.

Did not extract isCountStarExpression helper (Gemini suggestion) — the check is used exactly once in findCountStarItem(), so a single-use helper adds indirection without value.

@claude
Copy link

claude bot commented Mar 6, 2026

Code Review

The fix is correct and the approach is sound. A few observations:


Overall assessment

The core fix is solid. findCountStarItem() correctly replaces the old isCountOnly() with broader semantics — it scans aggregateProjection for a single count(*) aggregate while ignoring non-aggregate pass-through items, and returns the item rather than a boolean so the caller can use it directly. The GuaranteeEmptyCountStep extension cleanly evaluates preAggregateProjection items to populate the synthetic zero-count result.


Minor: undocumented null-Result contract in GuaranteeEmptyCountStep

result.setProperty(preAggItem.getProjectionAliasAsString(), preAggItem.execute((Result) null, context));

This works correctly for literals and LET/context variables because their values are resolved through context regardless of the current record. For a field reference (e.g. SELECT count(*), someField FROM empty_type), it will silently return null — which is actually the correct SQL semantics for zero rows, but is non-obvious to a future reader. A short inline comment would help:

// null record is intentional: on empty results, literals/LET vars resolve via context;
// field references return null, which is correct SQL semantics for an empty set
result.setProperty(preAggItem.getProjectionAliasAsString(), preAggItem.execute((Result) null, context));

Test coverage

Three tests are added and they cover the key paths well:

  • countStarWithLiteralProjectionOnEmptyType — the reported bug case ✓
  • countStarWithLetVariableProjectionOnEmptyType — LET variable variant ✓
  • countStarWithLiteralProjectionOnNonEmptyType — verifies the non-empty path is not broken ✓

One additional edge case worth considering: SELECT count(*), 2, 3 FROM empty_type (multiple non-aggregate projections). The loop in GuaranteeEmptyCountStep should handle it, but a quick test would confirm the alias iteration is exhaustive.


Logic trace for edge cases in findCountStarItem()

The guard logic is correct for all relevant combinations:

Projection Behaviour
count(*) alone returns count(*) item ✓
count(*), 2 non-aggregate skipped, returns count(*) item ✓
count(*), sum(x) second aggregate → countItem != null → returns null ✓
sum(x), count(*) first aggregate is not count(*) → returns null immediately ✓
count(*), count(*) second count(*) → countItem != null → returns null ✓

Summary

  • One actionable item: add the inline comment explaining the null Result contract.
  • Nice to have: test for multiple non-aggregate projections in the same query.
  • The fix itself is production-ready.

@codacy-production
Copy link

codacy-production bot commented Mar 6, 2026

Coverage summary from Codacy

See diff coverage on Codacy

Coverage variation Diff coverage
-10.11% 91.30%
Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (2768254) 103661 77384 74.65%
Head commit (c78fea6) 134231 (+30570) 86632 (+9248) 64.54% (-10.11%)

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#3587) 23 21 91.30%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

See your quality gate settings    Change summary preferences

@mergify
Copy link
Contributor

mergify bot commented Mar 6, 2026

🧪 CI Insights

Here's what we observed from your CI run for c78fea6.

🟢 All jobs passed!

But CI Insights is watching 👀

@robfrank robfrank changed the title fix: SQL aggregating projection with count(*) returns nothing on empty type #3585 fix: SQL aggregating projection with count(*) returns nothing on empty type Mar 6, 2026
@robfrank robfrank merged commit ee652d5 into main Mar 6, 2026
19 of 23 checks passed
@codecov
Copy link

codecov bot commented Mar 7, 2026

Codecov Report

❌ Patch coverage is 82.60870% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.37%. Comparing base (2768254) to head (c78fea6).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
...edb/query/sql/executor/SelectExecutionPlanner.java 81.25% 1 Missing and 2 partials ⚠️
...db/query/sql/executor/GuaranteeEmptyCountStep.java 85.71% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3587      +/-   ##
==========================================
- Coverage   65.71%   65.37%   -0.34%     
==========================================
  Files        1514     1514              
  Lines      103661   103672      +11     
  Branches    21454    21457       +3     
==========================================
- Hits        68118    67776     -342     
- Misses      26306    26660     +354     
+ Partials     9237     9236       -1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SQL: aggregating projection returns nothing

1 participant