From 3624e5c491fbe57c827f3e0b41592f9250bba46d Mon Sep 17 00:00:00 2001 From: kettasoft Date: Mon, 7 Sep 2026 09:45:46 +0300 Subject: [PATCH 1/3] feat: auto-apply filters on builder calls --- src/Engines/Invokable.php | 12 +- src/Facades/Filterable.php | 3 +- src/Filterable.php | 24 +- .../Unit/Filterable/AutoApplyFiltersTest.php | 227 ++++++++++++++++++ .../Filterable/FilterableForMethodTest.php | 7 +- 5 files changed, 259 insertions(+), 14 deletions(-) create mode 100644 tests/Unit/Filterable/AutoApplyFiltersTest.php diff --git a/src/Engines/Invokable.php b/src/Engines/Invokable.php index b8c8f97..5b72f40 100644 --- a/src/Engines/Invokable.php +++ b/src/Engines/Invokable.php @@ -3,8 +3,8 @@ namespace Kettasoft\Filterable\Engines; use Illuminate\Contracts\Database\Eloquent\Builder; +use ReflectionMethod; use Illuminate\Support\Str; -use Illuminate\Support\Traits\ForwardsCalls; use Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext; use Kettasoft\Filterable\Engines\Foundation\Attributes\AttributePipeline; use Kettasoft\Filterable\Engines\Foundation\Engine; @@ -16,8 +16,6 @@ class Invokable extends Engine { - use ForwardsCalls; - /** * Engine name. * @var string @@ -85,7 +83,13 @@ protected function applyFilterMethod(string $key, string $method, Payload $paylo $process = $pipeline->process($this->context, $method); $process->then(function () use ($method, $payload) { - $this->forwardCallTo($this->context, $method, [$payload]); + $result = (new ReflectionMethod($this->context, $method)) + ->invoke($this->context, $payload); + + if ($result instanceof Builder) { + $this->builder = $result; + $this->context->setBuilder($result); + } }) ->catch(function ($e) { throw $e; diff --git a/src/Facades/Filterable.php b/src/Facades/Filterable.php index f9b2c8a..6b54303 100644 --- a/src/Facades/Filterable.php +++ b/src/Facades/Filterable.php @@ -44,6 +44,7 @@ * * Engine Configuration: * @method static \Kettasoft\Filterable\Filterable useEngine(\Kettasoft\Filterable\Engines\Foundation\Engine|string $engine) Override the default engine for this filterable instance. + * @method static \Kettasoft\Filterable\Filterable using(\Kettasoft\Filterable\Engines\Foundation\Engine|string $engine) Alias for useEngine. * @method static \Kettasoft\Filterable\Engines\Foundation\Engine getEngine() Get current engine. * * Request & Data Management: @@ -53,7 +54,7 @@ * @method static mixed getData() Get current data. * @method static array getFilterAttributes() Fetch all relevant filters from the filter API class. * @method static \Kettasoft\Filterable\Filterable setSource(string $source) Set request source. - * @method static mixed get(string $key) Retrieve an input item from the request. + * @method static mixed getFromRequest(string $key) Retrieve an input item from the request. * * Sanitization: * @method static \Kettasoft\Filterable\Filterable setSanitizers(array $sanitizers, bool $override = true) Set a new sanitizers classes. diff --git a/src/Filterable.php b/src/Filterable.php index de7b28a..fc11153 100644 --- a/src/Filterable.php +++ b/src/Filterable.php @@ -727,6 +727,17 @@ public function useEngine(Engine|string $engine): static return $this; } + /** + * Alias for {@see useEngine()}. + * + * @param Engine|class-string|string $engine + * @return static + */ + public function using(Engine|string $engine): static + { + return $this->useEngine($engine); + } + /** * Get current engine. * @return Engine @@ -1052,11 +1063,12 @@ public function toSql(Builder|null $builder = null, $withBindings = false): stri } /** - * Retrieve an input item from the request. - * @param string $key - * @return mixed + * Retrieve an input item from the configured request source. + * @param string $key The key to retrieve from the request + * @return mixed The value from the request source + * @throws RequestSourceIsNotSupportedException */ - public function get(string $key) + public function getFromRequest(string $key): mixed { if (!in_array($source = $this->requestSource ?? config('filterable.request_source', 'query'), ['query', 'input', 'json'])) { throw new RequestSourceIsNotSupportedException($source); @@ -1112,7 +1124,7 @@ public function __get($property): mixed return $this->{$property}; } - return $this->get($property); + return $this->getFromRequest($property); } /** @@ -1123,6 +1135,6 @@ public function __get($property): mixed */ public function __call($method, $parameters) { - return $this->handleFluentReturn($method, $parameters); + return $this->forwardCallTo($this->apply(), $method, $parameters); } } diff --git a/tests/Unit/Filterable/AutoApplyFiltersTest.php b/tests/Unit/Filterable/AutoApplyFiltersTest.php new file mode 100644 index 0000000..5d98484 --- /dev/null +++ b/tests/Unit/Filterable/AutoApplyFiltersTest.php @@ -0,0 +1,227 @@ +create([ + 'title' => 'Active post', + 'status' => 'active', + 'views' => 10, + ]); + Post::factory(2)->create([ + 'title' => 'Pending post', + 'status' => 'pending', + 'views' => 20, + ]); + Post::factory(2)->create([ + 'title' => 'Stopped post', + 'status' => 'stopped', + 'views' => 30, + ]); + } + + public function test_get_auto_applies_filters() + { + $posts = $this->rulesetFor('active')->get(); + + $this->assertCount(2, $posts); + $this->assertTrue($posts->every(fn(Post $post) => $post->status === 'active')); + } + + public function test_get_accepts_a_column_list() + { + $posts = $this->rulesetFor('active')->get(['id', 'status']); + + $this->assertCount(2, $posts); + $this->assertFalse($posts->first()->offsetExists('title')); + } + + public function test_get_from_request_reads_from_the_configured_source() + { + $filterable = $this->rulesetFor('active'); + + $this->assertSame('active', $filterable->getFromRequest('status')); + } + + public function test_dynamic_builder_calls_apply_before_forwarding() + { + $filterable = $this->rulesetFor('active'); + + $result = $filterable + ->where('views', '>=', 10) + ->orderBy('id'); + + $this->assertInstanceOf(Invoker::class, $result); + $this->assertNotSame([], $filterable->applied()); + $this->assertCount(2, $result->get()); + } + + public function test_retrieval_and_aggregate_terminals_auto_apply() + { + $this->assertSame('active', $this->rulesetFor('active')->first()->status); + $this->assertSame(2, $this->rulesetFor('active')->count()); + $this->assertSame(20, (int) $this->rulesetFor('active')->sum('views')); + $this->assertSame(10, (int) $this->rulesetFor('active')->avg('views')); + $this->assertSame(10, (int) $this->rulesetFor('active')->min('views')); + $this->assertSame(10, (int) $this->rulesetFor('active')->max('views')); + } + + public function test_extended_retrieval_terminals_respect_filters() + { + $active = Post::where('status', 'active')->firstOrFail(); + $pending = Post::where('status', 'pending')->firstOrFail(); + + $this->assertNull( + $this->rulesetFor('active')->firstWhere('id', $pending->id) + ); + $this->assertSame( + [$active->id], + $this->rulesetFor('active')->findMany([$active->id, $pending->id])->modelKeys() + ); + $this->assertSame( + 'active', + $this->rulesetFor('active')->valueOrFail('status') + ); + } + + public function test_boolean_and_scalar_terminals_auto_apply() + { + $this->assertTrue($this->rulesetFor('active')->exists()); + $this->assertTrue($this->rulesetFor('missing')->doesntExist()); + $this->assertSame('active', $this->rulesetFor('active')->value('status')); + $this->assertSame(['active', 'active'], $this->rulesetFor('active')->pluck('status')->all()); + } + + public function test_pagination_auto_applies_filters() + { + $paginator = $this->rulesetFor('active')->paginate(1); + $simplePaginator = $this->rulesetFor('active')->simplePaginate(5); + + $this->assertSame(2, $paginator->total()); + $this->assertCount(2, $simplePaginator->items()); + } + + public function test_streaming_terminals_auto_apply() + { + $seen = []; + + $this->rulesetFor('active')->chunk(1, function ($posts) use (&$seen) { + array_push($seen, ...$posts->pluck('status')->all()); + }); + + $this->assertSame(['active', 'active'], $seen); + $this->assertSame(2, $this->rulesetFor('active')->lazy()->count()); + } + + public function test_update_and_delete_are_scoped_by_auto_applied_filters() + { + $updated = $this->rulesetFor('active')->update(['title' => 'Updated']); + + $this->assertSame(2, $updated); + $this->assertSame(2, Post::where('title', 'Updated')->count()); + + $deleted = $this->rulesetFor('pending')->delete(); + + $this->assertSame(2, $deleted); + $this->assertSame(4, Post::count()); + } + + public function test_expression_engine_auto_applies_on_terminal_calls() + { + $request = Request::create('/posts', 'GET', [ + 'filter' => ['views' => ['eq' => 20]], + ]); + + $count = Filterable::for(Post::class, $request) + ->using('expression') + ->setAllowedFields(['views']) + ->count(); + + $this->assertSame(2, $count); + } + + public function test_tree_engine_auto_applies_on_terminal_calls() + { + $request = Request::create('/posts', 'POST'); + $request->setJson(new InputBag([ + 'filter' => [ + 'and' => [[ + 'field' => 'status', + 'operator' => 'eq', + 'value' => 'stopped', + ]], + ], + ])); + + $posts = Filterable::for(Post::class, $request) + ->using('tree') + ->setAllowedFields(['status']) + ->get(); + + $this->assertCount(2, $posts); + $this->assertTrue($posts->every(fn(Post $post) => $post->status === 'stopped')); + } + + public function test_invokable_engine_can_execute_a_protected_filter_method() + { + $request = Request::create('/posts', 'GET', ['status' => 'pending']); + $filterClass = new class extends Filterable { + protected $filters = ['status']; + + protected function status(Payload $payload): Builder + { + $builder = clone $this->getBuilder(); + + return $builder->where($payload->field, $payload->value); + } + }; + + $posts = $filterClass::for(Post::class, $request) + ->using('invokable') + ->get(); + + $this->assertCount(2, $posts); + $this->assertTrue($posts->every(fn(Post $post) => $post->status === 'pending')); + } + + public function test_using_switches_the_runtime_engine_fluently() + { + $filterable = Filterable::for(Post::class); + + $result = $filterable->using('ruleset'); + + $this->assertSame($filterable, $result); + $this->assertSame('ruleset', $filterable->getEngine()->getEngineName()); + } + + public function test_unknown_builder_methods_fail_without_recursion() + { + $this->expectException(BadMethodCallException::class); + + $this->rulesetFor('active')->methodThatDoesNotExist(); + } + + private function rulesetFor(string $status): Filterable + { + $request = Request::create('/posts', 'GET', ['status' => $status]); + + return Filterable::for(Post::class, $request) + ->using('ruleset') + ->setAllowedFields(['status']); + } +} diff --git a/tests/Unit/Filterable/FilterableForMethodTest.php b/tests/Unit/Filterable/FilterableForMethodTest.php index 3aa4e67..69b7dd9 100644 --- a/tests/Unit/Filterable/FilterableForMethodTest.php +++ b/tests/Unit/Filterable/FilterableForMethodTest.php @@ -5,6 +5,7 @@ use Illuminate\Http\Request; use PHPUnit\Framework\Attributes\DataProvider; use Kettasoft\Filterable\Filterable; +use Kettasoft\Filterable\Foundation\Invoker; use Kettasoft\Filterable\Support\Payload; use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Tests\Models\Post; @@ -78,13 +79,13 @@ public function test_it_is_available_through_the_facade() $this->assertInstanceOf(Post::class, $filterable->getBuilder()->getModel()); } - public function test_builder_methods_remain_fluent_on_filterable() + public function test_builder_methods_are_forwarded_through_an_invoker() { $filterable = Filterable::for(Post::class); $result = $filterable->where('status', 'published'); - $this->assertSame($filterable, $result); + $this->assertInstanceOf(Invoker::class, $result); $this->assertStringContainsString('where "status" = ?', $filterable->getBuilder()->toSql()); } @@ -105,7 +106,7 @@ public function test_it_applies_invokable_engine_filters_for_a_model_class() $filterClass = new class extends Filterable { protected $filters = ['status']; - public function status(Payload $payload): Builder + protected function status(Payload $payload): Builder { return $this->getBuilder()->where($payload->field, $payload->value); } From 1500d271dd913ed0c60e31720382eb762eb2f7ad Mon Sep 17 00:00:00 2001 From: kettasoft Date: Mon, 7 Sep 2026 09:45:52 +0300 Subject: [PATCH 2/3] test: isolate profiler event listeners --- tests/Feature/Profiler/FilterProfilerTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Feature/Profiler/FilterProfilerTest.php b/tests/Feature/Profiler/FilterProfilerTest.php index 264d07b..73f721e 100644 --- a/tests/Feature/Profiler/FilterProfilerTest.php +++ b/tests/Feature/Profiler/FilterProfilerTest.php @@ -12,6 +12,13 @@ class FilterProfilerTest extends TestCase { + public function tearDown(): void + { + Profiler::dispatcher()->flush(); + + parent::tearDown(); + } + public function test_it_triggers_slow_query_event() { $triggered = false; From 549f0306a2b343abdd9e0dd13a03b47c8d02920a Mon Sep 17 00:00:00 2001 From: kettasoft Date: Mon, 7 Sep 2026 09:46:00 +0300 Subject: [PATCH 3/3] docs: document automatic builder forwarding --- docs/api/facade.md | 9 ++++++++- docs/api/filterable.md | 24 +++++++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/docs/api/facade.md b/docs/api/facade.md index 427ca3e..5f4fb8f 100644 --- a/docs/api/facade.md +++ b/docs/api/facade.md @@ -38,6 +38,9 @@ use Filterable; // Create a new filterable instance $filterable = Filterable::create(); +// Create an initialized filterable query +$filterable = Filterable::for(User::class); + // Apply filters to a query builder $results = Filterable::create() ->setModel(User::class) @@ -136,6 +139,9 @@ Filterable::create()->withoutSanitizers(); // Use specific engine Filterable::create()->useEngine('expression'); // or 'tree', 'ruleset', etc. +// Fluent alias when building a query +Filterable::for(User::class)->using('ruleset'); + // Enable header-driven mode Filterable::create()->withHeaderDrivenMode([ 'header_name' => 'X-Filter-Engine', @@ -164,6 +170,7 @@ The facade provides access to all public methods of the Filterable class, organi ### Static Factory Methods - `create()` - Create new Filterable instance +- `for()` - Create an initialized instance for a model or builder - `withRequest()` - Create new Filterable instance with custom Request ### Core Filtering Methods @@ -197,7 +204,7 @@ The facade provides access to all public methods of the Filterable class, organi - `setData()` - Set manual data injection - `getData()` - Get current data - `setSource()` - Set request source -- `get()` - Retrieve input item from request +- `getFromRequest()` - Retrieve an input item from the configured request source And many more methods for advanced configuration and customization. diff --git a/docs/api/filterable.md b/docs/api/filterable.md index 8c9d9ff..95b5927 100644 --- a/docs/api/filterable.md +++ b/docs/api/filterable.md @@ -111,6 +111,20 @@ $users = $invoker->get(); Alias of `apply()`. +#### Automatic Builder execution + +Instances created with `for()` apply their filters automatically before forwarding any dynamic Builder method. The returned `Invoker` keeps subsequent Builder calls fluent, so Laravel methods and macros work without maintaining a package-side method list. + +```php +$posts = Filterable::for(Post::class, $request) + ->using('ruleset') + ->setAllowedFields(['status']) + ->where('published', true) + ->paginate(15); +``` + +This applies consistently to query construction, retrieval, aggregates, pagination, streaming, mutations, and custom Builder macros. + #### `shouldReturnQueryBuilder(): static` Force `apply()` to return the Eloquent Builder instead of the Invoker wrapper. @@ -191,9 +205,9 @@ Return current working data. If a `filterKey` is set (via traits), returns that Set the request source: `query`, `input`, or `json`. Throws when unsupported. -#### `get(string $key): mixed` +#### `getFromRequest(string $key): mixed` -Retrieve an input value from the configured source. +Retrieve an input value from the configured request source. The `get()` method is reserved for Eloquent Builder execution and therefore triggers automatic filter application. --- @@ -203,6 +217,10 @@ Retrieve an input value from the configured source. Override the engine for this instance. Accepts an engine instance or a supported engine key. +#### `using(Engine|string $engine): static` + +Fluent alias for `useEngine()`, useful when building a query with `for()`. + #### `getEngine(): Engine` Return the current engine instance. @@ -414,7 +432,7 @@ Set and return class aliases as a collection. #### `__get($property): mixed` -Proxy missing properties to the request source via `get($property)` when not present on the instance. +Proxy missing properties to the request source via `getFromRequest($property)` when not present on the instance. ---