diff --git a/src/Commands/stubs/repository.stub b/src/Commands/stubs/repository.stub index c823db51..d61bda22 100644 --- a/src/Commands/stubs/repository.stub +++ b/src/Commands/stubs/repository.stub @@ -13,33 +13,4 @@ class DummyClass extends Repository * @var string */ public static $model = 'DummyFullModel'; - - /** - * The single value that should be used to represent the repository when being displayed. - * - * @var string - */ - public static $title = 'id'; - - /** - * The columns that should be searched. - * - * @var array - */ - public static $search = [ - 'id', - ]; - - /** - * Get the fields displayed by the repository. - * - * @param \Illuminate\Http\Request $request - * @return array - */ - public function fields(Request $request) - { - return [ - ID::make()->sortable(), - ]; - } } diff --git a/src/Contracts/RestifySearchable.php b/src/Contracts/RestifySearchable.php index e761cdd7..237127b6 100644 --- a/src/Contracts/RestifySearchable.php +++ b/src/Contracts/RestifySearchable.php @@ -2,7 +2,7 @@ namespace Binaryk\LaravelRestify\Contracts; -use Illuminate\Http\Request; +use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; /** * @author Eduard Lupacescu @@ -16,11 +16,11 @@ interface RestifySearchable const MATCH_INTEGER = 'integer'; /** - * @param Request $request + * @param RestifyRequest $request * @param array $fields * @return array */ - public function serializeForIndex(Request $request, array $fields = []); + public function serializeForIndex(RestifyRequest $request, array $fields = []); /** * @return array diff --git a/src/Controllers/RestController.php b/src/Controllers/RestController.php index eae58dfc..6f00bd83 100644 --- a/src/Controllers/RestController.php +++ b/src/Controllers/RestController.php @@ -5,10 +5,11 @@ use Binaryk\LaravelRestify\Contracts\RestifySearchable; use Binaryk\LaravelRestify\Exceptions\Guard\EntityNotFoundException; use Binaryk\LaravelRestify\Exceptions\Guard\GatePolicy; +use Binaryk\LaravelRestify\Exceptions\InstanceOfException; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; +use Binaryk\LaravelRestify\Repositories\Repository; use Binaryk\LaravelRestify\Services\Search\SearchService; use Binaryk\LaravelRestify\Traits\PerformsQueries; -use Illuminate\Config\Repository; use Illuminate\Config\Repository as Config; use Illuminate\Container\Container; use Illuminate\Contracts\Auth\Access\Gate; @@ -84,8 +85,8 @@ public function config() { $container = Container::getInstance(); - if (($this->config instanceof Repository) === false) { - $this->config = $container->make(Repository::class); + if (($this->config instanceof Config) === false) { + $this->config = $container->make(Config::class); } return $this->config; @@ -130,18 +131,27 @@ protected function response($data = null, $status = 200, array $headers = []) * @param array $filters * @return array * @throws BindingResolutionException + * @throws InstanceOfException */ public function search($modelClass, $filters = []) { $results = SearchService::instance() ->setPredefinedFilters($filters) - ->search($this->request(), new $modelClass); + ->search($this->request(), $modelClass instanceof Repository ? $modelClass->model() : new $modelClass); + $results->tap(function ($query) { static::indexQuery($this->request(), $query); }); + /** + * @var \Illuminate\Pagination\Paginator + */ $paginator = $results->paginate($this->request()->get('perPage') ?? ($modelClass::$defaultPerPage ?? RestifySearchable::DEFAULT_PER_PAGE)); - $items = $paginator->getCollection()->map->serializeForIndex($this->request()); + if ($modelClass instanceof Repository) { + $items = $paginator->getCollection()->mapInto(get_class($modelClass))->map->serializeForIndex($this->request()); + } else { + $items = $paginator->getCollection()->map->serializeForIndex($this->request()); + } return array_merge($paginator->toArray(), [ 'data' => $items, diff --git a/src/Exceptions/InstanceOfException.php b/src/Exceptions/InstanceOfException.php new file mode 100644 index 00000000..a26d0f1c --- /dev/null +++ b/src/Exceptions/InstanceOfException.php @@ -0,0 +1,12 @@ + + */ +class InstanceOfException extends Exception +{ +} diff --git a/src/Http/Controllers/RepositoryIndexController.php b/src/Http/Controllers/RepositoryIndexController.php index 894733d1..9303cf7e 100644 --- a/src/Http/Controllers/RepositoryIndexController.php +++ b/src/Http/Controllers/RepositoryIndexController.php @@ -18,9 +18,7 @@ class RepositoryIndexController extends RepositoryController */ public function handle(RestifyRequest $request) { - $resource = $request->repository(); - - $data = $this->search($resource::newModel()); + $data = $this->search($request->newRepository()); return $this->respond($data); } diff --git a/src/Http/Requests/InteractWithRepositories.php b/src/Http/Requests/InteractWithRepositories.php index 16482797..ccc37452 100644 --- a/src/Http/Requests/InteractWithRepositories.php +++ b/src/Http/Requests/InteractWithRepositories.php @@ -63,4 +63,44 @@ public function rules() // ]; } + + /** + * Get the route handling the request. + * + * @param string|null $param + * @param mixed $default + * @return \Illuminate\Routing\Route|object|string + */ + abstract public function route($param = null, $default = null); + + /** + * Get a new instance of the repository being requested. + * + * @return Repository + * @throws EntityNotFoundException + * @throws UnauthorizedException + */ + public function newRepository() + { + $repository = $this->repository(); + + return new $repository($repository::newModel()); + } + + /** + * Check if the route is resolved by the Repository class, or it uses the classical Models. + * @return bool + */ + public function isResolvedByRestify() + { + try { + $this->repository(); + + return true; + } catch (EntityNotFoundException $e) { + return false; + } catch (UnauthorizedException $e) { + return true; + } + } } diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index 492b51a9..9e59ac95 100644 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -2,42 +2,37 @@ namespace Binaryk\LaravelRestify\Repositories; -use Binaryk\LaravelRestify\Traits\AuthorizableModels; +use Binaryk\LaravelRestify\Contracts\RestifySearchable; +use Binaryk\LaravelRestify\Traits\InteractWithSearch; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; -use Illuminate\Http\Request; +use Illuminate\Http\Resources\DelegatesToResource; use Illuminate\Support\Str; /** * @author Eduard Lupacescu */ -abstract class Repository +abstract class Repository implements RestifySearchable { - use AuthorizableModels; + use InteractWithSearch, + DelegatesToResource; /** + * This is named `resource` because of the forwarding properties from DelegatesToResource trait. * @var Model */ - public $modelInstance; + public $resource; /** * Create a new resource instance. * - * @param \Illuminate\Database\Eloquent\Model $resource + * @param \Illuminate\Database\Eloquent\Model $model */ - public function __construct($resource) + public function __construct($model) { - $this->modelInstance = $resource; + $this->resource = $model; } - /** - * Get the fields displayed by the resource. - * - * @param \Illuminate\Http\Request $request - * @return array - */ - abstract public function fields(Request $request); - /** * Get the underlying model instance for the resource. * @@ -45,7 +40,7 @@ abstract public function fields(Request $request); */ public function model() { - return $this->modelInstance; + return $this->resource; } /** @@ -77,4 +72,14 @@ public static function query() { return static::newModel()->query(); } + + /** + * @return array + */ + public function toArray() + { + $model = $this->model(); + + return $model->toArray(); + } } diff --git a/src/Services/Search/SearchService.php b/src/Services/Search/SearchService.php index 6f180eb2..4bb04851 100644 --- a/src/Services/Search/SearchService.php +++ b/src/Services/Search/SearchService.php @@ -3,295 +3,188 @@ namespace Binaryk\LaravelRestify\Services\Search; use Binaryk\LaravelRestify\Contracts\RestifySearchable; +use Binaryk\LaravelRestify\Exceptions\InstanceOfException; use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; class SearchService extends Searchable { - /** - * @var Builder - */ - protected $builder; - /** * @param RestifyRequest $request * @param Model $model * @return Builder + * @throws InstanceOfException + * @throws \Throwable */ public function search(RestifyRequest $request, Model $model) { - $this->request = $request; - $this->model = $model; - - $this->builder = $model->newQuery(); + if (! $model instanceof RestifySearchable) { + return $model->newQuery(); + } - $this->prepare(); + $query = $this->prepareMatchFields($request, $this->prepareSearchFields($request, $model->newQuery(), $this->fixedInput), $this->fixedInput); - return $this->builder; - } - - /** - * Will prepare the eloquent array to return. - * - * @return array - */ - protected function prepare() - { - $this->prepareSearchFields($this->request->get('search', data_get($this->fixedInput, 'search', ''))) - ->prepareMatchFields($this->request->get('match', [])) - ->prepareIn($this->request->get('in', [])) - ->prepareOperator($this->request->get('operator', [])) - ->prepareOrders($this->request->get('sort', '')) - ->prepareRelations(); - - $results = $this->builder->get(); - - return [ - 'data' => $results, - 'aggregations' => null, - ]; + return $this->prepareRelations($request, $this->prepareOrders($request, $query), $this->fixedInput); } /** * Prepare eloquent exact fields. * - * @param $fields - * - * @return $this + * @param RestifyRequest $request + * @param Builder $query + * @param array $extra + * @return Builder */ - protected function prepareIn($fields) + public function prepareMatchFields(RestifyRequest $request, $query, $extra = []) { - if (isset($this->fixedInput['in']) === true) { - $fields = $this->fixedInput['in']; - } - - if (is_array($fields) === true) { - foreach ($fields as $key => $value) { - $field = $key; - - if ($field === null) { + $model = $query->getModel(); + if ($model instanceof RestifySearchable) { + foreach ($model::getMatchByFields() as $key => $type) { + if (! $request->has($key) && ! data_get($extra, "match.$key")) { continue; } - if (is_array($value) === true && isset($this->model::getInFields()[$key]) === true) { - foreach ($value as $val) { - switch ($this->model::getInFields()[$key]) { - case 'integer': - default: - $this->builder->whereIn($this->model->qualifyColumn($field), explode(',', $val)); - break; - } - } - } elseif (is_array($value) === false && isset($this->model::getInFields()[$key]) === true) { - switch ($this->model::getInFields()[$key]) { - case 'integer': - default: - $this->builder->whereIn($this->model->qualifyColumn($field), explode(',', $value)); - break; - } + $value = $request->get($key, data_get($extra, "match.$key")); + + if (empty($value)) { + continue; } - } - } - return $this; - } + $field = $model->qualifyColumn($key); - /** - * Prepare eloquent exact fields. - * - * @param $fields - * - * @return $this - */ - protected function prepareOperator($fields) - { - if (isset($this->fixedInput['operator']) === true) { - $fields = $this->fixedInput['operator']; - } + $values = explode(',', $value); - if (is_array($fields) === true) { - foreach ($fields as $key => $values) { - foreach ($values as $field => $value) { - $qualifiedField = $this->model->qualifyColumn($field); - switch ($key) { - case 'gte': - $this->builder->where($qualifiedField, '>=', $value); + foreach ($values as $match) { + switch ($model::getMatchByFields()[$key]) { + case RestifySearchable::MATCH_TEXT: + case 'string': + $query->where($field, '=', $match); break; - case 'gt': - $this->builder->where($qualifiedField, '>', $value); - break; - case 'lte': - $this->builder->where($qualifiedField, '<=', $value); + case RestifySearchable::MATCH_BOOL: + case 'boolean': + if ($match === 'false') { + $query->where(function ($query) use ($field) { + return $query->where($field, '=', false)->orWhereNull($field); + }); + break; + } + $query->where($field, '=', true); break; - case 'lt': - $this->builder->where($qualifiedField, '<', $value); + case RestifySearchable::MATCH_INTEGER: + case 'number': + case 'int': + $query->where($field, '=', (int) $match); break; } } } } - return $this; - } - - /** - * Prepare eloquent exact fields. - * - * @param $fields - * - * @return $this - */ - protected function prepareMatchFields($fields) - { - if (isset($this->fixedInput['match']) === true) { - if (is_array($fields) === false) { - $fields = $this->fixedInput['match']; - } else { - $fields = array_merge($this->fixedInput['match'], $fields); - } - } - - if (is_array($fields) === true) { - foreach ($fields as $key => $value) { - if (isset($this->model::getMatchByFields()[$key]) === true) { - $field = $this->model->qualifyColumn($key); - - $values = explode(',', $value); - foreach ($values as $match) { - switch ($this->model::getMatchByFields()[$key]) { - case RestifySearchable::MATCH_TEXT: - $this->builder->where($field, '=', $match); - break; - case RestifySearchable::MATCH_BOOL: - case 'boolean': - if ($match === 'false') { - $this->builder->where(function ($query) use ($field) { - return $query->where($field, '=', false)->orWhereNull($field); - }); - break; - } - $this->builder->where($field, '=', true); - break; - case RestifySearchable::MATCH_INTEGER: - case 'number': - case 'int': - $this->builder->where($field, '=', (int) $match); - break; - } - } - } - } - } - - return $this; + return $query; } /** * Prepare eloquent order by. * - * @param $sort - * - * @return $this + * @param RestifyRequest $request + * @param $query + * @param array $extra + * @return Builder */ - protected function prepareOrders($sort) + public function prepareOrders(RestifyRequest $request, $query, $extra = []) { - if (isset($this->fixedInput['sort'])) { - $sort = $this->fixedInput['sort']; + $sort = $request->get('sort', ''); + + if (isset($extra['sort'])) { + $sort = $extra['sort']; } $params = explode(',', $sort); if (is_array($params) === true && empty($params) === false) { foreach ($params as $param) { - $this->setOrder($param); + $this->setOrder($query, $param); } } if (empty($params) === true) { - $this->setOrder('+id'); + $this->setOrder($query, '+id'); } - return $this; + return $query; } /** * Prepare relations. * - * @return $this + * @param RestifyRequest $request + * @param Builder $query + * @param array $extra + * @return Builder */ - protected function prepareRelations() + public function prepareRelations(RestifyRequest $request, $query, $extra = []) { - $relations = null; - - if (isset($this->fixedInput['relations']) === true) { - $relations = $this->fixedInput['relations']; - } - - if (isset($this->fixedInput['relations']) === false) { - $relations = $this->request->get('with', null); - } - - if (empty($relations) === false) { - $foundRelations = explode(',', $relations); - foreach ($foundRelations as $relation) { - if (in_array($relation, $this->model->getWiths())) { - $this->builder->with($relation); + $model = $query->getModel(); + if ($model instanceof RestifySearchable) { + $relations = array_merge($extra, explode(',', $request->get('with'))); + foreach ($relations as $relation) { + if (in_array($relation, $model::getWiths())) { + $query->with($relation); } } } - return $this; + return $query; } /** * Prepare search. * - * @param $search - * @return $this + * @param RestifyRequest $request + * @param Builder $query + * @param array $extra + * @return Builder */ - protected function prepareSearchFields($search) + public function prepareSearchFields(RestifyRequest $request, $query, $extra = []) { - $this->builder->where(function (Builder $query) use ($search) { - /** - * @var RestifySearchable|Model - */ - $model = $query->getModel(); - - $connectionType = $query->getModel()->getConnection()->getDriverName(); - - $canSearchPrimaryKey = is_numeric($search) && - in_array($query->getModel()->getKeyType(), ['int', 'integer']) && - ($connectionType != 'pgsql' || $search <= PHP_INT_MAX) && - in_array($query->getModel()->getKeyName(), $model::getSearchableFields()); - - if ($canSearchPrimaryKey) { - $query->orWhere($query->getModel()->getQualifiedKeyName(), $search); - } + $search = $request->get('search', data_get($extra, 'search', '')); + $model = $query->getModel(); + if ($model instanceof RestifySearchable) { + $query->where(function (Builder $query) use ($search, $model) { + $connectionType = $model->getConnection()->getDriverName(); + + $canSearchPrimaryKey = is_numeric($search) && + in_array($query->getModel()->getKeyType(), ['int', 'integer']) && + ($connectionType != 'pgsql' || $search <= PHP_INT_MAX) && + in_array($query->getModel()->getKeyName(), $model::getSearchableFields()); + + if ($canSearchPrimaryKey) { + $query->orWhere($query->getModel()->getQualifiedKeyName(), $search); + } - $likeOperator = $connectionType == 'pgsql' ? 'ilike' : 'like'; + $likeOperator = $connectionType == 'pgsql' ? 'ilike' : 'like'; - foreach ($this->model::getSearchableFields() as $column) { - $query->orWhere($model->qualifyColumn($column), $likeOperator, '%'.$search.'%'); - } - }); + foreach ($model::getSearchableFields() as $column) { + $query->orWhere($model->qualifyColumn($column), $likeOperator, '%'.$search.'%'); + } + }); + } - return $this; + return $query; } /** - * Set order. - * + * @param $query * @param $param - * - * @return $this + * @return Builder */ - public function setOrder($param) + public function setOrder($query, $param) { if ($param === 'random') { - $this->builder->inRandomOrder(); + $query->inRandomOrder(); - return $this; + return $query; } $order = substr($param, 0, 1); @@ -309,19 +202,24 @@ public function setOrder($param) $field = $param; } - if (in_array($field, $this->model::getOrderByFields()) === true) { - if ($order === '-') { - $this->builder->orderBy($field, 'desc'); - } - if ($order === '+') { - $this->builder->orderBy($field, 'asc'); + $model = $query->getModel(); + + if (isset($field) && $model instanceof RestifySearchable) { + if (in_array($field, $model::getOrderByFields()) === true) { + if ($order === '-') { + $query->orderBy($field, 'desc'); + } + + if ($order === '+') { + $query->orderBy($field, 'asc'); + } } - } - if ($field === 'random') { - $this->builder->orderByRaw('RAND()'); + if ($field === 'random') { + $query->orderByRaw('RAND()'); + } } - return $this; + return $query; } } diff --git a/src/Services/Search/Searchable.php b/src/Services/Search/Searchable.php index 35ca074c..42d235dc 100644 --- a/src/Services/Search/Searchable.php +++ b/src/Services/Search/Searchable.php @@ -2,26 +2,12 @@ namespace Binaryk\LaravelRestify\Services\Search; -use Binaryk\LaravelRestify\Contracts\RestifySearchable; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Http\Request; - abstract class Searchable { /** - * @var Request - */ - protected $request; - - /** - * @var RestifySearchable|Model - */ - protected $model; - - /** - * @var array|null + * @var array */ - protected $fixedInput; + protected $fixedInput = []; /** * @param $input diff --git a/src/Traits/AuthorizableModels.php b/src/Traits/AuthorizableModels.php index 9580f238..553837db 100644 --- a/src/Traits/AuthorizableModels.php +++ b/src/Traits/AuthorizableModels.php @@ -4,6 +4,7 @@ use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Str; @@ -11,6 +12,7 @@ /** * Could be used as a trait in a model class and in a repository class. * + * @property Model resource * @author Eduard Lupacescu */ trait AuthorizableModels @@ -37,7 +39,9 @@ public static function authorizable() * Determine if the resource should be available for the given request. * * @param \Illuminate\Http\Request $request - * @return bool + * @return void + * @throws AuthorizationException + * @throws \Throwable */ public function authorizeToViewAny(Request $request) { @@ -71,9 +75,10 @@ public static function authorizedToViewAny(Request $request) * Determine if the current user can view the given resource or throw an exception. * * @param \Illuminate\Http\Request $request - * @return void + * @return bool * * @throws \Illuminate\Auth\Access\AuthorizationException + * @throws \Throwable */ public function authorizeToView(Request $request) { @@ -92,12 +97,13 @@ public function authorizedToView(Request $request) } /** - * Determine if the current user can create new resources or throw an exception. + * Determine if the current user can create new repositories or throw an exception. * * @param \Illuminate\Http\Request $request * @return void * * @throws \Illuminate\Auth\Access\AuthorizationException + * @throws \Throwable */ public static function authorizeToCreate(Request $request) { @@ -105,7 +111,7 @@ public static function authorizeToCreate(Request $request) } /** - * Determine if the current user can create new resources. + * Determine if the current user can create new repositories. * * @param \Illuminate\Http\Request $request * @return bool @@ -299,9 +305,14 @@ public function authorizedTo(Request $request, $ability) /** * @return AuthorizableModels|Model|mixed + * @throws \Throwable */ public function determineModel() { - return $this instanceof Model ? $this : $this->modelInstance; + $model = $this instanceof Model ? $this : ($this->resource ?? null); + + throw_if(is_null($model), new ModelNotFoundException(__('Model does not declared in :class', ['class' => self::class]))); + + return $model; } } diff --git a/src/Traits/InteractWithSearch.php b/src/Traits/InteractWithSearch.php index 59002a6c..e6d04333 100644 --- a/src/Traits/InteractWithSearch.php +++ b/src/Traits/InteractWithSearch.php @@ -2,7 +2,7 @@ namespace Binaryk\LaravelRestify\Traits; -use Illuminate\Http\Request; +use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; /** * @author Eduard Lupacescu @@ -50,17 +50,17 @@ public static function getMatchByFields() */ public static function getOrderByFields() { - return static::$order ?? []; + return static::$sort ?? []; } /** * Prepare the resource for JSON serialization. * - * @param Request $request + * @param RestifyRequest $request * @param array $fields * @return array */ - public function serializeForIndex(Request $request, array $fields = null) + public function serializeForIndex(RestifyRequest $request, array $fields = null) { return array_merge($fields ?: $this->toArray(), [ 'authorizedToView' => $this->authorizedToView($request), diff --git a/tests/Controllers/RepositoryIndexControllerTest.php b/tests/Controllers/RepositoryIndexControllerTest.php index 3a08ad22..9872a5f9 100644 --- a/tests/Controllers/RepositoryIndexControllerTest.php +++ b/tests/Controllers/RepositoryIndexControllerTest.php @@ -4,8 +4,10 @@ use Binaryk\LaravelRestify\Contracts\RestifySearchable; use Binaryk\LaravelRestify\Controllers\RestController; +use Binaryk\LaravelRestify\Http\Requests\RestifyRequest; use Binaryk\LaravelRestify\Tests\Fixtures\User; use Binaryk\LaravelRestify\Tests\IntegrationTest; +use Mockery; /** * @author Eduard Lupacescu @@ -45,7 +47,7 @@ public function users() $this->assertEquals(count($class->users()->getData()->data->data), User::$defaultPerPage); } - public function test_per_page() + public function test_that_default_per_page_works() { User::$defaultPerPage = 40; $this->mockUsers(50); @@ -67,4 +69,136 @@ public function users() $this->assertEquals(count($class->users()->getData()->data->data), 40); User::$defaultPerPage = RestifySearchable::DEFAULT_PER_PAGE; } + + public function test_search_query_works() + { + $users = $this->mockUsers(10, ['eduard.lupacescu@binarcode.com']); + $expected = $users->where('email', 'eduard.lupacescu@binarcode.com')->first()->serializeForIndex(Mockery::mock(RestifyRequest::class)); + $this->withExceptionHandling() + ->getJson('/restify-api/users?search=eduard.lupacescu@binarcode.com') + ->assertStatus(200) + ->assertJson([ + 'data' => [ + 'data' => [$expected], + 'current_page' => 1, + 'first_page_url' => 'http://localhost/restify-api/users?page=1', + 'from' => 1, + 'last_page' => 1, + 'last_page_url' => 'http://localhost/restify-api/users?page=1', + 'next_page_url' => null, + 'path' => 'http://localhost/restify-api/users', + 'per_page' => 15, + 'prev_page_url' => null, + 'to' => 1, + 'total' => 1, + ], + 'errors' => [], + ]); + + $this->withExceptionHandling() + ->getJson('/restify-api/users?search=some_unexpected_string_here') + ->assertStatus(200) + ->assertJson([ + 'data' => [ + 'data' => [], + 'current_page' => 1, + 'first_page_url' => 'http://localhost/restify-api/users?page=1', + 'from' => 1, + 'last_page' => 1, + 'last_page_url' => 'http://localhost/restify-api/users?page=1', + 'next_page_url' => null, + 'path' => 'http://localhost/restify-api/users', + 'per_page' => 15, + 'prev_page_url' => null, + 'to' => 1, + 'total' => 1, + ], + 'errors' => [], + ]); + } + + public function test_that_desc_sort_query_param_works() + { + $this->mockUsers(10); + $response = $this->withExceptionHandling()->get('/restify-api/users?sort=-id') + ->assertStatus(200) + ->getOriginalContent(); + + $this->assertSame($response->data['data'][0]['id'], 10); + $this->assertSame($response->data['data'][9]['id'], 1); + } + + public function test_that_asc_sort_query_param_works() + { + $this->mockUsers(10); + + $response = $this->withExceptionHandling()->get('/restify-api/users?sort=+id') + ->assertStatus(200) + ->getOriginalContent(); + + $this->assertSame($response->data['data'][0]['id'], 1); + $this->assertSame($response->data['data'][9]['id'], 10); + + $response = $this->withExceptionHandling()->get('/restify-api/users?sort=id')//assert default ASC sort + ->assertStatus(200) + ->getOriginalContent(); + + $this->assertSame($response->data['data'][0]['id'], 1); + $this->assertSame($response->data['data'][9]['id'], 10); + } + + public function test_that_default_asc_sort_query_param_works() + { + $this->mockUsers(10); + + $response = $this->withExceptionHandling()->get('/restify-api/users?sort=id') + ->assertStatus(200) + ->getOriginalContent(); + + $this->assertSame($response->data['data'][0]['id'], 1); + $this->assertSame($response->data['data'][9]['id'], 10); + } + + public function test_that_match_param_works() + { + User::$match = ['email' => RestifySearchable::MATCH_TEXT]; // it will automatically filter over these queries (email='test@email.com') + $users = $this->mockUsers(10, ['eduard.lupacescu@binarcode.com']); + $expected = $users->where('email', 'eduard.lupacescu@binarcode.com')->first()->serializeForIndex(Mockery::mock(RestifyRequest::class)); + + $this->withExceptionHandling() + ->get('/restify-api/users?email=eduard.lupacescu@binarcode.com') + ->assertStatus(200) + ->assertJson([ + 'data' => [ + 'data' => [$expected], + 'current_page' => 1, + 'first_page_url' => 'http://localhost/restify-api/users?page=1', + 'from' => 1, + 'last_page' => 1, + 'last_page_url' => 'http://localhost/restify-api/users?page=1', + 'next_page_url' => null, + 'path' => 'http://localhost/restify-api/users', + 'per_page' => 15, + 'prev_page_url' => null, + 'to' => 1, + 'total' => 1, + ], + 'errors' => [], + ]); + } + + public function test_that_with_param_works() + { + User::$match = ['email' => RestifySearchable::MATCH_TEXT]; // it will automatically filter over these queries (email='test@email.com') + $users = $this->mockUsers(1); + $posts = $this->mockPosts(1, 2); + $expected = $users->first()->serializeForIndex(Mockery::mock(RestifyRequest::class)); + $expected['posts'] = $posts->toArray(); + $r = $this->withExceptionHandling() + ->get('/restify-api/users?with=posts') + ->assertStatus(200) + ->getOriginalContent(); + + $this->assertSameSize($r->data['data']->first()['posts'], $expected['posts']); + } } diff --git a/tests/Factories/PostFactory.php b/tests/Factories/PostFactory.php new file mode 100644 index 00000000..8247515e --- /dev/null +++ b/tests/Factories/PostFactory.php @@ -0,0 +1,22 @@ +define(Binaryk\LaravelRestify\Tests\Fixtures\Post::class, function (Faker $faker) { + return [ + 'user_id' => 1, + 'title' => $faker->title, + 'description' => $faker->text, + ]; +}); diff --git a/tests/Fixtures/Post.php b/tests/Fixtures/Post.php new file mode 100644 index 00000000..1ba5be0a --- /dev/null +++ b/tests/Fixtures/Post.php @@ -0,0 +1,22 @@ + + */ +class Post extends Model implements RestifySearchable +{ + use InteractWithSearch; + + protected $fillable = [ + 'id', + 'user_id', + 'title', + 'description', + ]; +} diff --git a/tests/Fixtures/PostRepository.php b/tests/Fixtures/PostRepository.php new file mode 100644 index 00000000..c551686a --- /dev/null +++ b/tests/Fixtures/PostRepository.php @@ -0,0 +1,23 @@ + + */ +class PostRepository extends Repository +{ + public static $model = Post::class; + + /** + * Get the URI key for the resource. + * + * @return string + */ + public static function uriKey() + { + return 'posts'; + } +} diff --git a/tests/Fixtures/User.php b/tests/Fixtures/User.php index f747abfa..09e94a2b 100644 --- a/tests/Fixtures/User.php +++ b/tests/Fixtures/User.php @@ -20,10 +20,11 @@ class User extends Authenticatable implements Passportable, MustVerifyEmail, Res use Notifiable, InteractWithSearch; - public static $search = ['id']; - public static $match = [ - 'id' => 'int', - ]; + public static $search = ['id', 'email']; + public static $sort = ['id']; + public static $match = ['id' => 'int', 'email' => 'string']; + public static $in = ['id' => 'int']; + public static $withs = ['posts']; /** * The attributes that are mass assignable. @@ -71,4 +72,21 @@ public function tokens() { return Mockery::mock(Builder::class); } + + public function posts() + { + return $this->hasMany(Post::class); + } + + /** + * Set default test values. + */ + public static function reset() + { + static::$search = ['id', 'email']; + static::$sort = ['id']; + static::$match = ['id' => 'int', 'email' => 'string']; + static::$in = ['id' => 'int']; + static::$withs = ['posts']; + } } diff --git a/tests/Fixtures/UserRepository.php b/tests/Fixtures/UserRepository.php index 02e198b1..57292911 100644 --- a/tests/Fixtures/UserRepository.php +++ b/tests/Fixtures/UserRepository.php @@ -3,7 +3,6 @@ namespace Binaryk\LaravelRestify\Tests\Fixtures; use Binaryk\LaravelRestify\Repositories\Repository; -use Illuminate\Http\Request; /** * @author Eduard Lupacescu @@ -12,14 +11,6 @@ class UserRepository extends Repository { public static $model = User::class; - /** - * {@inheritdoc} - */ - public function fields(Request $request) - { - return []; - } - /** * Get the URI key for the resource. * diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index 98f86b27..910fc60f 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -4,9 +4,11 @@ use Binaryk\LaravelRestify\LaravelRestifyServiceProvider; use Binaryk\LaravelRestify\Restify; +use Binaryk\LaravelRestify\Tests\Fixtures\PostRepository; use Binaryk\LaravelRestify\Tests\Fixtures\User; use Binaryk\LaravelRestify\Tests\Fixtures\UserRepository; use Illuminate\Contracts\Translation\Translator; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Route; use Orchestra\Testbench\TestCase; @@ -25,6 +27,7 @@ abstract class IntegrationTest extends TestCase protected function setUp(): void { parent::setUp(); + DB::enableQueryLog(); Hash::driver('bcrypt')->setRounds(4); $this->repositoryMock(); $this->loadMigrations(); @@ -34,6 +37,7 @@ protected function setUp(): void Restify::repositories([ UserRepository::class, + PostRepository::class, ]); } @@ -138,4 +142,14 @@ public function loadRoutes() // AuthPassport -> resetPassword })->name('password.reset'); } + + /** + * @return array + */ + public function lastQuery() + { + $queries = DB::getQueryLog(); + + return end($queries); + } } diff --git a/tests/InteractWithModels.php b/tests/InteractWithModels.php index 970625aa..7aa7e5be 100644 --- a/tests/InteractWithModels.php +++ b/tests/InteractWithModels.php @@ -2,6 +2,7 @@ namespace Binaryk\LaravelRestify\Tests; +use Binaryk\LaravelRestify\Tests\Fixtures\Post; use Binaryk\LaravelRestify\Tests\Fixtures\User; /** @@ -9,7 +10,12 @@ */ trait InteractWithModels { - public function mockUsers($count = 1) + /** + * @param int $count + * @param array $predefinedEmails + * @return \Illuminate\Support\Collection + */ + public function mockUsers($count = 1, $predefinedEmails = []) { $users = collect([]); $i = 0; @@ -18,6 +24,31 @@ public function mockUsers($count = 1) $i++; } - return $users; + foreach ($predefinedEmails as $email) { + $users->push(factory(User::class)->create([ + 'email' => $email, + ])); + } + + return $users->shuffle(); // randomly shuffles the items in the collection + } + + /** + * @param $userId + * @param int $count + * @return \Illuminate\Support\Collection + */ + public function mockPosts($userId, $count = 1) + { + $users = collect([]); + $i = 0; + while ($i < $count) { + $users->push(factory(Post::class)->create( + ['user_id' => $userId] + )); + $i++; + } + + return $users->shuffle(); // randomly shuffles the items in the collection } } diff --git a/tests/Migrations/2019_12_22_000005_create_posts_table.php b/tests/Migrations/2019_12_22_000005_create_posts_table.php new file mode 100644 index 00000000..b6a7b6a9 --- /dev/null +++ b/tests/Migrations/2019_12_22_000005_create_posts_table.php @@ -0,0 +1,34 @@ +increments('id'); + $table->unsignedInteger('user_id')->index(); + $table->string('title'); + $table->longText('description'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('password_resets'); + } +} diff --git a/tests/SearchServiceTest.php b/tests/SearchServiceTest.php new file mode 100644 index 00000000..7042d776 --- /dev/null +++ b/tests/SearchServiceTest.php @@ -0,0 +1,216 @@ + + */ +class SearchServiceTest extends IntegrationTest +{ + /** + * @var SearchService + */ + private $service; + + protected function setUp(): void + { + parent::setUp(); + + $this->service = resolve(SearchService::class); + } + + public function test_should_attach_with_from_extra_in_eager_load() + { + $this->mockUsers(); + $this->mockPosts(1); + User::$withs = ['posts']; + $request = MockeryAlias::mock(RestifyRequest::class); + $builder = User::query(); + $request->shouldReceive('get') + ->andReturn(); + /** + * @var Builder + */ + $query = $this->service->prepareRelations($request, $builder, ['posts']); + $this->assertInstanceOf(Closure::class, $query->getEagerLoads()['posts']); + } + + public function test_should_attach_with_in_eager_load() + { + $this->mockUsers(); + $this->mockPosts(1); + User::$withs = ['posts']; + $request = MockeryAlias::mock(RestifyRequest::class); + $builder = User::query(); + $request->shouldReceive('get') + ->andReturn('posts'); + /** + * @var Builder + */ + $query = $this->service->prepareRelations($request, $builder); + $this->assertInstanceOf(Closure::class, $query->getEagerLoads()['posts']); + } + + public function test_should_order_desc_by_field() + { + $this->mockUsers(5); + User::$sort = ['id']; + $request = MockeryAlias::mock(RestifyRequest::class); + $builder = User::query(); + $request->shouldReceive('get') + ->andReturn('-id'); + /** + * @var Builder + */ + $query = $this->service->prepareOrders($request, $builder); + $this->assertEquals('id', $query->getQuery()->orders[0]['column']); + $this->assertEquals('desc', $query->getQuery()->orders[0]['direction']); + } + + public function test_should_order_asc_by_field() + { + $this->mockUsers(5); + User::$sort = ['id']; + $request = MockeryAlias::mock(RestifyRequest::class); + $builder = User::query(); + $request->shouldReceive('get') + ->andReturn('id'); + /** + * @var Builder + */ + $query = $this->service->prepareOrders($request, $builder); + $this->assertEquals('id', $query->getQuery()->orders[0]['column']); + $this->assertEquals('asc', $query->getQuery()->orders[0]['direction']); + } + + public function test_should_order_asc_by_extra_passed_field() + { + $this->mockUsers(5); + User::$sort = ['id']; + $request = MockeryAlias::mock(RestifyRequest::class); + $builder = User::query(); + $request->shouldReceive('get') + ->andReturn(); + /** + * @var Builder + */ + $query = $this->service->prepareOrders($request, $builder, [ + 'sort' => 'id', + ]); + $this->assertEquals('id', $query->getQuery()->orders[0]['column']); + $this->assertEquals('asc', $query->getQuery()->orders[0]['direction']); + } + + public function test_match_fields_should_add_equal_where_clause() + { + $this->mockUsers(); + User::$match = ['email' => 'string']; + $request = MockeryAlias::mock(RestifyRequest::class); + $builder = User::query(); + $request->shouldReceive('get') + ->andReturn('eduard.lupacescu@binarcode.com'); + + $request->shouldReceive('has') + ->andReturnTrue(); + /** + * @var Builder + */ + $query = $this->service->prepareMatchFields($request, $builder); + $this->assertCount(count(User::$match), $query->getQuery()->getRawBindings()['where']); + $this->assertEquals('eduard.lupacescu@binarcode.com', $query->getQuery()->getRawBindings()['where'][0]); + $query->get(); + $raw = $this->lastQuery(); + $this->assertEquals($raw['query'], 'select * from "users" where "users"."email" = ?'); + $this->assertEquals($raw['bindings'], ['eduard.lupacescu@binarcode.com']); + User::reset(); + } + + public function test_match_fields_from_extra_should_add_equal_where_clause() + { + $this->mockUsers(); + User::$match = ['email' => 'string']; + $request = MockeryAlias::mock(RestifyRequest::class); + $builder = User::query(); + $request->shouldReceive('get') + ->andReturnArg(1); //returns the default value + + $request->shouldReceive('has') + ->andReturnFalse(); + /** + * @var Builder + */ + $query = $this->service->prepareMatchFields($request, $builder, [ + 'match' => [ + 'email' => 'eduard.lupacescu@binarcode.com', + ], + ]); + $this->assertCount(count(User::$match), $query->getQuery()->getRawBindings()['where']); + $this->assertEquals('eduard.lupacescu@binarcode.com', $query->getQuery()->getRawBindings()['where'][0]); + $query->get(); + $raw = $this->lastQuery(); + $this->assertEquals($raw['query'], 'select * from "users" where "users"."email" = ?'); + $this->assertEquals($raw['bindings'], ['eduard.lupacescu@binarcode.com']); + User::reset(); + } + + public function test_match_fields_should_not_add_equal_where_if_value_passed_in_query_is_empty() + { + $this->mockUsers(); + User::$match = ['email' => 'string']; + $request = MockeryAlias::mock(RestifyRequest::class); + $builder = User::query(); + $request->shouldReceive('get') + ->andReturn(''); + + $request->shouldReceive('has') + ->andReturnTrue(); + /** + * @var Builder + */ + $query = $this->service->prepareMatchFields($request, $builder); + $this->assertCount(0, $query->getQuery()->getRawBindings()['where']); + User::reset(); + } + + public function test_should_not_call_anything_from_search_service_if_not_searchable_instance() + { + $service = MockeryAlias::spy(SearchService::class); + $this->instance(SearchService::class, $service); + $request = MockeryAlias::mock(RestifyRequest::class); + $class = (new class extends Model { + }); + $resolvedService = resolve(SearchService::class); + $resolvedService->search($request, $class); + $service->shouldHaveReceived('search'); + $service->shouldNotReceive('prepareSearchFields'); + $service->shouldNotReceive('prepareMatchFields'); + $service->shouldNotReceive('prepareRelations'); + $service->shouldNotReceive('prepareOrders'); + } + + public function test_prepare_search_should_add_where_clause() + { + $this->mockUsers(1); + $request = MockeryAlias::mock(RestifyRequest::class); + $builder = User::query(); + $request->shouldReceive('get') + ->andReturn('some search'); + /** + * @var Builder + */ + $query = $this->service->prepareSearchFields($request, $builder); + $this->assertArrayHasKey('where', $query->getQuery()->getRawBindings()); + $this->assertCount(count(User::$search), $query->getQuery()->getRawBindings()['where']); + foreach ($query->getQuery()->getRawBindings()['where'] as $k => $queryString) { + $this->assertEquals('%some search%', $queryString); + } + } +}