*/ 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([ DB::getTablePrefix() . 'users.first_name', DB::getTablePrefix() . '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 { $driver = config('database.connections.' . config('database.default') . '.driver'); if ($driver === 'sqlite') { return implode("||' '||", $columns) . ' LIKE ?'; } // Default to MySQL's concatenation method return 'CONCAT(' . implode('," ",', $columns) . ') 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}%"]); } }