API Allow use of :not, :nocase and :case modifiers to SearchFilters.

More modifiers can be added to each class as desired.
This commit is contained in:
Simon Welsh 2012-09-20 19:26:05 +12:00
parent 2faf7d112d
commit c49f7566c3
12 changed files with 268 additions and 113 deletions

View File

@ -205,13 +205,18 @@ This would be equivalent to a SQL query of
### Search Filter Modifiers
The where clauses showcased in the previous two sections (filter and exclude) specify case-insensitive exact
The where clauses showcased in the previous two sections (filter and exclude) specify exact
matches by default. However, there are a number of suffixes that you can put on field names to change this
behaviour `":StartsWith"`, `":EndsWith"`, `":PartialMatch"`, `":GreaterThan"`, `":LessThan"`, `":Negation"`.
Each of these suffixes is represented in the ORM as a subclass of `[api:SearchFilter]`. Developers can define
their own SearchFilters if needing to extend the ORM filter and exclude behaviours.
These suffixes can also take modifiers themselves. The modifiers currently supported are `":not"`, `":nocase"`
and `":case"`. These negate the filter, make it case-insensitive and make it case-sensitive respectively. The
default comparison uses the database's default. For MySQL and MSSQL, this is case-insensitive. For PostgreSQL,
this is case-sensitive.
The following is a query which will return everyone whose first name doesn't start with S, who has logged in
since 1/1/2011.

View File

@ -374,38 +374,15 @@ class DataList extends ViewableData implements SS_List, SS_Filterable, SS_Sortab
* Return a new instance of the list with an added filter
*/
public function addFilter($filterArray) {
$SQL_Statements = array();
foreach($filterArray as $field => $value) {
if(is_array($value)) {
$customQuery = 'IN (\''.implode('\',\'',Convert::raw2sql($value)).'\')';
} else {
$customQuery = '= \''.Convert::raw2sql($value).'\'';
}
if(stristr($field,':')) {
$fieldArgs = explode(':',$field);
$field = array_shift($fieldArgs);
foreach($fieldArgs as $fieldArg){
$comparisor = $this->applyFilterContext($field, $fieldArg, $value);
}
} else {
if($field == 'ID') {
$field = sprintf('"%s"."ID"', ClassInfo::baseDataClass($this->dataClass));
} else {
$field = '"' . Convert::raw2sql($field) . '"';
}
$SQL_Statements[] = $field . ' ' . $customQuery;
}
$fieldArgs = explode(':', $field);
$field = array_shift($fieldArgs);
$filterType = array_shift($fieldArgs);
$modifiers = $fieldArgs;
$this->applyFilterContext($field, $filterType, $modifiers, $value);
}
if(!count($SQL_Statements)) return $this;
return $this->alterDataQuery_30(function($query) use ($SQL_Statements){
foreach($SQL_Statements as $SQL_Statement){
$query->where($SQL_Statement);
}
});
return $this;
}
/**
@ -459,16 +436,22 @@ class DataList extends ViewableData implements SS_List, SS_Filterable, SS_Sortab
*
* @param string $field - the fieldname in the db
* @param string $comparisators - example StartsWith, relates to a filtercontext
* @param array $modifiers - Modifiers to pass to the filter, ie not,nocase
* @param string $value - the value that the filtercontext will use for matching
* @todo Deprecated SearchContexts and pull their functionality into the core of the ORM
*/
private function applyFilterContext($field, $comparisators, $value) {
private function applyFilterContext($field, $comparisators, $modifiers, $value) {
$t = singleton($this->dataClass())->dbObject($field);
$className = "{$comparisators}Filter";
if(!class_exists($className)){
throw new InvalidArgumentException('There are no '.$comparisators.' comparisator');
if($comparisators) {
$className = "{$comparisators}Filter";
} else {
$className = 'ExactMatchFilter';
}
$t = new $className($field,$value);
if(!class_exists($className)){
$className = 'ExactMatchFilter';
array_unshift($modifiers, $comparisators);
}
$t = new $className($field, $value, $modifiers);
$t->apply($this->dataQuery());
}
@ -508,29 +491,23 @@ class DataList extends ViewableData implements SS_List, SS_Filterable, SS_Sortab
$field = array_shift($fieldArgs);
$filterType = array_shift($fieldArgs);
$modifiers = $fieldArgs;
$list->excludeFilterContext($field, $filterType, $modifiers, $value, $subquery);
// This is here since PHP 5.3 can't call protected/private methods in a closure.
$t = singleton($list->dataClass())->dbObject($field);
if($filterType) {
$className = "{$filterType}Filter";
} else {
$className = 'ExactMatchFilter';
}
if(!class_exists($className)){
$className = 'ExactMatchFilter';
array_unshift($modifiers, $filterType);
}
$t = new $className($field, $value, $modifiers);
$t->exclude($subquery);
}
});
}
/**
* Translates the comparisator to the sql query
*
* @param string $field - the fieldname in the db
* @param string $comparisators - example StartsWith, relates to a filtercontext
* @param string $value - the value that the filtercontext will use for matching
* @param DataQuery $dataQuery - The (sub)query to add the exclusion clauses to
* @todo Deprecated SearchContexts and pull their functionality into the core of the ORM
*/
private function excludeFilterContext($field, $comparisators, $modifiers, $value, $dataQuery) {
$t = singleton($this->dataClass())->dbObject($field);
$className = "{$comparisators}Filter";
if(!class_exists($className)){
throw new InvalidArgumentException('There are no '.$comparisators.' comparisator');
}
$t = new $className($field, $value, $modifiers);
$t->exclude($dataQuery);
}
/**
* This method returns a copy of this list that does not contain any DataObjects that exists in $list

View File

@ -17,6 +17,28 @@
* @subpackage search
*/
class EndsWithFilter extends SearchFilter {
protected function comparison($exclude = false) {
$modifiers = $this->getModifiers();
if(($extras = array_diff($modifiers, array('not', 'nocase', 'case'))) != array()) {
throw new InvalidArgumentException(
get_class($this) . ' does not accept ' . implode(', ', $extras) . ' as modifiers');
}
if(DB::getConn() instanceof PostgreSQLDatabase) {
if(in_array('case', $modifiers)) {
$comparison = 'LIKE';
} else {
$comparison = 'ILIKE';
}
} elseif(in_array('case', $modifiers)) {
$comparison = 'LIKE BINARY';
} else {
$comparison = 'LIKE';
}
if($exclude) {
$comparison = 'NOT ' . $comparison;
}
return $comparison;
}
/**
* Applies a match on the trailing characters of a field value.
@ -28,7 +50,7 @@ class EndsWithFilter extends SearchFilter {
return $query->where(sprintf(
"%s %s '%%%s'",
$this->getDbName(),
(DB::getConn() instanceof PostgreSQLDatabase) ? 'ILIKE' : 'LIKE',
$this->comparison(false),
Convert::raw2sql($this->getValue())
));
}
@ -46,7 +68,7 @@ class EndsWithFilter extends SearchFilter {
$connectives[] = sprintf(
"%s %s '%%%s'",
$this->getDbName(),
(DB::getConn() instanceof PostgreSQLDatabase) ? 'ILIKE' : 'LIKE',
$this->comparison(false),
Convert::raw2sql($value)
);
}
@ -64,7 +86,7 @@ class EndsWithFilter extends SearchFilter {
return $query->where(sprintf(
"%s NOT %s '%%%s'",
$this->getDbName(),
(DB::getConn() instanceof PostgreSQLDatabase) ? 'ILIKE' : 'LIKE',
$this->comparison(true),
Convert::raw2sql($this->getValue())
));
}
@ -82,7 +104,7 @@ class EndsWithFilter extends SearchFilter {
$connectives[] = sprintf(
"%s NOT %s '%%%s'",
$this->getDbName(),
(DB::getConn() instanceof PostgreSQLDatabase) ? 'ILIKE' : 'LIKE',
$this->comparison(true),
Convert::raw2sql($value)
);
}

View File

@ -14,7 +14,35 @@
* @subpackage search
*/
class ExactMatchFilter extends SearchFilter {
protected function comparison($exclude = false) {
$modifiers = $this->getModifiers();
if(($extras = array_diff($modifiers, array('not', 'nocase', 'case'))) != array()) {
throw new InvalidArgumentException(
get_class($this) . ' does not accept ' . implode(', ', $extras) . ' as modifiers');
}
if(!in_array('case', $modifiers) && !in_array('nocase', $modifiers)) {
if($exclude) {
return '!=';
} else {
return '=';
}
} elseif(DB::getConn() instanceof PostgreSQLDatabase) {
if(in_array('case', $modifiers)) {
$comparison = 'LIKE';
} else {
$comparison = 'ILIKE';
}
} elseif(in_array('case', $modifiers)) {
$comparison = 'LIKE BINARY';
} else {
$comparison = 'LIKE';
}
if($exclude) {
$comparison = 'NOT ' . $comparison;
}
return $comparison;
}
/**
* Applies an exact match (equals) on a field value.
*
@ -23,8 +51,9 @@ class ExactMatchFilter extends SearchFilter {
protected function applyOne(DataQuery $query) {
$this->model = $query->applyRelation($this->relation);
return $query->where(sprintf(
"%s = '%s'",
"%s %s '%s'",
$this->getDbName(),
$this->comparison(false),
Convert::raw2sql($this->getValue())
));
}
@ -41,12 +70,26 @@ class ExactMatchFilter extends SearchFilter {
foreach($this->getValue() as $value) {
$values[] = Convert::raw2sql($value);
}
$valueStr = "'" . implode("', '", $values) . "'";
return $query->where(sprintf(
'%s IN (%s)',
$this->getDbName(),
$valueStr
));
if($this->comparison(false) == '=') {
// Neither :case nor :nocase
$valueStr = "'" . implode("', '", $values) . "'";
return $query->where(sprintf(
'%s IN (%s)',
$this->getDbName(),
$valueStr
));
} else {
foreach($values as &$v) {
$v = sprintf(
"%s %s '%s'",
$this->getDbName(),
$this->comparison(false),
$v
);
}
$where = implode(' OR ', $values);
return $query->where($where);
}
}
/**
@ -57,8 +100,9 @@ class ExactMatchFilter extends SearchFilter {
protected function excludeOne(DataQuery $query) {
$this->model = $query->applyRelation($this->relation);
return $query->where(sprintf(
"%s != '%s'",
"%s %s '%s'",
$this->getDbName(),
$this->comparison(true),
Convert::raw2sql($this->getValue())
));
}
@ -75,12 +119,26 @@ class ExactMatchFilter extends SearchFilter {
foreach($this->getValue() as $value) {
$values[] = Convert::raw2sql($value);
}
$valueStr = "'" . implode("', '", $values) . "'";
return $query->where(sprintf(
'%s NOT IN (%s)',
$this->getDbName(),
$valueStr
));
if($this->comparison(false) == '=') {
// Neither :case nor :nocase
$valueStr = "'" . implode("', '", $values) . "'";
return $query->where(sprintf(
'%s NOT IN (%s)',
$this->getDbName(),
$valueStr
));
} else {
foreach($values as &$v) {
$v = sprintf(
"%s %s '%s'",
$this->getDbName(),
$this->comparison(true),
$v
);
}
$where = implode(' OR ', $values);
return $query->where($where);
}
}
public function isEmpty() {

View File

@ -13,9 +13,9 @@
* @subpackage search
*/
class ExactMatchMultiFilter extends SearchFilter {
function __construct($fullName, $value = false) {
function __construct($fullName, $value = false, array $modifiers = array()) {
Deprecation::notice('3.1', 'Use ExactMatchFilter instead.');
parent::__construct($fullName, $value);
parent::__construct($fullName, $value, $modifiers);
}
public function apply(DataQuery $query) {
@ -24,7 +24,7 @@ class ExactMatchMultiFilter extends SearchFilter {
} else {
$values = $this->getValue();
}
$filter = new ExactMatchFilter($this->getFullName(), $values);
$filter = new ExactMatchFilter($this->getFullName(), $values, $this->getModifiers());
return $filter->apply($query);
}
@ -38,7 +38,7 @@ class ExactMatchMultiFilter extends SearchFilter {
} else {
$values = $this->getValue();
}
$filter = new ExactMatchFilter($this->getFullName(), $values);
$filter = new ExactMatchFilter($this->getFullName(), $values, $this->getModifiers());
return $filter->exclude($query);
}

View File

@ -2,28 +2,33 @@
/**
* Matches on rows where the field is not equal to the given value.
*
* @deprecated 3.1 Use ExactMatchFilter:not instead
* @package framework
* @subpackage search
*/
class NegationFilter extends SearchFilter {
// Deprecate this once modifiers are done
function __construct($fullName, $value = false, array $modifiers = array()) {
Deprecation::notice('3.1', 'Use ExactMatchFilter:not instead.');
$modifiers[] = 'not';
parent::__construct($fullName, $value, $modifiers);
}
public function apply(DataQuery $query) {
$this->model = $query->applyRelation($this->relation);
return $query->where(sprintf(
"%s != '%s'",
$this->getDbName(),
Convert::raw2sql($this->getValue())
));
$filter = new ExactMatchFilter($this->getFullName(), $this->getValue(), $this->getModifiers());
return $filter->apply($query);
}
protected function applyOne(DataQuery $query) {
/* NO OP */
}
public function exclude(DataQuery $query) {
$this->model = $query->applyRelation($this->relation);
return $query->where(sprintf(
"%s = '%s'",
$this->getDbName(),
Convert::raw2sql($this->getValue())
));
$filter = new ExactMatchFilter($this->getFullName(), $this->getValue(), $this->getModifiers());
return $filter->exclude($query);
}
protected function excludeOne(DataQuery $query) {
/* NO OP */
}
}

View File

@ -11,10 +11,32 @@
* @subpackage search
*/
class PartialMatchFilter extends SearchFilter {
protected function comparison($exclude = false) {
$modifiers = $this->getModifiers();
if(($extras = array_diff($modifiers, array('not', 'nocase', 'case'))) != array()) {
throw new InvalidArgumentException(
get_class($this) . ' does not accept ' . implode(', ', $extras) . ' as modifiers');
}
if(DB::getConn() instanceof PostgreSQLDatabase) {
if(in_array('case', $modifiers)) {
$comparison = 'LIKE';
} else {
$comparison = 'ILIKE';
}
} elseif(in_array('case', $modifiers)) {
$comparison = 'LIKE BINARY';
} else {
$comparison = 'LIKE';
}
if($exclude) {
$comparison = 'NOT ' . $comparison;
}
return $comparison;
}
protected function applyOne(DataQuery $query) {
$this->model = $query->applyRelation($this->relation);
$comparison = (DB::getConn() instanceof PostgreSQLDatabase) ? 'ILIKE' : 'LIKE';
$comparison = $this->comparison(false);
$where = sprintf("%s %s '%%%s%%'", $this->getDbName(), $comparison, Convert::raw2sql($this->getValue()));
return $query->where($where);
@ -23,7 +45,7 @@ class PartialMatchFilter extends SearchFilter {
protected function applyMany(DataQuery $query) {
$this->model = $query->applyRelation($this->relation);
$where = array();
$comparison = (DB::getConn() instanceof PostgreSQLDatabase) ? 'ILIKE' : 'LIKE';
$comparison = $this->comparison(false);
foreach($this->getValue() as $value) {
$where[]= sprintf("%s %s '%%%s%%'", $this->getDbName(), $comparison, Convert::raw2sql($value));
}
@ -33,8 +55,8 @@ class PartialMatchFilter extends SearchFilter {
protected function excludeOne(DataQuery $query) {
$this->model = $query->applyRelation($this->relation);
$comparison = (DB::getConn() instanceof PostgreSQLDatabase) ? 'ILIKE' : 'LIKE';
$where = sprintf("%s NOT %s '%%%s%%'", $this->getDbName(), $comparison, Convert::raw2sql($this->getValue()));
$comparison = $this->comparison(true);
$where = sprintf("%s %s '%%%s%%'", $this->getDbName(), $comparison, Convert::raw2sql($this->getValue()));
return $query->where($where);
}
@ -42,9 +64,9 @@ class PartialMatchFilter extends SearchFilter {
protected function excludeMany(DataQuery $query) {
$this->model = $query->applyRelation($this->relation);
$where = array();
$comparison = (DB::getConn() instanceof PostgreSQLDatabase) ? 'ILIKE' : 'LIKE';
$comparison = $this->comparison(true);
foreach($this->getValue() as $value) {
$where[]= sprintf("%s NOT %s '%%%s%%'", $this->getDbName(), $comparison, Convert::raw2sql($value));
$where[]= sprintf("%s %s '%%%s%%'", $this->getDbName(), $comparison, Convert::raw2sql($value));
}
return $query->where(implode(' AND ', $where));

View File

@ -30,6 +30,11 @@ abstract class SearchFilter extends Object {
*/
protected $value;
/**
* @var array
*/
protected $modifiers;
/**
* @var string Name of a has-one, has-many or many-many relation (not the classname).
* Set in the constructor as part of the name in dot-notation, and used in
@ -43,12 +48,14 @@ abstract class SearchFilter extends Object {
* the necessary tables (e.g. "Comments.Name" to join the "Comments" has-many relationship and
* search the "Name" column when applying this filter to a SiteTree class).
* @param mixed $value
* @param array $modifiers
*/
public function __construct($fullName, $value = false) {
public function __construct($fullName, $value = false, array $modifiers = array()) {
$this->fullName = $fullName;
// sets $this->name and $this->relation
$this->addRelation($fullName);
$this->value = $value;
$this->modifiers = array_map('strtolower', $modifiers);
}
/**
@ -95,6 +102,24 @@ abstract class SearchFilter extends Object {
public function getValue() {
return $this->value;
}
/**
* Set the current modifiers to apply to the filter
*
* @param array $modifiers
*/
public function setModifiers(array $modifiers) {
$this->modifiers = array_map('strtolower', $modifiers);
}
/**
* Accessor for the current modifiers to apply to the filter.
*
* @return array
*/
public function getModifiers() {
return $this->modifiers;
}
/**
* The original name of the field.
@ -177,6 +202,10 @@ abstract class SearchFilter extends Object {
* @return DataQuery
*/
public function apply(DataQuery $query) {
if(($key = array_search('not', $this->modifiers)) !== false) {
unset($this->modifiers[$key]);
return $this->exclude($query);
}
if(is_array($this->value)) {
return $this->applyMany($query);
} else {
@ -209,6 +238,10 @@ abstract class SearchFilter extends Object {
* @return DataQuery
*/
public function exclude(DataQuery $query) {
if(($key = array_search('not', $this->modifiers)) !== false) {
unset($this->modifiers[$key]);
return $this->apply($query);
}
if(is_array($this->value)) {
return $this->excludeMany($query);
} else {

View File

@ -17,6 +17,28 @@
* @subpackage search
*/
class StartsWithFilter extends SearchFilter {
protected function comparison($exclude = false) {
$modifiers = $this->getModifiers();
if(($extras = array_diff($modifiers, array('not', 'nocase', 'case'))) != array()) {
throw new InvalidArgumentException(
get_class($this) . ' does not accept ' . implode(', ', $extras) . ' as modifiers');
}
if(DB::getConn() instanceof PostgreSQLDatabase) {
if(in_array('case', $modifiers)) {
$comparison = 'LIKE';
} else {
$comparison = 'ILIKE';
}
} elseif(in_array('case', $modifiers)) {
$comparison = 'LIKE BINARY';
} else {
$comparison = 'LIKE';
}
if($exclude) {
$comparison = 'NOT ' . $comparison;
}
return $comparison;
}
/**
* Applies a match on the starting characters of a field value.
@ -28,7 +50,7 @@ class StartsWithFilter extends SearchFilter {
return $query->where(sprintf(
"%s %s '%s%%'",
$this->getDbName(),
(DB::getConn() instanceof PostgreSQLDatabase) ? 'ILIKE' : 'LIKE',
$this->comparison(false),
Convert::raw2sql($this->getValue())
));
}
@ -46,7 +68,7 @@ class StartsWithFilter extends SearchFilter {
$connectives[] = sprintf(
"%s %s '%s%%'",
$this->getDbName(),
(DB::getConn() instanceof PostgreSQLDatabase) ? 'ILIKE' : 'LIKE',
$this->comparison(false),
Convert::raw2sql($value)
);
}
@ -62,9 +84,9 @@ class StartsWithFilter extends SearchFilter {
protected function excludeOne(DataQuery $query) {
$this->model = $query->applyRelation($this->relation);
return $query->where(sprintf(
"%s NOT %s '%s%%'",
"%s %s '%s%%'",
$this->getDbName(),
(DB::getConn() instanceof PostgreSQLDatabase) ? 'ILIKE' : 'LIKE',
$this->comparison(true),
Convert::raw2sql($this->getValue())
));
}
@ -80,9 +102,9 @@ class StartsWithFilter extends SearchFilter {
$connectives = array();
foreach($this->getValue() as $value) {
$connectives[] = sprintf(
"%s NOT %s '%s%%'",
"%s %s '%s%%'",
$this->getDbName(),
(DB::getConn() instanceof PostgreSQLDatabase) ? 'ILIKE' : 'LIKE',
$this->comparison(true),
Convert::raw2sql($value)
);
}

View File

@ -13,9 +13,9 @@
* @subpackage search
*/
class StartsWithMultiFilter extends SearchFilter {
function __construct($fullName, $value = false) {
function __construct($fullName, $value = false, array $modifiers = array()) {
Deprecation::notice('3.1', 'Use StartsWithFilter instead.');
parent::__construct($fullName, $value);
parent::__construct($fullName, $value, $modifiers);
}
public function apply(DataQuery $query) {
@ -24,7 +24,7 @@ class StartsWithMultiFilter extends SearchFilter {
} else {
$values = $this->getValue();
}
$filter = new StartsWithFilter($this->getFullName(), $values);
$filter = new StartsWithFilter($this->getFullName(), $values, $this->getModifiers());
return $filter->apply($query);
}
@ -38,7 +38,7 @@ class StartsWithMultiFilter extends SearchFilter {
} else {
$values = $this->getValue();
}
$filter = new StartsWithFilter($this->getFullName(), $values);
$filter = new StartsWithFilter($this->getFullName(), $values, $this->getModifiers());
return $filter->exclude($query);
}

View File

@ -12,14 +12,14 @@
* @subpackage search
*/
class SubstringFilter extends PartialMatchFilter {
public function __construct($fullName, $value = false) {
public function __construct($fullName, $value = false, array $modifiers = array()) {
Deprecation::notice('3.0', 'PartialMatchFilter instead.');
parent::__construct($fullName, $value);
parent::__construct($fullName, $value, $modifiers);
}
public function apply(DataQuery $query) {
$values = $this->getValue();
$filter = new PartialMatchFilter($this->getFullName(), $values);
$filter = new PartialMatchFilter($this->getFullName(), $values, $this->getModifiers());
return $filter->apply($query);
}
@ -29,7 +29,7 @@ class SubstringFilter extends PartialMatchFilter {
public function exclude(DataQuery $query) {
$values = $this->getValue();
$filter = new PartialMatchFilter($this->getFullName(), $values);
$filter = new PartialMatchFilter($this->getFullName(), $values, $this->getModifiers());
return $filter->exclude($query);
}

View File

@ -442,10 +442,21 @@ class DataListTest extends SapphireTest {
$list = $list->filter(array(
'Name'=>array('Bob','Phil'),
'TeamID'=>array($this->idFromFixture('DataObjectTest_Team', 'team1'))));
$this->assertEquals(1, $list->count(), 'There should be one comments');
$this->assertEquals(1, $list->count(), 'There should be one comment');
$this->assertEquals('Bob', $list->first()->Name, 'Only comment should be from Bob');
}
public function testFilterWithModifiers() {
$list = DataObjectTest_TeamComment::get();
$nocaseList = $list->filter('Name:nocase', 'bob');
$this->assertEquals(1, $nocaseList->count(), 'There should be one comment');
$caseList = $list->filter('Name:case', 'bob');
$this->assertEquals(0, $caseList->count(), 'There should be no comments');
$gtList = $list->filter('TeamID:GreaterThan:not',
$this->idFromFixture('DataObjectTest_Team', 'team1'));
$this->assertEquals(2, $gtList->count());
}
public function testFilterAndExcludeById() {
$id = $this->idFromFixture('DataObjectTest_SubTeam', 'subteam1');
$list = DataObjectTest_SubTeam::get()->filter('ID', $id);