namespace()} on the $fields constructor parameter. * * Each DataObject subclass can have multiple search contexts for different cases, * e.g. for a limited frontend search and a fully featured backend search. * By default, you can use {@link DataObject->getDefaultSearchContext()} which is automatically * scaffolded. It uses {@link DataObject::$searchable_fields} to determine which fields * to include. * * @see http://doc.silverstripe.com/doku.php?id=searchcontext */ class SearchContext { use Injectable; /** * DataObject subclass to which search parameters relate to. * Also determines as which object each result is provided. * * @var string */ protected $modelClass; /** * FormFields mapping to {@link DataObject::$db} properties * which are supposed to be searchable. * * @var FieldList */ protected $fields; /** * Array of {@link SearchFilter} subclasses. * * @var SearchFilter[] */ protected $filters; /** * Key/value pairs of search fields to search terms * * @var array */ protected $searchParams = []; /** * The logical connective used to join WHERE clauses. Must be "AND". * @deprecated 5.0 * @var string */ public $connective = 'AND'; /** * A key value pair of values that should be searched for. * The keys should match the field names specified in {@link self::$fields}. * Usually these values come from a submitted searchform * in the form of a $_REQUEST object. * CAUTION: All values should be treated as insecure client input. * * @param string $modelClass The base {@link DataObject} class that search properties related to. * Also used to generate a set of result objects based on this class. * @param FieldList $fields Optional. FormFields mapping to {@link DataObject::$db} properties * which are to be searched. Derived from modelclass using * {@link DataObject::scaffoldSearchFields()} if left blank. * @param array $filters Optional. Derived from modelclass if left blank */ public function __construct($modelClass, $fields = null, $filters = null) { $this->modelClass = $modelClass; $this->fields = ($fields) ? $fields : new FieldList(); $this->filters = ($filters) ? $filters : []; } /** * Returns scaffolded search fields for UI. * * @return FieldList */ public function getSearchFields() { return ($this->fields) ? $this->fields : singleton($this->modelClass)->scaffoldSearchFields(); } /** * @todo move to SQLSelect * @todo fix hack */ protected function applyBaseTableFields() { $classes = ClassInfo::dataClassesFor($this->modelClass); $baseTable = DataObject::getSchema()->baseDataTable($this->modelClass); $fields = ["\"{$baseTable}\".*"]; if ($this->modelClass != $classes[0]) { $fields[] = '"' . $classes[0] . '".*'; } //$fields = array_keys($model->db()); $fields[] = '"' . $classes[0] . '".\"ClassName\" AS "RecordClassName"'; return $fields; } /** * Returns a SQL object representing the search context for the given * list of query parameters. * * @param array $searchParams Map of search criteria, mostly taken from $_REQUEST. * If a filter is applied to a relationship in dot notation, * the parameter name should have the dots replaced with double underscores, * for example "Comments__Name" instead of the filter name "Comments.Name". * @param array|bool|string $sort Database column to sort on. * Falls back to {@link DataObject::$default_sort} if not provided. * @param array|bool|string $limit * @param DataList $existingQuery * @return DataList * @throws Exception */ public function getQuery($searchParams, $sort = false, $limit = false, $existingQuery = null) { if ($this->connective != "AND") { throw new Exception("SearchContext connective '$this->connective' not supported after ORM-rewrite."); } $this->setSearchParams($searchParams); $query = $this->prepareQuery($sort, $limit, $existingQuery); return $this->search($query); } /** * Perform a search on the passed DataList based on $this->searchParams. */ private function search(DataList $query): DataList { /** @var DataObject $modelObj */ $modelObj = Injector::inst()->create($this->modelClass); $searchableFields = $modelObj->searchableFields(); foreach ($this->searchParams as $searchField => $searchPhrase) { $searchField = str_replace('__', '.', $searchField ?? ''); if ($searchField !== '' && $searchField === $modelObj->getGeneralSearchFieldName()) { $query = $this->generalFieldSearch($query, $searchableFields, $searchPhrase); } else { $query = $this->individualFieldSearch($query, $searchableFields, $searchField, $searchPhrase); } } return $query; } /** * Prepare the query to begin searching * * @param array|bool|string $sort Database column to sort on. * @param array|bool|string $limit */ private function prepareQuery($sort, $limit, ?DataList $existingQuery): DataList { $query = null; if ($existingQuery) { if (!($existingQuery instanceof DataList)) { throw new InvalidArgumentException("existingQuery must be DataList"); } if ($existingQuery->dataClass() != $this->modelClass) { throw new InvalidArgumentException("existingQuery's dataClass is " . $existingQuery->dataClass() . ", $this->modelClass expected."); } $query = $existingQuery; } else { $query = DataList::create($this->modelClass); } if (is_array($limit)) { $query = $query->limit( isset($limit['limit']) ? $limit['limit'] : null, isset($limit['start']) ? $limit['start'] : null ); } else { $query = $query->limit($limit); } return $query->sort($sort); } /** * Takes a search phrase or search term and searches for it across all searchable fields. * * @param string|array $searchPhrase */ private function generalSearchAcrossFields($searchPhrase, DataQuery $subGroup, array $searchableFields): void { $formFields = $this->getSearchFields(); foreach ($searchableFields as $field => $spec) { $formFieldName = str_replace('.', '__', $field); $filter = $this->getGeneralSearchFilter($this->modelClass, $field); // Only apply filter if the field is allowed to be general and is backed by a form field. // Otherwise we could be dealing with, for example, a DataObject which implements scaffoldSearchField // to provide some unexpected field name, where the below would result in a DatabaseException. if ((!isset($spec['general']) || $spec['general']) && ($formFields->fieldByName($formFieldName) || $formFields->dataFieldByName($formFieldName)) && $filter !== null ) { $filter->setModel($this->modelClass); $filter->setValue($searchPhrase); $this->applyFilter($filter, $subGroup, $spec); } } } /** * Use the global general search for searching across multiple fields. * * @param string|array $searchPhrase */ private function generalFieldSearch(DataList $query, array $searchableFields, $searchPhrase): DataList { return $query->alterDataQuery(function (DataQuery $dataQuery) use ($searchableFields, $searchPhrase) { // If necessary, split search phrase into terms, then search across fields. if (Config::inst()->get($this->modelClass, 'general_search_split_terms')) { if (is_array($searchPhrase)) { // Allow matches from ANY query in the array (i.e. return $obj where query1 matches OR query2 matches) $dataQuery = $dataQuery->disjunctiveGroup(); foreach ($searchPhrase as $phrase) { // where ((field1 LIKE %lorem% OR field2 LIKE %lorem%) AND (field1 LIKE %ipsum% OR field2 LIKE %ipsum%)) $generalSubGroup = $dataQuery->conjunctiveGroup(); foreach (explode(' ', $phrase) as $searchTerm) { $this->generalSearchAcrossFields($searchTerm, $generalSubGroup->disjunctiveGroup(), $searchableFields); } } } else { // where ((field1 LIKE %lorem% OR field2 LIKE %lorem%) AND (field1 LIKE %ipsum% OR field2 LIKE %ipsum%)) $generalSubGroup = $dataQuery->conjunctiveGroup(); foreach (explode(' ', $searchPhrase) as $searchTerm) { $this->generalSearchAcrossFields($searchTerm, $generalSubGroup->disjunctiveGroup(), $searchableFields); } } } else { // where (field1 LIKE %lorem ipsum% OR field2 LIKE %lorem ipsum%) $this->generalSearchAcrossFields($searchPhrase, $dataQuery->disjunctiveGroup(), $searchableFields); } }); } /** * Get the search filter for the given fieldname when searched from the general search field. */ private function getGeneralSearchFilter(string $modelClass, string $fieldName): ?SearchFilter { if ($filterClass = Config::inst()->get($modelClass, 'general_search_field_filter')) { return Injector::inst()->create($filterClass, $fieldName); } return $this->getFilter($fieldName); } /** * Search against a single field * * @param string|array $searchPhrase */ private function individualFieldSearch(DataList $query, array $searchableFields, string $searchField, $searchPhrase): DataList { $filter = $this->getFilter($searchField); if (!$filter) { return $query; } $filter->setModel($this->modelClass); $filter->setValue($searchPhrase); $searchableFieldSpec = $searchableFields[$searchField] ?? []; return $query->alterDataQuery(function ($dataQuery) use ($filter, $searchableFieldSpec) { $this->applyFilter($filter, $dataQuery, $searchableFieldSpec); }); } /** * Apply a SearchFilter to a DataQuery for a given field's specifications */ private function applyFilter(SearchFilter $filter, DataQuery $dataQuery, array $searchableFieldSpec): void { if ($filter->isEmpty()) { return; } if (isset($searchableFieldSpec['match_any'])) { $searchFields = $searchableFieldSpec['match_any']; $filterClass = get_class($filter); $value = $filter->getValue(); $modifiers = $filter->getModifiers(); $subGroup = $dataQuery->disjunctiveGroup(); foreach ($searchFields as $matchField) { /** @var SearchFilter $filter */ $filter = Injector::inst()->create($filterClass, $matchField, $value, $modifiers); $filter->apply($subGroup); } } else { $filter->apply($dataQuery); } } /** * Returns a result set from the given search parameters. * * @todo rearrange start and limit params to reflect DataObject * * @param array $searchParams * @param array|bool|string $sort * @param array|bool|string $limit * @return DataList * @throws Exception */ public function getResults($searchParams, $sort = false, $limit = false) { $searchParams = array_filter((array)$searchParams, [$this, 'clearEmptySearchFields']); // getQuery actually returns a DataList return $this->getQuery($searchParams, $sort, $limit); } /** * Callback map function to filter fields with empty values from * being included in the search expression. * * @param mixed $value * @return boolean */ public function clearEmptySearchFields($value) { return ($value != ''); } /** * Accessor for the filter attached to a named field. * * @param string $name * @return SearchFilter */ public function getFilter($name) { if (isset($this->filters[$name])) { return $this->filters[$name]; } else { return null; } } /** * Get the map of filters in the current search context. * * @return SearchFilter[] */ public function getFilters() { return $this->filters; } /** * Overwrite the current search context filter map. * * @param array $filters */ public function setFilters($filters) { $this->filters = $filters; } /** * Adds a instance of {@link SearchFilter}. * * @param SearchFilter $filter */ public function addFilter($filter) { $this->filters[$filter->getFullName()] = $filter; } /** * Removes a filter by name. * * @param string $name */ public function removeFilterByName($name) { unset($this->filters[$name]); } /** * Get the list of searchable fields in the current search context. * * @return FieldList */ public function getFields() { return $this->fields; } /** * Apply a list of searchable fields to the current search context. * * @param FieldList $fields */ public function setFields($fields) { $this->fields = $fields; } /** * Adds a new {@link FormField} instance. * * @param FormField $field */ public function addField($field) { $this->fields->push($field); } /** * Removes an existing formfield instance by its name. * * @param string $fieldName */ public function removeFieldByName($fieldName) { $this->fields->removeByName($fieldName); } /** * Set search param values * * @param array|HTTPRequest $searchParams * @return $this */ public function setSearchParams($searchParams) { // hack to work with $searchParams when it's an Object if ($searchParams instanceof HTTPRequest) { $this->searchParams = $searchParams->getVars(); } else { $this->searchParams = $searchParams; } return $this; } /** * @return array */ public function getSearchParams() { return $this->searchParams; } /** * Gets a list of what fields were searched and the values provided * for each field. Returns an ArrayList of ArrayData, suitable for * rendering on a template. * * @return ArrayList */ public function getSummary() { $list = ArrayList::create(); foreach ($this->searchParams as $searchField => $searchValue) { if (empty($searchValue)) { continue; } $filter = $this->getFilter($searchField); if (!$filter) { continue; } $field = $this->fields->fieldByName($filter->getFullName()); if (!$field) { continue; } // For dropdowns, checkboxes, etc, get the value that was presented to the user // e.g. not an ID if ($field instanceof SelectField) { $source = $field->getSource(); if (isset($source[$searchValue])) { $searchValue = $source[$searchValue]; } } else { // For checkboxes, it suffices to simply include the field in the list, since it's binary if ($field instanceof CheckboxField) { $searchValue = null; } } $list->push(ArrayData::create([ 'Field' => $field->Title(), 'Value' => $searchValue, ])); } return $list; } }