301 lines
		
	
	
		
			9.6 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			301 lines
		
	
	
		
			9.6 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
<?php
 | 
						|
 | 
						|
namespace App\Models\Traits;
 | 
						|
 | 
						|
use App\Models\Asset;
 | 
						|
use App\Models\CustomField;
 | 
						|
use Illuminate\Database\Eloquent\Builder;
 | 
						|
use Illuminate\Support\Facades\DB;
 | 
						|
 | 
						|
/**
 | 
						|
 * 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($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();
 | 
						|
 | 
						|
        $firstConditionAdded = false;
 | 
						|
 | 
						|
        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;
 | 
						|
                }
 | 
						|
 | 
						|
                /**
 | 
						|
                 * We need to form the query properly, starting with a "where",
 | 
						|
                 * otherwise the generated select is wrong.
 | 
						|
                 *
 | 
						|
                 * @todo  This does the job, but is inelegant and fragile
 | 
						|
                 */
 | 
						|
                if (! $firstConditionAdded) {
 | 
						|
                    $query = $query->where($table.'.'.$column, 'LIKE', '%'.$term.'%');
 | 
						|
 | 
						|
                    $firstConditionAdded = true;
 | 
						|
                    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.'%');
 | 
						|
                    }
 | 
						|
                }
 | 
						|
                // I put this here because I only want to add the concat one time in the end of the user relation search
 | 
						|
                if($relation == 'user') {
 | 
						|
                    $query->orWhereRaw(
 | 
						|
                            $this->buildMultipleColumnSearch([
 | 
						|
                                'users.first_name',
 | 
						|
                                'users.last_name',
 | 
						|
                            ]),
 | 
						|
                            ["%{$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 $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 $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();
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Builds a search string for either MySQL or sqlite by separating the provided columns with a space.
 | 
						|
     *
 | 
						|
     * @param array $columns Columns to include in search string.
 | 
						|
     * @return string
 | 
						|
     */
 | 
						|
    private function buildMultipleColumnSearch(array $columns): string
 | 
						|
    {
 | 
						|
        $mappedColumns = collect($columns)->map(fn($column) => DB::getTablePrefix() . $column)->toArray();
 | 
						|
 | 
						|
        $driver = config('database.connections.' . config('database.default') . '.driver');
 | 
						|
 | 
						|
        if ($driver === 'sqlite') {
 | 
						|
            return implode("||' '||", $mappedColumns) . ' LIKE ?';
 | 
						|
        }
 | 
						|
 | 
						|
        // Default to MySQL's concatenation method
 | 
						|
        return 'CONCAT(' . implode('," ",', $mappedColumns) . ') LIKE ?';
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * Search a string across multiple columns separated with a space.
 | 
						|
     *
 | 
						|
     * @param Builder $query
 | 
						|
     * @param array $columns - Columns to include in search string.
 | 
						|
     * @param $term
 | 
						|
     * @return Builder
 | 
						|
     */
 | 
						|
    public function scopeOrWhereMultipleColumns($query, array $columns, $term)
 | 
						|
    {
 | 
						|
        return $query->orWhereRaw($this->buildMultipleColumnSearch($columns), ["%{$term}%"]);
 | 
						|
    }
 | 
						|
}
 |