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'
|
||||
other: '{count} Data Objects'
|
||||
SINGULARNAME: 'Data Object'
|
||||
GENERALSEARCH: 'General Search'
|
||||
SilverStripe\ORM\FieldType\DBBoolean:
|
||||
ANY: Any
|
||||
NOANSWER: 'No'
|
||||
|
@ -16,6 +16,7 @@ use SilverStripe\Forms\FieldList;
|
||||
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;
|
||||
@ -23,6 +24,7 @@ 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;
|
||||
@ -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
|
||||
* searchable, and map them to their default {@link FormField}
|
||||
@ -2399,6 +2413,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
|
||||
(array)$_params
|
||||
);
|
||||
$fields = new FieldList();
|
||||
|
||||
foreach ($this->searchableFields() as $fieldName => $spec) {
|
||||
if ($params['restrictFields'] && !in_array($fieldName, $params['restrictFields'] ?? [])) {
|
||||
continue;
|
||||
@ -2451,6 +2466,16 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
@ -4198,6 +4223,29 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
|
||||
*/
|
||||
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.
|
||||
|
@ -59,7 +59,7 @@ class PartialMatchFilter extends SearchFilter
|
||||
);
|
||||
|
||||
$clause = [$comparisonClause => $this->getMatchPattern($this->getValue())];
|
||||
|
||||
|
||||
return $this->aggregate ?
|
||||
$this->applyAggregate($query, $clause) :
|
||||
$query->where($clause);
|
||||
|
@ -17,6 +17,7 @@ use SilverStripe\Forms\SelectField;
|
||||
use SilverStripe\Forms\CheckboxField;
|
||||
use InvalidArgumentException;
|
||||
use Exception;
|
||||
use SilverStripe\Core\Config\Config;
|
||||
use SilverStripe\ORM\DataQuery;
|
||||
|
||||
/**
|
||||
@ -110,8 +111,6 @@ class SearchContext
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -151,8 +150,38 @@ class SearchContext
|
||||
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)) {
|
||||
@ -176,38 +205,120 @@ class SearchContext
|
||||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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\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
|
||||
{
|
||||
@ -89,9 +89,12 @@ class GridFieldFilterHeaderTest extends SapphireTest
|
||||
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);
|
||||
|
||||
@ -110,9 +113,12 @@ class GridFieldFilterHeaderTest extends SapphireTest
|
||||
);
|
||||
$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);
|
||||
@ -145,9 +151,10 @@ class GridFieldFilterHeaderTest extends SapphireTest
|
||||
$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']);
|
||||
|
||||
|
@ -2,6 +2,9 @@
|
||||
|
||||
namespace SilverStripe\ORM\Tests\Search;
|
||||
|
||||
use LogicException;
|
||||
use ReflectionMethod;
|
||||
use SilverStripe\Core\Config\Config;
|
||||
use SilverStripe\Dev\SapphireTest;
|
||||
use SilverStripe\Forms\TextField;
|
||||
use SilverStripe\Forms\TextareaField;
|
||||
@ -9,7 +12,10 @@ use SilverStripe\Forms\NumericField;
|
||||
use SilverStripe\Forms\DropdownField;
|
||||
use SilverStripe\Forms\CheckboxField;
|
||||
use SilverStripe\Forms\FieldList;
|
||||
use SilverStripe\Forms\HiddenField;
|
||||
use SilverStripe\ORM\DataList;
|
||||
use SilverStripe\ORM\Filters\EndsWithFilter;
|
||||
use SilverStripe\ORM\Filters\ExactMatchFilter;
|
||||
use SilverStripe\ORM\Filters\PartialMatchFilter;
|
||||
use SilverStripe\ORM\Search\SearchContext;
|
||||
|
||||
@ -20,6 +26,7 @@ class SearchContextTest extends SapphireTest
|
||||
|
||||
protected static $extra_dataobjects = [
|
||||
SearchContextTest\Person::class,
|
||||
SearchContextTest\NoSearchableFields::class,
|
||||
SearchContextTest\Book::class,
|
||||
SearchContextTest\Company::class,
|
||||
SearchContextTest\Project::class,
|
||||
@ -29,6 +36,7 @@ class SearchContextTest extends SapphireTest
|
||||
SearchContextTest\Customer::class,
|
||||
SearchContextTest\Address::class,
|
||||
SearchContextTest\Order::class,
|
||||
SearchContextTest\GeneralSearch::class,
|
||||
];
|
||||
|
||||
public function testResultSetFilterReturnsExpectedCount()
|
||||
@ -45,6 +53,20 @@ class SearchContextTest extends SapphireTest
|
||||
$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()
|
||||
{
|
||||
$person = SearchContextTest\Person::singleton();
|
||||
@ -109,6 +131,7 @@ class SearchContextTest extends SapphireTest
|
||||
$context = $company->getDefaultSearchContext();
|
||||
$this->assertEquals(
|
||||
new FieldList(
|
||||
new HiddenField($company->getGeneralSearchFieldName(), 'General Search'),
|
||||
(new TextField("Name", 'Name'))
|
||||
->setMaxLength(255),
|
||||
new TextareaField("Industry", 'Industry'),
|
||||
@ -255,6 +278,195 @@ class SearchContextTest extends SapphireTest
|
||||
$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()
|
||||
{
|
||||
$order1 = $this->objFromFixture(SearchContextTest\Order::class, 'order1');
|
||||
|
@ -74,6 +74,7 @@ SilverStripe\ORM\Tests\Search\SearchContextTest\AllFilterTypes:
|
||||
SilverStripe\ORM\Tests\Search\SearchContextTest\Customer:
|
||||
customer1:
|
||||
FirstName: Bill
|
||||
MatchAny: Some arbitrary Value
|
||||
customer2:
|
||||
FirstName: Bailey
|
||||
customer3:
|
||||
@ -100,3 +101,34 @@ SilverStripe\ORM\Tests\Search\SearchContextTest\Order:
|
||||
Name: 'Jack'
|
||||
Customer: =>SilverStripe\ORM\Tests\Search\SearchContextTest\Customer.customer3
|
||||
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 $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