Skip to content

Fix "When using QueryBuilder with Actions memory exhausts almost instantly" #15884

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 13 commits into from
3 changes: 3 additions & 0 deletions packages/forms/src/Concerns/InteractsWithForms.php
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,9 @@ protected function cacheForm(string $name, Form | Closure | null $form): ?Form
*/
protected function cacheForms(): array
{
if ($this->isCachingForms()) {
return [];
}
$this->isCachingForms = true;

$this->cachedForms = collect($this->getForms())
Expand Down
2 changes: 2 additions & 0 deletions packages/tables/.stubs.php
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ public function assertCanSeeTableRecords(array | Collection $records, bool $inOr

public function assertCanNotSeeTableRecords(array | Collection $records): static {}

public function queryBuilderTable(string $filter, string $operator, $data = null): static {}

public function assertCountTableRecords(int $count): static {}

public function loadTable(): static {}
Expand Down
2 changes: 2 additions & 0 deletions packages/tables/src/TablesServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Filament\Tables\Testing\TestsBulkActions;
use Filament\Tables\Testing\TestsColumns;
use Filament\Tables\Testing\TestsFilters;
use Filament\Tables\Testing\TestsQueryBuilders;
use Filament\Tables\Testing\TestsRecords;
use Filament\Tables\Testing\TestsSummaries;
use Illuminate\Filesystem\Filesystem;
Expand Down Expand Up @@ -44,6 +45,7 @@ public function packageBooted(): void
Testable::mixin(new TestsBulkActions);
Testable::mixin(new TestsColumns);
Testable::mixin(new TestsFilters);
Testable::mixin(new TestsQueryBuilders);
Testable::mixin(new TestsRecords);
Testable::mixin(new TestsSummaries);
}
Expand Down
79 changes: 79 additions & 0 deletions packages/tables/src/Testing/TestsQueryBuilders.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

namespace Filament\Tables\Testing;

use Closure;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\QueryBuilder\Constraints\Constraint;
use Filament\Tables\Filters\QueryBuilder\Constraints\DateConstraint;
use Filament\Tables\Filters\QueryBuilder\Constraints\NumberConstraint;
use Filament\Tables\Filters\QueryBuilder\Constraints\SelectConstraint;
use Filament\Tables\Filters\QueryBuilder\Constraints\TextConstraint;
use Illuminate\Testing\Assert;
use Livewire\Features\SupportTesting\Testable;

/**
* @method HasTable instance()
*
* @mixin Testable
*/
class TestsQueryBuilders
{
public function queryBuilderTable(): Closure
{
return function (string $filter, string $operator, $data = null): static {
/** @phpstan-ignore-next-line */
$this->assertTableConstraintExists($filter);

/** @phpstan-ignore-next-line */
$constraint = $this->instance()->getTable()->getFilter('queryBuilder')->getConstraint($filter);

$queryBuilder = [
'type' => $filter,
'data' => [
'operator' => $operator,
'settings' => [],
],
];
if ($constraint instanceof TextConstraint) {
if ($data) {
$queryBuilder['data']['settings'] = ['text' => $data];
}
} elseif ($constraint instanceof NumberConstraint) {
if ($data) {
$queryBuilder['data']['settings'] = ['number' => $data];
}
} elseif ($constraint instanceof DateConstraint) {
if ($data) {
$queryBuilder['data']['settings'] = ['date' => $data];
}
} elseif ($constraint instanceof SelectConstraint) {
if ($data) {
$queryBuilder['data']['settings'] = ['values' => $data];
}
}

$this->set("tableFilters.queryBuilder.rules.{$constraint->getName()}", $queryBuilder);

return $this;
};
}

public function assertTableConstraintExists(): Closure
{
return function (string $name): static {
/** @phpstan-ignore-next-line */
$filter = $this->instance()->getTable()->getFilter('queryBuilder')->getConstraint($name);

$livewireClass = $this->instance()::class;

Assert::assertInstanceOf(
Constraint::class,
$filter,
"Failed asserting that a query builder with name [{$name}] exists on the [{$livewireClass}] component.",
);

return $this;
};
}
}
83 changes: 83 additions & 0 deletions tests/src/Tables/Filters/QueryBuilderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

use Filament\Tables\Actions\DeleteAction;
use Filament\Tests\Models\Post;
use Filament\Tests\Tables\Fixtures\PostsQueryBuilderTable;
use Filament\Tests\Tables\TestCase;

use function Filament\Tests\livewire;

uses(TestCase::class);

it('can filter text constraint by `contains`', function () {
$posts = Post::factory(10)->create();
$content = $posts->random()->content;
$start = fake()->numberBetween(0, strlen($content));
$filter = substr($content, $start, fake()->numberBetween($start, strlen($content)));

livewire(PostsQueryBuilderTable::class)
->assertCanSeeTableRecords($posts)
->queryBuilderTable('content', 'contains', $filter)
->assertCanSeeTableRecords(Post::where('content', 'like', '%' . $filter . '%')->get())
->assertCanNotSeeTableRecords(Post::where('content', 'not like', '%' . $filter . '%')->get());
});

it('can filter text constraint by `startsWith`', function () {
$posts = Post::factory(10)->create();
$content = $posts->random()->content;
$filter = substr($content, 0, fake()->numberBetween(1, strlen($content)));

livewire(PostsQueryBuilderTable::class)
->assertCanSeeTableRecords($posts)
->queryBuilderTable('content', 'startsWith', $filter)
->assertCanSeeTableRecords(Post::where('content', 'like', $filter . '%')->get())
->assertCanNotSeeTableRecords(Post::where('content', 'not like', $filter . '%')->get());
});

it('can filter text constraint by `endsWith`', function () {
$posts = Post::factory(10)->create();
$content = $posts->random()->content;
$filter = substr($content, -(fake()->numberBetween(1, strlen($content))));

livewire(PostsQueryBuilderTable::class)
->assertCanSeeTableRecords($posts)
->queryBuilderTable('content', 'endsWith', $filter)
->assertCanSeeTableRecords(Post::where('content', 'like', '%' . $filter)->get())
->assertCanNotSeeTableRecords(Post::where('content', 'not like', '%' . $filter)->get());
});

it('can filter text constraint by `equals`', function () {
$posts = Post::factory(10)->create();
$content = $posts->random()->content;

livewire(PostsQueryBuilderTable::class)
->assertCanSeeTableRecords($posts)
->queryBuilderTable('content', 'equals', $content)
->assertCanSeeTableRecords(Post::where('content', $content)->get())
->assertCanNotSeeTableRecords(Post::where('content', '<>', $content)->get());
});

it('can filter text constraint by `isFilled`', function () {
Post::factory()->create();
Post::factory()->create(['content' => null]);
Post::factory()->create(['content' => '']);

livewire(PostsQueryBuilderTable::class)
->assertCanSeeTableRecords(Post::all())
->queryBuilderTable('content', 'isFilled')
->assertCanSeeTableRecords(Post::where('content', '<>', null)->where('content', '<>', '')->get())
->assertCanNotSeeTableRecords(Post::where('content', null)->orWhere('content', '')->get());
});

it('can modal actions during text constraint', function () {
$posts = Post::factory()->count(10)->create();
$content = $posts->first()->content;
$post = Post::where('content', $content);

livewire(PostsQueryBuilderTable::class)
->assertCanSeeTableRecords($posts)
->queryBuilderTable('content', 'contains', $content)
->assertCanSeeTableRecords($post->get())
->callTableAction(DeleteAction::class, $post->first())
->assertCanNotSeeTableRecords($post->get());
});
53 changes: 53 additions & 0 deletions tests/src/Tables/Fixtures/PostsQueryBuilderTable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

namespace Filament\Tests\Tables\Fixtures;

use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Tables;
use Filament\Tables\Filters\QueryBuilder;
use Filament\Tables\Filters\QueryBuilder\Constraints\TextConstraint;
use Filament\Tables\Table;
use Filament\Tests\Models\Post;
use Illuminate\Contracts\View\View;
use Livewire\Component;

class PostsQueryBuilderTable extends Component implements HasForms, Tables\Contracts\HasTable
{
use InteractsWithForms;
use Tables\Concerns\InteractsWithTable;

public static function table(Table $table): Table
{
return $table
->query(Post::query())
->columns([
Tables\Columns\TextColumn::make('title')
->sortable()
->searchable(),
Tables\Columns\TextColumn::make('author.name')
->sortable()
->searchable(),
])
->filters([
QueryBuilder::make()
->constraints([
TextConstraint::make('content')
->nullable(),
]),
])
->actions([
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\DeleteBulkAction::make(),
]);
}

public function render(): View
{
return view('tables.fixtures.table');
}
}