From 40c5b8b6758676a3e2a5daf3c438a7720c49baaf Mon Sep 17 00:00:00 2001 From: micmania1 Date: Sun, 25 May 2014 01:46:07 +0100 Subject: [PATCH 1/2] FIX FulltextFilter did not work and was not usable --- search/filters/FulltextFilter.php | 42 ++++++++++++++++++-- search/filters/SearchFilter.php | 7 +++- tests/search/FulltextFilterTest.php | 61 +++++++++++++++++++++++++++++ tests/search/FulltextFilterTest.yml | 16 ++++++++ 4 files changed, 122 insertions(+), 4 deletions(-) mode change 100644 => 100755 search/filters/FulltextFilter.php create mode 100755 tests/search/FulltextFilterTest.php create mode 100644 tests/search/FulltextFilterTest.yml diff --git a/search/filters/FulltextFilter.php b/search/filters/FulltextFilter.php old mode 100644 new mode 100755 index 36b77b4c2..ec98e2f63 --- a/search/filters/FulltextFilter.php +++ b/search/filters/FulltextFilter.php @@ -17,17 +17,17 @@ * database table, using the {$indexes} hash in your DataObject subclass: * * - * static $indexes = array( + * private static $indexes = array( * 'SearchFields' => 'fulltext(Name, Title, Description)' * ); * * - * @package framework - * @subpackage search + * @todo Add support for databases besides MySQL */ class FulltextFilter extends SearchFilter { protected function applyOne(DataQuery $query) { + $this->model = $query->applyRelation($this->relation); return $query->where(sprintf( "MATCH (%s) AGAINST ('%s')", $this->getDbName(), @@ -36,6 +36,7 @@ class FulltextFilter extends SearchFilter { } protected function excludeOne(DataQuery $query) { + $this->model = $query->applyRelation($this->relation); return $query->where(sprintf( "NOT MATCH (%s) AGAINST ('%s')", $this->getDbName(), @@ -46,4 +47,39 @@ class FulltextFilter extends SearchFilter { public function isEmpty() { return $this->getValue() === array() || $this->getValue() === null || $this->getValue() === ''; } + + + /** + * This implementation allows for a list of columns to be passed into MATCH() instead of just one. + * + * @example + * + * MyDataObject::get()->filter('SearchFields:fulltext', 'search term') + * + * + * @return string + */ + public function getDbName() { + $indexes = Config::inst()->get($this->model, "indexes"); + if(is_array($indexes) && array_key_exists($this->getName(), $indexes)) { + $index = $indexes[$this->getName()]; + if(is_array($index) && array_key_exists("value", $index)) { + return $index['value']; + } else { + // Parse a fulltext string (eg. fulltext ("ColumnA", "ColumnB")) to figure out which columns + // we need to search. + if(preg_match('/^fulltext\ \((.+)\)$/i', $index, $matches)) { + return $matches[1]; + } else { + throw new Exception("Invalid fulltext index format for '" . $this->getName() + . "' on '" . $this->model . "'"); + return; + } + } + return $columns; + } + + throw new Exception($this->getName() . ' is not a fulltext index on ' . $this->model . '.'); + } + } diff --git a/search/filters/SearchFilter.php b/search/filters/SearchFilter.php index e09e97dd1..b4efdfc9a 100644 --- a/search/filters/SearchFilter.php +++ b/search/filters/SearchFilter.php @@ -166,6 +166,11 @@ abstract class SearchFilter extends Object { return $this->name; } + // Ensure that we're dealing with a DataObject. + if (!is_subclass_of($this->model, 'DataObject')) { + throw new Exception("Model supplied to " . get_class($this) . " should be an instance of DataObject."); + } + $candidateClass = ClassInfo::table_for_object_field( $this->model, $this->name @@ -178,7 +183,7 @@ abstract class SearchFilter extends Object { return '"' . implode('"."', $parts) . '"'; } - return "\"$candidateClass\".\"$this->name\""; + return "\"{$candidateClass}\".\"{$this->name}\""; } /** diff --git a/tests/search/FulltextFilterTest.php b/tests/search/FulltextFilterTest.php new file mode 100755 index 000000000..49b75911d --- /dev/null +++ b/tests/search/FulltextFilterTest.php @@ -0,0 +1,61 @@ +assertEquals(3, $baseQuery->count(), "FulltextDataObject count does not match."); + + // First we'll text the 'SearchFields' which has been set using an array + $search = $baseQuery->filter("SearchFields:fulltext", 'SilverStripe'); + $this->assertEquals(1, $search->count()); + + $search = $baseQuery->exclude("SearchFields:fulltext", "SilverStripe"); + $this->assertEquals(2, $search->count()); + + // Now we'll run the same tests on 'OtherSearchFields' which should yield the same resutls + // but has been set using a string. + $search = $baseQuery->filter("OtherSearchFields:fulltext", 'SilverStripe'); + $this->assertEquals(1, $search->count()); + + $search = $baseQuery->exclude("OtherSearchFields:fulltext", "SilverStripe"); + $this->assertEquals(2, $search->count()); + + // Edgecase + $this->setExpectedException("Exception"); + $search = $baseQuery->exclude("Madeup:fulltext", "SilverStripe"); + } else { + $this->markTestSkipped("FulltextFilter only supports MySQL syntax."); + } + } + +} + + +class FulltextDataObject extends DataObject { + + private static $db = array( + "ColumnA" => "Varchar(255)", + "ColumnB" => "HTMLText", + "ColumnC" => "Varchar(255)", + "ColumnD" => "HTMLText", + ); + + private static $indexes = array( + 'SearchFields' => array( + 'type' => 'fulltext', + 'name' => 'SearchFields', + 'value' => '"ColumnA", "ColumnB"', + ), + 'OtherSearchFields' => 'fulltext ("ColumnC", "ColumnD")', + ); + + private static $create_table_options = array( + "MySQLDatabase" => "ENGINE=MyISAM", + ); + +} \ No newline at end of file diff --git a/tests/search/FulltextFilterTest.yml b/tests/search/FulltextFilterTest.yml new file mode 100644 index 000000000..b3be2215b --- /dev/null +++ b/tests/search/FulltextFilterTest.yml @@ -0,0 +1,16 @@ +FulltextDataObject: + object1: + ColumnA: 'SilverStripe' + CluumnB:

Some content about SilverStripe.

+ ColumnC: 'SilverStripe' + ColumnD: '

Some content about SilverStripe.

+ object2: + ColumnA: 'Test Row' + ColumnB: '

Some information about this test row.

' + ColumnC: 'Test Row' + ColumnD: '

Some information about this test row.

' + object3: + ColumnA: 'Fulltext Search' + ColumnB: '

Testing fulltext search.

' + ColumnC: 'Fulltext Search + ColumnD: '

Testing fulltext search.

' \ No newline at end of file From 782c4cbf6f5cde2fa4d45cdbd17552773a67f88f Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Thu, 29 Jan 2015 12:48:46 +1300 Subject: [PATCH 2/2] API Enable single-column fulltext filter search as fallback --- .../12_Search/02_FulltextSearch.md | 37 ++++++++++++ search/filters/FulltextFilter.php | 10 ++-- search/filters/SearchFilter.php | 37 ++++++------ tests/search/FulltextFilterTest.php | 60 ++++++++++++++++--- tests/search/FulltextFilterTest.yml | 13 ++-- 5 files changed, 120 insertions(+), 37 deletions(-) diff --git a/docs/en/02_Developer_Guides/12_Search/02_FulltextSearch.md b/docs/en/02_Developer_Guides/12_Search/02_FulltextSearch.md index 11fdb7c20..82153ca2d 100644 --- a/docs/en/02_Developer_Guides/12_Search/02_FulltextSearch.md +++ b/docs/en/02_Developer_Guides/12_Search/02_FulltextSearch.md @@ -39,6 +39,43 @@ records and cannot easily be adapted to include custom `DataObject` instances. T default site search, have a look at those extensions and modify as required. +### Fulltext Filter + +SilverStripe provides a `[api:FulltextFiler]` which you can use to perform custom fulltext searches on +`[api:DataList]`'s. + +Example DataObject: + + :::php + class SearchableDataObject extends DataObject { + + private static $db = array( + "Title" => "Varchar(255)", + "Content" => "HTMLText", + ); + + private static $indexes = array( + 'SearchFields' => array( + 'type' => 'fulltext', + 'name' => 'SearchFields', + 'value' => '"Title", "Content"', + ) + ); + + private static $create_table_options = array( + 'MySQLDatabase' => 'ENGINE=MyISAM' + ); + + } + +Performing the search: + + :::php + SearchableDataObject::get()->filter('SearchFields:fulltext', 'search term'); + +If your search index is a single field size, then you may also specify the search filter by the name of the +field instead of the index. + ## API Documentation * [api:FulltextSearchable] \ No newline at end of file diff --git a/search/filters/FulltextFilter.php b/search/filters/FulltextFilter.php index ec98e2f63..36fbf222e 100755 --- a/search/filters/FulltextFilter.php +++ b/search/filters/FulltextFilter.php @@ -68,18 +68,16 @@ class FulltextFilter extends SearchFilter { } else { // Parse a fulltext string (eg. fulltext ("ColumnA", "ColumnB")) to figure out which columns // we need to search. - if(preg_match('/^fulltext\ \((.+)\)$/i', $index, $matches)) { + if(preg_match('/^fulltext\s+\((.+)\)$/i', $index, $matches)) { return $matches[1]; } else { throw new Exception("Invalid fulltext index format for '" . $this->getName() . "' on '" . $this->model . "'"); - return; } } - return $columns; - } - - throw new Exception($this->getName() . ' is not a fulltext index on ' . $this->model . '.'); + } + + return parent::getDbName(); } } diff --git a/search/filters/SearchFilter.php b/search/filters/SearchFilter.php index b4efdfc9a..c1dbd90c5 100644 --- a/search/filters/SearchFilter.php +++ b/search/filters/SearchFilter.php @@ -9,27 +9,27 @@ * @subpackage search */ abstract class SearchFilter extends Object { - + /** * @var string Classname of the inspected {@link DataObject} */ protected $model; - + /** * @var string */ protected $name; - + /** * @var string */ protected $fullName; - + /** * @var mixed */ protected $value; - + /** * @var array */ @@ -41,7 +41,7 @@ abstract class SearchFilter extends Object { * {@link applyRelation()}. */ protected $relation; - + /** * @param string $fullName Determines the name of the field, as well as the searched database * column. Can contain a relation name in dot notation, which will automatically join @@ -58,7 +58,7 @@ abstract class SearchFilter extends Object { $this->value = $value; $this->setModifiers($modifiers); } - + /** * Called by constructor to convert a string pathname into * a well defined relationship sequence. @@ -74,7 +74,7 @@ abstract class SearchFilter extends Object { $this->name = $name; } } - + /** * Set the root model class to be selected by this * search query. @@ -84,7 +84,7 @@ abstract class SearchFilter extends Object { public function setModel($className) { $this->model = $className; } - + /** * Set the current value to be filtered on. * @@ -93,7 +93,7 @@ abstract class SearchFilter extends Object { public function setValue($value) { $this->value = $value; } - + /** * Accessor for the current value to be filtered on. * Caution: Data is not escaped. @@ -121,7 +121,7 @@ abstract class SearchFilter extends Object { public function getModifiers() { return $this->modifiers; } - + /** * The original name of the field. * @@ -137,7 +137,7 @@ abstract class SearchFilter extends Object { public function setName($name) { $this->name = $name; } - + /** * The full name passed to the constructor, * including any (optional) relations in dot notation. @@ -154,7 +154,7 @@ abstract class SearchFilter extends Object { public function setFullName($name) { $this->fullName = $name; } - + /** * Normalizes the field name to table mapping. * @@ -168,7 +168,9 @@ abstract class SearchFilter extends Object { // Ensure that we're dealing with a DataObject. if (!is_subclass_of($this->model, 'DataObject')) { - throw new Exception("Model supplied to " . get_class($this) . " should be an instance of DataObject."); + throw new InvalidArgumentException( + "Model supplied to " . get_class($this) . " should be an instance of DataObject." + ); } $candidateClass = ClassInfo::table_for_object_field( @@ -183,9 +185,9 @@ abstract class SearchFilter extends Object { return '"' . implode('"."', $parts) . '"'; } - return "\"{$candidateClass}\".\"{$this->name}\""; + return sprintf('"%s"."%s"', $candidateClass, $this->name); } - + /** * Return the value of the field as processed by the DBField class * @@ -200,7 +202,6 @@ abstract class SearchFilter extends Object { return $dbField->RAW(); } - /** * Apply filter criteria to a SQL query. * @@ -272,7 +273,7 @@ abstract class SearchFilter extends Object { protected function excludeMany(DataQuery $query) { throw new InvalidArgumentException(get_class($this) . " can't be used to filter by a list of items."); } - + /** * Determines if a field has a value, * and that the filter should be applied. diff --git a/tests/search/FulltextFilterTest.php b/tests/search/FulltextFilterTest.php index 49b75911d..efca15c6e 100755 --- a/tests/search/FulltextFilterTest.php +++ b/tests/search/FulltextFilterTest.php @@ -1,14 +1,17 @@ assertEquals(3, $baseQuery->count(), "FulltextDataObject count does not match."); + $baseQuery = FulltextFilterTest_DataObject::get(); + $this->assertEquals(3, $baseQuery->count(), "FulltextFilterTest_DataObject count does not match."); // First we'll text the 'SearchFields' which has been set using an array $search = $baseQuery->filter("SearchFields:fulltext", 'SilverStripe'); @@ -25,24 +28,64 @@ class FulltextFilterTest extends SapphireTest { $search = $baseQuery->exclude("OtherSearchFields:fulltext", "SilverStripe"); $this->assertEquals(2, $search->count()); - // Edgecase - $this->setExpectedException("Exception"); - $search = $baseQuery->exclude("Madeup:fulltext", "SilverStripe"); + // Search on a single field + $search = $baseQuery->filter("ColumnE:fulltext", 'Dragons'); + $this->assertEquals(1, $search->count()); + + $search = $baseQuery->exclude("ColumnE:fulltext", "Dragons"); + $this->assertEquals(2, $search->count()); } else { $this->markTestSkipped("FulltextFilter only supports MySQL syntax."); } } - + + public function testGenerateQuery() { + // Test SearchFields + $filter1 = new FulltextFilter('SearchFields', 'SilverStripe'); + $filter1->setModel('FulltextFilterTest_DataObject'); + $query1 = FulltextFilterTest_DataObject::get()->dataQuery(); + $filter1->apply($query1); + $this->assertEquals('"ColumnA", "ColumnB"', $filter1->getDbName()); + $this->assertEquals( + array("MATCH (\"ColumnA\", \"ColumnB\") AGAINST ('SilverStripe')"), + $query1->query()->getWhere() + ); + + + // Test Other searchfields + $filter2 = new FulltextFilter('OtherSearchFields', 'SilverStripe'); + $filter2->setModel('FulltextFilterTest_DataObject'); + $query2 = FulltextFilterTest_DataObject::get()->dataQuery(); + $filter2->apply($query2); + $this->assertEquals('"ColumnC", "ColumnD"', $filter2->getDbName()); + $this->assertEquals( + array("MATCH (\"ColumnC\", \"ColumnD\") AGAINST ('SilverStripe')"), + $query2->query()->getWhere() + ); + + // Test fallback to single field + $filter3 = new FulltextFilter('ColumnA', 'SilverStripe'); + $filter3->setModel('FulltextFilterTest_DataObject'); + $query3 = FulltextFilterTest_DataObject::get()->dataQuery(); + $filter3->apply($query3); + $this->assertEquals('"FulltextFilterTest_DataObject"."ColumnA"', $filter3->getDbName()); + $this->assertEquals( + array("MATCH (\"FulltextFilterTest_DataObject\".\"ColumnA\") AGAINST ('SilverStripe')"), + $query3->query()->getWhere() + ); + } + } -class FulltextDataObject extends DataObject { +class FulltextFilterTest_DataObject extends DataObject implements TestOnly { private static $db = array( "ColumnA" => "Varchar(255)", "ColumnB" => "HTMLText", "ColumnC" => "Varchar(255)", "ColumnD" => "HTMLText", + "ColumnE" => 'Varchar(255)' ); private static $indexes = array( @@ -52,6 +95,7 @@ class FulltextDataObject extends DataObject { 'value' => '"ColumnA", "ColumnB"', ), 'OtherSearchFields' => 'fulltext ("ColumnC", "ColumnD")', + 'SingleIndex' => 'fulltext ("ColumnE")' ); private static $create_table_options = array( diff --git a/tests/search/FulltextFilterTest.yml b/tests/search/FulltextFilterTest.yml index b3be2215b..1f59ca081 100644 --- a/tests/search/FulltextFilterTest.yml +++ b/tests/search/FulltextFilterTest.yml @@ -1,16 +1,19 @@ -FulltextDataObject: +FulltextFilterTest_DataObject: object1: ColumnA: 'SilverStripe' - CluumnB:

Some content about SilverStripe.

+ CluumnB: '

Some content about SilverStripe.

' ColumnC: 'SilverStripe' - ColumnD: '

Some content about SilverStripe.

+ ColumnD: '

Some content about SilverStripe.

' + ColumnE: 'Dragons be here' object2: ColumnA: 'Test Row' ColumnB: '

Some information about this test row.

' ColumnC: 'Test Row' ColumnD: '

Some information about this test row.

' + ColumnE: 'No' object3: ColumnA: 'Fulltext Search' ColumnB: '

Testing fulltext search.

' - ColumnC: 'Fulltext Search - ColumnD: '

Testing fulltext search.

' \ No newline at end of file + ColumnC: 'Fulltext Search' + ColumnD: '

Testing fulltext search.

' + ColumnE: ''