diff --git a/src/Filters/Resolve.php b/src/Filters/Resolve.php index 8168edf..e435b3e 100644 --- a/src/Filters/Resolve.php +++ b/src/Filters/Resolve.php @@ -46,6 +46,7 @@ public function __construct(FilterList $filterList, Model $model) * @param array|string $values * * @throws Exception + * @throws Exception * * @return void */ @@ -64,6 +65,7 @@ public function apply(Builder $query, string $field, array|string $values): void * @param Closure $closure * * @throws Exception + * @throws Exception * * @return bool */ @@ -76,9 +78,9 @@ private function safe(Closure $closure): bool } catch (Exception $exception) { if (config('purity.silent')) { return false; - } else { - throw $exception; } + + throw $exception; } } @@ -89,13 +91,11 @@ private function safe(Closure $closure): bool */ private function validate(array|string $values = []) { - if (empty($values) or is_string($values)) { + if (empty($values) || is_string($values)) { throw NoOperatorMatch::create($this->filterList->keys()); } - if (in_array(key($values), $this->filterList->keys())) { - return; - } else { + if (!in_array(key($values), $this->filterList->keys())) { $this->validate(array_values($values)[0]); } } @@ -108,15 +108,14 @@ private function validate(array|string $values = []) * @param array|string|null $filters * * @throws Exception + * @throws Exception * * @return void */ private function filter(Builder $query, string $field, array|string|null $filters): void { // Ensure that the filter is an array - if (!is_array($filters)) { - $filters = [$filters]; - } + $filters = is_array($filters) ? $filters : [$filters]; // Resolve the filter using the appropriate strategy if ($this->filterList->get($field) !== null) { @@ -186,11 +185,9 @@ private function applyRelations(Builder $query, Closure $callback): void */ private function relation(Builder $query, Closure $callback) { - // remove last field until its empty + // remove the last field until its empty $field = array_shift($this->fields); - $query->whereHas($field, function ($subQuery) use ($callback) { - $this->applyRelations($subQuery, $callback); - }); + $query->whereHas($field, fn ($subQuery) => $this->applyRelations($subQuery, $callback)); } /** @@ -205,21 +202,31 @@ private function relation(Builder $query, Closure $callback) private function applyRelationFilter(Builder $query, string $field, array $filters): void { foreach ($filters as $subField => $subFilter) { - $relation = end($this->fields); - if ($relation !== false) { - array_push($this->previousModels, $this->model); - $this->model = $this->model->$relation()->getRelated(); - } + $this->prepareModelForRelation($field); $this->validateField($field); $this->validateOperator($field, $subField); $this->fields[] = $this->model->getField($field); $this->filter($query, $subField, $subFilter); } + $this->restorePreviousModel(); + } + + private function prepareModelForRelation(string $field): void + { + $relation = end($this->fields); + if ($relation !== false) { + $this->previousModels[] = $this->model; + + $this->model = $this->model->$relation()->getRelated(); + } + } + + private function restorePreviousModel(): void + { array_pop($this->fields); - if (count($this->previousModels)) { - $this->model = end($this->previousModels); - array_pop($this->previousModels); + if (!empty($this->previousModels)) { + $this->model = array_pop($this->previousModels); } } diff --git a/src/Traits/Filterable.php b/src/Traits/Filterable.php index 510bcf7..16a26ff 100644 --- a/src/Traits/Filterable.php +++ b/src/Traits/Filterable.php @@ -12,11 +12,11 @@ use ReflectionClass; /** - * List of available filters, can be set on the model otherwise it will be read from config. + * The List of available filters can be set on the model otherwise it will be read from config. * * @property array $filters * - * List of available fields, if not declared will accept every thing. + * List of available fields, if not declared, will accept everything. * @property array $filterFields * * Fields will restrict to defined filters. @@ -64,7 +64,7 @@ public function scopeFilter(Builder $query, array|null $params = null): Builder // Apply each filter to the query builder instance foreach ($params as $field => $value) { - app($this->getFilterResolver())->apply($query, $field, $value); + app(Resolve::class)->apply($query, $field, $value); } return $query; @@ -81,7 +81,11 @@ private function bootFilter(): void return (new FilterList())->only($this->getFilters()); }); - app()->when($this->getFilterResolver())->needs(Model::class)->give(fn () => $this); + app()->bind(Resolve::class, function () { + $resolver = $this->getFilterResolver(); + + return new $resolver(app(FilterList::class), $this); + }); } /** @@ -210,8 +214,7 @@ public function getRestrictedFilters(): array foreach ($this->restrictedFilters ?? $this->filterFields ?? [] as $key => $value) { if (is_int($key) && Str::contains($value, ':')) { $tKey = str($value)->before(':')->squish()->toString(); - $tValue = str($value)->after(':')->squish()->explode(',')->all(); - $restrictedFilters[$tKey] = $tValue; + $restrictedFilters[$tKey] = str($value)->after(':')->squish()->explode(',')->all(); } if (is_string($key)) { $restrictedFilters[$key] = Arr::wrap($value); diff --git a/tests/App/Migrations/0001_01_01_000000_create_test_tables.php b/tests/App/Migrations/0001_01_01_000000_create_test_tables.php index 4162546..1beb45b 100644 --- a/tests/App/Migrations/0001_01_01_000000_create_test_tables.php +++ b/tests/App/Migrations/0001_01_01_000000_create_test_tables.php @@ -2,6 +2,7 @@ use Abbasudo\Purity\Tests\App\Models\Author; use Abbasudo\Purity\Tests\App\Models\Post; +use Abbasudo\Purity\Tests\App\Models\Product; use Abbasudo\Purity\Tests\App\Models\Tag; use Abbasudo\Purity\Tests\App\Models\User; use Illuminate\Database\Migrations\Migration; @@ -31,6 +32,7 @@ public function up(): void $table->id(); $table->foreignIdFor(User::class)->nullable(); $table->string('title')->nullable(); + $table->nullableMorphs('postable'); $table->timestamps(); }); @@ -60,6 +62,7 @@ public function up(): void $table->foreignIdFor(Author::class)->nullable(); $table->string('name'); $table->string('description'); + $table->foreignIdFor(Product::class)->nullable(); $table->timestamps(); }); diff --git a/tests/App/Models/Book.php b/tests/App/Models/Book.php index 5e934a1..5f1b675 100644 --- a/tests/App/Models/Book.php +++ b/tests/App/Models/Book.php @@ -6,6 +6,7 @@ use Abbasudo\Purity\Traits\Sortable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphMany; class Book extends Model { @@ -26,4 +27,9 @@ public function author(): BelongsTo { return $this->belongsTo(Author::class); } + + public function posts(): MorphMany + { + return $this->morphMany(Post::class, 'postable'); + } } diff --git a/tests/App/Models/Post.php b/tests/App/Models/Post.php index 1e361ff..5d4530a 100644 --- a/tests/App/Models/Post.php +++ b/tests/App/Models/Post.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphTo; class Post extends Model { @@ -18,6 +19,7 @@ class Post extends Model protected $fillable = [ 'title', + 'user_id', ]; public function comments(): HasMany @@ -34,4 +36,9 @@ public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class); } + + public function postable(): MorphTo + { + return $this->morphTo(); + } } diff --git a/tests/App/Models/Product.php b/tests/App/Models/Product.php index 72e5f96..fedf726 100644 --- a/tests/App/Models/Product.php +++ b/tests/App/Models/Product.php @@ -7,6 +7,8 @@ use Abbasudo\Purity\Traits\Sortable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\Relations\MorphMany; class Product extends Model { @@ -26,4 +28,14 @@ protected static function newFactory() 'description', 'is_available', ]; + + public function posts(): MorphMany + { + return $this->morphMany(Post::class, 'postable'); + } + + public function book(): HasOne + { + return $this->hasOne(Book::class); + } } diff --git a/tests/Feature/FilterableTest.php b/tests/Feature/FilterableTest.php index 17f234c..3828987 100644 --- a/tests/Feature/FilterableTest.php +++ b/tests/Feature/FilterableTest.php @@ -409,7 +409,7 @@ public function it_can_filter_with_and_operator(): void ]); $response = $this->getJson( - '/posts?filters[$and][0][name][$eq]=laravel purity is the best&filters[$and][1][description][$eq]=laravel purity is the best' + '/products?filters[$and][0][name][$eq]=laravel purity&filters[$and][1][description][$eq]=laravel purity is the best' ) ->assertOk() ->assertJsonCount(1); diff --git a/tests/Feature/RelationFilterTest.php b/tests/Feature/RelationFilterTest.php new file mode 100644 index 0000000..2ee34d7 --- /dev/null +++ b/tests/Feature/RelationFilterTest.php @@ -0,0 +1,97 @@ +get(); + }); + + Route::get('/products', function () { + return Product::filter()->get(); + }); + + Post::create([ + 'title' => 'laravel purity is the best', + ]); + } + + /** @test */ + public function it_can_filter_by_has_many_relation(): void + { + $post = Post::first(); + + $post->comments()->create([ + 'content' => 'first comment', + ]); + + $post->comments()->create([ + 'content' => 'second comment', + ]); + + $response = $this->getJson('/posts?filters[comments][content][$eq]=first comment'); + + $response->assertOk(); + $response->assertJsonCount(1); + } + + /** @test */ + public function it_can_filter_by_belongs_to_relation(): void + { + $user = User::create([ + 'name' => 'Test', + ]); + + $post = Post::create([ + 'title' => 'laravel purity is the best', + 'user_id' => $user->id, + ]); + + $response = $this->getJson('/posts?filters[user][name][$eq]=Test'); + + $response->assertOk(); + $response->assertJsonCount(1); + } + + /** @test */ + public function it_can_filter_by_belongs_to_many_relation(): void + { + $post = Post::first(); + + $post->tags()->create([ + 'name' => 'Laravel', + ]); + + $response = $this->getJson('/posts?filters[tags][name][$eq]=Laravel'); + + $response->assertOk(); + $response->assertJsonCount(1); + } + + /** @test */ + public function it_can_filter_by_has_one_relation(): void + { + $product = Product::factory([ + 'name' => 'Laravel Purity', + ])->create(); + + $product->book()->create([ + 'name' => 'book', + 'description' => 'book for product', + ]); + + $response = $this->getJson('/products?filters[book][name][$eq]=book'); + + $response->assertOk(); + $response->assertJsonCount(1); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 76fc026..23fdea0 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -25,6 +25,6 @@ protected function getPackageProviders($app): array protected function getEnvironmentSetUp($app) { - // + $app['config']->set('purity.silent', false); } }