snipe-it/app/Models/Traits/Searchable.php
Till Deeke b5de5ac19c Fix: Searching for multiple terms on assets (#5860)
* Give advancedTextSearch all search terms at one

The additional conditions for assets had some problems, since they were joining tables for the additional attributes. The method was called once for every search term, so the join was added multiple times if the user entered multiple search terms.

* Allows search to handle multiple search terms better

The search now better handles multiple search terms, adding additional orWhere clauses, instead of duplicating all queries.

* Fixing typo
2018-07-16 17:44:31 -07:00

240 lines
7.6 KiB
PHP

<?php
namespace App\Models\Traits;
use App\Models\Asset;
use App\Models\CustomField;
use Illuminate\Database\Eloquent\Builder;
/**
* This trait allows for cleaner searching of models,
* moving from complex queries to an easier declarative syntax.
*
* @author Till Deeke <kontakt@tilldeeke.de>
*/
trait Searchable {
/**
* Performs a search on the model, using the provided search terms
*
* @param Illuminate\Database\Eloquent\Builder $query The query to start the search on
* @param string $search
* @return Illuminate\Database\Eloquent\Builder A query with added "where" clauses
*/
public function scopeTextSearch($query, $search)
{
$terms = $this->prepeareSearchTerms($search);
/**
* Search the attributes of this model
*/
$query = $this->searchAttributes($query, $terms);
/**
* Search through the custom fields of the model
*/
$query = $this->searchCustomFields($query, $terms);
/**
* Search through the relations of the model
*/
$query = $this->searchRelations($query, $terms);
/**
* Search for additional attributes defined by the model
*/
$query = $this->advancedTextSearch($query, $terms);
return $query;
}
/**
* Prepares the search term, splitting and cleaning it up
* @param string $search The search term
* @return array An array of search terms
*/
private function prepeareSearchTerms(string $search) {
return explode(' OR ', $search);
}
/**
* Searches the models attributes for the search terms
*
* @param Illuminate\Database\Eloquent\Builder $query
* @param array $terms
* @return Illuminate\Database\Eloquent\Builder
*/
private function searchAttributes(Builder $query, array $terms) {
$table = $this->getTable();
foreach($this->getSearchableAttributes() as $column) {
foreach($terms as $term) {
/**
* Making sure to only search in date columns if the search term consists of characters that can make up a MySQL timestamp!
*
* @see https://github.com/snipe/snipe-it/issues/4590
*/
if (!preg_match('/^[0-9 :-]++$/', $term) && in_array($column, $this->getDates())) {
continue;
}
$query = $query->orWhere($table . '.' . $column, 'LIKE', '%'.$term.'%');
}
}
return $query;
}
/**
* Searches the models custom fields for the search terms
*
* @param Illuminate\Database\Eloquent\Builder $query
* @param array $terms
* @return Illuminate\Database\Eloquent\Builder
*/
private function searchCustomFields(Builder $query, array $terms) {
/**
* If we are searching on something other that an asset, skip custom fields.
*/
if (! $this instanceof Asset) {
return $query;
}
$customFields = CustomField::all();
foreach ($customFields as $field) {
foreach($terms as $term) {
$query->orWhere($this->getTable() . '.'. $field->db_column_name(), 'LIKE', '%'.$term.'%');
}
}
return $query;
}
/**
* Searches the models relations for the search terms
*
* @param Illuminate\Database\Eloquent\Builder $query
* @param array $terms
* @return Illuminate\Database\Eloquent\Builder
*/
private function searchRelations(Builder $query, array $terms) {
foreach($this->getSearchableRelations() as $relation => $columns) {
$query = $query->orWhereHas($relation, function($query) use ($relation, $columns, $terms) {
$table = $this->getRelationTable($relation);
/**
* We need to form the query properly, starting with a "where",
* otherwise the generated nested select is wrong.
*
* @todo This does the job, but is inelegant and fragile
*/
$firstConditionAdded = false;
foreach($columns as $column) {
foreach($terms as $term) {
if (!$firstConditionAdded) {
$query->where($table . '.' . $column, 'LIKE', '%'.$term.'%');
$firstConditionAdded = true;
continue;
}
$query->orWhere($table . '.' . $column, 'LIKE', '%'.$term.'%');
}
}
});
}
return $query;
}
/**
* Run additional, advanced searches that can't be done using the attributes or relations.
*
* This is a noop in this trait, but can be overridden in the implementing model, to allow more advanced searches
*
* @param Illuminate\Database\Eloquent\Builder $query
* @param array $terms The search terms
* @return Illuminate\Database\Eloquent\Builder
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function advancedTextSearch(Builder $query, array $terms) {
return $query;
}
/**
* Get the searchable attributes, if defined. Otherwise it returns an empty array
*
* @return array The attributes to search in
*/
private function getSearchableAttributes() {
return isset($this->searchableAttributes) ? $this->searchableAttributes : [];
}
/**
* Get the searchable relations, if defined. Otherwise it returns an empty array
*
* @return array The relations to search in
*/
private function getSearchableRelations() {
return isset($this->searchableRelations) ? $this->searchableRelations : [];
}
/**
* Get the table name of a relation.
*
* This method loops over a relation name,
* getting the table name of the last relation in the series.
* So "category" would get the table name for the Category model,
* "model.manufacturer" would get the tablename for the Manufacturer model.
*
* @param string $relation
* @return string The table name
*/
private function getRelationTable($relation) {
$related = $this;
foreach(explode('.', $relation) as $relationName) {
$related = $related->{$relationName}()->getRelated();
}
/**
* Are we referencing the model that called?
* Then get the internal join-tablename, since laravel
* has trouble selecting the correct one in this type of
* parent-child self-join.
*
* @todo Does this work with deeply nested resources? Like "category.assets.model.category" or something like that?
*/
if ($this instanceof $related) {
/**
* Since laravel increases the counter on the hash on retrieval, we have to count it down again.
*
* This causes side effects! Every time we access this method, laravel increases the counter!
*
* Format: laravel_reserved_XXX
*/
$relationCountHash = $this->{$relationName}()->getRelationCountHash();
$parts = collect(explode('_', $relationCountHash));
$counter = $parts->pop();
$parts->push($counter - 1);
return implode('_', $parts->toArray());
}
return $related->getTable();
}
}