Skip to content

Commit

Permalink
NEW Search across multiple searchable fields by default
Browse files Browse the repository at this point in the history
  • Loading branch information
GuySartorelli committed Jul 12, 2022
1 parent cfb347d commit f3f0667
Show file tree
Hide file tree
Showing 11 changed files with 522 additions and 32 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'
PRIMARYSEARCH: 'Primary Search'
SilverStripe\ORM\FieldType\DBBoolean:
ANY: Any
NOANSWER: 'No'
Expand Down
1 change: 1 addition & 0 deletions src/Forms/GridField/GridFieldFilterHeader.php
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ public function getSearchFieldSchema(GridField $gridField)
$searchAction = GridField_FormAction::create($gridField, 'filter', false, 'filter', null);
$clearAction = GridField_FormAction::create($gridField, 'reset', false, 'reset', null);
$schema = [
'modelClass' => $gridField->getModelClass(),
'formSchemaUrl' => $schemaUrl,
'name' => $searchField,
'placeholder' => _t(__CLASS__ . '.Search', 'Search "{name}"', ['name' => $name]),
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, global primary search functionality is disabled
* and the primary search field falls back to using the first field in
* the searchable fields array.
*/
public function getPrimarySearchFieldName(): string
{
return $this->config()->get('primary_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 global primary search if there are fields it can search on
$primarySearch = $this->getPrimarySearchFieldName();
if ($primarySearch !== '' && $fields->count() > 0) {
if ($fields->fieldByName($primarySearch) || $fields->dataFieldByName($primarySearch)) {
throw new LogicException('Primary search field name must be unique.');
}
$fields->unshift(HiddenField::create($primarySearch, _t(self::class . 'PRIMARYSEARCH', 'Primary 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, global primary search functionality is disabled
* and the primary search field falls back to using the first field in
* the searchable fields array.
*/
private static string $primary_search_field_name = 'q';

/**
* The search filter to use when searching with the primary search field.
* If this is an empty string, the search filters configured for each field are used instead.
*/
private static string $primary_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 $primary_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->getPrimarySearchFieldName()) {
$query = $this->primaryFieldSearch($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 searchAcrossFields($searchPhrase, DataQuery $subGroup, array $searchableFields): void
{
$formFields = $this->getSearchFields();
foreach ($searchableFields as $field => $spec) {
$formFieldName = str_replace('.', '__', $field);
$filter = $this->getPrimarySearchFilter($this->modelClass, $field);
// Only apply filter if the field is allowed to be primary 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['primary']) || $spec['primary'])
&& ($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 primary search for searching across multiple fields
*
* @param string|array $searchPhrase
*/
private function primaryFieldSearch(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, 'primary_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))
$primarySubGroup = $dataQuery->conjunctiveGroup();
foreach (explode(' ', $phrase) as $searchTerm) {
$this->searchAcrossFields($searchTerm, $primarySubGroup->disjunctiveGroup(), $searchableFields);
}
}
} else {
// where ((field1 like lorem OR field2 like lorem) AND (field1 like ipsum OR field2 like ipsum))
$primarySubGroup = $dataQuery->conjunctiveGroup();
foreach (explode(' ', $searchPhrase) as $searchTerm) {
$this->searchAcrossFields($searchTerm, $primarySubGroup->disjunctiveGroup(), $searchableFields);
}
}
} else {
// where (field1 like lorem ipsum OR field2 like lorem ipsum)
$this->searchAcrossFields($searchPhrase, $dataQuery->disjunctiveGroup(), $searchableFields);
}
});
}

/**
* Get the search filter for the given fieldname when searched from the primary search field.
*/
private function getPrimarySearchFilter(string $modelClass, string $fieldName): ?SearchFilter
{
if ($filterClass = Config::inst()->get($modelClass, 'primary_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
11 changes: 9 additions & 2 deletions tests/php/Forms/GridField/GridFieldFilterHeaderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
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 +90,12 @@ public function testRenderHeaders()
public function testSearchFieldSchema()
{
$searchSchema = json_decode($this->component->getSearchFieldSchema($this->gridField) ?? '');
$modelClass = $searchSchema->modelClass;
/** @var DataObject $obj */
$obj = new $modelClass();

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

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

$this->assertEquals('field/testfield/schema/SearchForm', $searchSchema->formSchemaUrl);
$this->assertEquals('Name', $searchSchema->name);
$this->assertEquals($obj->getPrimarySearchFieldName(), $searchSchema->name);
$this->assertEquals('Search "Teams"', $searchSchema->placeholder);
$this->assertEquals('test', $searchSchema->filters->Search__Name);
$this->assertEquals('place', $searchSchema->filters->Search__City);
Expand Down
Loading

0 comments on commit f3f0667

Please sign in to comment.