diff --git a/docs/en/topics/datamodel.md b/docs/en/topics/datamodel.md index 912335182..25f1c454a 100755 --- a/docs/en/topics/datamodel.md +++ b/docs/en/topics/datamodel.md @@ -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. diff --git a/model/DataList.php b/model/DataList.php index bf7710168..dd0a4387f 100644 --- a/model/DataList.php +++ b/model/DataList.php @@ -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()); } @@ -500,25 +483,29 @@ class DataList extends ViewableData implements SS_List, SS_Filterable, SS_Sortab throw new InvalidArgumentException('Incorrect number of arguments passed to exclude()'); } - $SQL_Statements = array(); - foreach($whereArguments as $fieldName => $value) { - if($fieldName == 'ID') { - $fieldName = sprintf('"%s"."ID"', ClassInfo::baseDataClass($this->dataClass)); - } else { - $fieldName = '"' . Convert::raw2sql($fieldName) . '"'; + return $this->alterDataQuery(function($query, $list) use ($whereArguments) { + $subquery = $query->disjunctiveGroup(); + + foreach($whereArguments as $field => $value) { + $fieldArgs = explode(':', $field); + $field = array_shift($fieldArgs); + $filterType = array_shift($fieldArgs); + $modifiers = $fieldArgs; + + // 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); } - - if(is_array($value)){ - $SQL_Statements[] = ($fieldName . ' NOT IN (\''.implode('\',\'', Convert::raw2sql($value)).'\')'); - } else { - $SQL_Statements[] = ($fieldName . ' != \''.Convert::raw2sql($value).'\''); - } - } - - if(!count($SQL_Statements)) return $this; - - return $this->alterDataQuery_30(function($query) use ($SQL_Statements){ - $query->whereAny($SQL_Statements); }); } diff --git a/search/filters/EndsWithFilter.php b/search/filters/EndsWithFilter.php index 74539da3e..83444f320 100644 --- a/search/filters/EndsWithFilter.php +++ b/search/filters/EndsWithFilter.php @@ -17,23 +17,102 @@ * @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. * - * @return unknown + * @return DataQuery */ - public function apply(DataQuery $query) { + protected function applyOne(DataQuery $query) { $this->model = $query->applyRelation($this->relation); return $query->where(sprintf( "%s %s '%%%s'", $this->getDbName(), - (DB::getConn() instanceof PostgreSQLDatabase) ? 'ILIKE' : 'LIKE', + $this->comparison(false), Convert::raw2sql($this->getValue()) )); } + + /** + * Applies a match on the trailing characters of a field value. + * Matches against one of the many values. + * + * @return DataQuery + */ + protected function applyMany(DataQuery $query) { + $this->model = $query->applyRelation($this->relation); + $connectives = array(); + foreach($this->getValue() as $value) { + $connectives[] = sprintf( + "%s %s '%%%s'", + $this->getDbName(), + $this->comparison(false), + Convert::raw2sql($value) + ); + } + $whereClause = implode(' OR ', $connectives); + return $query->where($whereClause); + } + + /** + * Excludes a match on the trailing characters of a field value. + * + * @return DataQuery + */ + protected function excludeOne(DataQuery $query) { + $this->model = $query->applyRelation($this->relation); + return $query->where(sprintf( + "%s NOT %s '%%%s'", + $this->getDbName(), + $this->comparison(true), + Convert::raw2sql($this->getValue()) + )); + } + + /** + * Excludes a match on the trailing characters of a field value. + * Excludes a field if it matches any of the values. + * + * @return DataQuery + */ + protected function excludeMany(DataQuery $query) { + $this->model = $query->applyRelation($this->relation); + $connectives = array(); + foreach($this->getValue() as $value) { + $connectives[] = sprintf( + "%s NOT %s '%%%s'", + $this->getDbName(), + $this->comparison(true), + Convert::raw2sql($value) + ); + } + $whereClause = implode(' AND ', $connectives); + return $query->where($whereClause); + } public function isEmpty() { - return $this->getValue() == null || $this->getValue() == ''; + return $this->getValue() === array() || $this->getValue() === null || $this->getValue() === ''; } } diff --git a/search/filters/ExactMatchFilter.php b/search/filters/ExactMatchFilter.php index d6fbdc32a..9cf3e9b1f 100644 --- a/search/filters/ExactMatchFilter.php +++ b/search/filters/ExactMatchFilter.php @@ -14,22 +14,134 @@ * @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. * - * @return unknown + * @return DataQuery */ - public function apply(DataQuery $query) { + 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()) )); } + + /** + * Applies an exact match (equals) on a field value against multiple + * possible values. + * + * @return DataQuery + */ + protected function applyMany(DataQuery $query) { + $this->model = $query->applyRelation($this->relation); + $values = array(); + foreach($this->getValue() as $value) { + $values[] = Convert::raw2sql($value); + } + 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); + } + } + + /** + * Excludes an exact match (equals) on a field value. + * + * @return DataQuery + */ + protected function excludeOne(DataQuery $query) { + $this->model = $query->applyRelation($this->relation); + return $query->where(sprintf( + "%s %s '%s'", + $this->getDbName(), + $this->comparison(true), + Convert::raw2sql($this->getValue()) + )); + } + + /** + * Excludes an exact match (equals) on a field value against multiple + * possible values. + * + * @return DataQuery + */ + protected function excludeMany(DataQuery $query) { + $this->model = $query->applyRelation($this->relation); + $values = array(); + foreach($this->getValue() as $value) { + $values[] = Convert::raw2sql($value); + } + 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() { - return $this->getValue() == null || $this->getValue() == ''; + return $this->getValue() === array() || $this->getValue() === null || $this->getValue() === ''; } } diff --git a/search/filters/ExactMatchMultiFilter.php b/search/filters/ExactMatchMultiFilter.php index 39856aa28..7edd7cd6f 100644 --- a/search/filters/ExactMatchMultiFilter.php +++ b/search/filters/ExactMatchMultiFilter.php @@ -7,46 +7,46 @@ /** * Checks if a value is in a given set. * SQL syntax used: Column IN ('val1','val2') + * @deprecated 3.1 Use ExactMatchFilter instead * - * @todo Add negation (NOT IN)6 * @package framework * @subpackage search */ class ExactMatchMultiFilter extends SearchFilter { + function __construct($fullName, $value = false, array $modifiers = array()) { + Deprecation::notice('3.1', 'Use ExactMatchFilter instead.'); + parent::__construct($fullName, $value, $modifiers); + } public function apply(DataQuery $query) { - $this->model = $query->applyRelation($this->relation); - // hack - // PREVIOUS $values = explode(',',$this->getValue()); - $values = array(); - if (is_string($this->getValue())) { + if (!is_array($this->getValue())) { $values = explode(',',$this->getValue()); + } else { + $values = $this->getValue(); } - else { - foreach($this->getValue() as $v) { - $values[] = $v; - } + $filter = new ExactMatchFilter($this->getFullName(), $values, $this->getModifiers()); + return $filter->apply($query); + } + + protected function applyOne(DataQuery $query) { + /* NO OP */ + } + + public function exclude(DataQuery $query) { + if (!is_array($this->getValue())) { + $values = explode(',',$this->getValue()); + } else { + $values = $this->getValue(); } - - - if(! $values) return false; - for($i = 0; $i < count($values); $i++) { - if(! is_numeric($values[$i])) { - // @todo Fix string replacement to only replace leading and tailing quotes - $values[$i] = str_replace("'", '', $values[$i]); - $values[$i] = Convert::raw2sql($values[$i]); - } - } - $SQL_valueStr = "'" . implode("','", $values) . "'"; - - return $query->where(sprintf( - "%s IN (%s)", - $this->getDbName(), - $SQL_valueStr - )); + $filter = new ExactMatchFilter($this->getFullName(), $values, $this->getModifiers()); + return $filter->exclude($query); + } + + protected function excludeOne(DataQuery $query) { + /* NO OP */ } public function isEmpty() { - return $this->getValue() == null || $this->getValue() == ''; + return $this->getValue() === array() || $this->getValue() === null || $this->getValue() === ''; } } diff --git a/search/filters/FulltextFilter.php b/search/filters/FulltextFilter.php index 118f4fe4f..36b77b4c2 100644 --- a/search/filters/FulltextFilter.php +++ b/search/filters/FulltextFilter.php @@ -27,7 +27,7 @@ */ class FulltextFilter extends SearchFilter { - public function apply(DataQuery $query) { + protected function applyOne(DataQuery $query) { return $query->where(sprintf( "MATCH (%s) AGAINST ('%s')", $this->getDbName(), @@ -35,7 +35,15 @@ class FulltextFilter extends SearchFilter { )); } + protected function excludeOne(DataQuery $query) { + return $query->where(sprintf( + "NOT MATCH (%s) AGAINST ('%s')", + $this->getDbName(), + Convert::raw2sql($this->getValue()) + )); + } + public function isEmpty() { - return $this->getValue() == null || $this->getValue() == ''; + return $this->getValue() === array() || $this->getValue() === null || $this->getValue() === ''; } } diff --git a/search/filters/GreaterThanFilter.php b/search/filters/GreaterThanFilter.php index 8756d908c..a17c3a2f5 100644 --- a/search/filters/GreaterThanFilter.php +++ b/search/filters/GreaterThanFilter.php @@ -10,9 +10,9 @@ class GreaterThanFilter extends SearchFilter { /** - * @return $query + * @return DataQuery */ - public function apply(DataQuery $query) { + protected function applyOne(DataQuery $query) { $this->model = $query->applyRelation($this->relation); $value = $this->getDbFormattedValue(); @@ -21,8 +21,21 @@ class GreaterThanFilter extends SearchFilter { return $query->where($filter); } + + /** + * @return DataQuery + */ + protected function excludeOne(DataQuery $query) { + $this->model = $query->applyRelation($this->relation); + $value = $this->getDbFormattedValue(); + + if(is_numeric($value)) $filter = sprintf("%s <= %s", $this->getDbName(), Convert::raw2sql($value)); + else $filter = sprintf("%s <= '%s'", $this->getDbName(), Convert::raw2sql($value)); + + return $query->where($filter); + } public function isEmpty() { - return $this->getValue() == null || $this->getValue() == ''; + return $this->getValue() === array() || $this->getValue() === null || $this->getValue() === ''; } } diff --git a/search/filters/LessThanFilter.php b/search/filters/LessThanFilter.php index b327d1555..6c9538ab8 100644 --- a/search/filters/LessThanFilter.php +++ b/search/filters/LessThanFilter.php @@ -10,9 +10,9 @@ class LessThanFilter extends SearchFilter { /** - * @return $query + * @return DataQuery */ - public function apply(DataQuery $query) { + protected function applyOne(DataQuery $query) { $this->model = $query->applyRelation($this->relation); $value = $this->getDbFormattedValue(); @@ -21,8 +21,21 @@ class LessThanFilter extends SearchFilter { return $query->where($filter); } + + /** + * @return DataQuery + */ + protected function excludeOne(DataQuery $query) { + $this->model = $query->applyRelation($this->relation); + $value = $this->getDbFormattedValue(); + + if(is_numeric($value)) $filter = sprintf("%s >= %s", $this->getDbName(), Convert::raw2sql($value)); + else $filter = sprintf("%s >= '%s'", $this->getDbName(), Convert::raw2sql($value)); + + return $query->where($filter); + } public function isEmpty() { - return $this->getValue() == null || $this->getValue() == ''; + return $this->getValue() === array() || $this->getValue() === null || $this->getValue() === ''; } } diff --git a/search/filters/NegationFilter.php b/search/filters/NegationFilter.php index 2cec7c41f..9858a750f 100755 --- a/search/filters/NegationFilter.php +++ b/search/filters/NegationFilter.php @@ -2,19 +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 { - - public function apply(DataQuery $query) { - $this->model = $query->applyRelation($this->relation); - return $query->where(sprintf( - "%s != '%s'", - $this->getDbName(), - Convert::raw2sql($this->getValue()) - )); + 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) { + $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) { + $filter = new ExactMatchFilter($this->getFullName(), $this->getValue(), $this->getModifiers()); + return $filter->exclude($query); + } + + protected function excludeOne(DataQuery $query) { + /* NO OP */ + } } diff --git a/search/filters/PartialMatchFilter.php b/search/filters/PartialMatchFilter.php index 418e1975c..73a4be1f2 100644 --- a/search/filters/PartialMatchFilter.php +++ b/search/filters/PartialMatchFilter.php @@ -11,24 +11,68 @@ * @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; + } - public function apply(DataQuery $query) { + protected function applyOne(DataQuery $query) { + $this->model = $query->applyRelation($this->relation); + $comparison = $this->comparison(false); + $where = sprintf("%s %s '%%%s%%'", $this->getDbName(), $comparison, Convert::raw2sql($this->getValue())); + + return $query->where($where); + } + + protected function applyMany(DataQuery $query) { $this->model = $query->applyRelation($this->relation); $where = array(); - $comparison = (DB::getConn() instanceof PostgreSQLDatabase) ? 'ILIKE' : 'LIKE'; - if(is_array($this->getValue())) { - foreach($this->getValue() as $value) { - $where[]= sprintf("%s %s '%%%s%%'", $this->getDbName(), $comparison, Convert::raw2sql($value)); - } - - } else { - $where[] = sprintf("%s %s '%%%s%%'", $this->getDbName(), $comparison, Convert::raw2sql($this->getValue())); + $comparison = $this->comparison(false); + foreach($this->getValue() as $value) { + $where[]= sprintf("%s %s '%%%s%%'", $this->getDbName(), $comparison, Convert::raw2sql($value)); } return $query->where(implode(' OR ', $where)); } + + protected function excludeOne(DataQuery $query) { + $this->model = $query->applyRelation($this->relation); + $comparison = $this->comparison(true); + $where = sprintf("%s %s '%%%s%%'", $this->getDbName(), $comparison, Convert::raw2sql($this->getValue())); + + return $query->where($where); + } + + protected function excludeMany(DataQuery $query) { + $this->model = $query->applyRelation($this->relation); + $where = array(); + $comparison = $this->comparison(true); + foreach($this->getValue() as $value) { + $where[]= sprintf("%s %s '%%%s%%'", $this->getDbName(), $comparison, Convert::raw2sql($value)); + } + + return $query->where(implode(' AND ', $where)); + } public function isEmpty() { - return $this->getValue() == null || $this->getValue() == ''; + return $this->getValue() === array() || $this->getValue() === null || $this->getValue() === ''; } } diff --git a/search/filters/SearchFilter.php b/search/filters/SearchFilter.php index c904fb333..243fd9795 100644 --- a/search/filters/SearchFilter.php +++ b/search/filters/SearchFilter.php @@ -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. @@ -173,10 +198,74 @@ abstract class SearchFilter extends Object { /** * Apply filter criteria to a SQL query. * - * @param SQLQuery $query - * @return SQLQuery + * @param DataQuery $query + * @return DataQuery */ - abstract public function apply(DataQuery $query); + 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 { + return $this->applyOne($query); + } + } + + /** + * Apply filter criteria to a SQL query with a single value. + * + * @param DataQuery $query + * @return DataQuery + */ + abstract protected function applyOne(DataQuery $query); + + /** + * Apply filter criteria to a SQL query with an array of values. + * + * @param DataQuery $query + * @return DataQuery + */ + protected function applyMany(DataQuery $query) { + throw new InvalidArgumentException(get_class($this) . "can't be used to filter by a list of items."); + } + + /** + * Exclude filter criteria from a SQL query. + * + * @param DataQuery $query + * @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 { + return $this->excludeOne($query); + } + } + + /** + * Exclude filter criteria from a SQL query with a single value. + * + * @param DataQuery $query + * @return DataQuery + */ + abstract protected function excludeOne(DataQuery $query); + + /** + * Exclude filter criteria from a SQL query with an array of values. + * + * @param DataQuery $query + * @return DataQuery + */ + 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, diff --git a/search/filters/StartsWithFilter.php b/search/filters/StartsWithFilter.php index e81dbcbd8..c44086bc4 100644 --- a/search/filters/StartsWithFilter.php +++ b/search/filters/StartsWithFilter.php @@ -17,23 +17,102 @@ * @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 substring match on a field value. + * Applies a match on the starting characters of a field value. * - * @return unknown + * @return DataQuery */ - public function apply(DataQuery $query) { + protected function applyOne(DataQuery $query) { $this->model = $query->applyRelation($this->relation); return $query->where(sprintf( "%s %s '%s%%'", $this->getDbName(), - (DB::getConn() instanceof PostgreSQLDatabase) ? 'ILIKE' : 'LIKE', + $this->comparison(false), Convert::raw2sql($this->getValue()) )); } + + /** + * Applies a match on the starting characters of a field value. + * Matches against one of the many values. + * + * @return DataQuery + */ + protected function applyMany(DataQuery $query) { + $this->model = $query->applyRelation($this->relation); + $connectives = array(); + foreach($this->getValue() as $value) { + $connectives[] = sprintf( + "%s %s '%s%%'", + $this->getDbName(), + $this->comparison(false), + Convert::raw2sql($value) + ); + } + $whereClause = implode(' OR ', $connectives); + return $query->where($whereClause); + } + + /** + * Excludes a match on the starting characters of a field value. + * + * @return DataQuery + */ + protected function excludeOne(DataQuery $query) { + $this->model = $query->applyRelation($this->relation); + return $query->where(sprintf( + "%s %s '%s%%'", + $this->getDbName(), + $this->comparison(true), + Convert::raw2sql($this->getValue()) + )); + } + + /** + * Excludes a match on the starting characters of a field value. + * Excludes a field if it matches any of the values. + * + * @return DataQuery + */ + protected function excludeMany(DataQuery $query) { + $this->model = $query->applyRelation($this->relation); + $connectives = array(); + foreach($this->getValue() as $value) { + $connectives[] = sprintf( + "%s %s '%s%%'", + $this->getDbName(), + $this->comparison(true), + Convert::raw2sql($value) + ); + } + $whereClause = implode(' AND ', $connectives); + return $query->where($whereClause); + } public function isEmpty() { - return $this->getValue() == null || $this->getValue() == ''; + return $this->getValue() === array() || $this->getValue() === null || $this->getValue() === ''; } } diff --git a/search/filters/StartsWithMultiFilter.php b/search/filters/StartsWithMultiFilter.php index 4e36b078d..f0cd71e95 100644 --- a/search/filters/StartsWithMultiFilter.php +++ b/search/filters/StartsWithMultiFilter.php @@ -6,29 +6,47 @@ /** * Checks if a value starts with one of the items of in a given set. - * SQL syntax used: Column IN ('val1','val2') + * @deprecated 3.1 Use StartsWithFilter instead * * @todo Add negation (NOT IN)6 * @package framework * @subpackage search */ class StartsWithMultiFilter extends SearchFilter { + function __construct($fullName, $value = false, array $modifiers = array()) { + Deprecation::notice('3.1', 'Use StartsWithFilter instead.'); + parent::__construct($fullName, $value, $modifiers); + } public function apply(DataQuery $query) { - $this->model = $query->applyRelation($this->relation); - $values = explode(',', $this->getValue()); - - foreach($values as $value) { - $matches[] = sprintf("%s LIKE '%s%%'", - $this->getDbName(), - Convert::raw2sql(str_replace("'", '', $value)) - ); + if (!is_array($this->getValue())) { + $values = explode(',',$this->getValue()); + } else { + $values = $this->getValue(); } - - return $query->where(implode(" OR ", $matches)); + $filter = new StartsWithFilter($this->getFullName(), $values, $this->getModifiers()); + return $filter->apply($query); + } + + protected function applyOne(DataQuery $query) { + /* NO OP */ + } + + public function exclude(DataQuery $query) { + if (!is_array($this->getValue())) { + $values = explode(',',$this->getValue()); + } else { + $values = $this->getValue(); + } + $filter = new StartsWithFilter($this->getFullName(), $values, $this->getModifiers()); + return $filter->exclude($query); + } + + protected function excludeOne(DataQuery $query) { + /* NO OP */ } public function isEmpty() { - return $this->getValue() == null || $this->getValue() == ''; + return $this->getValue() === array() || $this->getValue() === null || $this->getValue() === ''; } } diff --git a/search/filters/SubstringFilter.php b/search/filters/SubstringFilter.php index 468042b76..470963443 100755 --- a/search/filters/SubstringFilter.php +++ b/search/filters/SubstringFilter.php @@ -12,22 +12,33 @@ * @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.'); - SearchFilter::__construct($fullName, $value); + parent::__construct($fullName, $value, $modifiers); } public function apply(DataQuery $query) { - $this->model = $query->applyRelation($this->relation); - return $query->where(sprintf( - "LOCATE('%s', %s) != 0", - Convert::raw2sql($this->getValue()), - $this->getDbName() - )); + $values = $this->getValue(); + $filter = new PartialMatchFilter($this->getFullName(), $values, $this->getModifiers()); + return $filter->apply($query); } + protected function applyOne(DataQuery $query) { + /* NO OP */ + } + + public function exclude(DataQuery $query) { + $values = $this->getValue(); + $filter = new PartialMatchFilter($this->getFullName(), $values, $this->getModifiers()); + return $filter->exclude($query); + } + + protected function excludeOne(DataQuery $query) { + /* NO OP */ + } + public function isEmpty() { - return $this->getValue() == null || $this->getValue() == ''; + return $this->getValue() === array() || $this->getValue() === null || $this->getValue() === ''; } } diff --git a/search/filters/WithinRangeFilter.php b/search/filters/WithinRangeFilter.php index 062cb94e8..ca0bb10d4 100644 --- a/search/filters/WithinRangeFilter.php +++ b/search/filters/WithinRangeFilter.php @@ -25,7 +25,7 @@ class WithinRangeFilter extends SearchFilter { $this->max = $max; } - public function apply(DataQuery $query) { + protected function applyOne(DataQuery $query) { $this->model = $query->applyRelation($this->relation); return $query->where(sprintf( "%s >= '%s' AND %s <= '%s'", @@ -35,6 +35,15 @@ class WithinRangeFilter extends SearchFilter { Convert::raw2sql($this->max) )); } - -} + protected function excludeOne(DataQuery $query) { + $this->model = $query->applyRelation($this->relation); + return $query->where(sprintf( + "%s < '%s' OR %s > '%s'", + $this->getDbName(), + Convert::raw2sql($this->min), + $this->getDbName(), + Convert::raw2sql($this->max) + )); + } +} diff --git a/tests/model/DataListTest.php b/tests/model/DataListTest.php index 29ca2f11c..59b36850d 100644 --- a/tests/model/DataListTest.php +++ b/tests/model/DataListTest.php @@ -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); @@ -501,7 +512,7 @@ class DataListTest extends SapphireTest { */ public function testMultipleExclude() { $list = DataObjectTest_TeamComment::get(); - $list->exclude(array('Name'=>'Bob', 'Comment'=>'This is a team comment by Bob')); + $list = $list->exclude(array('Name'=>'Bob', 'Comment'=>'This is a team comment by Bob')); $this->assertEquals(2, $list->count()); } @@ -514,9 +525,17 @@ class DataListTest extends SapphireTest { $list = $list->exclude('Name', 'Bob'); $this->assertContains( - 'WHERE ("Comment" = \'Phil is a unique guy, and comments on team2\') AND ("Name" != \'Bob\')', + 'WHERE ("DataObjectTest_TeamComment"."Comment" = ' + . '\'Phil is a unique guy, and comments on team2\') ' + . 'AND (("DataObjectTest_TeamComment"."Name" != \'Bob\'))', $list->sql()); } + + public function testExcludeWithSearchFilter() { + $list = DataObjectTest_TeamComment::get(); + $list = $list->exclude('Name:LessThan', 'Bob'); + $this->assertContains('WHERE (("DataObjectTest_TeamComment"."Name" >= \'Bob\'))', $list->sql()); + } /** * $list->exclude(array('Name'=>'bob, 'Age'=>array(21, 43))); // exclude bob with Age 21 or 43