Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NEW Search across multiple searchable fields by default #10382

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -4196,6 +4221,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