fix: respect table prefixes in query builder relationship aggregates (#20386)

* fix: respect table prefixes in query builder relationship aggregates

* fix: preserve one-of-many relationship semantics in query builder aggregates

* fix: respect relationship method constraints in query builder aggregates

Aggregate subqueries were built from a fresh query on the related model,
so constraints defined inside the relationship method itself were never
applied. An aggregate over a relationship such as
`hasMany(Post::class)->where('is_published', true)` silently aggregated
every related row, and `wherePivot()` on a `BelongsToMany` was ignored
in the same way.

Merge the relationship's own query into the subquery with
`mergeConstraintsFrom()`, matching how Laravel builds its own
`withAggregate()` subqueries.

---------

Co-authored-by: Dan Harrin <git@danharrin.com>
This commit is contained in:
RenHai Wen
2026-08-25 11:24:44 +01:00
committed by GitHub
co-authored by Dan Harrin
parent dbefee2f66
commit ec1db7c43d
6 changed files with 189 additions and 30 deletions
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('fo_query_builder_items', function (Blueprint $table): void {
$table->id();
$table->foreignId('parent_id')->nullable();
$table->unsignedInteger('length');
});
}
public function down(): void
{
Schema::dropIfExists('fo_query_builder_items');
}
};
@@ -43,6 +43,10 @@ class UsersQueryBuilderTable extends Component implements HasActions, HasSchemas
->label('Posts Rating Aggregate (Dot Syntax)'),
NumberConstraint::make('teams.budget')
->label('Teams Budget Aggregate (Dot Syntax)'),
NumberConstraint::make('publishedPosts.rating')
->label('Published Posts Rating Aggregate'),
NumberConstraint::make('ownedTeams.budget')
->label('Owned Teams Budget Aggregate'),
RelationshipConstraint::make('posts')
->multiple()
->selectable(
@@ -0,0 +1,18 @@
<?php
namespace Filament\Tests\Fixtures\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class QueryBuilderItem extends Model
{
public $timestamps = false;
protected $guarded = [];
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id');
}
}
+15
View File
@@ -68,6 +68,16 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication
return $this->hasOne(Post::class, 'author_id')->where('is_published', true);
}
public function latestPost(): HasOne
{
return $this->hasOne(Post::class, 'author_id')->latestOfMany();
}
public function publishedPosts(): HasMany
{
return $this->hasMany(Post::class, 'author_id')->where('is_published', true);
}
protected static function newFactory()
{
return UserFactory::new();
@@ -93,6 +103,11 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication
return $this->belongsToMany(Team::class);
}
public function ownedTeams(): BelongsToMany
{
return $this->belongsToMany(Team::class)->wherePivot('role', 'owner');
}
public function profile(): HasOne
{
return $this->hasOne(Profile::class);
@@ -27,6 +27,7 @@ use Filament\Tests\Fixtures\Livewire\UsersQueryBuilderTable;
use Filament\Tests\Fixtures\Livewire\UsersQueryBuilderTableWithScopedPostsCount;
use Filament\Tests\Fixtures\Livewire\UsersQueryBuilderTableWithScopedPostsRatingAggregate;
use Filament\Tests\Fixtures\Models\Post;
use Filament\Tests\Fixtures\Models\QueryBuilderItem;
use Filament\Tests\Fixtures\Models\Team;
use Filament\Tests\Fixtures\Models\User;
use Filament\Tests\Tables\TestCase;
@@ -1726,6 +1727,119 @@ describe('relationship method constraints', function (): void {
->assertCanNotSeeTableRecords([$lowMinUser]);
});
it('can filter self-related records using number constraint aggregate with a connection table prefix', function (): void {
$databaseConnection = QueryBuilderItem::query()->getConnection();
$originalTablePrefix = $databaseConnection->getTablePrefix();
$databaseConnection->setTablePrefix('fo_');
try {
$matchingParent = QueryBuilderItem::query()->create(['length' => 1]);
$matchingParent->children()->create(['length' => 700]);
$nonMatchingParent = QueryBuilderItem::query()->create(['length' => 1]);
$nonMatchingParent->children()->create(['length' => 699]);
$constraint = NumberConstraint::make('children.length');
$operator = IsMinOperator::make()
->constraint($constraint)
->settings(['number' => 700, 'aggregate' => 'min']);
$filteredQuery = $operator->applyToBaseQuery(QueryBuilderItem::query());
expect($filteredQuery->pluck('id')->all())
->toBe([$matchingParent->id]);
} finally {
$databaseConnection->setTablePrefix($originalTablePrefix);
}
});
it('can filter records using number constraint aggregate on a `latestOfMany()` relationship', function (): void {
$matchingUser = User::factory()->create();
Post::factory()->create(['author_id' => $matchingUser->id, 'rating' => 3]);
Post::factory()->create(['author_id' => $matchingUser->id, 'rating' => 8]);
$nonMatchingUser = User::factory()->create();
Post::factory()->create(['author_id' => $nonMatchingUser->id, 'rating' => 8]);
Post::factory()->create(['author_id' => $nonMatchingUser->id, 'rating' => 3]);
$constraint = NumberConstraint::make('latestPost.rating');
$operator = IsMinOperator::make()
->constraint($constraint)
->settings(['number' => 7, 'aggregate' => 'min']);
$filteredQuery = $operator->applyToBaseQuery(User::query());
expect($filteredQuery->pluck('id')->all())
->toBe([$matchingUser->id]);
});
it('applies constraints defined in the relationship method to the aggregate subquery', function (): void {
$matchingUser = User::factory()->create();
Post::factory()->create([
'author_id' => $matchingUser->id,
'is_published' => true,
'rating' => 9,
]);
Post::factory()->create([
'author_id' => $matchingUser->id,
'is_published' => false,
'rating' => 1,
]);
$nonMatchingUser = User::factory()->create();
Post::factory()->create([
'author_id' => $nonMatchingUser->id,
'is_published' => true,
'rating' => 1,
]);
Post::factory()->create([
'author_id' => $nonMatchingUser->id,
'is_published' => false,
'rating' => 9,
]);
livewire(UsersQueryBuilderTable::class)
->assertCanSeeTableRecords([$matchingUser, $nonMatchingUser])
->tap(applyQueryBuilderFilter([
[
'type' => 'publishedPosts.rating',
'data' => [
'operator' => 'isMin',
'settings' => ['number' => 7, 'aggregate' => 'min'],
],
],
]))
->assertCanSeeTableRecords([$matchingUser])
->assertCanNotSeeTableRecords([$nonMatchingUser]);
});
it('applies `wherePivot()` constraints defined in the relationship method to the aggregate subquery', function (): void {
$matchingUser = User::factory()->create();
$matchingUser->teams()->attach(Team::factory()->create(['budget' => 5000])->id, ['role' => 'owner']);
$matchingUser->teams()->attach(Team::factory()->create(['budget' => 100])->id, ['role' => 'member']);
$nonMatchingUser = User::factory()->create();
$nonMatchingUser->teams()->attach(Team::factory()->create(['budget' => 100])->id, ['role' => 'owner']);
$nonMatchingUser->teams()->attach(Team::factory()->create(['budget' => 5000])->id, ['role' => 'member']);
livewire(UsersQueryBuilderTable::class)
->assertCanSeeTableRecords([$matchingUser, $nonMatchingUser])
->tap(applyQueryBuilderFilter([
[
'type' => 'ownedTeams.budget',
'data' => [
'operator' => 'isMin',
'settings' => ['number' => 1000, 'aggregate' => 'min'],
],
],
]))
->assertCanSeeTableRecords([$matchingUser])
->assertCanNotSeeTableRecords([$nonMatchingUser]);
});
it('can filter records using number constraint with max aggregate on relationship', function (): void {
// Create user with at least one very high rating
$highMaxUser = User::factory()->create();