diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb756e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +vendor +.idea \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..1075df2 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d698c3 --- /dev/null +++ b/README.md @@ -0,0 +1,261 @@ +# Eloquent Filter +An Eloquent way to filter Eloquent Models + +## Introduction +Lets say we want to return a list of users filtered by multiple parameters. When we navigate to: + +`/users?name=er&last_name=&company_id=2&roles[]=1&roles[]=4&roles[]=7&industry=5` + +`$request->all()` will return: +```php +[ + 'name' => 'er', + 'last_name' => '' + 'company_id' => '2', + 'roles' => ['1','4','7'], + 'industry' => '5' +] +``` +To filter by all those parameters we would need to do something like: +```php +input('company_id')); + + if ($request->input('last_name') !== '') + { + $query->where('last_name', 'LIKE', '%' . $request->input('last_name') . '%'); + } + + if ($request->input('name') !== '') + { + $query->where(function ($q) use ($request) + { + return $q->where('first_name', 'LIKE', $request->input('name') . '%') + ->orWhere('last_name', 'LIKE', '%' . $request->input('name') . '%'); + }); + } + + $query->whereHas('roles', function ($q) use ($request) + { + return $q->whereIn('id', $request->input('roles')); + }) + ->whereHas('clients', function ($q) use ($request) + { + return $q->whereHas('industry_id', $request->input('industry')); + }); + + return $query->get(); + } + +} +``` +To filter that same input With Eloquent Filters: + +```php +all())->get(); + } + +} +``` + +## Configuration +After installing the Eloquent Filter library, register the `EloquentFilter\ServiceProvider::class` in your `config/app.php` configuration file: +```php +'providers' => [ + // Other service providers... + + EloquentFilter\ServiceProvider::class, +], +``` +Copy the package config to your local config with the publish command: +```bash +php artisan vendor:publish --provider="EloquentFilter\ServiceProvider" +``` +In the `app/eloquentfilter.php` config file. Set the namespace your model filters will reside in: +```php +'namespace' => "App\\ModelFilters\\", +``` + +## Usage + +### Generating The Filter +You can create a model filter with the following artisan command: +```bash +php artisan model:filter User +``` +Where `User` is the Eloquent Model you are creating the filter for. This will create `app/ModelFilters/UserFilter.php` + +### Defining The Filter Logic +Define the filter logic based on the camel cased input key passed to the `filter()` method. + +- Empty strings are ignored +- `_id` is dropped from the end of the input to define the method so filtering `user_id` would use the `user()` method +- Input without a corresponding filter method are ignored +- The value of the key is injected into the method +- All values are accessible through the `$this->input()` method or a single value by key `$this->input($key)` +- All Eloquent Builder methods are accessible in `this` context in the model filter class. + +### Applying The Filter To A Model + +Implement the `EloquentFilter\Filterable` trait on any Eloquent model: +```php +all())->get(); + } +} +``` + +#### Filtering By Relationships +In order to filter by a relationship (whether the relation is joined in the query or not) add the relation in the `$relations` array with the name of the relation as referred to on the model as the key and the column names that will be received as input to filter. + +The related model **MUST** have a ModelFilter associated with it. We instantiate the related model's filter and use the column values from the `$relations` array to call the associated methods. + +This is helpful when querying multiple columns on a relation's table. For a single column using a `$this->whereHas()` method in the model filter works just fine + +##### Example: + +If I have a `User` that `hasMany` `App\Client::class` my model would look like: +```php +class User extends Model +{ + use Filterable; + + public function clients() + { + return $this->hasMany(Client::class); + } +} +``` +Let's also say each `App\Client` has belongs to `App\Industry::class`: +```php +class User extends Model +{ + use Filterable; + + public function industry() + { + return $this->belongsTo(Industry::class); + } +} +``` +We want to query our User's and filter them based on the industry of their client: + +Input used to filter: +```php +$input = [ + industry => 5 +]; +``` +`UserFilter` with the relation defined so it's able to be queried. +```php +class UserFilter extends ModelFilter +{ + public $relations = [ + 'clients' => ['industry'], + ]; +} +``` +`ClientFilter` with the `industry` method that's used to filter: +```php +class UserFilter extends ModelFilter +{ + public $relations = []; + + public function industry($id) + { + return $this->where('industry_id', $id); + } +} +``` + +If the following array is passed to the `filter()` method: +```php +[ + 'name' => 'er', + 'last_name' => '' + 'company_id' => 2, + 'roles' => [1,4,7], + 'industry' => 5 +] +``` +In `app/ModelFilters/UserFilter.php`: +```php + ['industry'], + ]; + + public function name($name) + { + return $this->where(function($q) + { + return $q->where('first_name', 'LIKE', $name . '%')->orWhere('last_name', 'LIKE', '%' . $name.'%'); + }); + } + + public function lastName($lastName) + { + return $this->where('last_name', 'LIKE', '%' . $lastName); + } + + public function company($id) + { + return $this->where('company_id',$id); + } + + public function roles($ids) + { + return $this->whereHas('roles', function($query) use ($ids) + { + return $query->whereIn('id', $ids); + }); + } +} +``` +# Contributing +Any contributions welcome! \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..eaca10c --- /dev/null +++ b/composer.json @@ -0,0 +1,26 @@ +{ + "name": "tucker-eric/eloquentfilter", + "description": "An Eloquent way to filter Eloquent Models", + "keywords": [ + "laravel", + "eloquent", + "filter" + ], + "license": "MIT", + "authors": [ + { + "name": "Eric Tucker", + "email": "tucker.ericm@gmail.com" + } + ], + "require": { + "php": ">=5.5.9", + "illuminate/database": "~5.0" + }, + "autoload": { + "psr-4": { + "EloquentFilter\\": "src/" + } + }, + "minimum-stability": "dev" +} \ No newline at end of file diff --git a/config/eloquentfilter.php b/config/eloquentfilter.php new file mode 100644 index 0000000..e8019b6 --- /dev/null +++ b/config/eloquentfilter.php @@ -0,0 +1,16 @@ + "App\\ModelFilters\\", + +); diff --git a/src/Filterable.php b/src/Filterable.php new file mode 100644 index 0000000..740ed58 --- /dev/null +++ b/src/Filterable.php @@ -0,0 +1,13 @@ +handle(); + } +} \ No newline at end of file diff --git a/src/ModelFilter.php b/src/ModelFilter.php new file mode 100644 index 0000000..8998115 --- /dev/null +++ b/src/ModelFilter.php @@ -0,0 +1,207 @@ + [method1, method2]] + * + * @var array + */ + public $relations = []; + + /** + * Array of input to filter + * + * @var array + */ + protected $input; + + /** + * @var \Illuminate\Database\Eloquent\Builder + */ + protected $query; + + private $_joinedTables = null; + + /** + * ModelFilter constructor. + * + * @param $query + * @param array $input + */ + public function __construct($query, array $input) + { + $this->query = $query; + $this->input = $this->removeEmptyInput($input); + } + + /** + * Handle calling methods on the query object + * + * @param $method + * @param $args + */ + public function __call($method, $args) + { + $class = method_exists($this, $method) ? $this : $this->query; + + return call_user_func_array([$class, $method], $args); + } + + /** + * Remove empty strings from the input array + * + * @param $input + * @return array + */ + public function removeEmptyInput($input) + { + return array_where($input, function ($key, $val) + { + return $val != ''; + }); + } + + /** + * Handle all input filters + * + * @return QueryBuilder + */ + public function handle() + { + foreach ($this->input as $key => $val) + { + // Call all local methods on filter + $method = camel_case(preg_replace('/^(.*)_id$/', '$1', $key)); + + if (method_exists($this, $method)) + { + call_user_func([$this, $method], $val); + } + } + + // Set up all the whereHas and joins constraints + $this->filterRelations(); + + return $this->query; + } + + /** + * Filter relationships defined in $this->relations array + * @return $this + */ + public function filterRelations() + { + // No need to filer if we dont have any relations + if (count($this->relations) === 0) + return $this; + + foreach ($this->relations as $related => $fields) + { + if (count($filterableInput = array_only($this->input, $fields)) > 0) + { + if ($this->relationIsJoined($related)) + { + $this->filterJoinedRelation($related, $filterableInput); + } else + { + $this->filterUnjoinedRelation($related, $filterableInput); + } + } + } + + return $this; + } + + /** + * Run the filter on models that already have their tables joined + * + * @param $related + * @param $filterableInput + */ + public function filterJoinedRelation($related, $filterableInput) + { + $relatedModel = $this->query->getModel()->{$related}()->getRelated(); + + $filterClass = __NAMESPACE__ . '\\' . class_basename($relatedModel) . 'Filter'; + + with(new $filterClass($this->query, $filterableInput))->handle(); + } + + /** + * Gets all the joined tables + * + * @return array + */ + public function getJoinedTables() + { + $joins = []; + + if (is_array($queryJoins = $this->query->getQuery()->joins)) + { + $joins = array_map(function ($join) + { + return $join->table; + }, $queryJoins); + } + + return $joins; + } + + /** + * Checks if the relation to filter's table is already joined + * + * @param $relation + * @return boolean + */ + public function relationIsJoined($relation) + { + if (is_null($this->_joinedTables)) + $this->_joinedTables = $this->getJoinedTables(); + + return in_array($this->getRelatedTable($relation), $this->_joinedTables); + } + + /** + * Get the table name from a relationship + * + * @param $relation + * @return string + */ + public function getRelatedTable($relation) + { + return $this->query->getModel()->{$relation}()->getRelated()->getTable(); + } + + /** + * Filters by a relationship that isnt joined by using that relation's ModelFilter + * + * @param $related + * @param $filterableInput + */ + public function filterUnjoinedRelation($related, $filterableInput) + { + $this->query->whereHas($related, function ($q) use ($filterableInput) + { + return $q->filter($filterableInput); + }); + } + + /** + * Retrieve input by key or all input as array + * + * @param null $key + * @return array|mixed|null + */ + public function input($key = null) + { + if (is_null($key)) + return $this->input; + + return isset($this->input[$key]) ? $this->input[$key] : null; + } +} \ No newline at end of file diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php new file mode 100644 index 0000000..4874aff --- /dev/null +++ b/src/ServiceProvider.php @@ -0,0 +1,36 @@ +publishes([ + __DIR__.'/../config/eloquentfilter.php' => config_path('eloquentfilter.php'), + ]); + } + /** + * Register any application services. + * + * @return void + */ + public function register() + { + $this->registerFilterGeneratorCommand(); + } + + private function registerFilterGeneratorCommand() + { + $this->app->singleton('command.eloquentfilter.make', function ($app) { + return $app['EloquentFilter\Commands\MakeEloquentFilter']; + }); + $this->commands('command.eloquentfilter.make'); + } +} \ No newline at end of file diff --git a/src/commands/MakeEloquentFilter.php b/src/commands/MakeEloquentFilter.php new file mode 100644 index 0000000..1d9b90d --- /dev/null +++ b/src/commands/MakeEloquentFilter.php @@ -0,0 +1,126 @@ +files = $files; + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + $this->makeClassName()->compileStub(); + $this->info(class_basename($this->class) . ' Created Successfully!'); + } + + public function compileStub() + { + if ($this->files->exists($path = $this->getPath())) + { + $this->error("\n\n\t" . $path . ' Already Exists!' . "\n"); + die; + } + $tmp = $this->applyValuesToStub($this->files->get(__DIR__ . '/../stubs/modelfilter.stub')); + $this->files->put($path, $tmp); + } + + public function applyValuesToStub($stub) + { + $className = class_basename($this->class); + $search = ['{{class}}', '{{namespace}}']; + $replace = [$className, str_replace('\\' . $className, '', $this->class)]; + + return str_replace($search, $replace, $stub); + } + + public function getPath() + { + return app_path(str_replace([$this->getAppNamespace(), '\\'], ['', '/'], $this->class . '.php')); + } + + /** + * Build the directory for the class if necessary. + * + * @param string $path + * @return string + */ + protected function makeDirectory($path) + { + if (!$this->files->isDirectory(dirname($path))) + { + $this->files->makeDirectory(dirname($path), 0777, true, true); + } + } + + /** + * Create Filter Class Name + * + * @return $this + */ + public function makeClassName() + { + $parts = explode('\\', $this->argument('name')); + $className = array_pop($parts); + $ns = count($parts) > 0 ? implode('\\', $parts) . "\\" : ''; + + $this->class = config('eloquentfilter.namespace') . $ns . studly_case($className); + + if (substr($this->class, -6, 6) !== 'Filter') + { + $this->class = (substr($this->class, -6, 6) === 'filter' ? str_replace('filter', '', $this->class) : $this->class) . 'Filter'; + } + + if (class_exists($this->class)) + { + $this->error("\n\n\t" . $this->class . ' Already Exists!' . "\n"); + die; + } + + return $this; + } +} diff --git a/src/stubs/modelfilter.stub b/src/stubs/modelfilter.stub new file mode 100644 index 0000000..e84ec27 --- /dev/null +++ b/src/stubs/modelfilter.stub @@ -0,0 +1,14 @@ + [method1, method2]] + * + * @var array + */ + public $relations = []; +} \ No newline at end of file