Skip to content

chore(sql): move join const-filter merge to optimizer#6856

Merged
bluestreak01 merged 5 commits intomasterfrom
vi_fixup
Mar 9, 2026
Merged

chore(sql): move join const-filter merge to optimizer#6856
bluestreak01 merged 5 commits intomasterfrom
vi_fixup

Conversation

@bluestreak01
Copy link
Copy Markdown
Member

Follow-up to #6824 (fixes #6762).

Summary

  • SqlOptimiser.optimiseJoins() now merges non-compile-time-constant constWhereClause expressions (e.g. NOW() = NOW()) into the last join model's postJoinWhereClause, so the code generator never sees both clauses separately and cannot double-wrap FilteredRecordCursorFactory
  • Truly constant expressions (e.g. 1 > 10, false) stay as constWhereClause so the code generator can still optimize them away to EmptyTableRecordCursorFactory
  • The generator-level wrapWithFilter() workaround and AND_FUNCTION_FACTORY static field from fix(sql): fix AssertionError that could be triggered by JOIN SQL #6824 are removed; the assertion at FilteredRecordCursorFactory:44 remains as a development-time canary
  • Fixed misleading test comment on testJoinInnerPostJoinMultipleJoinsFilter — it tests multi-way joins with post-join filters, not the double-wrap bug

Test plan

The fix for #6762 (commit 2263b2a) addressed double-wrapping of
FilteredRecordCursorFactory in SqlCodeGenerator by combining filters
at runtime. This follow-up moves the fix earlier into the pipeline:
SqlOptimiser.optimiseJoins() now merges non-compile-time-constant
constWhereClause expressions (e.g. NOW() = NOW()) into the last join
model's postJoinWhereClause, so the code generator never sees both
clauses separately.

Truly constant expressions (e.g. 1 > 10, false) remain as
constWhereClause so the code generator can still optimize them away
to EmptyTableRecordCursorFactory.

The generator-level wrapWithFilter() workaround and its
AND_FUNCTION_FACTORY static field are removed. The assertion at
FilteredRecordCursorFactory:44 stays as a development-time canary.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@bluestreak01 bluestreak01 added the SQL Issues or changes relating to SQL execution label Mar 6, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 6, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b1037acb-7d93-463e-8179-c8e19d119f00

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

The pull request removes the filter composition pattern using AndFunctionFactory and wrapWithFilter helper method from SqlCodeGenerator, replacing it with direct FilteredRecordCursorFactory instantiations. It adds compile-time constant detection and post-join WHERE clause merging optimizations in SqlOptimiser, and simplifies the validateHorizonJoinGroupBy method signature. Test comments are updated to reflect the new filter composition behavior.

Changes

Cohort / File(s) Summary
Filter Composition Refactoring
core/src/main/java/io/questdb/griffin/SqlCodeGenerator.java
Removed AndFunctionFactory import, AND_FUNCTION_FACTORY instance, and wrapWithFilter() helper method. Replaced all wrapWithFilter(factory, filter, executionContext) calls with direct FilteredRecordCursorFactory(factory, filter) instantiations, eliminating the path that previously combined existing filters via an AND function.
Optimizer Enhancements
core/src/main/java/io/questdb/griffin/SqlOptimiser.java
Added isCompileTimeConstant(ExpressionNode node) helper to detect compile-time-constant expressions. Introduced mergeConstIntoPostJoinWhereClause(QueryModel model) to fold constant predicates from outer queries into post-join WHERE clauses. Updated validateHorizonJoinGroupBy() signature to remove baseModel parameter and simplified corresponding call sites.
Test Updates
core/src/test/java/io/questdb/test/griffin/engine/join/JoinTest.java
Updated test comments and descriptions to reflect the new filter composition model (single merged post-join WHERE clause instead of nested FilteredRecordCursorFactory instances). Refactored assertion blocks in testJoiningSubqueryWithDotInColumnName and testJoinInnerPostJoinAndConstFilter for improved readability.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • #6824 — Modifies the same filter-wrapping logic in SqlCodeGenerator/FilteredRecordCursorFactory, creating a direct inverse relationship with this PR's filter composition changes.

Suggested labels

Bug

Suggested reviewers

  • bluestreak01
  • puzpuzpuz
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: moving join const-filter merge logic from the code generator to the optimizer, which is the primary objective of this PR.
Description check ✅ Passed The description relates directly to the changeset, explaining the optimization of merging non-compile-time-constant constWhereClause expressions and removal of workarounds in both SqlOptimiser and SqlCodeGenerator.
Linked Issues check ✅ Passed The PR successfully addresses issue #6762 by preventing double-wrapping of FilteredRecordCursorFactory through moving the const-filter merge logic to the optimizer level, eliminating the need for generator-level workarounds.
Out of Scope Changes check ✅ Passed All changes are directly related to the linked issue objectives: SqlCodeGenerator removes the wrapWithFilter workaround, SqlOptimiser adds merge logic, and JoinTest updates test documentation to reflect new behavior.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch vi_fixup

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@glasstiger
Copy link
Copy Markdown
Contributor

@CodeRabbit review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 6, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
core/src/test/java/io/questdb/test/griffin/engine/join/JoinTest.java (1)

3668-3669: Keep SQL keywords uppercase in the updated test query.

Line 3669 reintroduces lowercase SQL keywords inside test SQL (select). Please keep them uppercase to match the repo’s test SQL convention.

As per coding guidelines "In SQL statements within test code, use UPPERCASE for SQL keywords (CREATE TABLE, INSERT, SELECT ... AS ... FROM, etc.)"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/test/java/io/questdb/test/griffin/engine/join/JoinTest.java` around
lines 3668 - 3669, The SQL in JoinTest.java contains lowercase keywords in the
inline query; update the inner query from "select 1" to "SELECT 1" so all SQL
keywords are uppercase (e.g., the fragment SELECT * FROM (SELECT x as "foo.bar"
FROM long_sequence(5)) LEFT JOIN (SELECT 1) ON true;), ensuring the test follows
the repo convention for uppercase SQL keywords.
core/src/main/java/io/questdb/griffin/SqlOptimiser.java (1)

4542-4551: Move new static helper to the static-method section (alphabetical slot).

isCompileTimeConstant is private static but currently sits in the instance-method area. Please place it with other static helpers in alphabetical order.

As per coding guidelines: "**/*.java: Group Java class members by kind (static vs. instance) and visibility, sorted alphabetically. When adding new methods or fields, insert them in the correct alphabetical position among existing members of the same kind."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/src/main/java/io/questdb/griffin/SqlOptimiser.java` around lines 4542 -
4551, The private static helper method isCompileTimeConstant(ExpressionNode) is
currently placed among instance methods; move it into the class's private static
helpers section and insert it alphabetically with other static methods so it
appears in the correct static-method area (keep the method signature and logic
unchanged; reference ExpressionNode.CONSTANT and ExpressionNode.OPERATION remain
the same). Ensure it resides with other private static members and follows
alphabetical ordering for consistency with the project's Java member grouping
rules.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@core/src/main/java/io/questdb/griffin/SqlCodeGenerator.java`:
- Line 3289: The code currently wraps an already-filtered factory in a new
FilteredRecordCursorFactory, which violates FilteredRecordCursorFactory's
assertion that its base is not filtered; update the wrap sites (the return that
constructs new FilteredRecordCursorFactory in SqlCodeGenerator around
generateSelectChoose(), and the analogous sites near the other repeats) to
detect when factory instanceof FilteredRecordCursorFactory, then merge the two
filters and return a single FilteredRecordCursorFactory whose base is the inner
factory's base and whose filter is the combined predicate (or otherwise unwrap
the inner factory and attach a combined filter), rather than nesting; apply the
same change at the other two occurrences referenced (the wrap around lines
5040-5043 and the one at 5103) and add a regression test that executes a nested
filtered subquery (e.g., select * from (select * from x where a>0) where b>0)
with parallel filters disabled to ensure no AssertionError is thrown.

In `@core/src/main/java/io/questdb/griffin/SqlOptimiser.java`:
- Around line 4528-4539: The code currently moves the entire constWhere
(constWhere) to the last join's post-join filter whenever
isCompileTimeConstant(constWhere) is false; instead, break constWhere into
conjunctive terms, keep the terms that are compile-time-constant
(isCompileTimeConstant(term)) in model.setConstWhereClause(...) and only move
the non-compile-time terms into
lastModel.setPostJoinWhereClause(concatFilters(...,
lastModel.getPostJoinWhereClause(), nonConstCombined)); update the logic around
model.getOrderedJoinModels()/lastIndex to only be triggered when there are
non-compile-time terms to move, and ensure that if a compile-time constant term
evaluates to false the constWhere remains (so EmptyTable optimization still
applies); add regression tests for mixed filters like WHERE false AND now() =
now() and WHERE true AND now() = now() to validate behavior.

---

Nitpick comments:
In `@core/src/main/java/io/questdb/griffin/SqlOptimiser.java`:
- Around line 4542-4551: The private static helper method
isCompileTimeConstant(ExpressionNode) is currently placed among instance
methods; move it into the class's private static helpers section and insert it
alphabetically with other static methods so it appears in the correct
static-method area (keep the method signature and logic unchanged; reference
ExpressionNode.CONSTANT and ExpressionNode.OPERATION remain the same). Ensure it
resides with other private static members and follows alphabetical ordering for
consistency with the project's Java member grouping rules.

In `@core/src/test/java/io/questdb/test/griffin/engine/join/JoinTest.java`:
- Around line 3668-3669: The SQL in JoinTest.java contains lowercase keywords in
the inline query; update the inner query from "select 1" to "SELECT 1" so all
SQL keywords are uppercase (e.g., the fragment SELECT * FROM (SELECT x as
"foo.bar" FROM long_sequence(5)) LEFT JOIN (SELECT 1) ON true;), ensuring the
test follows the repo convention for uppercase SQL keywords.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5046d39a-3a05-47ef-b1a2-9b3c1b964c9b

📥 Commits

Reviewing files that changed from the base of the PR and between 2263b2a and e38830c.

📒 Files selected for processing (3)
  • core/src/main/java/io/questdb/griffin/SqlCodeGenerator.java
  • core/src/main/java/io/questdb/griffin/SqlOptimiser.java
  • core/src/test/java/io/questdb/test/griffin/engine/join/JoinTest.java

@glasstiger
Copy link
Copy Markdown
Contributor

glasstiger commented Mar 6, 2026

@bluestreak01, comments from Claude:


The core approach is sound. Merging at the AST level during optimization is architecturally cleaner than the runtime wrapWithFilter approach that combined compiled Function objects.
The optimizer already has concatFilters() for this exact pattern, and the method is used consistently elsewhere.

One concern with isCompileTimeConstant — mixed expressions:
If constWhereClause contains both a compile-time-constant part and a non-compile-time-constant part ANDed together (e.g., (1 > 10) AND (NOW() = NOW())), the top-level check returns false and merges the entire expression into postJoinWhereClause. This loses the EmptyTableRecordCursorFactory short-circuit that the code generator path provides.

With the old code, the compiled AndFunction could detect that one operand is constant false and report isConstant() == true, letting the code generator produce EmptyTableRecordCursorFactory. After this PR, the same expression is compiled as a row-level filter — it still filters all rows out, but it scans the data to do so.

This is a minor performance regression for an extremely unlikely query pattern. In practice, nobody writes WHERE 1 > 10 AND NOW() = NOW(). If the team cares, a possible fix would be to split the constWhereClause AND-tree into compile-time and non-compile-time subtrees, merging only the non-compile-time parts. But I wouldn't block on this.


isCompileTimeConstant — Narrow but Safe

  private static boolean isCompileTimeConstant(ExpressionNode node) {
      if (node == null) { return true; }
      return switch (node.type) {
          case ExpressionNode.CONSTANT -> true;
          case ExpressionNode.OPERATION -> isCompileTimeConstant(node.lhs) && isCompileTimeConstant(node.rhs);
          default -> false;
      };
  }

Observations:

  1. Only checks lhs/rhs, not args. OPERATION nodes with paramCount > 2 would have children in the args list, which aren't checked. This is safe in practice since boolean operations are binary, but it's worth documenting or adding an args check for robustness.
  2. Recursive without depth limit. Deeply nested constant expressions could stack-overflow. Not a realistic concern for constWhereClause which is typically shallow, but worth noting.
  3. Conservative classification. Expressions like CAST(1 AS SHORT) > 10 contain a FUNCTION node (CAST) and would be classified as non-compile-time-constant. This is safe (they get merged) but misses an optimization. Acceptable tradeoff.
  4. null returns true. Correct for the recursive case (unary operations have rhs == null), but a standalone call with null returns true. The caller guards with constWhere != null, so this isn't reachable, but the semantics are a bit surprising.

Test Coverage: Adequate but Could Be Stronger

Missing:

  • No explicit test for compile-time constant preservation. A test verifying that WHERE 1 > 10 on a join query still produces EmptyTableRecordCursorFactory (via explain plan) would validate that the split between compile-time and non-compile-time is working correctly.
  • No test for the edge case where constWhereClause mixes compile-time and non-compile-time parts (e.g., WHERE (1 > 10) AND (NOW() = NOW())). Even if the behaviour is acceptable, documenting it with a test prevents future surprises.

bluestreak01 and others added 2 commits March 6, 2026 18:24
mergeConstIntoPostJoinWhereClause now flattens the AND-tree and
classifies each conjunct independently. Compile-time constant terms
(e.g. 1 > 10) stay in constWhereClause for EmptyTableRecordCursorFactory
folding; runtime terms (e.g. NOW() = NOW()) move to postJoinWhereClause.

Previously, a mixed expression like (1 > 10 AND NOW() = NOW()) was
treated as a single non-compile-time block, losing the Empty table
optimization.

Adds extractAndTerms() and isCompileTimeConstant() static helpers.
Adds tests for mixed false+runtime, true+runtime, and plan assertions.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@bluestreak01
Copy link
Copy Markdown
Member Author

@glasstiger thanks for the thorough review. Addressing each point:

Mixed expressions ((1 > 10) AND (NOW() = NOW())) — Fixed in the second commit (3022bf6). mergeConstIntoPostJoinWhereClause now flattens the AND-tree with extractAndTerms() and classifies each conjunct independently. Compile-time terms stay in constWhereClause (preserving EmptyTableRecordCursorFactory folding), runtime terms move to postJoinWhereClause.

isCompileTimeConstant observations:

  1. args list not checked — correct, boolean operations are always binary (paramCount=2, lhs/rhs only). No OPERATION node uses the args list.
  2. Recursive depth — agreed, not a practical concern. constWhereClause is built from assignFilters conjuncts, which are individual filter terms AND-ed together — the tree depth equals the number of zero-table-reference terms in the WHERE clause.
  3. CAST classified as non-compile-time — yes, this is conservative but safe. CAST is a FUNCTION node, so CAST(1 AS SHORT) > 10 gets merged into postJoinWhereClause and evaluated at runtime. Correct results, just misses the empty-table shortcut for an unrealistic query.
  4. null returns true — only reachable in the recursive case (unary operations with null rhs). The top-level caller guards with constWhere == null early return.

Test coverage — both gaps are now covered:

  • testNoArgMixedConstAndRuntimeExprInJoin (ExplainPlanTest) — asserts Empty table plan for WHERE 1 > 10 AND NOW() = NOW() on a join
  • testJoinInnerPostJoinAndMixedConstFilter (JoinTest) — asserts empty result for mixed false + runtime
  • testJoinInnerPostJoinAndMixedConstTrueFilter (JoinTest) — asserts correct result + plan for mixed true + runtime, verifying NOW()=NOW() appears in the post-join filter

glasstiger
glasstiger previously approved these changes Mar 6, 2026
@glasstiger
Copy link
Copy Markdown
Contributor

[PR Coverage check]

😍 pass : 36 / 37 (97.30%)

file detail

path covered line new line coverage
🔵 io/questdb/griffin/SqlCodeGenerator.java 2 3 66.67%
🔵 io/questdb/griffin/SqlOptimiser.java 34 34 100.00%

@bluestreak01 bluestreak01 merged commit 8185fe0 into master Mar 9, 2026
50 checks passed
@bluestreak01 bluestreak01 deleted the vi_fixup branch March 9, 2026 13:32
maciulis pushed a commit to maciulis/questdb that referenced this pull request Mar 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

SQL Issues or changes relating to SQL execution

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AssertionError at FilteredRecordCursorFactory.java

2 participants