From 34ede56ae47b75403061b0ad6ee9b979417e0651 Mon Sep 17 00:00:00 2001 From: kettasoft Date: Tue, 14 Apr 2026 04:34:26 +0200 Subject: [PATCH 1/2] test: add comprehensive tests for FilterableMethodConflictException - Add test for single method conflict detection - Add test for multiple core methods conflicts - Add test to verify non-conflicting methods work correctly - Add test for exception message formatting - Move conflict check outside attempt() block to ensure exception is thrown - Fix Invokable engine to check for conflicts before executing filter methods Closes: Exception handling for method name conflicts --- src/Engines/Invokable.php | 28 ++--- .../FilterableMethodConflictException.php | 16 +++ tests/Unit/Engines/InvokableEngineTest.php | 114 ++++++++++++++++++ 3 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 src/Exceptions/FilterableMethodConflictException.php diff --git a/src/Engines/Invokable.php b/src/Engines/Invokable.php index 4501bc73..0bccf85c 100644 --- a/src/Engines/Invokable.php +++ b/src/Engines/Invokable.php @@ -2,17 +2,17 @@ namespace Kettasoft\Filterable\Engines; -use Illuminate\Support\Str; -use Kettasoft\Filterable\Filterable; use Illuminate\Contracts\Database\Eloquent\Builder; -use Kettasoft\Filterable\Support\Payload; +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; use Kettasoft\Filterable\Engines\Foundation\PayloadFactory; use Kettasoft\Filterable\Engines\Foundation\Parsers\Dissector; -use Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeContext; -use Kettasoft\Filterable\Engines\Foundation\Attributes\AttributePipeline; -use Kettasoft\Filterable\Engines\Foundation\Attributes\AttributeRegistry; +use Kettasoft\Filterable\Exceptions\FilterableMethodConflictException; +use Kettasoft\Filterable\Filterable; +use Kettasoft\Filterable\Support\Payload; class Invokable extends Engine { @@ -43,20 +43,20 @@ public function execute(Builder $builder): Builder $this->context->setAllowedFields($this->context->getFilterAttributes()); foreach ($this->context->getFilterAttributes() as $filter) { - $this->attempt(function () use ($filter) { + $method = $this->getMethodName($filter); + + // Check for method name conflicts with Filterable core methods BEFORE attempt. + if (method_exists(Filterable::class, $method)) { + throw new FilterableMethodConflictException($method); + } + + $this->attempt(function () use ($filter, $method) { $dissector = Dissector::parse($this->context->getRequest()->get($filter), $this->defaultOperator()); $payload = new Payload($filter, $dissector->operator, $this->sanitizeValue($filter, $dissector->value), $dissector->value); $payload = (new PayloadFactory($this))->make($payload); - $method = $this->getMethodName($filter); - - // Check for method name conflicts with Filterable core methods. - if (method_exists(Filterable::class, $method)) { - throw new \RuntimeException(sprintf("Filter method [%s] conflicts with core Filterable method.", [$method])); - } - $this->applyFilterMethod($filter, $method, $payload); $this->commit($method, $payload); diff --git a/src/Exceptions/FilterableMethodConflictException.php b/src/Exceptions/FilterableMethodConflictException.php new file mode 100644 index 00000000..681a1529 --- /dev/null +++ b/src/Exceptions/FilterableMethodConflictException.php @@ -0,0 +1,16 @@ +assertEquals('active', $posts->first()->status); $this->assertGreaterThanOrEqual(100, $posts->first()->views); } + + /** + * @test + */ + public function it_throws_exception_when_filter_method_conflicts_with_core_filterable_methods() + { + $this->expectException(\Kettasoft\Filterable\Exceptions\FilterableMethodConflictException::class); + + Post::truncate(); + Post::factory()->create(['status' => 'active']); + + // Using 'apply' as filter name which will map to 'apply' method (conflict) + request()->merge([ + 'apply' => 'test' + ]); + + $filter = new class extends Filterable { + protected $filters = ['apply']; + + // This will cause a conflict because Filterable already has apply() method + }; + + // Should throw FilterableMethodConflictException + Post::filter($filter)->get(); + } + + /** + * @test + */ + public function it_throws_exception_for_multiple_core_method_conflicts() + { + $coreMethodsThatShouldConflict = [ + 'apply', + 'filter', + 'getData', + 'getModel', + 'getBuilder', + 'getEngine' + ]; + + foreach ($coreMethodsThatShouldConflict as $coreMethod) { + Post::truncate(); + Post::factory()->create(['status' => 'active']); + + request()->merge([ + $coreMethod => 'test_value' + ]); + + try { + $filter = new class($coreMethod) extends Filterable { + protected $filters = []; + + public function __construct($method) + { + $this->filters = [$method]; + parent::__construct(); + } + }; + + Post::filter($filter)->get(); + + $this->fail("Expected FilterableMethodConflictException for method: {$coreMethod}"); + } catch (\Kettasoft\Filterable\Exceptions\FilterableMethodConflictException $e) { + $this->assertStringContainsString($coreMethod, $e->getMessage()); + $this->assertStringContainsString('conflicts with core Filterable method', $e->getMessage()); + } + } + } + + /** + * @test + */ + public function it_allows_filter_methods_that_do_not_conflict() + { + Post::query()->delete(); // Use delete instead of truncate to respect RefreshDatabase + + Post::factory()->create(['status' => 'active', 'title' => 'Active Post']); + Post::factory()->create(['status' => 'pending', 'title' => 'Pending Post']); + + request()->merge([ + 'custom_status' => 'active' + ]); + + // This should NOT throw an exception because 'customStatus' is not a core method + $filter = new class extends Filterable { + protected $filters = ['custom_status']; + + public function customStatus(Payload $payload) + { + $this->builder->where('status', $payload->value); + } + }; + + $posts = Post::filter($filter)->get(); + + $this->assertCount(1, $posts); + $this->assertEquals('active', $posts->first()->status); + $this->assertEquals('Active Post', $posts->first()->title); + } + + /** + * @test + */ + public function it_properly_formats_exception_message() + { + try { + throw new \Kettasoft\Filterable\Exceptions\FilterableMethodConflictException('testMethod'); + } catch (\Kettasoft\Filterable\Exceptions\FilterableMethodConflictException $e) { + $this->assertEquals( + 'Filter method [testMethod] conflicts with core Filterable method.', + $e->getMessage() + ); + } + } } From 88a654886b3747143ed346a9506f1ba55cb53c6c Mon Sep 17 00:00:00 2001 From: kettasoft Date: Mon, 7 Sep 2026 07:14:10 +0300 Subject: [PATCH 2/2] docs: explain reserved invokable method names --- docs/engines/invokable/index.md | 7 +++++ src/Engines/Invokable.php | 2 +- tests/Unit/Engines/InvokableEngineTest.php | 32 +++++++--------------- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/docs/engines/invokable/index.md b/docs/engines/invokable/index.md index 8c1c3060..dfb72d89 100644 --- a/docs/engines/invokable/index.md +++ b/docs/engines/invokable/index.md @@ -147,6 +147,13 @@ If `$mentors` is empty or not defined, the engine automatically matches request 'created_at' → calls createdAt() ``` +::: warning Reserved Method Names +Filter methods must not resolve to a method already defined by the base +`Filterable` class, such as `apply`, `filter`, or `getBuilder`. The engine +throws a `FilterableMethodConflictException` when a conflict is detected. Use +`$mentors` to map the request key to a unique filter method name. +::: + --- ## Attribute Pipeline diff --git a/src/Engines/Invokable.php b/src/Engines/Invokable.php index 0bccf85c..380facb1 100644 --- a/src/Engines/Invokable.php +++ b/src/Engines/Invokable.php @@ -45,7 +45,7 @@ public function execute(Builder $builder): Builder foreach ($this->context->getFilterAttributes() as $filter) { $method = $this->getMethodName($filter); - // Check for method name conflicts with Filterable core methods BEFORE attempt. + // Conflicts are configuration errors and must not be swallowed in non-strict mode. if (method_exists(Filterable::class, $method)) { throw new FilterableMethodConflictException($method); } diff --git a/tests/Unit/Engines/InvokableEngineTest.php b/tests/Unit/Engines/InvokableEngineTest.php index d0de1fa6..2d253cc7 100644 --- a/tests/Unit/Engines/InvokableEngineTest.php +++ b/tests/Unit/Engines/InvokableEngineTest.php @@ -3,6 +3,7 @@ namespace Kettasoft\Filterable\Tests\Unit\Engines; use Kettasoft\Filterable\Filterable; +use Kettasoft\Filterable\Exceptions\FilterableMethodConflictException; use Kettasoft\Filterable\Tests\TestCase; use Kettasoft\Filterable\Support\Payload; use Kettasoft\Filterable\Tests\Models\Post; @@ -988,23 +989,16 @@ public function title(Payload $payload) */ public function it_throws_exception_when_filter_method_conflicts_with_core_filterable_methods() { - $this->expectException(\Kettasoft\Filterable\Exceptions\FilterableMethodConflictException::class); + $this->expectException(FilterableMethodConflictException::class); - Post::truncate(); - Post::factory()->create(['status' => 'active']); - - // Using 'apply' as filter name which will map to 'apply' method (conflict) request()->merge([ 'apply' => 'test' ]); $filter = new class extends Filterable { protected $filters = ['apply']; - - // This will cause a conflict because Filterable already has apply() method }; - // Should throw FilterableMethodConflictException Post::filter($filter)->get(); } @@ -1023,9 +1017,6 @@ public function it_throws_exception_for_multiple_core_method_conflicts() ]; foreach ($coreMethodsThatShouldConflict as $coreMethod) { - Post::truncate(); - Post::factory()->create(['status' => 'active']); - request()->merge([ $coreMethod => 'test_value' ]); @@ -1044,7 +1035,7 @@ public function __construct($method) Post::filter($filter)->get(); $this->fail("Expected FilterableMethodConflictException for method: {$coreMethod}"); - } catch (\Kettasoft\Filterable\Exceptions\FilterableMethodConflictException $e) { + } catch (FilterableMethodConflictException $e) { $this->assertStringContainsString($coreMethod, $e->getMessage()); $this->assertStringContainsString('conflicts with core Filterable method', $e->getMessage()); } @@ -1056,7 +1047,7 @@ public function __construct($method) */ public function it_allows_filter_methods_that_do_not_conflict() { - Post::query()->delete(); // Use delete instead of truncate to respect RefreshDatabase + Post::query()->delete(); Post::factory()->create(['status' => 'active', 'title' => 'Active Post']); Post::factory()->create(['status' => 'pending', 'title' => 'Pending Post']); @@ -1065,7 +1056,6 @@ public function it_allows_filter_methods_that_do_not_conflict() 'custom_status' => 'active' ]); - // This should NOT throw an exception because 'customStatus' is not a core method $filter = new class extends Filterable { protected $filters = ['custom_status']; @@ -1087,13 +1077,11 @@ public function customStatus(Payload $payload) */ public function it_properly_formats_exception_message() { - try { - throw new \Kettasoft\Filterable\Exceptions\FilterableMethodConflictException('testMethod'); - } catch (\Kettasoft\Filterable\Exceptions\FilterableMethodConflictException $e) { - $this->assertEquals( - 'Filter method [testMethod] conflicts with core Filterable method.', - $e->getMessage() - ); - } + $exception = new FilterableMethodConflictException('testMethod'); + + $this->assertSame( + 'Filter method [testMethod] conflicts with core Filterable method.', + $exception->getMessage() + ); } }