mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
NEW Search across multiple searchable fields by default (#10382)
* NEW Search across multiple searchable fields by default * ENH Split search query and search each term separately.
This commit is contained in:
parent
c7504aa337
commit
11595952f4
@ -148,6 +148,7 @@ en:
|
|||||||
one: 'A Data Object'
|
one: 'A Data Object'
|
||||||
other: '{count} Data Objects'
|
other: '{count} Data Objects'
|
||||||
SINGULARNAME: 'Data Object'
|
SINGULARNAME: 'Data Object'
|
||||||
|
GENERALSEARCH: 'General Search'
|
||||||
SilverStripe\ORM\FieldType\DBBoolean:
|
SilverStripe\ORM\FieldType\DBBoolean:
|
||||||
ANY: Any
|
ANY: Any
|
||||||
NOANSWER: 'No'
|
NOANSWER: 'No'
|
||||||
|
@ -16,6 +16,7 @@ use SilverStripe\Forms\FieldList;
|
|||||||
use SilverStripe\Forms\FormField;
|
use SilverStripe\Forms\FormField;
|
||||||
use SilverStripe\Forms\FormScaffolder;
|
use SilverStripe\Forms\FormScaffolder;
|
||||||
use SilverStripe\Forms\CompositeValidator;
|
use SilverStripe\Forms\CompositeValidator;
|
||||||
|
use SilverStripe\Forms\HiddenField;
|
||||||
use SilverStripe\i18n\i18n;
|
use SilverStripe\i18n\i18n;
|
||||||
use SilverStripe\i18n\i18nEntityProvider;
|
use SilverStripe\i18n\i18nEntityProvider;
|
||||||
use SilverStripe\ORM\Connect\MySQLSchemaManager;
|
use SilverStripe\ORM\Connect\MySQLSchemaManager;
|
||||||
@ -23,6 +24,7 @@ use SilverStripe\ORM\FieldType\DBComposite;
|
|||||||
use SilverStripe\ORM\FieldType\DBDatetime;
|
use SilverStripe\ORM\FieldType\DBDatetime;
|
||||||
use SilverStripe\ORM\FieldType\DBEnum;
|
use SilverStripe\ORM\FieldType\DBEnum;
|
||||||
use SilverStripe\ORM\FieldType\DBField;
|
use SilverStripe\ORM\FieldType\DBField;
|
||||||
|
use SilverStripe\ORM\Filters\PartialMatchFilter;
|
||||||
use SilverStripe\ORM\Filters\SearchFilter;
|
use SilverStripe\ORM\Filters\SearchFilter;
|
||||||
use SilverStripe\ORM\Queries\SQLDelete;
|
use SilverStripe\ORM\Queries\SQLDelete;
|
||||||
use SilverStripe\ORM\Search\SearchContext;
|
use SilverStripe\ORM\Search\SearchContext;
|
||||||
@ -2374,6 +2376,18 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Determine which properties on the DataObject are
|
||||||
* searchable, and map them to their default {@link FormField}
|
* searchable, and map them to their default {@link FormField}
|
||||||
@ -2399,6 +2413,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
|
|||||||
(array)$_params
|
(array)$_params
|
||||||
);
|
);
|
||||||
$fields = new FieldList();
|
$fields = new FieldList();
|
||||||
|
|
||||||
foreach ($this->searchableFields() as $fieldName => $spec) {
|
foreach ($this->searchableFields() as $fieldName => $spec) {
|
||||||
if ($params['restrictFields'] && !in_array($fieldName, $params['restrictFields'] ?? [])) {
|
if ($params['restrictFields'] && !in_array($fieldName, $params['restrictFields'] ?? [])) {
|
||||||
continue;
|
continue;
|
||||||
@ -2451,6 +2466,16 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
|
|||||||
|
|
||||||
$fields->push($field);
|
$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;
|
return $fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4198,6 +4223,29 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
|
|||||||
*/
|
*/
|
||||||
private static $searchable_fields = null;
|
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
|
* User defined labels for searchable_fields, used to override
|
||||||
* default display in the search form.
|
* default display in the search form.
|
||||||
|
@ -17,6 +17,7 @@ use SilverStripe\Forms\SelectField;
|
|||||||
use SilverStripe\Forms\CheckboxField;
|
use SilverStripe\Forms\CheckboxField;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use SilverStripe\Core\Config\Config;
|
||||||
use SilverStripe\ORM\DataQuery;
|
use SilverStripe\ORM\DataQuery;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -110,8 +111,6 @@ class SearchContext
|
|||||||
public function getSearchFields()
|
public function getSearchFields()
|
||||||
{
|
{
|
||||||
return ($this->fields) ? $this->fields : singleton($this->modelClass)->scaffoldSearchFields();
|
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -151,8 +150,38 @@ class SearchContext
|
|||||||
if ($this->connective != "AND") {
|
if ($this->connective != "AND") {
|
||||||
throw new Exception("SearchContext connective '$this->connective' not supported after ORM-rewrite.");
|
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;
|
$query = null;
|
||||||
if ($existingQuery) {
|
if ($existingQuery) {
|
||||||
if (!($existingQuery instanceof DataList)) {
|
if (!($existingQuery instanceof DataList)) {
|
||||||
@ -176,38 +205,120 @@ class SearchContext
|
|||||||
$query = $query->limit($limit);
|
$query = $query->limit($limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var DataList $query */
|
return $query->sort($sort);
|
||||||
$query = $query->sort($sort);
|
}
|
||||||
$this->setSearchParams($searchParams);
|
|
||||||
|
|
||||||
$modelObj = Injector::inst()->create($this->modelClass);
|
/**
|
||||||
$searchableFields = $modelObj->searchableFields();
|
* Takes a search phrase or search term and searches for it across all searchable fields.
|
||||||
foreach ($this->searchParams as $key => $value) {
|
*
|
||||||
$key = str_replace('__', '.', $key ?? '');
|
* @param string|array $searchPhrase
|
||||||
if ($filter = $this->getFilter($key)) {
|
*/
|
||||||
|
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->setModel($this->modelClass);
|
||||||
$filter->setValue($value);
|
$filter->setValue($searchPhrase);
|
||||||
if (!$filter->isEmpty()) {
|
$this->applyFilter($filter, $subGroup, $spec);
|
||||||
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']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $query;
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -14,8 +14,8 @@ use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\CheerleaderHat;
|
|||||||
use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\Mom;
|
use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\Mom;
|
||||||
use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\Team;
|
use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\Team;
|
||||||
use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\TeamGroup;
|
use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\TeamGroup;
|
||||||
use SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest\TestController;
|
|
||||||
use SilverStripe\ORM\DataList;
|
use SilverStripe\ORM\DataList;
|
||||||
|
use SilverStripe\ORM\DataObject;
|
||||||
|
|
||||||
class GridFieldFilterHeaderTest extends SapphireTest
|
class GridFieldFilterHeaderTest extends SapphireTest
|
||||||
{
|
{
|
||||||
@ -89,9 +89,12 @@ class GridFieldFilterHeaderTest extends SapphireTest
|
|||||||
public function testSearchFieldSchema()
|
public function testSearchFieldSchema()
|
||||||
{
|
{
|
||||||
$searchSchema = json_decode($this->component->getSearchFieldSchema($this->gridField) ?? '');
|
$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('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('Search "Teams"', $searchSchema->placeholder);
|
||||||
$this->assertEquals(new \stdClass, $searchSchema->filters);
|
$this->assertEquals(new \stdClass, $searchSchema->filters);
|
||||||
|
|
||||||
@ -110,9 +113,12 @@ class GridFieldFilterHeaderTest extends SapphireTest
|
|||||||
);
|
);
|
||||||
$this->gridField->setRequest($request);
|
$this->gridField->setRequest($request);
|
||||||
$searchSchema = json_decode($this->component->getSearchFieldSchema($this->gridField) ?? '');
|
$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('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('Search "Teams"', $searchSchema->placeholder);
|
||||||
$this->assertEquals('test', $searchSchema->filters->Search__Name);
|
$this->assertEquals('test', $searchSchema->filters->Search__Name);
|
||||||
$this->assertEquals('place', $searchSchema->filters->Search__City);
|
$this->assertEquals('place', $searchSchema->filters->Search__City);
|
||||||
@ -145,9 +151,10 @@ class GridFieldFilterHeaderTest extends SapphireTest
|
|||||||
$searchForm = $this->component->getSearchForm($this->gridField);
|
$searchForm = $this->component->getSearchForm($this->gridField);
|
||||||
|
|
||||||
$this->assertTrue($searchForm instanceof Form);
|
$this->assertTrue($searchForm instanceof Form);
|
||||||
$this->assertEquals('Search__Name', $searchForm->fields[0]->Name);
|
$this->assertEquals('Search__q', $searchForm->fields[0]->Name);
|
||||||
$this->assertEquals('Search__City', $searchForm->fields[1]->Name);
|
$this->assertEquals('Search__Name', $searchForm->fields[1]->Name);
|
||||||
$this->assertEquals('Search__Cheerleader__Hat__Colour', $searchForm->fields[2]->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('TeamsSearchForm', $searchForm->Name);
|
||||||
$this->assertEquals('cms-search-form', $searchForm->extraClasses['cms-search-form']);
|
$this->assertEquals('cms-search-form', $searchForm->extraClasses['cms-search-form']);
|
||||||
|
|
||||||
|
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
namespace SilverStripe\ORM\Tests\Search;
|
namespace SilverStripe\ORM\Tests\Search;
|
||||||
|
|
||||||
|
use LogicException;
|
||||||
|
use ReflectionMethod;
|
||||||
|
use SilverStripe\Core\Config\Config;
|
||||||
use SilverStripe\Dev\SapphireTest;
|
use SilverStripe\Dev\SapphireTest;
|
||||||
use SilverStripe\Forms\TextField;
|
use SilverStripe\Forms\TextField;
|
||||||
use SilverStripe\Forms\TextareaField;
|
use SilverStripe\Forms\TextareaField;
|
||||||
@ -9,7 +12,10 @@ use SilverStripe\Forms\NumericField;
|
|||||||
use SilverStripe\Forms\DropdownField;
|
use SilverStripe\Forms\DropdownField;
|
||||||
use SilverStripe\Forms\CheckboxField;
|
use SilverStripe\Forms\CheckboxField;
|
||||||
use SilverStripe\Forms\FieldList;
|
use SilverStripe\Forms\FieldList;
|
||||||
|
use SilverStripe\Forms\HiddenField;
|
||||||
use SilverStripe\ORM\DataList;
|
use SilverStripe\ORM\DataList;
|
||||||
|
use SilverStripe\ORM\Filters\EndsWithFilter;
|
||||||
|
use SilverStripe\ORM\Filters\ExactMatchFilter;
|
||||||
use SilverStripe\ORM\Filters\PartialMatchFilter;
|
use SilverStripe\ORM\Filters\PartialMatchFilter;
|
||||||
use SilverStripe\ORM\Search\SearchContext;
|
use SilverStripe\ORM\Search\SearchContext;
|
||||||
|
|
||||||
@ -20,6 +26,7 @@ class SearchContextTest extends SapphireTest
|
|||||||
|
|
||||||
protected static $extra_dataobjects = [
|
protected static $extra_dataobjects = [
|
||||||
SearchContextTest\Person::class,
|
SearchContextTest\Person::class,
|
||||||
|
SearchContextTest\NoSearchableFields::class,
|
||||||
SearchContextTest\Book::class,
|
SearchContextTest\Book::class,
|
||||||
SearchContextTest\Company::class,
|
SearchContextTest\Company::class,
|
||||||
SearchContextTest\Project::class,
|
SearchContextTest\Project::class,
|
||||||
@ -29,6 +36,7 @@ class SearchContextTest extends SapphireTest
|
|||||||
SearchContextTest\Customer::class,
|
SearchContextTest\Customer::class,
|
||||||
SearchContextTest\Address::class,
|
SearchContextTest\Address::class,
|
||||||
SearchContextTest\Order::class,
|
SearchContextTest\Order::class,
|
||||||
|
SearchContextTest\GeneralSearch::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
public function testResultSetFilterReturnsExpectedCount()
|
public function testResultSetFilterReturnsExpectedCount()
|
||||||
@ -45,6 +53,20 @@ class SearchContextTest extends SapphireTest
|
|||||||
$this->assertEquals(1, $results->Count());
|
$this->assertEquals(1, $results->Count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testSearchableFieldsDefaultsToSummaryFields()
|
||||||
|
{
|
||||||
|
$obj = new SearchContextTest\NoSearchableFields();
|
||||||
|
$summaryFields = $obj->summaryFields();
|
||||||
|
$expected = [];
|
||||||
|
foreach ($summaryFields as $field => $label) {
|
||||||
|
$expected[$field] = [
|
||||||
|
'title' => $obj->fieldLabel($field),
|
||||||
|
'filter' => 'PartialMatchFilter',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$this->assertEquals($expected, $obj->searchableFields());
|
||||||
|
}
|
||||||
|
|
||||||
public function testSummaryIncludesDefaultFieldsIfNotDefined()
|
public function testSummaryIncludesDefaultFieldsIfNotDefined()
|
||||||
{
|
{
|
||||||
$person = SearchContextTest\Person::singleton();
|
$person = SearchContextTest\Person::singleton();
|
||||||
@ -109,6 +131,7 @@ class SearchContextTest extends SapphireTest
|
|||||||
$context = $company->getDefaultSearchContext();
|
$context = $company->getDefaultSearchContext();
|
||||||
$this->assertEquals(
|
$this->assertEquals(
|
||||||
new FieldList(
|
new FieldList(
|
||||||
|
new HiddenField($company->getGeneralSearchFieldName(), 'General Search'),
|
||||||
(new TextField("Name", 'Name'))
|
(new TextField("Name", 'Name'))
|
||||||
->setMaxLength(255),
|
->setMaxLength(255),
|
||||||
new TextareaField("Industry", 'Industry'),
|
new TextareaField("Industry", 'Industry'),
|
||||||
@ -255,6 +278,195 @@ class SearchContextTest extends SapphireTest
|
|||||||
$this->assertNull($nothing);
|
$this->assertNull($nothing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testGeneralSearch()
|
||||||
|
{
|
||||||
|
$general1 = $this->objFromFixture(SearchContextTest\GeneralSearch::class, 'general1');
|
||||||
|
$context = $general1->getDefaultSearchContext();
|
||||||
|
$generalField = $general1->getGeneralSearchFieldName();
|
||||||
|
|
||||||
|
// Matches on a variety of fields
|
||||||
|
$results = $context->getResults([$generalField => 'General']);
|
||||||
|
$this->assertCount(2, $results);
|
||||||
|
$this->assertNotContains('MatchNothing', $results->column('Name'));
|
||||||
|
$results = $context->getResults([$generalField => 'brown']);
|
||||||
|
$this->assertCount(1, $results);
|
||||||
|
$this->assertEquals('General One', $results->first()->Name);
|
||||||
|
|
||||||
|
// Uses its own filter (not field filters)
|
||||||
|
$results = $context->getResults([$generalField => 'exact']);
|
||||||
|
$this->assertCount(1, $results);
|
||||||
|
$this->assertEquals('General One', $results->first()->Name);
|
||||||
|
|
||||||
|
// Uses match_any fields
|
||||||
|
$results = $context->getResults([$generalField => 'first']);
|
||||||
|
$this->assertCount(1, $results);
|
||||||
|
$this->assertEquals('General One', $results->first()->Name);
|
||||||
|
// Even across a relation
|
||||||
|
$results = $context->getResults([$generalField => 'arbitrary']);
|
||||||
|
$this->assertCount(1, $results);
|
||||||
|
$this->assertEquals('General One', $results->first()->Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSpecificSearchFields()
|
||||||
|
{
|
||||||
|
$general1 = $this->objFromFixture(SearchContextTest\GeneralSearch::class, 'general1');
|
||||||
|
$context = $general1->getDefaultSearchContext();
|
||||||
|
$generalField = $general1->getGeneralSearchFieldName();
|
||||||
|
$results = $context->getResults([$generalField => $general1->ExcludeThisField]);
|
||||||
|
$this->assertNotEmpty($general1->ExcludeThisField);
|
||||||
|
$this->assertCount(0, $results);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGeneralOnlyUsesSearchableFields()
|
||||||
|
{
|
||||||
|
$general1 = $this->objFromFixture(SearchContextTest\GeneralSearch::class, 'general1');
|
||||||
|
$context = $general1->getDefaultSearchContext();
|
||||||
|
$generalField = $general1->getGeneralSearchFieldName();
|
||||||
|
$results = $context->getResults([$generalField => $general1->DoNotUseThisField]);
|
||||||
|
$this->assertNotEmpty($general1->DoNotUseThisField);
|
||||||
|
$this->assertCount(0, $results);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGeneralSearchSplitTerms()
|
||||||
|
{
|
||||||
|
$general1 = $this->objFromFixture(SearchContextTest\GeneralSearch::class, 'general1');
|
||||||
|
$context = $general1->getDefaultSearchContext();
|
||||||
|
$generalField = $general1->getGeneralSearchFieldName();
|
||||||
|
|
||||||
|
// These terms don't exist in a single field in this order on any object, but they do exist in separate fields.
|
||||||
|
$results = $context->getResults([$generalField => 'general blue']);
|
||||||
|
$this->assertCount(1, $results);
|
||||||
|
$this->assertEquals('General Zero', $results->first()->Name);
|
||||||
|
|
||||||
|
// These terms exist in a single field, but not in this order.
|
||||||
|
$results = $context->getResults([$generalField => 'matches partial']);
|
||||||
|
$this->assertCount(1, $results);
|
||||||
|
$this->assertEquals('General One', $results->first()->Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGeneralSearchNoSplitTerms()
|
||||||
|
{
|
||||||
|
Config::modify()->set(SearchContextTest\GeneralSearch::class, 'general_search_split_terms', false);
|
||||||
|
$general1 = $this->objFromFixture(SearchContextTest\GeneralSearch::class, 'general1');
|
||||||
|
$context = $general1->getDefaultSearchContext();
|
||||||
|
$generalField = $general1->getGeneralSearchFieldName();
|
||||||
|
|
||||||
|
// These terms don't exist in a single field in this order on any object
|
||||||
|
$results = $context->getResults([$generalField => 'general blue']);
|
||||||
|
$this->assertCount(0, $results);
|
||||||
|
|
||||||
|
// These terms exist in a single field, but not in this order.
|
||||||
|
$results = $context->getResults([$generalField => 'matches partial']);
|
||||||
|
$this->assertCount(0, $results);
|
||||||
|
|
||||||
|
// These terms exist in a single field in this order.
|
||||||
|
$results = $context->getResults([$generalField => 'partial matches']);
|
||||||
|
$this->assertCount(1, $results);
|
||||||
|
$this->assertEquals('General One', $results->first()->Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetGeneralSearchFilter()
|
||||||
|
{
|
||||||
|
$general1 = $this->objFromFixture(SearchContextTest\GeneralSearch::class, 'general1');
|
||||||
|
$context = $general1->getDefaultSearchContext();
|
||||||
|
$getSearchFilterReflection = new ReflectionMethod($context, 'getGeneralSearchFilter');
|
||||||
|
$getSearchFilterReflection->setAccessible(true);
|
||||||
|
|
||||||
|
// By default, uses the PartialMatchFilter.
|
||||||
|
$this->assertSame(
|
||||||
|
PartialMatchFilter::class,
|
||||||
|
get_class($getSearchFilterReflection->invoke($context, $general1->ClassName, 'ExactMatchField'))
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
PartialMatchFilter::class,
|
||||||
|
get_class($getSearchFilterReflection->invoke($context, $general1->ClassName, 'PartialMatchField'))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Changing the config changes the filter.
|
||||||
|
Config::modify()->set(SearchContextTest\GeneralSearch::class, 'general_search_field_filter', EndsWithFilter::class);
|
||||||
|
$this->assertSame(
|
||||||
|
EndsWithFilter::class,
|
||||||
|
get_class($getSearchFilterReflection->invoke($context, $general1->ClassName, 'ExactMatchField'))
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
EndsWithFilter::class,
|
||||||
|
get_class($getSearchFilterReflection->invoke($context, $general1->ClassName, 'PartialMatchField'))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Removing the filter config defaults to use the field's filter.
|
||||||
|
Config::modify()->set(SearchContextTest\GeneralSearch::class, 'general_search_field_filter', '');
|
||||||
|
$this->assertSame(
|
||||||
|
ExactMatchFilter::class,
|
||||||
|
get_class($getSearchFilterReflection->invoke($context, $general1->ClassName, 'ExactMatchField'))
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
PartialMatchFilter::class,
|
||||||
|
get_class($getSearchFilterReflection->invoke($context, $general1->ClassName, 'PartialMatchField'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGeneralSearchFilterIsUsed()
|
||||||
|
{
|
||||||
|
Config::modify()->set(SearchContextTest\GeneralSearch::class, 'general_search_field_filter', '');
|
||||||
|
$general1 = $this->objFromFixture(SearchContextTest\GeneralSearch::class, 'general1');
|
||||||
|
$context = $general1->getDefaultSearchContext();
|
||||||
|
$generalField = $general1->getGeneralSearchFieldName();
|
||||||
|
|
||||||
|
// Respects ExactMatchFilter
|
||||||
|
$results = $context->getResults([$generalField => 'exact']);
|
||||||
|
$this->assertCount(0, $results);
|
||||||
|
// No match when splitting terms
|
||||||
|
$results = $context->getResults([$generalField => 'This requires an exact match']);
|
||||||
|
$this->assertCount(0, $results);
|
||||||
|
|
||||||
|
|
||||||
|
// When not splitting terms, the behaviour of `ExactMatchFilter` is slightly different.
|
||||||
|
Config::modify()->set(SearchContextTest\GeneralSearch::class, 'general_search_split_terms', false);
|
||||||
|
// Respects ExactMatchFilter
|
||||||
|
$results = $context->getResults([$generalField => 'exact']);
|
||||||
|
$this->assertCount(0, $results);
|
||||||
|
$results = $context->getResults([$generalField => 'This requires an exact match']);
|
||||||
|
$this->assertCount(1, $results);
|
||||||
|
$this->assertEquals('General One', $results->first()->Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGeneralSearchDisabled()
|
||||||
|
{
|
||||||
|
Config::modify()->set(SearchContextTest\GeneralSearch::class, 'general_search_field_name', '');
|
||||||
|
$general1 = $this->objFromFixture(SearchContextTest\GeneralSearch::class, 'general1');
|
||||||
|
$context = $general1->getDefaultSearchContext();
|
||||||
|
$generalField = $general1->getGeneralSearchFieldName();
|
||||||
|
$this->assertEmpty($generalField);
|
||||||
|
|
||||||
|
// Defaults to returning all objects, because the field doesn't exist in the SearchContext
|
||||||
|
$numObjs = SearchContextTest\GeneralSearch::get()->count();
|
||||||
|
$results = $context->getResults([$generalField => 'General']);
|
||||||
|
$this->assertCount($numObjs, $results);
|
||||||
|
$results = $context->getResults([$generalField => 'brown']);
|
||||||
|
$this->assertCount($numObjs, $results);
|
||||||
|
|
||||||
|
// Searching on other fields still works as expected (e.g. first field, which is the UI default in this situation)
|
||||||
|
$results = $context->getResults(['Name' => 'General']);
|
||||||
|
$this->assertCount(2, $results);
|
||||||
|
$this->assertNotContains('MatchNothing', $results->column('Name'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGeneralSearchCustomFieldName()
|
||||||
|
{
|
||||||
|
Config::modify()->set(SearchContextTest\GeneralSearch::class, 'general_search_field_name', 'some_arbitrary_field_name');
|
||||||
|
$obj = new SearchContextTest\GeneralSearch();
|
||||||
|
$this->assertSame('some_arbitrary_field_name', $obj->getGeneralSearchFieldName());
|
||||||
|
$this->testGeneralSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGeneralSearchFieldNameMustBeUnique()
|
||||||
|
{
|
||||||
|
Config::modify()->set(SearchContextTest\GeneralSearch::class, 'general_search_field_name', 'MatchAny');
|
||||||
|
$general1 = $this->objFromFixture(SearchContextTest\GeneralSearch::class, 'general1');
|
||||||
|
$this->expectException(LogicException::class);
|
||||||
|
$general1->getDefaultSearchContext();
|
||||||
|
}
|
||||||
|
|
||||||
public function testMatchAnySearch()
|
public function testMatchAnySearch()
|
||||||
{
|
{
|
||||||
$order1 = $this->objFromFixture(SearchContextTest\Order::class, 'order1');
|
$order1 = $this->objFromFixture(SearchContextTest\Order::class, 'order1');
|
||||||
|
@ -74,6 +74,7 @@ SilverStripe\ORM\Tests\Search\SearchContextTest\AllFilterTypes:
|
|||||||
SilverStripe\ORM\Tests\Search\SearchContextTest\Customer:
|
SilverStripe\ORM\Tests\Search\SearchContextTest\Customer:
|
||||||
customer1:
|
customer1:
|
||||||
FirstName: Bill
|
FirstName: Bill
|
||||||
|
MatchAny: Some arbitrary Value
|
||||||
customer2:
|
customer2:
|
||||||
FirstName: Bailey
|
FirstName: Bailey
|
||||||
customer3:
|
customer3:
|
||||||
@ -100,3 +101,34 @@ SilverStripe\ORM\Tests\Search\SearchContextTest\Order:
|
|||||||
Name: 'Jack'
|
Name: 'Jack'
|
||||||
Customer: =>SilverStripe\ORM\Tests\Search\SearchContextTest\Customer.customer3
|
Customer: =>SilverStripe\ORM\Tests\Search\SearchContextTest\Customer.customer3
|
||||||
ShippingAddress: =>SilverStripe\ORM\Tests\Search\SearchContextTest\Address.address3
|
ShippingAddress: =>SilverStripe\ORM\Tests\Search\SearchContextTest\Address.address3
|
||||||
|
|
||||||
|
SilverStripe\ORM\Tests\Search\SearchContextTest\GeneralSearch:
|
||||||
|
general0:
|
||||||
|
Name: General Zero
|
||||||
|
DoNotUseThisField: omitted
|
||||||
|
HairColor: blue
|
||||||
|
ExcludeThisField: excluded
|
||||||
|
ExactMatchField: Some specific value here
|
||||||
|
PartialMatchField: A partial match is allowed for this field
|
||||||
|
MatchAny1: Some match any field
|
||||||
|
MatchAny2: Another match any field
|
||||||
|
Customer: =>SilverStripe\ORM\Tests\Search\SearchContextTest\Customer.customer2
|
||||||
|
general1:
|
||||||
|
Name: General One
|
||||||
|
DoNotUseThisField: omitted
|
||||||
|
HairColor: brown
|
||||||
|
ExcludeThisField: excluded
|
||||||
|
ExactMatchField: This requires an exact match
|
||||||
|
PartialMatchField: This explicitly allows partial matches
|
||||||
|
MatchAny1: first match
|
||||||
|
MatchAny2: second match
|
||||||
|
Customer: =>SilverStripe\ORM\Tests\Search\SearchContextTest\Customer.customer1
|
||||||
|
general2:
|
||||||
|
Name: MatchNothing
|
||||||
|
DoNotUseThisField: MatchNothing
|
||||||
|
HairColor: MatchNothing
|
||||||
|
ExcludeThisField: MatchNothing
|
||||||
|
ExactMatchField: MatchNothing
|
||||||
|
PartialMatchField: MatchNothing
|
||||||
|
MatchAny1: MatchNothing
|
||||||
|
MatchAny2: MatchNothing
|
||||||
|
@ -10,6 +10,7 @@ class Customer extends DataObject implements TestOnly
|
|||||||
private static $table_name = 'SearchContextTest_Customer';
|
private static $table_name = 'SearchContextTest_Customer';
|
||||||
|
|
||||||
private static $db = [
|
private static $db = [
|
||||||
'FirstName' => 'Text'
|
'FirstName' => 'Text',
|
||||||
|
'MatchAny' => 'Varchar',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
50
tests/php/ORM/Search/SearchContextTest/GeneralSearch.php
Normal file
50
tests/php/ORM/Search/SearchContextTest/GeneralSearch.php
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\ORM\Tests\Search\SearchContextTest;
|
||||||
|
|
||||||
|
use SilverStripe\Dev\TestOnly;
|
||||||
|
use SilverStripe\Forms\TextField;
|
||||||
|
use SilverStripe\ORM\DataObject;
|
||||||
|
|
||||||
|
class GeneralSearch extends DataObject implements TestOnly
|
||||||
|
{
|
||||||
|
private static $table_name = 'SearchContextTest_GeneralSearch';
|
||||||
|
|
||||||
|
private static $db = [
|
||||||
|
'Name' => 'Varchar',
|
||||||
|
'DoNotUseThisField' => 'Varchar',
|
||||||
|
'HairColor' => 'Varchar',
|
||||||
|
'ExcludeThisField' => 'Varchar',
|
||||||
|
'ExactMatchField' => 'Varchar',
|
||||||
|
'PartialMatchField' => 'Varchar',
|
||||||
|
'MatchAny1' => 'Varchar',
|
||||||
|
'MatchAny2' => 'Varchar',
|
||||||
|
];
|
||||||
|
|
||||||
|
private static $has_one = [
|
||||||
|
'Customer' => Customer::class,
|
||||||
|
'ShippingAddress' => Address::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
private static $searchable_fields = [
|
||||||
|
'Name',
|
||||||
|
'HairColor',
|
||||||
|
'ExcludeThisField' => [
|
||||||
|
'general' => false,
|
||||||
|
],
|
||||||
|
'ExactMatchField' => [
|
||||||
|
'filter' => 'ExactMatchFilter',
|
||||||
|
],
|
||||||
|
'PartialMatchField' => [
|
||||||
|
'filter' => 'PartialMatchFilter',
|
||||||
|
],
|
||||||
|
'MatchAny' => [
|
||||||
|
'field' => TextField::class,
|
||||||
|
'match_any' => [
|
||||||
|
'MatchAny1',
|
||||||
|
'MatchAny2',
|
||||||
|
'Customer.MatchAny',
|
||||||
|
]
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace SilverStripe\ORM\Tests\Search\SearchContextTest;
|
||||||
|
|
||||||
|
use SilverStripe\Dev\TestOnly;
|
||||||
|
use SilverStripe\ORM\DataObject;
|
||||||
|
|
||||||
|
class NoSearchableFields extends DataObject implements TestOnly
|
||||||
|
{
|
||||||
|
private static $table_name = 'SearchContextTest_NoSearchableFields';
|
||||||
|
|
||||||
|
private static $db = [
|
||||||
|
'Name' => 'Varchar',
|
||||||
|
'Email' => 'Varchar',
|
||||||
|
'HairColor' => 'Varchar',
|
||||||
|
'EyeColor' => 'Varchar'
|
||||||
|
];
|
||||||
|
|
||||||
|
private static $has_one = [
|
||||||
|
'Customer' => Customer::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
private static $summary_fields = [
|
||||||
|
'Name' => 'Custom Label',
|
||||||
|
'Customer.FirstName' => 'Customer',
|
||||||
|
'HairColor',
|
||||||
|
'EyeColor',
|
||||||
|
];
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user