mirror of
https://github.com/filamentphp/filament.git
synced 2026-09-19 02:04:55 +08:00
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:
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user