Skip to content

Commit

Permalink
NEW Search across multiple searchable fields by default (#10382)
Browse files Browse the repository at this point in the history
* NEW Search across multiple searchable fields by default

* ENH Split search query and search each term separately.
  • Loading branch information
GuySartorelli authored Aug 1, 2022
1 parent c7504aa commit 1159595
Show file tree
Hide file tree
Showing 10 changed files with 527 additions and 36 deletions.
1 change: 1 addition & 0 deletions lang/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ en:
one: 'A Data Object'
other: '{count} Data Objects'
SINGULARNAME: 'Data Object'
GENERALSEARCH: 'General Search'
SilverStripe\ORM\FieldType\DBBoolean:
ANY: Any
NOANSWER: 'No'
Expand Down
48 changes: 48 additions & 0 deletions src/ORM/DataObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@
use SilverStripe\Forms\FormField;
use SilverStripe\Forms\FormScaffolder;
use SilverStripe\Forms\CompositeValidator;
use SilverStripe\Forms\HiddenField;
use SilverStripe\i18n\i18n;
use SilverStripe\i18n\i18nEntityProvider;
use SilverStripe\ORM\Connect\MySQLSchemaManager;
use SilverStripe\ORM\FieldType\DBComposite;
use SilverStripe\ORM\FieldType\DBDatetime;
use SilverStripe\ORM\FieldType\DBEnum;
use SilverStripe\ORM\FieldType\DBField;
use SilverStripe\ORM\Filters\PartialMatchFilter;
use SilverStripe\ORM\Filters\SearchFilter;
use SilverStripe\ORM\Queries\SQLDelete;
use SilverStripe\ORM\Search\SearchContext;
Expand Down Expand Up @@ -2374,6 +2376,18 @@ public function getDefaultSearchContext()
);
}

/**
* Name of the field which is used as a stand-in for searching across all searchable fields.
*
* If this is a blank string, general search functionality is disabled
* and the general search field falls back to using the first field in
* the searchable fields array.
*/
public function getGeneralSearchFieldName(): string
{
return $this->config()->get('general_search_field_name');
}

/**
* Determine which properties on the DataObject are
* searchable, and map them to their default {@link FormField}
Expand All @@ -2399,6 +2413,7 @@ public function scaffoldSearchFields($_params = null)
(array)$_params
);
$fields = new FieldList();

foreach ($this->searchableFields() as $fieldName => $spec) {
if ($params['restrictFields'] && !in_array($fieldName, $params['restrictFields'] ?? [])) {
continue;
Expand Down Expand Up @@ -2451,6 +2466,16 @@ public function scaffoldSearchFields($_params = null)

$fields->push($field);
}

// Only include general search if there are fields it can search on
$generalSearch = $this->getGeneralSearchFieldName();
if ($generalSearch !== '' && $fields->count() > 0) {
if ($fields->fieldByName($generalSearch) || $fields->dataFieldByName($generalSearch)) {
throw new LogicException('General search field name must be unique.');
}
$fields->unshift(HiddenField::create($generalSearch, _t(self::class . 'GENERALSEARCH', 'General Search')));
}

return $fields;
}

Expand Down Expand Up @@ -4198,6 +4223,29 @@ public static function enable_subclass_access()
*/
private static $searchable_fields = null;

/**
* Name of the field which is used as a stand-in for searching across all searchable fields.
*
* If this is a blank string, general search functionality is disabled
* and the general search field falls back to using the first field in
* the searchable_fields array.
*/
private static string $general_search_field_name = 'q';

/**
* The search filter to use when searching with the general search field.
* If this is an empty string, the search filters configured for each field are used instead.
*/
private static string $general_search_field_filter = PartialMatchFilter::class;

/**
* If true, the search phrase is split into individual terms, and checks all searchable fields for each search term.
* If false, all fields are checked for the entire search phrase as a whole.
*
* Note that splitting terms may cause unexpected resuls if using an ExactMatchFilter.
*/
private static bool $general_search_split_terms = true;

/**
* User defined labels for searchable_fields, used to override
* default display in the search form.
Expand Down
2 changes: 1 addition & 1 deletion src/ORM/Filters/PartialMatchFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ protected function applyOne(DataQuery $query)
);

$clause = [$comparisonClause => $this->getMatchPattern($this->getValue())];

return $this->aggregate ?
$this->applyAggregate($query, $clause) :
$query->where($clause);
Expand Down
167 changes: 139 additions & 28 deletions src/ORM/Search/SearchContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use SilverStripe\Forms\CheckboxField;
use InvalidArgumentException;
use Exception;
use SilverStripe\Core\Config\Config;
use SilverStripe\ORM\DataQuery;

/**
Expand Down Expand Up @@ -110,8 +111,6 @@ public function __construct($modelClass, $fields = null, $filters = null)
public function getSearchFields()
{
return ($this->fields) ? $this->fields : singleton($this->modelClass)->scaffoldSearchFields();
// $this->fields is causing weirdness, so we ignore for now, using the default scaffolding
//return singleton($this->modelClass)->scaffoldSearchFields();
}

/**
Expand Down Expand Up @@ -151,8 +150,38 @@ public function getQuery($searchParams, $sort = false, $limit = false, $existing
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);
}

/** DataList $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)) {
Expand All @@ -176,38 +205,120 @@ public function getQuery($searchParams, $sort = false, $limit = false, $existing
$query = $query->limit($limit);
}

/** @var DataList $query */
$query = $query->sort($sort);
$this->setSearchParams($searchParams);
return $query->sort($sort);
}

$modelObj = Injector::inst()->create($this->modelClass);
$searchableFields = $modelObj->searchableFields();
foreach ($this->searchParams as $key => $value) {
$key = str_replace('__', '.', $key ?? '');
if ($filter = $this->getFilter($key)) {
/**
* 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($value);
if (!$filter->isEmpty()) {
if (isset($searchableFields[$key]['match_any'])) {
$searchFields = $searchableFields[$key]['match_any'];
$filterClass = get_class($filter);
$modifiers = $filter->getModifiers();
$query = $query->alterDataQuery(function (DataQuery $dataQuery) use ($searchFields, $filterClass, $modifiers, $value) {
$subGroup = $dataQuery->disjunctiveGroup();
foreach ($searchFields as $matchField) {
/** @var SearchFilter $filterClass */
$filter = new $filterClass($matchField, $value, $modifiers);
$filter->apply($subGroup);
}
});
} else {
$query = $query->alterDataQuery([$filter, 'apply']);
$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);
}

return $query;
/**
* 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);
}
}

/**
Expand Down
19 changes: 13 additions & 6 deletions tests/php/Forms/GridField/GridFieldFilterHeaderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\Mom;
use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\Team;
use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\TeamGroup;
use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\TestController;
use SilverStripe\ORM\DataList;
use SilverStripe\ORM\DataObject;

class GridFieldFilterHeaderTest extends SapphireTest
{
Expand Down Expand Up @@ -89,9 +89,12 @@ public function testRenderHeaders()
public function testSearchFieldSchema()
{
$searchSchema = json_decode($this->component->getSearchFieldSchema($this->gridField) ?? '');
$modelClass = $this->gridField->getModelClass();
/** @var DataObject $obj */
$obj = new $modelClass();

$this->assertEquals('field/testfield/schema/SearchForm', $searchSchema->formSchemaUrl);
$this->assertEquals('Name', $searchSchema->name);
$this->assertEquals($obj->getGeneralSearchFieldName(), $searchSchema->name);
$this->assertEquals('Search "Teams"', $searchSchema->placeholder);
$this->assertEquals(new \stdClass, $searchSchema->filters);

Expand All @@ -110,9 +113,12 @@ public function testSearchFieldSchema()
);
$this->gridField->setRequest($request);
$searchSchema = json_decode($this->component->getSearchFieldSchema($this->gridField) ?? '');
$modelClass = $this->gridField->getModelClass();
/** @var DataObject $obj */
$obj = new $modelClass();

$this->assertEquals('field/testfield/schema/SearchForm', $searchSchema->formSchemaUrl);
$this->assertEquals('Name', $searchSchema->name);
$this->assertEquals($obj->getGeneralSearchFieldName(), $searchSchema->name);
$this->assertEquals('Search "Teams"', $searchSchema->placeholder);
$this->assertEquals('test', $searchSchema->filters->Search__Name);
$this->assertEquals('place', $searchSchema->filters->Search__City);
Expand Down Expand Up @@ -145,9 +151,10 @@ public function testGetSearchForm()
$searchForm = $this->component->getSearchForm($this->gridField);

$this->assertTrue($searchForm instanceof Form);
$this->assertEquals('Search__Name', $searchForm->fields[0]->Name);
$this->assertEquals('Search__City', $searchForm->fields[1]->Name);
$this->assertEquals('Search__Cheerleader__Hat__Colour', $searchForm->fields[2]->Name);
$this->assertEquals('Search__q', $searchForm->fields[0]->Name);
$this->assertEquals('Search__Name', $searchForm->fields[1]->Name);
$this->assertEquals('Search__City', $searchForm->fields[2]->Name);
$this->assertEquals('Search__Cheerleader__Hat__Colour', $searchForm->fields[3]->Name);
$this->assertEquals('TeamsSearchForm', $searchForm->Name);
$this->assertEquals('cms-search-form', $searchForm->extraClasses['cms-search-form']);

Expand Down
Loading

0 comments on commit 1159595

Please sign in to comment.