From 11595952f48efb40a17dafc2b37a6ad7171bf8fa Mon Sep 17 00:00:00 2001 From: Guy Sartorelli <36352093+GuySartorelli@users.noreply.github.com> Date: Mon, 1 Aug 2022 12:19:02 +1200 Subject: [PATCH] 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. --- lang/en.yml | 1 + src/ORM/DataObject.php | 48 ++++ src/ORM/Filters/PartialMatchFilter.php | 2 +- src/ORM/Search/SearchContext.php | 171 +++++++++++--- .../GridField/GridFieldFilterHeaderTest.php | 19 +- tests/php/ORM/Search/SearchContextTest.php | 212 ++++++++++++++++++ tests/php/ORM/Search/SearchContextTest.yml | 32 +++ .../ORM/Search/SearchContextTest/Customer.php | 3 +- .../SearchContextTest/GeneralSearch.php | 50 +++++ .../SearchContextTest/NoSearchableFields.php | 29 +++ 10 files changed, 529 insertions(+), 38 deletions(-) create mode 100644 tests/php/ORM/Search/SearchContextTest/GeneralSearch.php create mode 100644 tests/php/ORM/Search/SearchContextTest/NoSearchableFields.php diff --git a/lang/en.yml b/lang/en.yml index 8b35ea48f..6a44c5e96 100644 --- a/lang/en.yml +++ b/lang/en.yml @@ -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' diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php index 11e1af6db..b236b253c 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -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. diff --git a/src/ORM/Filters/PartialMatchFilter.php b/src/ORM/Filters/PartialMatchFilter.php index 24d85ab34..a93f45776 100644 --- a/src/ORM/Filters/PartialMatchFilter.php +++ b/src/ORM/Filters/PartialMatchFilter.php @@ -59,7 +59,7 @@ class PartialMatchFilter extends SearchFilter ); $clause = [$comparisonClause => $this->getMatchPattern($this->getValue())]; - + return $this->aggregate ? $this->applyAggregate($query, $clause) : $query->where($clause); diff --git a/src/ORM/Search/SearchContext.php b/src/ORM/Search/SearchContext.php index ed74faa1f..1b751de00 100644 --- a/src/ORM/Search/SearchContext.php +++ b/src/ORM/Search/SearchContext.php @@ -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); + } } /** diff --git a/tests/php/Forms/GridField/GridFieldFilterHeaderTest.php b/tests/php/Forms/GridField/GridFieldFilterHeaderTest.php index e7bf6a3fb..174412e7f 100644 --- a/tests/php/Forms/GridField/GridFieldFilterHeaderTest.php +++ b/tests/php/Forms/GridField/GridFieldFilterHeaderTest.php @@ -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']); diff --git a/tests/php/ORM/Search/SearchContextTest.php b/tests/php/ORM/Search/SearchContextTest.php index c3a0a379f..8c80a4492 100644 --- a/tests/php/ORM/Search/SearchContextTest.php +++ b/tests/php/ORM/Search/SearchContextTest.php @@ -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'); diff --git a/tests/php/ORM/Search/SearchContextTest.yml b/tests/php/ORM/Search/SearchContextTest.yml index 2759311eb..ea297934b 100644 --- a/tests/php/ORM/Search/SearchContextTest.yml +++ b/tests/php/ORM/Search/SearchContextTest.yml @@ -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 diff --git a/tests/php/ORM/Search/SearchContextTest/Customer.php b/tests/php/ORM/Search/SearchContextTest/Customer.php index 4efe9efa9..fc72d8b2e 100644 --- a/tests/php/ORM/Search/SearchContextTest/Customer.php +++ b/tests/php/ORM/Search/SearchContextTest/Customer.php @@ -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', ]; } diff --git a/tests/php/ORM/Search/SearchContextTest/GeneralSearch.php b/tests/php/ORM/Search/SearchContextTest/GeneralSearch.php new file mode 100644 index 000000000..d20fe4981 --- /dev/null +++ b/tests/php/ORM/Search/SearchContextTest/GeneralSearch.php @@ -0,0 +1,50 @@ + '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', + ] + ] + ]; +} diff --git a/tests/php/ORM/Search/SearchContextTest/NoSearchableFields.php b/tests/php/ORM/Search/SearchContextTest/NoSearchableFields.php new file mode 100644 index 000000000..39ead8537 --- /dev/null +++ b/tests/php/ORM/Search/SearchContextTest/NoSearchableFields.php @@ -0,0 +1,29 @@ + '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', + ]; +}