From d70a38bf08e84a08089f6ff29c0d5badd3b4a906 Mon Sep 17 00:00:00 2001 From: Refringe Date: Tue, 17 Sep 2024 01:38:28 -0400 Subject: [PATCH] API Filters Clean-up Generalized similar API filter methods and moved them into a FilterMethods trait. Rewrote ModFilter and UserFilter methods to use the general trait methods. --- app/Http/Filters/V1/ModFilter.php | 91 ++++----------------- app/Http/Filters/V1/QueryFilter.php | 61 ++++++++------ app/Http/Filters/V1/UserFilter.php | 29 +------ app/Models/ModVersion.php | 2 + app/Traits/V1/FilterMethods.php | 106 +++++++++++++++++++++++++ tests/Feature/Api/V1/ModFilterTest.php | 54 +++++++++++++ 6 files changed, 220 insertions(+), 123 deletions(-) create mode 100644 app/Traits/V1/FilterMethods.php create mode 100644 tests/Feature/Api/V1/ModFilterTest.php diff --git a/app/Http/Filters/V1/ModFilter.php b/app/Http/Filters/V1/ModFilter.php index 58a0992..8ef54ac 100644 --- a/app/Http/Filters/V1/ModFilter.php +++ b/app/Http/Filters/V1/ModFilter.php @@ -4,13 +4,17 @@ namespace App\Http\Filters\V1; use App\Models\Mod; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Support\Str; /** * @extends QueryFilter */ class ModFilter extends QueryFilter { + /** + * The sortable fields. + * + * @var array + */ protected array $sortable = [ 'name', 'slug', @@ -24,9 +28,6 @@ class ModFilter extends QueryFilter 'published_at', ]; - // TODO: Many of these are repeated across UserFilter and ModFilter. Consider refactoring into a shared trait. - // Also, consider using common filter types and making the field names dynamic. - /** * Filter by ID. * @@ -34,9 +35,7 @@ class ModFilter extends QueryFilter */ public function id(string $value): Builder { - $ids = array_map('trim', explode(',', $value)); - - return $this->builder->whereIn('id', $ids); + return $this->filterWhereIn('id', $value); } /** @@ -46,9 +45,7 @@ class ModFilter extends QueryFilter */ public function hub_id(string $value): Builder { - $ids = array_map('trim', explode(',', $value)); - - return $this->builder->whereIn('hub_id', $ids); + return $this->filterWhereIn('hub_id', $value); } /** @@ -58,10 +55,7 @@ class ModFilter extends QueryFilter */ public function name(string $value): Builder { - // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). - $like = Str::replace('*', '%', $value); - - return $this->builder->where('name', 'like', $like); + return $this->filterByWildcardLike('name', $value); } /** @@ -71,10 +65,7 @@ class ModFilter extends QueryFilter */ public function slug(string $value): Builder { - // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). - $like = Str::replace('*', '%', $value); - - return $this->builder->where('slug', 'like', $like); + return $this->filterByWildcardLike('slug', $value); } /** @@ -84,10 +75,7 @@ class ModFilter extends QueryFilter */ public function teaser(string $value): Builder { - // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). - $like = Str::replace('*', '%', $value); - - return $this->builder->where('teaser', 'like', $like); + return $this->filterByWildcardLike('teaser', $value); } /** @@ -97,10 +85,7 @@ class ModFilter extends QueryFilter */ public function source_code_link(string $value): Builder { - // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). - $like = Str::replace('*', '%', $value); - - return $this->builder->where('source_code_link', 'like', $like); + return $this->filterByWildcardLike('source_code_link', $value); } /** @@ -110,13 +95,7 @@ class ModFilter extends QueryFilter */ public function created_at(string $value): Builder { - // The API allows for a range of dates to be passed as a comma-separated list. - $dates = array_map('trim', explode(',', $value)); - if (count($dates) > 1) { - return $this->builder->whereBetween('created_at', $dates); - } - - return $this->builder->whereDate('created_at', $value); + return $this->filterByDate('created_at', $value); } /** @@ -126,13 +105,7 @@ class ModFilter extends QueryFilter */ public function updated_at(string $value): Builder { - // The API allows for a range of dates to be passed as a comma-separated list. - $dates = array_map('trim', explode(',', $value)); - if (count($dates) > 1) { - return $this->builder->whereBetween('updated_at', $dates); - } - - return $this->builder->whereDate('updated_at', $value); + return $this->filterByDate('updated_at', $value); } /** @@ -142,13 +115,7 @@ class ModFilter extends QueryFilter */ public function published_at(string $value): Builder { - // The API allows for a range of dates to be passed as a comma-separated list. - $dates = array_map('trim', explode(',', $value)); - if (count($dates) > 1) { - return $this->builder->whereBetween('published_at', $dates); - } - - return $this->builder->whereDate('published_at', $value); + return $this->filterByDate('published_at', $value); } /** @@ -158,15 +125,7 @@ class ModFilter extends QueryFilter */ public function featured(string $value): Builder { - // We need to convert the string user input to a boolean, or null if it's not a valid "truthy/falsy" value. - $value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); - - // This column is not nullable. - if ($value === null) { - return $this->builder; - } - - return $this->builder->where('featured', $value); + return $this->filterByBoolean('featured', $value); } /** @@ -176,15 +135,7 @@ class ModFilter extends QueryFilter */ public function contains_ads(string $value): Builder { - // We need to convert the string user input to a boolean, or null if it's not a valid "truthy/falsy" value. - $value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); - - // This column is not nullable. - if ($value === null) { - return $this->builder; - } - - return $this->builder->where('contains_ads', $value); + return $this->filterByBoolean('contains_ads', $value); } /** @@ -194,14 +145,6 @@ class ModFilter extends QueryFilter */ public function contains_ai_content(string $value): Builder { - // We need to convert the string user input to a boolean, or null if it's not a valid "truthy/falsy" value. - $value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); - - // This column is not nullable. - if ($value === null) { - return $this->builder; - } - - return $this->builder->where('contains_ai_content', $value); + return $this->filterByBoolean('contains_ai_content', $value); } } diff --git a/app/Http/Filters/V1/QueryFilter.php b/app/Http/Filters/V1/QueryFilter.php index 3e4c789..bdaa249 100644 --- a/app/Http/Filters/V1/QueryFilter.php +++ b/app/Http/Filters/V1/QueryFilter.php @@ -2,16 +2,23 @@ namespace App\Http\Filters\V1; +use App\Traits\V1\FilterMethods; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; -use Illuminate\Support\Str; /** * @template TModelClass of Model */ abstract class QueryFilter { + /** + * Include general filter methods. + * + * @use FilterMethods + */ + use FilterMethods; + /** * The query builder instance. * @@ -19,18 +26,45 @@ abstract class QueryFilter */ protected Builder $builder; + /** + * The request instance. + */ protected Request $request; - /** @var array */ + /** + * The sortable fields. + * + * @var array + */ protected array $sortable = []; + /** + * Create a new QueryFilter instance. + */ public function __construct(Request $request) { $this->request = $request; } /** - * Apply the filter to the query builder. + * Iterate over each of the filter options and call the appropriate method if it exists. + * + * @param array $filters + * @return Builder + */ + public function filter(array $filters): Builder + { + foreach ($filters as $attribute => $value) { + if (method_exists($this, $attribute)) { + $this->$attribute($value); + } + } + + return $this->builder; + } + + /** + * Iterate over all request data and call the appropriate method if it exists. * * @param Builder $builder * @return Builder @@ -47,25 +81,4 @@ abstract class QueryFilter return $this->builder; } - - /** - * Apply the sort type to the query. - * - * @return Builder - */ - protected function sort(string $values): Builder - { - $sortables = array_map('trim', explode(',', $values)); - - foreach ($sortables as $sortable) { - $direction = Str::startsWith($sortable, '-') ? 'desc' : 'asc'; - $column = Str::of($sortable)->remove('-')->value(); - - if (in_array($column, $this->sortable)) { - $this->builder->orderBy($column, $direction); - } - } - - return $this->builder; - } } diff --git a/app/Http/Filters/V1/UserFilter.php b/app/Http/Filters/V1/UserFilter.php index f7c5c51..e3d7ff2 100644 --- a/app/Http/Filters/V1/UserFilter.php +++ b/app/Http/Filters/V1/UserFilter.php @@ -4,7 +4,6 @@ namespace App\Http\Filters\V1; use App\Models\User; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Support\Str; /** * @extends QueryFilter @@ -22,9 +21,6 @@ class UserFilter extends QueryFilter 'updated_at', ]; - // TODO: Many of these are repeated across UserFilter and ModFilter. Consider refactoring into a shared trait. - // Also, consider using common filter types and making the field names dynamic. - /** * Filter by ID. * @@ -32,9 +28,7 @@ class UserFilter extends QueryFilter */ public function id(string $value): Builder { - $ids = array_map('trim', explode(',', $value)); - - return $this->builder->whereIn('id', $ids); + return $this->filterWhereIn('id', $value); } /** @@ -44,10 +38,7 @@ class UserFilter extends QueryFilter */ public function name(string $value): Builder { - // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). - $like = Str::replace('*', '%', $value); - - return $this->builder->where('name', 'like', $like); + return $this->filterByWildcardLike('name', $value); } /** @@ -57,13 +48,7 @@ class UserFilter extends QueryFilter */ public function created_at(string $value): Builder { - // The API allows for a range of dates to be passed as a comma-separated list. - $dates = array_map('trim', explode(',', $value)); - if (count($dates) > 1) { - return $this->builder->whereBetween('created_at', $dates); - } - - return $this->builder->whereDate('created_at', $value); + return $this->filterByDate('created_at', $value); } /** @@ -73,12 +58,6 @@ class UserFilter extends QueryFilter */ public function updated_at(string $value): Builder { - // The API allows for a range of dates to be passed as a comma-separated list. - $dates = array_map('trim', explode(',', $value)); - if (count($dates) > 1) { - return $this->builder->whereBetween('updated_at', $dates); - } - - return $this->builder->whereDate('updated_at', $value); + return $this->filterByDate('updated_at', $value); } } diff --git a/app/Models/ModVersion.php b/app/Models/ModVersion.php index c4fb9c0..f05435f 100644 --- a/app/Models/ModVersion.php +++ b/app/Models/ModVersion.php @@ -24,6 +24,8 @@ class ModVersion extends Model /** * Update the parent mod's updated_at timestamp when the mod version is updated. + * + * @var array */ protected $touches = ['mod']; diff --git a/app/Traits/V1/FilterMethods.php b/app/Traits/V1/FilterMethods.php new file mode 100644 index 0000000..39059c1 --- /dev/null +++ b/app/Traits/V1/FilterMethods.php @@ -0,0 +1,106 @@ + + */ +trait FilterMethods +{ + /** + * Filter using a whereIn clause. + * + * @return Builder + */ + public function filterWhereIn(string $column, string $value): Builder + { + $ids = array_map('trim', explode(',', $value)); + + $result = $this->builder->whereIn($column, $ids); + + /** @var Builder $result */ + return $result; + } + + /** + * Filter using a LIKE clause with a wildcard characters. + * + * @return Builder + */ + public function filterByWildcardLike(string $column, string $value): Builder + { + $like = Str::replace('*', '%', $value); + + $result = $this->builder->where($column, 'like', $like); + + /** @var Builder $result */ + return $result; + } + + /** + * Filter by date range or specific date. + * + * @return Builder + */ + public function filterByDate(string $column, string $value): Builder + { + $dates = array_map('trim', explode(',', $value)); + + if (count($dates) > 1) { + $result = $this->builder->whereBetween($column, $dates); + } else { + $result = $this->builder->whereDate($column, $dates[0]); + } + + /** @var Builder $result */ + return $result; + } + + /** + * Filter by boolean value. + * + * @return Builder + */ + public function filterByBoolean(string $column, string $value): Builder + { + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($value === null) { + $result = $this->builder; // The unmodified builder + } else { + $result = $this->builder->where($column, $value); + } + + /** @var Builder $result */ + return $result; + } + + /** + * Apply the sort type to the query. + * + * @return Builder + */ + protected function sort(string $values): Builder + { + $result = $this->builder; + $sortables = array_map('trim', explode(',', $values)); + + foreach ($sortables as $sortable) { + $direction = Str::startsWith($sortable, '-') ? 'desc' : 'asc'; + $column = Str::of($sortable)->remove('-')->value(); + + if (in_array($column, $this->sortable)) { + $result = $this->builder->orderBy($column, $direction); + } + } + + /** @var Builder $result */ + return $result; + } +} diff --git a/tests/Feature/Api/V1/ModFilterTest.php b/tests/Feature/Api/V1/ModFilterTest.php new file mode 100644 index 0000000..67db6a4 --- /dev/null +++ b/tests/Feature/Api/V1/ModFilterTest.php @@ -0,0 +1,54 @@ +create(['name' => 'Mod C', 'slug' => 'mod-c', 'featured' => true]); + Mod::factory()->create(['name' => 'Mod B', 'slug' => 'mod-b', 'featured' => false]); + Mod::factory()->create(['name' => 'Mod A', 'slug' => 'mod-a', 'featured' => true]); +}); + +it('can filter mods by id', function () { + $request = new Request(['id' => '1,2']); + $filter = new ModFilter($request); + $query = $filter->apply(Mod::query()); + + expect($query->get()->pluck('id')->toArray())->toBe([1, 2]); +}); + +it('can filter mods by name with wildcard', function () { + $request = new Request(['name' => 'Mod*']); + $filter = new ModFilter($request); + $query = $filter->apply(Mod::query()); + + expect($query->get()->pluck('name')->toArray())->toContain('Mod A', 'Mod B', 'Mod C'); +}); + +it('can filter mods by featured status', function () { + $request = new Request(['featured' => 'true']); + $filter = new ModFilter($request); + $query = $filter->apply(Mod::query()); + + expect($query->get()->pluck('name')->toArray())->toContain('Mod A', 'Mod C'); +}); + +it('can sort mods by name in ascending order', function () { + $request = new Request(['sort' => 'name']); + $filter = new ModFilter($request); + $query = $filter->apply(Mod::query()); + + expect($query->get()->pluck('name')->toArray())->toBe(['Mod A', 'Mod B', 'Mod C']); +}); + +it('can sort mods by name in descending order', function () { + $request = new Request(['sort' => '-name']); + $filter = new ModFilter($request); + $query = $filter->apply(Mod::query()); + + expect($query->get()->pluck('name')->toArray())->toBe(['Mod C', 'Mod B', 'Mod A']); +});