Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/api/filterable.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/Facades/Filterable.php
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
30 changes: 30 additions & 0 deletions src/Filterable.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -45,6 +46,7 @@ class Filterable implements FilterableContext, Authorizable, Validatable, Commit
Traits\InteractsWithProvidedData,
Traits\HasFilterableCache,
HandleFluentReturn,
ForwardsCalls,
Macroable;

/**
Expand Down Expand Up @@ -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<Model> $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
Expand Down
13 changes: 13 additions & 0 deletions src/Foundation/Invoker.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
11 changes: 6 additions & 5 deletions src/Foundation/Traits/HandleFluentReturn.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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 {
Expand Down
256 changes: 256 additions & 0 deletions tests/Unit/Filterable/FilterableForMethodTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
<?php

namespace Kettasoft\Filterable\Tests\Unit\Filterable;

use Illuminate\Http\Request;
use PHPUnit\Framework\Attributes\DataProvider;
use Kettasoft\Filterable\Filterable;
use Kettasoft\Filterable\Support\Payload;
use Kettasoft\Filterable\Tests\TestCase;
use Kettasoft\Filterable\Tests\Models\Post;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Kettasoft\Filterable\Facades\Filterable as FilterableFacade;
use Symfony\Component\HttpFoundation\InputBag;
use Kettasoft\Filterable\Engines\Exceptions\InvalidDataFormatException;

class FilterableForMethodTest extends TestCase
{
public function test_it_creates_an_initialized_instance_for_a_model_class()
{
$filterable = Filterable::for(Post::class);

$this->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,
]);
}
}
Loading