mirror of
https://github.com/sp-tarkov/forge.git
synced 2025-02-12 12:10:41 -05:00
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.
This commit is contained in:
parent
2199e34569
commit
d70a38bf08
@ -4,13 +4,17 @@ namespace App\Http\Filters\V1;
|
||||
|
||||
use App\Models\Mod;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends QueryFilter<Mod>
|
||||
*/
|
||||
class ModFilter extends QueryFilter
|
||||
{
|
||||
/**
|
||||
* The sortable fields.
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -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<TModelClass>
|
||||
*/
|
||||
use FilterMethods;
|
||||
|
||||
/**
|
||||
* The query builder instance.
|
||||
*
|
||||
@ -19,18 +26,45 @@ abstract class QueryFilter
|
||||
*/
|
||||
protected Builder $builder;
|
||||
|
||||
/**
|
||||
* The request instance.
|
||||
*/
|
||||
protected Request $request;
|
||||
|
||||
/** @var array<string> */
|
||||
/**
|
||||
* The sortable fields.
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
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<string, string> $filters
|
||||
* @return Builder<TModelClass>
|
||||
*/
|
||||
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<TModelClass> $builder
|
||||
* @return Builder<TModelClass>
|
||||
@ -47,25 +81,4 @@ abstract class QueryFilter
|
||||
|
||||
return $this->builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the sort type to the query.
|
||||
*
|
||||
* @return Builder<TModelClass>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,6 @@ namespace App\Http\Filters\V1;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends QueryFilter<User>
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -24,6 +24,8 @@ class ModVersion extends Model
|
||||
|
||||
/**
|
||||
* Update the parent mod's updated_at timestamp when the mod version is updated.
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
protected $touches = ['mod'];
|
||||
|
||||
|
106
app/Traits/V1/FilterMethods.php
Normal file
106
app/Traits/V1/FilterMethods.php
Normal file
@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits\V1;
|
||||
|
||||
use App\Http\Filters\V1\QueryFilter;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @template TModelClass of Model
|
||||
*
|
||||
* @mixin QueryFilter<TModelClass>
|
||||
*/
|
||||
trait FilterMethods
|
||||
{
|
||||
/**
|
||||
* Filter using a whereIn clause.
|
||||
*
|
||||
* @return Builder<TModelClass>
|
||||
*/
|
||||
public function filterWhereIn(string $column, string $value): Builder
|
||||
{
|
||||
$ids = array_map('trim', explode(',', $value));
|
||||
|
||||
$result = $this->builder->whereIn($column, $ids);
|
||||
|
||||
/** @var Builder<TModelClass> $result */
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter using a LIKE clause with a wildcard characters.
|
||||
*
|
||||
* @return Builder<TModelClass>
|
||||
*/
|
||||
public function filterByWildcardLike(string $column, string $value): Builder
|
||||
{
|
||||
$like = Str::replace('*', '%', $value);
|
||||
|
||||
$result = $this->builder->where($column, 'like', $like);
|
||||
|
||||
/** @var Builder<TModelClass> $result */
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter by date range or specific date.
|
||||
*
|
||||
* @return Builder<TModelClass>
|
||||
*/
|
||||
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<TModelClass> $result */
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter by boolean value.
|
||||
*
|
||||
* @return Builder<TModelClass>
|
||||
*/
|
||||
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<TModelClass> $result */
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the sort type to the query.
|
||||
*
|
||||
* @return Builder<TModelClass>
|
||||
*/
|
||||
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<TModelClass> $result */
|
||||
return $result;
|
||||
}
|
||||
}
|
54
tests/Feature/Api/V1/ModFilterTest.php
Normal file
54
tests/Feature/Api/V1/ModFilterTest.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Filters\V1\ModFilter;
|
||||
use App\Models\Mod;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Mod::factory()->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']);
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user