Skip to content

perf: group global search columns of the same relation into one sub-query - #3299

Open
OzanKurt wants to merge 1 commit into
yajra:masterfrom
OzanKurt:fix/group-global-search-relation-columns
Open

perf: group global search columns of the same relation into one sub-query#3299
OzanKurt wants to merge 1 commit into
yajra:masterfrom
OzanKurt:fix/group-global-search-relation-columns

Conversation

@OzanKurt

Copy link
Copy Markdown
Contributor

Closes #2519

Problem

Global search compiles one query per searchable column. For an eager loaded relation that means one exists sub-query per column, all hitting the same table:

where (
    exists (select * from "users" where "posts"."user_id" = "users"."id" and lower("users"."name") like ?)
    or exists (select * from "users" where "posts"."user_id" = "users"."id" and lower("users"."email") like ?)
)

Column search and ordering already resolve relation columns once, so global search was the odd one out, and the cost grows with every searchable column of the relation.

Solution

Searchable columns are now grouped per relation before being compiled, so each relation produces a single sub-query:

where exists (
    select * from "users"
    where "posts"."user_id" = "users"."id"
    and (lower("users"."name") like ? or lower("users"."email") like ?)
)

The column conditions are wrapped in a nested where so the or cannot leak out of the relation constraint. That wrapper is only added when a relation has more than one searchable column, keeping the generated SQL identical to before for every single column case.

The original suggestion in the issue was to call resolveRelationColumn() in globalSearch(). That turns the sub-query into a join, which also multiplies rows for hasMany / belongsToMany relations and changes recordsFiltered. Grouping keeps the current semantics and only removes the duplicated sub-queries, which is what was agreed on in the discussion.

Changes

  • QueryDataTable::globalSearch() now delegates to compileGlobalSearch(), which loops the searchable columns through compileGlobalSearchColumn(). Behaviour is unchanged for the query engine.
  • EloquentDataTable::compileGlobalSearch() groups the columns by relation, preserving the order of first appearance, and compiles each group into one whereHas / whereHasMorph.
  • EloquentDataTable::compileQuerySearch() was split into resolveSearchableRelation() (which relation, if any, a column belongs to) and compileRelationSearch() (compile one relation with n columns), so single column search and grouped global search share one code path.

Notes

  • Not affected: column search, ordering, filterColumn() callbacks, columns of relations that are not eager loaded, and morph relations keep their existing per relation handling.
  • tests/Unit/GlobalSearchRelationTest.php covers the grouping, one sub-query per relation, the untouched non eager loaded case, the and (... or ...) precedence, and that the result set is unchanged. Three of these tests fail on the current implementation.
  • composer stan passes and the existing relation tests (BelongsTo, HasMany, HasOne, HasOneThrough, BelongsToMany, MorphTo, DeepRelation, ArrayNotationRelation) pass unchanged.

@yajra

yajra commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Please resolve the conflicts, thanks!

…uery

Global search compiled one where has query per searchable column, so a
relation with n searchable columns produced n exists sub-queries for the
same table. Searchable columns are now grouped per relation and compiled
into a single sub-query:

    where exists (
        select * from users
        where posts.user_id = users.id
        and (lower(users.name) like ? or lower(users.email) like ?)
    )

The column conditions are wrapped in a nested where so the or does not
leak out of the relation constraint. Column search and ordering are not
affected.

Closes yajra#2519
@OzanKurt
OzanKurt force-pushed the fix/group-global-search-relation-columns branch from 5dafc3d to 9a43d7b Compare August 14, 2026 08:48
@sonarqubecloud

Copy link
Copy Markdown

@OzanKurt

Copy link
Copy Markdown
Contributor Author

Rebased on v13.2.0 and resolved, the conflict was only in the use block of EloquentDataTable.php against #3303. Tests and static analysis are green.

@OzanKurt

Copy link
Copy Markdown
Contributor Author

Heads up on an interaction with #3300: both PRs rewrite EloquentDataTable::compileQuerySearch(), and they were branched off master independently.

#3300 adds resolveRelationName() calls inside compileQuerySearch(), while this one replaces that method with resolveSearchableRelation(), which does not call it. Merging both conflicts in that method. Resolving the conflict by keeping the version from this PR compiles and looks right, but the snake case resolution then only applies to ordering and is lost for search. SnakeCaseRelationTest fails in that case, so it will not slip through, but it is easier to just resolve it correctly.

The correct resolution is to call resolveRelationName() at both return points of resolveSearchableRelation():

$relation = $this->resolveRelationName(array_shift($parts));
// ...
$relation = $this->resolveRelationName(implode('.', $parts));

I merged both branches locally with that resolution and the suite is green, 182 tests, PHPStan clean.

Whichever of the two you merge first, I will push the follow up to the other one right away, so there is no need to resolve it by hand.

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.

Global Search performance

2 participants