diff --git a/docs/api/filterable.md b/docs/api/filterable.md index 17ed86de..8c9d9ff4 100644 --- a/docs/api/filterable.md +++ b/docs/api/filterable.md @@ -70,6 +70,22 @@ Create a new Filterable instance using the provided request or the current conta $filterable = Filterable::create(); ``` +#### `static for(Model|Builder|string $source, Request|null $request = null): static` + +Create a Filterable instance with its model and query builder ready for use. Existing constraints are preserved when a builder is supplied. + +```php +$filterable = Filterable::for(User::class) + ->setAllowedFields(['name', 'email']); + +$filterable = Filterable::for( + User::query()->where('active', true), + $customRequest +); +``` + +Invalid class strings are rejected with an `InvalidArgumentException`. + #### `static withRequest(Request $request): static` Create a new instance bound to a specific request. diff --git a/src/Facades/Filterable.php b/src/Facades/Filterable.php index d92d7507..f9b2c8a0 100644 --- a/src/Facades/Filterable.php +++ b/src/Facades/Filterable.php @@ -9,6 +9,7 @@ * * Static Factory Methods: * @method static \Kettasoft\Filterable\Filterable create(\Illuminate\Http\Request|null $request = null) Create new Filterable instance. + * @method static \Kettasoft\Filterable\Filterable for(\Illuminate\Database\Eloquent\Model|\Illuminate\Contracts\Database\Eloquent\Builder|string $source, \Illuminate\Http\Request|null $request = null) Create a Filterable instance for a model or builder. * @method static \Kettasoft\Filterable\Filterable withRequest(\Illuminate\Http\Request $request) Create new Filterable instance with custom Request. * * Static Event Methods: diff --git a/src/Filterable.php b/src/Filterable.php index deba6e25..de7b28a4 100644 --- a/src/Filterable.php +++ b/src/Filterable.php @@ -7,6 +7,7 @@ use Illuminate\Support\Facades\App; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Traits\Macroable; +use Illuminate\Support\Traits\ForwardsCalls; use Kettasoft\Filterable\Foundation\Invoker; use Kettasoft\Filterable\Contracts\Commitable; use Kettasoft\Filterable\Foundation\Resources; @@ -45,6 +46,7 @@ class Filterable implements FilterableContext, Authorizable, Validatable, Commit Traits\InteractsWithProvidedData, Traits\HasFilterableCache, HandleFluentReturn, + ForwardsCalls, Macroable; /** @@ -637,6 +639,34 @@ public static function create(Request|null $request = null): static return new static($request ?? App::make(Request::class)); } + /** + * Create a new Filterable instance for a model or Eloquent builder. + * + * @param Model|Builder|class-string $source + * @param Request|null $request + * @return static + */ + public static function for(Model|Builder|string $source, Request|null $request = null): static + { + if (is_string($source) && !is_a($source, Model::class, true)) { + throw new \InvalidArgumentException("Model [{$source}] must extend " . Model::class . '.'); + } + + $instance = static::create($request); + + if ($source instanceof Builder) { + return $instance + ->setModel($source->getModel()) + ->setBuilder($source); + } + + $instance->setModel($source); + + return $instance->setBuilder( + $source instanceof Model ? $source->newQuery() : $source::query() + ); + } + /** * Apply a callback conditionally and return a new modified instance. * @param bool $condition diff --git a/src/Foundation/Invoker.php b/src/Foundation/Invoker.php index 04f81528..d4c83b39 100644 --- a/src/Foundation/Invoker.php +++ b/src/Foundation/Invoker.php @@ -193,6 +193,19 @@ public function getBuilder(): EloquentBuilder|Builder|QueryBuilderInterface return $this->builder; } + /** + * Replace the underlying query builder instance. + * + * @param QueryBuilder|EloquentBuilder|QueryBuilderInterface $builder + * @return static + */ + public function setBuilder(QueryBuilder|EloquentBuilder|QueryBuilderInterface $builder): static + { + $this->builder = $builder; + + return $this; + } + /** * Enable caching for this invoker * diff --git a/src/Foundation/Traits/HandleFluentReturn.php b/src/Foundation/Traits/HandleFluentReturn.php index 5f6692cb..b90fcbe0 100644 --- a/src/Foundation/Traits/HandleFluentReturn.php +++ b/src/Foundation/Traits/HandleFluentReturn.php @@ -2,7 +2,8 @@ namespace Kettasoft\Filterable\Foundation\Traits; -use Kettasoft\Filterable\Foundation\Contracts\QueryBuilderInterface; +use Illuminate\Database\Query\Builder as QueryBuilder; +use Illuminate\Contracts\Database\Eloquent\Builder as EloquentBuilder; trait HandleFluentReturn { @@ -12,19 +13,19 @@ trait HandleFluentReturn * If the result is an instance of Builder, it updates the internal builder * reference and returns $this for fluent chaining. Otherwise, it returns the result as-is. * - * @param mixed $result The result returned from the forwarded call. + * @param string $method The forwarded method name. + * @param array $args The forwarded method arguments. * @return mixed Returns $this if the result is a Builder, otherwise returns the original result. */ - protected function handleFluentReturn($method, $args) + protected function handleFluentReturn(string $method, array $args): mixed { - $builder = method_exists($this, 'getBuilder') ? $this->getBuilder() : $this->builder; $result = $this->forwardCallTo($builder, $method, $args); - if ($result instanceof QueryBuilderInterface) { + if ($result instanceof EloquentBuilder || $result instanceof QueryBuilder) { if (method_exists($this, 'setBuilder')) { $this->setBuilder($result); } else { diff --git a/tests/Unit/Filterable/FilterableForMethodTest.php b/tests/Unit/Filterable/FilterableForMethodTest.php new file mode 100644 index 00000000..3aa4e672 --- /dev/null +++ b/tests/Unit/Filterable/FilterableForMethodTest.php @@ -0,0 +1,256 @@ +assertSame(Post::class, $filterable->getModel()); + $this->assertInstanceOf(Builder::class, $filterable->getBuilder()); + $this->assertInstanceOf(Post::class, $filterable->getBuilder()->getModel()); + } + + public function test_it_preserves_a_model_instance() + { + $model = new Post; + $filterable = Filterable::for($model); + + $this->assertSame($model, $filterable->getModel()); + $this->assertSame($model, $filterable->getBuilder()->getModel()); + } + + public function test_it_preserves_a_builder_and_derives_its_model() + { + $builder = Post::query()->where('status', 'published'); + $filterable = Filterable::for($builder); + + $this->assertSame($builder, $filterable->getBuilder()); + $this->assertSame($builder->getModel(), $filterable->getModel()); + $this->assertStringContainsString('where "status" = ?', $filterable->getBuilder()->toSql()); + } + + public function test_it_accepts_a_custom_request() + { + $request = Request::create('/posts', 'GET', ['status' => 'published']); + + $filterable = Filterable::for(Post::class, $request); + + $this->assertSame($request, $filterable->getRequest()); + $this->assertSame('published', $filterable->getData()['status']); + } + + public function test_it_uses_late_static_binding() + { + $filterClass = new class extends Filterable {}; + + $filterable = $filterClass::for(Post::class); + + $this->assertInstanceOf($filterClass::class, $filterable); + } + + public function test_it_rejects_a_non_model_class_string() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must extend ' . \Illuminate\Database\Eloquent\Model::class); + + Filterable::for(\stdClass::class); + } + + public function test_it_is_available_through_the_facade() + { + $filterable = FilterableFacade::for(Post::class); + + $this->assertInstanceOf(Filterable::class, $filterable); + $this->assertInstanceOf(Post::class, $filterable->getBuilder()->getModel()); + } + + public function test_builder_methods_remain_fluent_on_filterable() + { + $filterable = Filterable::for(Post::class); + + $result = $filterable->where('status', 'published'); + + $this->assertSame($filterable, $result); + $this->assertStringContainsString('where "status" = ?', $filterable->getBuilder()->toSql()); + } + + public function test_invoker_builder_methods_remain_fluent() + { + $invoker = Filterable::for(Post::class)->apply(); + + $result = $invoker->where('status', 'published'); + + $this->assertSame($invoker, $result); + $this->assertStringContainsString('where "status" = ?', $invoker->getBuilder()->toSql()); + } + + public function test_it_applies_invokable_engine_filters_for_a_model_class() + { + $this->seedPosts(); + $request = Request::create('/posts', 'GET', ['status' => 'active']); + $filterClass = new class extends Filterable { + protected $filters = ['status']; + + public function status(Payload $payload): Builder + { + return $this->getBuilder()->where($payload->field, $payload->value); + } + }; + + $results = $filterClass::for(Post::class, $request) + ->useEngine('invokable') + ->apply() + ->get(); + + $this->assertCount(2, $results); + $this->assertSame(['active'], $results->pluck('status')->unique()->values()->all()); + } + + public function test_it_applies_ruleset_engine_filters_for_a_model_instance() + { + $this->seedPosts(); + $request = Request::create('/posts', 'GET', ['status' => 'pending']); + + $results = Filterable::for(new Post, $request) + ->useEngine('ruleset') + ->setAllowedFields(['status']) + ->apply() + ->get(); + + $this->assertCount(1, $results); + $this->assertSame('pending', $results->first()->status); + } + + public function test_it_applies_expression_engine_without_losing_builder_constraints() + { + $this->seedPosts(); + $builder = Post::query()->where('status', 'active'); + $request = Request::create('/posts', 'GET', [ + 'filter' => ['views' => ['eq' => 250]], + ]); + + $filterable = Filterable::for($builder, $request) + ->useEngine('expression') + ->setAllowedFields(['views']); + $results = $filterable->apply()->get(); + + $this->assertSame($builder, $filterable->getBuilder()); + $this->assertCount(1, $results); + $this->assertSame('Second active post', $results->first()->title); + } + + public function test_it_applies_tree_engine_without_losing_builder_constraints() + { + $this->seedPosts(); + $builder = Post::query()->where('views', '>=', 100); + $request = Request::create('/posts', 'POST'); + $request->setJson(new InputBag([ + 'filter' => [ + 'and' => [[ + 'field' => 'status', + 'operator' => 'eq', + 'value' => 'active', + ]], + ], + ])); + + $results = Filterable::for($builder, $request) + ->useEngine('tree') + ->setAllowedFields(['status']) + ->apply() + ->get(); + + $this->assertCount(2, $results); + $this->assertSame(['active'], $results->pluck('status')->unique()->values()->all()); + } + + #[DataProvider('engineProvider')] + public function test_non_tree_engines_accept_an_empty_request(string $engine) + { + $this->seedPosts(); + $request = Request::create('/posts'); + + $count = Filterable::for(Post::class, $request) + ->useEngine($engine) + ->setAllowedFields(['*']) + ->apply() + ->count(); + + $this->assertSame(4, $count); + } + + public static function engineProvider(): array + { + return [ + 'invokable' => ['invokable'], + 'ruleset' => ['ruleset'], + 'expression' => ['expression'], + ]; + } + + public function test_tree_engine_reports_an_empty_request_as_invalid() + { + $this->expectException(InvalidDataFormatException::class); + + Filterable::for(Post::class, Request::create('/posts')) + ->useEngine('tree') + ->setAllowedFields(['*']) + ->apply(); + } + + public function test_created_instances_do_not_share_builder_constraints() + { + $active = Filterable::for(Post::class)->where('status', 'active'); + $pending = Filterable::for(Post::class)->where('status', 'pending'); + + $this->assertNotSame($active->getBuilder(), $pending->getBuilder()); + $this->assertSame(['active'], $active->getBuilder()->getBindings()); + $this->assertSame(['pending'], $pending->getBuilder()->getBindings()); + } + + public function test_it_rejects_an_unknown_model_class_before_booting() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Model [App\\Models\\MissingPost] must extend'); + + Filterable::for('App\\Models\\MissingPost'); + } + + private function seedPosts(): void + { + Post::factory()->create([ + 'title' => 'First active post', + 'status' => 'active', + 'views' => 100, + ]); + Post::factory()->create([ + 'title' => 'Second active post', + 'status' => 'active', + 'views' => 250, + ]); + Post::factory()->create([ + 'title' => 'Pending post', + 'status' => 'pending', + 'views' => 250, + ]); + Post::factory()->create([ + 'title' => 'Stopped post', + 'status' => 'stopped', + 'views' => 50, + ]); + } +}