From 2a817969458d47344cf8d8cad281ce238ffc77e0 Mon Sep 17 00:00:00 2001 From: Abbas mkhzomi Date: Sun, 13 Oct 2024 18:12:47 +0330 Subject: [PATCH 1/5] bind resolve --- src/Filters/Resolve.php | 62 ++++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/src/Filters/Resolve.php b/src/Filters/Resolve.php index 8168edf..35026f5 100644 --- a/src/Filters/Resolve.php +++ b/src/Filters/Resolve.php @@ -37,7 +37,7 @@ class Resolve public function __construct(FilterList $filterList, Model $model) { $this->filterList = $filterList; - $this->model = $model; + $this->model = $model; } /** @@ -48,6 +48,8 @@ public function __construct(FilterList $filterList, Model $model) * @throws Exception * * @return void + * @throws Exception + * */ public function apply(Builder $query, string $field, array|string $values): void { @@ -66,6 +68,8 @@ public function apply(Builder $query, string $field, array|string $values): void * @throws Exception * * @return bool + * @throws Exception + * */ private function safe(Closure $closure): bool { @@ -76,9 +80,9 @@ private function safe(Closure $closure): bool } catch (Exception $exception) { if (config('purity.silent')) { return false; - } else { - throw $exception; } + + throw $exception; } } @@ -89,13 +93,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]); } } @@ -110,28 +112,28 @@ private function validate(array|string $values = []) * @throws Exception * * @return void + * @throws Exception + * */ 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) { //call apply method of the appropriate filter class - $this->safe(fn () => $this->applyFilterStrategy($query, $field, $filters)); + $this->safe(fn() => $this->applyFilterStrategy($query, $field, $filters)); } else { // If the field is not recognized as a filter strategy, it is treated as a relation - $this->safe(fn () => $this->applyRelationFilter($query, $field, $filters)); + $this->safe(fn() => $this->applyRelationFilter($query, $field, $filters)); } } /** * @param Builder $query - * @param string $operator - * @param array $filters + * @param string $operator + * @param array $filters * * @return void */ @@ -186,11 +188,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 +205,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); } } From 687973cfc106f0c5476600e2e5e5094de7adecf5 Mon Sep 17 00:00:00 2001 From: Abbas mkhzomi Date: Sun, 13 Oct 2024 18:14:06 +0330 Subject: [PATCH 2/5] bind resolve to custom resolver and model --- src/Traits/Filterable.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Traits/Filterable.php b/src/Traits/Filterable.php index 510bcf7..0ca4f0c 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,10 @@ 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 +213,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); From 650a031a4f76a78f58da48c7816c8be6ed7199b1 Mon Sep 17 00:00:00 2001 From: Abbas mkhzomi Date: Sun, 13 Oct 2024 18:14:38 +0330 Subject: [PATCH 3/5] add relation tests --- .../0001_01_01_000000_create_test_tables.php | 3 + tests/App/Models/Book.php | 6 ++ tests/App/Models/Post.php | 7 ++ tests/App/Models/Product.php | 12 +++ tests/Feature/FilterableTest.php | 2 +- tests/Feature/RelationFilterTest.php | 98 +++++++++++++++++++ 6 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/RelationFilterTest.php 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..f4bd34e 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..30c4ca2 --- /dev/null +++ b/tests/Feature/RelationFilterTest.php @@ -0,0 +1,98 @@ +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); + } +} \ No newline at end of file From 62e096db8faca024c15c379c41f5fc9678b8b67d Mon Sep 17 00:00:00 2001 From: Abbas mkhzomi Date: Sun, 13 Oct 2024 18:15:15 +0330 Subject: [PATCH 4/5] change purity config in the test case file --- tests/TestCase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); } } From 1ac8f5207a9c8f410b7749ce0787c90cc361dc0b Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Sun, 13 Oct 2024 14:47:13 +0000 Subject: [PATCH 5/5] Apply fixes from StyleCI --- src/Filters/Resolve.php | 21 +++++++++------------ src/Traits/Filterable.php | 1 + tests/App/Models/Post.php | 2 +- tests/Feature/RelationFilterTest.php | 5 ++--- 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/Filters/Resolve.php b/src/Filters/Resolve.php index 35026f5..e435b3e 100644 --- a/src/Filters/Resolve.php +++ b/src/Filters/Resolve.php @@ -37,7 +37,7 @@ class Resolve public function __construct(FilterList $filterList, Model $model) { $this->filterList = $filterList; - $this->model = $model; + $this->model = $model; } /** @@ -46,10 +46,9 @@ public function __construct(FilterList $filterList, Model $model) * @param array|string $values * * @throws Exception - * - * @return void * @throws Exception * + * @return void */ public function apply(Builder $query, string $field, array|string $values): void { @@ -66,10 +65,9 @@ public function apply(Builder $query, string $field, array|string $values): void * @param Closure $closure * * @throws Exception - * - * @return bool * @throws Exception * + * @return bool */ private function safe(Closure $closure): bool { @@ -110,10 +108,9 @@ private function validate(array|string $values = []) * @param array|string|null $filters * * @throws Exception - * - * @return void * @throws Exception * + * @return void */ private function filter(Builder $query, string $field, array|string|null $filters): void { @@ -123,17 +120,17 @@ private function filter(Builder $query, string $field, array|string|null $filter // Resolve the filter using the appropriate strategy if ($this->filterList->get($field) !== null) { //call apply method of the appropriate filter class - $this->safe(fn() => $this->applyFilterStrategy($query, $field, $filters)); + $this->safe(fn () => $this->applyFilterStrategy($query, $field, $filters)); } else { // If the field is not recognized as a filter strategy, it is treated as a relation - $this->safe(fn() => $this->applyRelationFilter($query, $field, $filters)); + $this->safe(fn () => $this->applyRelationFilter($query, $field, $filters)); } } /** * @param Builder $query - * @param string $operator - * @param array $filters + * @param string $operator + * @param array $filters * * @return void */ @@ -190,7 +187,7 @@ private function relation(Builder $query, Closure $callback) { // remove the last field until its empty $field = array_shift($this->fields); - $query->whereHas($field, fn($subQuery) => $this->applyRelations($subQuery, $callback)); + $query->whereHas($field, fn ($subQuery) => $this->applyRelations($subQuery, $callback)); } /** diff --git a/src/Traits/Filterable.php b/src/Traits/Filterable.php index 0ca4f0c..16a26ff 100644 --- a/src/Traits/Filterable.php +++ b/src/Traits/Filterable.php @@ -83,6 +83,7 @@ private function bootFilter(): void app()->bind(Resolve::class, function () { $resolver = $this->getFilterResolver(); + return new $resolver(app(FilterList::class), $this); }); } diff --git a/tests/App/Models/Post.php b/tests/App/Models/Post.php index f4bd34e..5d4530a 100644 --- a/tests/App/Models/Post.php +++ b/tests/App/Models/Post.php @@ -19,7 +19,7 @@ class Post extends Model protected $fillable = [ 'title', - 'user_id' + 'user_id', ]; public function comments(): HasMany diff --git a/tests/Feature/RelationFilterTest.php b/tests/Feature/RelationFilterTest.php index 30c4ca2..2ee34d7 100644 --- a/tests/Feature/RelationFilterTest.php +++ b/tests/Feature/RelationFilterTest.php @@ -1,6 +1,5 @@ book()->create([ 'name' => 'book', - 'description' => 'book for product' + 'description' => 'book for product', ]); $response = $this->getJson('/products?filters[book][name][$eq]=book'); @@ -95,4 +94,4 @@ public function it_can_filter_by_has_one_relation(): void $response->assertOk(); $response->assertJsonCount(1); } -} \ No newline at end of file +}