API Enable single-column fulltext filter search as fallback

This commit is contained in:
Damian Mooyman 2015-01-29 12:48:46 +13:00
parent 40c5b8b675
commit 782c4cbf6f
5 changed files with 120 additions and 37 deletions

View File

@ -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. default site search, have a look at those extensions and modify as required.
</div> </div>
### 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 Documentation
* [api:FulltextSearchable] * [api:FulltextSearchable]

View File

@ -68,18 +68,16 @@ class FulltextFilter extends SearchFilter {
} else { } else {
// Parse a fulltext string (eg. fulltext ("ColumnA", "ColumnB")) to figure out which columns // Parse a fulltext string (eg. fulltext ("ColumnA", "ColumnB")) to figure out which columns
// we need to search. // we need to search.
if(preg_match('/^fulltext\ \((.+)\)$/i', $index, $matches)) { if(preg_match('/^fulltext\s+\((.+)\)$/i', $index, $matches)) {
return $matches[1]; return $matches[1];
} else { } else {
throw new Exception("Invalid fulltext index format for '" . $this->getName() throw new Exception("Invalid fulltext index format for '" . $this->getName()
. "' on '" . $this->model . "'"); . "' on '" . $this->model . "'");
return;
} }
} }
return $columns; }
}
return parent::getDbName();
throw new Exception($this->getName() . ' is not a fulltext index on ' . $this->model . '.');
} }
} }

View File

@ -9,27 +9,27 @@
* @subpackage search * @subpackage search
*/ */
abstract class SearchFilter extends Object { abstract class SearchFilter extends Object {
/** /**
* @var string Classname of the inspected {@link DataObject} * @var string Classname of the inspected {@link DataObject}
*/ */
protected $model; protected $model;
/** /**
* @var string * @var string
*/ */
protected $name; protected $name;
/** /**
* @var string * @var string
*/ */
protected $fullName; protected $fullName;
/** /**
* @var mixed * @var mixed
*/ */
protected $value; protected $value;
/** /**
* @var array * @var array
*/ */
@ -41,7 +41,7 @@ abstract class SearchFilter extends Object {
* {@link applyRelation()}. * {@link applyRelation()}.
*/ */
protected $relation; protected $relation;
/** /**
* @param string $fullName Determines the name of the field, as well as the searched database * @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 * 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->value = $value;
$this->setModifiers($modifiers); $this->setModifiers($modifiers);
} }
/** /**
* Called by constructor to convert a string pathname into * Called by constructor to convert a string pathname into
* a well defined relationship sequence. * a well defined relationship sequence.
@ -74,7 +74,7 @@ abstract class SearchFilter extends Object {
$this->name = $name; $this->name = $name;
} }
} }
/** /**
* Set the root model class to be selected by this * Set the root model class to be selected by this
* search query. * search query.
@ -84,7 +84,7 @@ abstract class SearchFilter extends Object {
public function setModel($className) { public function setModel($className) {
$this->model = $className; $this->model = $className;
} }
/** /**
* Set the current value to be filtered on. * Set the current value to be filtered on.
* *
@ -93,7 +93,7 @@ abstract class SearchFilter extends Object {
public function setValue($value) { public function setValue($value) {
$this->value = $value; $this->value = $value;
} }
/** /**
* Accessor for the current value to be filtered on. * Accessor for the current value to be filtered on.
* Caution: Data is not escaped. * Caution: Data is not escaped.
@ -121,7 +121,7 @@ abstract class SearchFilter extends Object {
public function getModifiers() { public function getModifiers() {
return $this->modifiers; return $this->modifiers;
} }
/** /**
* The original name of the field. * The original name of the field.
* *
@ -137,7 +137,7 @@ abstract class SearchFilter extends Object {
public function setName($name) { public function setName($name) {
$this->name = $name; $this->name = $name;
} }
/** /**
* The full name passed to the constructor, * The full name passed to the constructor,
* including any (optional) relations in dot notation. * including any (optional) relations in dot notation.
@ -154,7 +154,7 @@ abstract class SearchFilter extends Object {
public function setFullName($name) { public function setFullName($name) {
$this->fullName = $name; $this->fullName = $name;
} }
/** /**
* Normalizes the field name to table mapping. * Normalizes the field name to table mapping.
* *
@ -168,7 +168,9 @@ abstract class SearchFilter extends Object {
// Ensure that we're dealing with a DataObject. // Ensure that we're dealing with a DataObject.
if (!is_subclass_of($this->model, '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( $candidateClass = ClassInfo::table_for_object_field(
@ -183,9 +185,9 @@ abstract class SearchFilter extends Object {
return '"' . implode('"."', $parts) . '"'; 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 * Return the value of the field as processed by the DBField class
* *
@ -200,7 +202,6 @@ abstract class SearchFilter extends Object {
return $dbField->RAW(); return $dbField->RAW();
} }
/** /**
* Apply filter criteria to a SQL query. * Apply filter criteria to a SQL query.
* *
@ -272,7 +273,7 @@ abstract class SearchFilter extends Object {
protected function excludeMany(DataQuery $query) { protected function excludeMany(DataQuery $query) {
throw new InvalidArgumentException(get_class($this) . " can't be used to filter by a list of items."); throw new InvalidArgumentException(get_class($this) . " can't be used to filter by a list of items.");
} }
/** /**
* Determines if a field has a value, * Determines if a field has a value,
* and that the filter should be applied. * and that the filter should be applied.

View File

@ -1,14 +1,17 @@
<?php <?php
class FulltextFilterTest extends SapphireTest { class FulltextFilterTest extends SapphireTest {
protected $extraDataObjects = array(
'FulltextFilterTest_DataObject'
);
protected static $fixture_file = "FulltextFilterTest.yml"; protected static $fixture_file = "FulltextFilterTest.yml";
public function testFilter() { public function testFilter() {
if(DB::getConn() instanceof MySQLDatabase) { if(DB::getConn() instanceof MySQLDatabase) {
$baseQuery = FulltextDataObject::get(); $baseQuery = FulltextFilterTest_DataObject::get();
$this->assertEquals(3, $baseQuery->count(), "FulltextDataObject count does not match."); $this->assertEquals(3, $baseQuery->count(), "FulltextFilterTest_DataObject count does not match.");
// First we'll text the 'SearchFields' which has been set using an array // First we'll text the 'SearchFields' which has been set using an array
$search = $baseQuery->filter("SearchFields:fulltext", 'SilverStripe'); $search = $baseQuery->filter("SearchFields:fulltext", 'SilverStripe');
@ -25,24 +28,64 @@ class FulltextFilterTest extends SapphireTest {
$search = $baseQuery->exclude("OtherSearchFields:fulltext", "SilverStripe"); $search = $baseQuery->exclude("OtherSearchFields:fulltext", "SilverStripe");
$this->assertEquals(2, $search->count()); $this->assertEquals(2, $search->count());
// Edgecase // Search on a single field
$this->setExpectedException("Exception"); $search = $baseQuery->filter("ColumnE:fulltext", 'Dragons');
$search = $baseQuery->exclude("Madeup:fulltext", "SilverStripe"); $this->assertEquals(1, $search->count());
$search = $baseQuery->exclude("ColumnE:fulltext", "Dragons");
$this->assertEquals(2, $search->count());
} else { } else {
$this->markTestSkipped("FulltextFilter only supports MySQL syntax."); $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( private static $db = array(
"ColumnA" => "Varchar(255)", "ColumnA" => "Varchar(255)",
"ColumnB" => "HTMLText", "ColumnB" => "HTMLText",
"ColumnC" => "Varchar(255)", "ColumnC" => "Varchar(255)",
"ColumnD" => "HTMLText", "ColumnD" => "HTMLText",
"ColumnE" => 'Varchar(255)'
); );
private static $indexes = array( private static $indexes = array(
@ -52,6 +95,7 @@ class FulltextDataObject extends DataObject {
'value' => '"ColumnA", "ColumnB"', 'value' => '"ColumnA", "ColumnB"',
), ),
'OtherSearchFields' => 'fulltext ("ColumnC", "ColumnD")', 'OtherSearchFields' => 'fulltext ("ColumnC", "ColumnD")',
'SingleIndex' => 'fulltext ("ColumnE")'
); );
private static $create_table_options = array( private static $create_table_options = array(

View File

@ -1,16 +1,19 @@
FulltextDataObject: FulltextFilterTest_DataObject:
object1: object1:
ColumnA: 'SilverStripe' ColumnA: 'SilverStripe'
CluumnB: <p>Some content about SilverStripe.</p> CluumnB: '<p>Some content about SilverStripe.</p>'
ColumnC: 'SilverStripe' ColumnC: 'SilverStripe'
ColumnD: '<p>Some content about SilverStripe.</p> ColumnD: '<p>Some content about SilverStripe.</p>'
ColumnE: 'Dragons be here'
object2: object2:
ColumnA: 'Test Row' ColumnA: 'Test Row'
ColumnB: '<p>Some information about this test row.</p>' ColumnB: '<p>Some information about this test row.</p>'
ColumnC: 'Test Row' ColumnC: 'Test Row'
ColumnD: '<p>Some information about this test row.</p>' ColumnD: '<p>Some information about this test row.</p>'
ColumnE: 'No'
object3: object3:
ColumnA: 'Fulltext Search' ColumnA: 'Fulltext Search'
ColumnB: '<p>Testing fulltext search.</p>' ColumnB: '<p>Testing fulltext search.</p>'
ColumnC: 'Fulltext Search ColumnC: 'Fulltext Search'
ColumnD: '<p>Testing fulltext search.</p>' ColumnD: '<p>Testing fulltext search.</p>'
ColumnE: ''