Skip to content

Thread filter bindings through aggregate SQL composition#186

Merged
Vusys merged 4 commits into
masterfrom
refactor/filter-bindings
Jun 6, 2026
Merged

Thread filter bindings through aggregate SQL composition#186
Vusys merged 4 commits into
masterfrom
refactor/filter-bindings

Conversation

@Vusys
Copy link
Copy Markdown
Owner

@Vusys Vusys commented Jun 5, 2026

Summary

  • Filter equality values now ride the driver's bound-parameter stream instead of inlining as literals via PDO::quote. Addresses reviewer feedback about ergonomics + correctness on the read path.
  • New BoundFragment value object (sql + bindings) flows through every aggregate-SQL helper.
  • New FragmentSplicer adapter runs each string-based emitter once with a sentinel and counts splices to repeat predicate bindings — avoids rewriting variance / quantile / topk / collection-aggregate internals.
  • FilterValueQuoter removed. Separator/JSON-key escaping in AggregateSqlEmitter now uses Laravel's blessed Connection::escape().

Why

Inlined literals via PDO::quote had three issues:

  1. Required an open connection at SQL-compose time (forecloses render-once-run-anywhere).
  2. Bypassed Laravel's binding stream, so debug logging / profiling / log sanitisation lost the bound values.
  3. The custom quoter had a per-driver boolean carve-out (TRUE/FALSE vs 1/0) that Connection::escape() already handles correctly via driver overrides.

Bindings via ? placeholders fix all three.

Architecture

The cascade is mechanical but wide because the filter predicate gets spliced multiple times per aggregate expression (variance splices 3+×, stddev 6×, BoolAnd 3×, etc.). Two design choices kept the diff bounded:

  1. BoundFragment as the unified return type for every helper that previously returned string and embedded the predicate. Caller merges ->bindings into its own binding stream.
  2. FragmentSplicer sentinel pattern for emitters with conditional / per-driver internals. Each emitter has a thin BoundFragment-aware wrapper; the splicer feeds a sentinel into the internal ?string $filterSql path, counts occurrences in the result, swaps for the real predicate, and repeats bindings N times. Internal SQL composition stays untouched.

Result: bindings flow correctly through every read shape (scalar / derived / LATERAL / correlated) and through the recompute UPDATE and differ paths.

Verification

  • PHPStan level 9: clean
  • Pint, Rector: clean
  • PHPUnit (sqlite): 2128 tests, 22643 assertions, all green
  • New FragmentSplicerTest covers null filter, single splice, N-splice binding repetition, and the literal-shortcut path

Test plan

  • CI green across the 24-cell matrix (PHP × Laravel × DB)
  • Manual verification on a filtered aggregate model that bindings appear in the query log instead of inlined values
  • Fuzzer pass with default seeds (composer fuzz)

Summary by CodeRabbit

Release Notes

  • Breaking Changes

    • Updated public API signatures in aggregate SQL emission and filter handling to use bound SQL fragments with explicit parameter bindings instead of raw SQL strings. Applications directly calling AggregateSqlEmitter, DerivedAggregateFragments, or VarianceSqlFragments methods require updates.
    • Removed FilterValueQuoter class; use connection's escape methods directly.
  • Refactor

    • Restructured filter and aggregate SQL generation to properly align parameter bindings with SQL placeholders throughout composition pipelines.

Filter equality values now ride the driver's bound-parameter stream
instead of inlining as literals via PDO::quote. The aggregate SQL
helpers return a new `BoundFragment` (sql + bindings) value object;
call sites merge bindings into connection->select/scalar or the
builder's 'select'/'join' binding positions.

Internal emitter signatures stay string-based — a `FragmentSplicer`
adapter runs each emitter once with a sentinel placeholder, then
counts and repeats predicate bindings to match splice occurrences.
This avoids rewriting every variance/quantile/topk helper.

`FilterValueQuoter` is removed; separator/JSON-key escaping in
`AggregateSqlEmitter` switches to the Laravel-blessed
`Connection::escape()`.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 5, 2026

Wondering what really moved? Review this PR in Change Stack to inspect semantic changes, definitions, and references.

Review Change Stack

Warning

Review limit reached

@Vusys, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 22 minutes and 4 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 99f0e4fc-291f-4bbb-b786-a80ef50281b7

📥 Commits

Reviewing files that changed from the base of the PR and between 4c2f4f2 and 1a7cdcc.

📒 Files selected for processing (3)
  • src/Query/Aggregates/Read/FreshAggregateProjector.php
  • tests/Feature/Aggregates/Maintenance/RecomputeBindingOrderTest.php
  • tests/Unit/Query/Aggregates/Read/AggregateSqlFragmentsTest.php
📝 Walkthrough

Walkthrough

This PR refactors aggregate SQL generation from inlined quoted values to composable bound SQL fragments. BoundFragment pairs SQL strings with parameter bindings; FragmentSplicer composes filters into templates while preserving binding alignment. FilterValueQuoter is removed. FilterPredicate::toFragment() generates bound filter fragments. All aggregate emitters, maintenance strategies, and query projectors thread BoundFragment through their pipelines, with bindings accumulated and ordered consistently in final prepared statements.

Changes

Aggregate SQL binding refactor

Layer / File(s) Summary
Fragment foundation: BoundFragment and FragmentSplicer primitives
src/Aggregates/Filters/BoundFragment.php, src/Aggregates/Filters/FragmentSplicer.php, tests/Unit/Aggregates/Filters/FragmentSplicerTest.php
BoundFragment readonly value object stores SQL fragment and positional parameter bindings. FragmentSplicer::splice() injects a filter fragment into an emitted SQL template by counting and replacing a sentinel, then repeats filter bindings once per occurrence to maintain ? placeholder alignment.
FilterPredicate fragment generation
src/Aggregates/Filters/FilterPredicate.php, tests/Unit/Aggregates/Filters/FilterPredicateTest.php
FilterPredicate::toFragment(qualifier) converts equality/null/raw predicates to BoundFragment with positional ? bindings. Equality emits IS NULL for null values or column = ? for scalars (collecting bindings), rejects non-scalar values with validation error. NotNull and Raw predicates emit their respective SQL.
Remove FilterValueQuoter quoting helper
src/Aggregates/Filters/FilterValueQuoter.php, tests/Unit/Aggregates/Filters/FilterValueQuoterTest.php
Delete FilterValueQuoter class and test suite; positional binding approach replaces the prior inline PDO quoting strategy.
Variance/stddev SQL fragment builders
src/Aggregates/Sql/VarianceSqlFragments.php
variance() and stddev() accept BoundFragment subexpressions (sumExpr, sumSqExpr, countExpr) and return composed fragments with bindings concatenated in fixed positional order (matching the repeated embedding in numerator/denominator/CASE expressions).
AggregateSqlEmitter core refactor
src/Aggregates/Sql/AggregateSqlEmitter.php, tests/Unit/Aggregates/Sql/AggregateSqlEmitterTest.php
emit(), leafInline(), emitQuantile*, and leafTopK() refactored to accept ?BoundFragment $filter and return BoundFragment. Backend-specific match logic moved inside FragmentSplicer::splice() closures so filter SQL and bindings propagate correctly. quoteString() now uses Connection::escape() instead of removed FilterValueQuoter.
DerivedAggregateFragments binding refactor
src/Aggregates/Sql/DerivedAggregateFragments.php, tests/Unit/Aggregates/Sql/DerivedAggregateFragmentsTest.php
build() accepts ?BoundFragment $filter and returns BoundFragment. Uses FragmentSplicer to compose filter predicates into CASE-wrapped SUM/COUNT expressions within the derived table, preserving filter bindings through the composition.
DeltaMaintenance variance clause refactor
src/Aggregates/Strategy/DeltaMaintenance.php
buildVarianceSetClauses() constructs companion column expressions as BoundFragment::literal(...), composes variance/stddev via updated builders, and stores the resulting SQL via $fragment->sql in the final TreeExpression.
RecomputeMaintenance aggregate expression binding
src/Aggregates/Strategy/RecomputeMaintenance.php
innerAggregateExpression() returns BoundFragment for both filtered and unfiltered aggregates. Uses FragmentSplicer::splice() to compose predicates from FilterPredicate::toFragment() into aggregate templates. Collects bindings from TopK correlated subqueries and appends them before outer bounds parameters.
AggregateSqlFragments query expression generation
src/Query/Aggregates/Read/AggregateSqlFragments.php, tests/Unit/Query/Aggregates/Read/AggregateSqlFragmentsTest.php
aggregateExpression(), filteredAggregateExpression(), leafInlineExpression(), aggregateExpressionInJoinedContext(), and wrapLeafFastPath() refactored to return BoundFragment. Use FilterPredicate::toFragment() and FragmentSplicer::splice() to compose bound predicates into CASE expressions while threading bindings through soft-delete wrapping and fast-path composition. Added test helper inline(BoundFragment) to snapshot fragments by inlining bindings.
AggregateDiffer binding order management
src/Query/Aggregates/Maintenance/AggregateDiffer.php
groupedAggregateQuery() accumulates aggregate expression bindings in $aggBindings while building derived-table SELECT expressions, then places $aggBindings first in the query's binding array before appending scope/root/outerIds bindings to ensure positional placeholder alignment.
FreshAggregateProjector query building
src/Query/Aggregates/Read/FreshAggregateProjector.php
Refactored to build subqueries and aggregate expressions as BoundFragment. Propagates bindings through correlated fallback loops, quantile/TopK correlated-subquery loops, and leaf fast-path wrapping. MariaDB derived-JOIN and LATERAL-JOIN shapes accumulate aggregate and leaf-case bindings separately, adding them to join and select binding groups respectively. Helper methods buildQuantileSubquery, buildTopKSubquery, buildCorrelatedSubquery now return BoundFragment with SQL and bindings.
Unit test updates for fragments
tests/Unit/Aggregates/Sql/AggregateSqlEmitterTest.php, tests/Unit/Aggregates/Sql/DerivedAggregateFragmentsTest.php, tests/Unit/Query/Aggregates/Read/AggregateSqlFragmentsTest.php
Tests updated to handle BoundFragment return types. Added inline(BoundFragment) helper to snapshot fragments by replacing ? placeholders with literal values. Filter-predicate tests refactored to check both SQL and bindings separately. Removed tests for deleted FilterValueQuoter.

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Vusys/laravel-nestedset#76: Introduces FilterValueQuoter for inline PDO quoting; main PR removes it and switches to positional bindings instead.
  • Vusys/laravel-nestedset#131: Adds median/percentile quantile SQL emission in AggregateSqlEmitter; main PR refactors its filter handling to use BoundFragment and return types.
  • Vusys/laravel-nestedset#143: Splits TreeAggregateBuilder into focused classes and refactors aggregate SQL construction surface; main PR rewrites the same AggregateSqlFragments/projector surface to use bound fragments.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.37% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and specifically describes the main architectural change: threading filter bindings (parameter placeholders) through aggregate SQL composition helpers instead of inlining literal values.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/filter-bindings

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.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 5, 2026

🐰 Bencher Report

Branchrefactor/filter-bindings
Testbedubuntu-latest

⚠️ WARNING: Truncated view!

The full continuous benchmarking report exceeds the maximum length allowed on this platform.

⚠️ WARNING: No Threshold found!

Without a Threshold, no Alerts will ever be generated.

🐰 View full continuous benchmarking report in Bencher

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Jun 5, 2026

Codecov Report

❌ Patch coverage is 94.88536% with 29 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.91%. Comparing base (d0b308f) to head (1a7cdcc).

Files with missing lines Patch % Lines
src/Aggregates/Strategy/RecomputeMaintenance.php 76.34% 22 Missing ⚠️
.../Query/Aggregates/Read/FreshAggregateProjector.php 94.02% 4 Missing ⚠️
src/Aggregates/Sql/AggregateSqlEmitter.php 97.27% 3 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master     #186      +/-   ##
============================================
+ Coverage     93.82%   93.91%   +0.09%     
- Complexity     3779     3790      +11     
============================================
  Files           134      135       +1     
  Lines         11847    11924      +77     
============================================
+ Hits          11116    11199      +83     
+ Misses          731      725       -6     
Flag Coverage Δ
mariadb 92.77% <90.29%> (-0.04%) ⬇️
mysql 92.34% <86.77%> (-0.07%) ⬇️
pgsql 92.08% <83.24%> (-0.09%) ⬇️
sqlite 92.41% <86.06%> (-0.12%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 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.

@Vusys Vusys marked this pull request as ready for review June 5, 2026 23:52
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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/Query/Aggregates/Read/FreshAggregateProjector.php (1)

585-630: ⚠️ Potential issue | 🔴 Critical

Fix reversed parameter binding order for filtered quantile/TopK scalar aggregates.

  • AggregateSqlEmitter::emitQuantileWindowSubquery() appends the filter predicate (AND ({$filterSql})) after the $innerFromClause (which already contains the ? placeholders for bounds/scope), so the scalar bindings must be [...$bindings, ...$fragment->bindings] (currently reversed).
  • AggregateSqlEmitter::emitTopKCorrelatedSubqueryInternal() sets $innerWhere = $boundsAndScope first and only then appends the filter (AND ({$filterSql})), so bindings must be [...$innerBindings, ...$topKFragment->bindings] (currently reversed).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Query/Aggregates/Read/FreshAggregateProjector.php` around lines 585 -
630, The scalar calls pass bindings in the wrong order for filtered
quantile/TopK aggregates; for
AggregateSqlEmitter::emitQuantileWindowSubquery(...) return use
$connection->scalar(..., [...$bindings, ...$fragment->bindings]) (swap the
current order) and for AggregateSqlEmitter::emitTopKCorrelatedSubquery(...)
return use $connection->scalar(..., [...$innerBindings,
...$topKFragment->bindings]) (swap the current order) so the positional ?
placeholders from bounds/scope come before the filter fragment bindings; update
the two scalar invocation arguments accordingly where
$fragment->bindings/$topKFragment->bindings and $bindings/$innerBindings are
combined.
🧹 Nitpick comments (1)
src/Aggregates/Strategy/RecomputeMaintenance.php (1)

176-227: ⚡ Quick win

Please add a unit test that locks down the new binding-order contract.

This path now depends on two easy-to-regress invariants: inner aggregate bindings must stay ahead of the outer bounds placeholders, and the nested FragmentSplicer flow for filtered collection aggregates must duplicate predicate bindings exactly once per textual splice. Given the uncovered changed lines in this file, a small pure unit test around one filtered recompute query would add a lot of confidence here.

As per coding guidelines, "For new/changed aggregates/filter-binding behavior, add/adjust pure unit tests where possible."

Also applies to: 341-483, 528-534

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Aggregates/Strategy/RecomputeMaintenance.php` around lines 176 - 227, Add
a unit test in the Strategy\RecomputeMaintenance spec that constructs a filtered
recompute query (exercise the code path that calls innerAggregateExpression and
the filtered FragmentSplicer / AggregateSqlEmitter flows) and asserts two
binding-order invariants: (1) all inner-aggregate bindings (the bindings
collected into $aggBindings from innerAggregateExpression /
emitTopKCorrelatedSubquery) appear before the outer bounds placeholders
($bounds->lft, $bounds->rgt) in the final bindings array, and (2) the predicate
bindings produced by FragmentSplicer are duplicated exactly once per textual
splice (i.e., the filter predicate bindings appear the expected number of times
in $aggBindings). Use the public API that triggers RecomputeMaintenance's
select-building logic and assert on the returned SQL fragments and binding array
to lock down this contract.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/Unit/Query/Aggregates/Read/AggregateSqlFragmentsTest.php`:
- Around line 58-79: The inline(BoundFragment $fragment) helper currently stops
replacing when strpos($sql, '?') returns false and silently ignores any leftover
bindings; update inline to fail fast by validating that the number of '?'
placeholders matches the number of $fragment->bindings before returning (or
after the substitution loop) and throw a clear exception (e.g.,
InvalidArgumentException) if there are surplus bindings so tests can't silently
pass; reference the inline function and BoundFragment to locate and modify the
logic that counts placeholders and bindings.

---

Outside diff comments:
In `@src/Query/Aggregates/Read/FreshAggregateProjector.php`:
- Around line 585-630: The scalar calls pass bindings in the wrong order for
filtered quantile/TopK aggregates; for
AggregateSqlEmitter::emitQuantileWindowSubquery(...) return use
$connection->scalar(..., [...$bindings, ...$fragment->bindings]) (swap the
current order) and for AggregateSqlEmitter::emitTopKCorrelatedSubquery(...)
return use $connection->scalar(..., [...$innerBindings,
...$topKFragment->bindings]) (swap the current order) so the positional ?
placeholders from bounds/scope come before the filter fragment bindings; update
the two scalar invocation arguments accordingly where
$fragment->bindings/$topKFragment->bindings and $bindings/$innerBindings are
combined.

---

Nitpick comments:
In `@src/Aggregates/Strategy/RecomputeMaintenance.php`:
- Around line 176-227: Add a unit test in the Strategy\RecomputeMaintenance spec
that constructs a filtered recompute query (exercise the code path that calls
innerAggregateExpression and the filtered FragmentSplicer / AggregateSqlEmitter
flows) and asserts two binding-order invariants: (1) all inner-aggregate
bindings (the bindings collected into $aggBindings from innerAggregateExpression
/ emitTopKCorrelatedSubquery) appear before the outer bounds placeholders
($bounds->lft, $bounds->rgt) in the final bindings array, and (2) the predicate
bindings produced by FragmentSplicer are duplicated exactly once per textual
splice (i.e., the filter predicate bindings appear the expected number of times
in $aggBindings). Use the public API that triggers RecomputeMaintenance's
select-building logic and assert on the returned SQL fragments and binding array
to lock down this contract.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f0400020-7e0c-4cdc-99d4-81826bacd2ed

📥 Commits

Reviewing files that changed from the base of the PR and between d0b308f and 4c2f4f2.

📒 Files selected for processing (18)
  • src/Aggregates/Filters/BoundFragment.php
  • src/Aggregates/Filters/FilterPredicate.php
  • src/Aggregates/Filters/FilterValueQuoter.php
  • src/Aggregates/Filters/FragmentSplicer.php
  • src/Aggregates/Sql/AggregateSqlEmitter.php
  • src/Aggregates/Sql/DerivedAggregateFragments.php
  • src/Aggregates/Sql/VarianceSqlFragments.php
  • src/Aggregates/Strategy/DeltaMaintenance.php
  • src/Aggregates/Strategy/RecomputeMaintenance.php
  • src/Query/Aggregates/Maintenance/AggregateDiffer.php
  • src/Query/Aggregates/Read/AggregateSqlFragments.php
  • src/Query/Aggregates/Read/FreshAggregateProjector.php
  • tests/Unit/Aggregates/Filters/FilterPredicateTest.php
  • tests/Unit/Aggregates/Filters/FilterValueQuoterTest.php
  • tests/Unit/Aggregates/Filters/FragmentSplicerTest.php
  • tests/Unit/Aggregates/Sql/AggregateSqlEmitterTest.php
  • tests/Unit/Aggregates/Sql/DerivedAggregateFragmentsTest.php
  • tests/Unit/Query/Aggregates/Read/AggregateSqlFragmentsTest.php
💤 Files with no reviewable changes (2)
  • src/Aggregates/Filters/FilterValueQuoter.php
  • tests/Unit/Aggregates/Filters/FilterValueQuoterTest.php

Comment thread tests/Unit/Query/Aggregates/Read/AggregateSqlFragmentsTest.php
Vusys added 2 commits June 6, 2026 01:22
The scalar fresh-read path placed filter bindings before bounds/scope
bindings, but the textual order in the emitted SQL is bounds → scope →
filter (the filter predicate sits inside the inner derived's WHERE).
Positional `?` placeholders therefore picked up the wrong values.

No feature test exercised the filtered scalar quantile or TopK path so
the regression slipped past CI. Added `RecomputeBindingOrderTest` that
captures the recompute SELECT via the query log and asserts:

- predicate bindings precede outer bounds bindings (textual order)
- predicate bindings appear once per textual splice

Also hardened the snapshot-test `inline()` helper to fail fast when
`?`-count and bindings-count diverge, so a future drop or stray would
surface as an `InvalidArgumentException` instead of silently passing.
@Vusys Vusys merged commit 5996cfd into master Jun 6, 2026
42 checks passed
@Vusys Vusys deleted the refactor/filter-bindings branch June 6, 2026 01:15
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.

2 participants