feat: index-friendly sort and search via per-filter case/join modes#729
Conversation
PR Summary
Test Case Implementations
|
There was a problem hiding this comment.
Review
Tight scope, careful back-compat, real perf rationale with measured production data. The trait extraction is the right call and the parity test (test_subquery_and_join_strategies_produce_identical_id_order) is exactly the assertion that makes the JOIN switch safe to land.
A few items worth addressing before merge — none are blocking.
Items I'd want addressed
1. SearchableFilter::make() overrides the Make trait and silently drops args — src/Filters/SearchableFilter.php
public static function make(...$arguments): static
{
$filter = new static; // args dropped
if (isset($arguments[0]) && is_string($arguments[0])) {
$filter->setColumn($arguments[0]);
}
return $filter;
}The base Make trait forwards all args to the constructor (new static(...$arguments)). The override only handles the string first arg and discards the rest. Existing callers like CustomSearchableFilter::make() (no args) still work, but any subclass that relies on constructor-arg forwarding will silently lose them. Suggest new static(...$arguments) or document the override.
2. column() ?? '' swallows missing columns — src/Filters/SearchableFilter.php (resolveBelongsToEntry) and src/Fields/BelongsTo.php lines 58 / 78 / 92
If a caller passes SearchableFilter::make()->upperValue() (no column set) into BelongsTo->searchable([...]), the ?? '' coalesce produces broken SQL like where '' like '%foo%' instead of throwing. Fail-fast on null/empty column would prevent silent SQL corruption.
Nits
3. applyBelongsToSubquery hard-codes 'like' instead of $likeOperator in the wrapped branch. The RAW branch a few lines up uses $likeOperator (which becomes ilike on pgsql), so the two branches are inconsistent. Pre-existing behavior, but worth noting that caseRaw() on pgsql still uses ilike and is therefore not strictly case-sensitive.
4. flatten() → flatten(1) in BelongsTo::searchable — necessary so SearchableFilter instances aren't dissolved by deep flattening, but a one-line comment explaining why would prevent a future "let's clean this up" regression.
5. ensureLeftJoin() table-name dedup ignores join type — if anything upstream registered an INNER JOIN users (e.g., a custom repository hook), this returns early and the sort path silently relies on the inner join. A $join->type === 'left' guard would be more defensive. Unlikely in practice.
6. Test gap. The BelongsTo case-mode mix test only verifies upperValue. One assertion on lowerValue (or caseRaw) on a BelongsTo searchable would round out the matrix.
7. Doc/test mismatch. The basic-filters doc shows SearchableFilter::make('gross_amount')->caseRaw() (short factory form), but every test uses SearchableFilter::make()->setColumn('gross_amount')->caseRaw(). Add one test in the short form to lock the public API shown in docs.
Risk
- Sort: low — opt-in two ways, parity test asserts identical IDs.
- Search: low — no new config keys, byte-identical SQL when no new methods are called (covered by the two
test_default_*tests). BelongsTo::searchablepolymorphism: low — string-only arrays unchanged; mixed form covered.
Nice work on the Flare APM data in the description — the production hotspot table makes the case for the design choices much easier to evaluate.
binaryk
left a comment
There was a problem hiding this comment.
Re-review
All seven items addressed. Quick verification:
| # | Item | Resolution |
|---|---|---|
| 1 | SearchableFilter::make() arg-forwarding |
new static(...$arguments) — args now reach the constructor ✓ |
| 2 | Empty-column fail-fast | InvalidArgumentException thrown in BelongsTo::extractColumn() and SearchableFilter::resolveBelongsToEntry() with a helpful "Pass it as SearchableFilter::make('column') or call setColumn()" message ✓ |
| 3 | pgsql like vs $likeOperator mismatch |
Acknowledged with an inline comment in applyBelongsToSubquery explaining the legacy contract and the pgsql ILIKE-on-RAW consequence ✓ |
| 4 | flatten(1) rationale |
Comment added: "flatten(1) preserves SearchableFilter instances inside the array; deeper flattening would dissolve them into their internal arrays." ✓ |
| 5 | ensureLeftJoin join-type guard |
Now matches $join->table === $relatedTable && $join->type === 'left', with a comment on INNER vs LEFT semantics ✓ |
| 6 | Test gap — lowerValue on BelongsTo |
New test_belongs_to_searchable_supports_lower_value_per_entry ✓ |
| 7 | Short-form factory tested | New test_searchable_filter_short_form_make_with_column_argument exercises SearchableFilter::make('title')->upperValue() ✓ |
Bonus: test_belongs_to_searchable_throws_when_filter_has_no_column locks the fail-fast contract from item 2.
LGTM — happy to see this merged.
Generated by Claude Code
Problem
Two SQL patterns produced by
SearchableFilterandSortableFilterbypass database indexes on consumer apps' hot endpoints:1. Sort by relation column → correlated subquery
Per-row evaluation. The optimizer can't push the relation into the main scan. Asymmetric with the search side, which has had a
LEFT JOINstrategy (restify.search.use_joins_for_belongs_to) for some time.2. Case-insensitive search →
UPPER()on every columnUPPER(numeric)casts a DECIMAL to text per row.UPPER(vendors.code)is wasteful when codes are stored uppercase by convention. Same for status enums stored lowercase. Index never used.Production data (Flare APM, last 7d)
Sort hotspots:
GET billings ?sort=customers.codeGET invoices ?sort=vendors.codeSearch hotspots — most frequent
UPPER(column)in slow query set:vendors.name,vendors.codejobs.number,invoices.number,invoices.gross_amount,customers.name,customers.codework_orders.number,invoice_payments.number,billings.number,employees.code,purchase_orders.*journals.*,work_sites.*,line_items.*Audit across 461 searchable columns in 10 domain repositories of one consumer app: ~70% would benefit from raw (numerics, FKs, dates, known-case codes/enums); ~29% are human text genuinely needing case-insensitive matching.
Solution
Two independent, opt-in, backward-compatible mechanisms:
Feature 1 — Sortable JOIN strategy
LEFT JOIN strategy for sort-by-BelongsTo / sort-by-HasOne, gated by:
restify.sort.use_joins_for_belongs_to(defaultfalse).->useJoin()/->useSubquery().JOIN dedup logic extracted to a shared
Filters/Concerns/AppliesRelationJointrait — used by both the new sort path and the existingRepositorySearchService::applyBelongsToJoins(). Search-side behavior preserved exactly.Feature 2 — Searchable per-field case modes
Five chainable case-mode methods on
SearchableFilter+ a flexibletransform(callable)escape hatch. Single class, no subclasses. Per-column choice of column expression and value transformation:->caseRaw()->upperValue()vendors.code)->lowerValue()status)->upperBoth()case_sensitive=false)->lowerBoth()->transform(callable)The five named methods are convenience wrappers around
transform()plus a column-wrap setting. No new config key — default behavior derives from existingrestify.search.case_sensitive.BelongsTo::->searchable([...])extended to accept mixedstring | SearchableFilterarrays, so per-column overrides work for joined searchables too.Examples
Sort — before
emits:
Sort — after (config flag on, or per-filter
->useJoin())Sort — dedup with search
When search has already left-joined
vendors(fromBelongsTo->searchable(['vendors.name'])+restify.search.use_joins_for_belongs_to=true), the sort path detects the existing join and reuses it — exactly oneLEFT JOIN vendorsin final SQL.Search — before (
case_sensitive=false)emits:
None of the four predicates use an index.
Search — after, opt critical columns in
emits:
Search — custom value normalization
Impact
Sort: production SQL shape changes from
O(n) per-row subquerytoO(1) LEFT JOIN. Expected ≥ 2× p95 reduction on the listed routes based on plan analysis.Search: 70% of audited columns can stop wrapping the column in
UPPER()and become directly indexable. The 29% needing case-insensitive matching opt into->upperBoth()explicitly — same SQL as today by default. Net: per-column rollout, zero regression risk, every opt-in is a strict perf win.API summary
Resolution order (sort): per-filter > config > legacy subquery.
Resolution order (search): per-filter method > legacy
case_sensitive.Why this design, not others
Sort
truein package configfalselets every consumer evaluate the trade-off explicitly.->useJoin()makes single-hotspot validation cheap.usingRelation()already accepts).applyBelongsToJoins. Duplication would diverge over time. Trait = one source of truth across sort and search.Search
case_modeconfig key (raw|upper_both|lower_both)case_sensitivecovers the global. Per-field methods cover the rest. Less surface to mis-configure.SearchableFilter::make('col', CASE_UPPER_VALUE)SortableFilter::make()->useJoin()and broaderfield()->rules()->matchableText()style. Breaks IDE autocomplete discovery. Less self-documenting at the call site.RawSearchableFilter,UpperValueSearchableFilter…) NaturalSort-stylematchUpper / matchLowervsbothUpper / bothLowerupperValue/upperBoth: prefix is action (upper), suffix is scope (value-only vs both). Parallel and explicit.BelongsTo::searchable([])vendors.code,customers.code,jobs.number) live behind BelongsTo searchables. Skipping leaves half the perf gain on the table. Two small relaxations + per-entry unwrap make it transparent.->transform(callable)subsumes the named ones AND opens the door to anything else. Single internal primitive; named methods are wrappers around it.case_sensitive=trueupperBoth()to avoid regressing human-text searches. Feasible follow-up — not in this PR. API ships first, sweep follows at consumer pace.Backward compatibility
false→ no SQL change for any consumer that doesn't flip it. Per-filter methods opt-in.searchable(['col_a', 'col_b'])(string-only) keeps working unchanged. Mixed-array form is purely additive.RepositorySearchService::applyBelongsToJoins()refactor (trait extraction) is behavior-preserving — same dedup, same column qualification, same exception handling.Test coverage
Sort — 8 new cases in
tests/Feature/Filters/SortableJoinStrategyTest.php:->useJoin()overrides config=false.->useSubquery()overrides config=true.Search — 8 new cases in
tests/Feature/Filters/SearchableCaseModeTest.php:case_sensitive=falsedefault still emits UPPER both — strict back-compat.case_sensitive=truedefault emits raw — strict back-compat.->caseRaw()overrides global insensitive.->upperValue()— column raw, value uppercased.->lowerValue()— column raw, value lowercased.->lowerBoth()— both sides lowered.->transform(callable)— custom closure (verified withtrim + strtolower).SearchableFilterinstances coexist.Vendor regression: 53 tests / 207 assertions green across
tests/Feature/Filters,tests/Feature/BelongsToJoinConfigTest.php,tests/Fields/BelongsToFieldTest.php.Files changed
src/Filters/SortableFilter.phpuseJoin(),useSubquery(),applyJoinSort(), mode resolutionsrc/Filters/SearchableFilter.phpapplyValueAndColumn()helper, BelongsTo per-entry unwrapsrc/Filters/Concerns/AppliesRelationJoin.phpensureLeftJoin(...)trait shared by sort and searchsrc/Services/Search/RepositorySearchService.phpapplyBelongsToJoins()uses trait; behavior unchangedsrc/Fields/BelongsTo.phpsearchable([])accepts mixedstring | SearchableFilterarraysconfig/restify.phpsort.use_joins_for_belongs_toblocktests/Feature/Filters/SortableJoinStrategyTest.phptests/Feature/Filters/SearchableCaseModeTest.phpMigration
None required. Both features are off-by-default / opt-in. Consumers can:
RESTIFY_SORT_USE_JOINS_FOR_BELONGS_TO=truefor global sort JOIN rollout.->useJoin()/->useSubquery()on perf-critical sortables for surgical sort rollout.SearchableFilter::make('col')->caseRaw()(or any other method) when ready for the perf win.