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 4501bc73..380facb1 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); + + // Conflicts are configuration errors and must not be swallowed in non-strict mode. + 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(FilterableMethodConflictException::class); + + request()->merge([ + 'apply' => 'test' + ]); + + $filter = new class extends Filterable { + protected $filters = ['apply']; + }; + + 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) { + 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 (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(); + + Post::factory()->create(['status' => 'active', 'title' => 'Active Post']); + Post::factory()->create(['status' => 'pending', 'title' => 'Pending Post']); + + request()->merge([ + 'custom_status' => 'active' + ]); + + $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() + { + $exception = new FilterableMethodConflictException('testMethod'); + + $this->assertSame( + 'Filter method [testMethod] conflicts with core Filterable method.', + $exception->getMessage() + ); + } }