*/ 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.'%'); } } }); } 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(); } }