diff --git a/src/ORM/ArrayList.php b/src/ORM/ArrayList.php index a8436d45028..e6f9b7bc20c 100644 --- a/src/ORM/ArrayList.php +++ b/src/ORM/ArrayList.php @@ -5,7 +5,11 @@ use ArrayIterator; use InvalidArgumentException; use LogicException; +use SilverStripe\Core\ClassInfo; +use SilverStripe\Core\Injector\Injector; use SilverStripe\Dev\Debug; +use SilverStripe\ORM\Filters\SearchFilter; +use SilverStripe\ORM\Filters\UsesSearchFilters; use SilverStripe\View\ArrayData; use SilverStripe\View\ViewableData; use Traversable; @@ -26,6 +30,7 @@ */ class ArrayList extends ViewableData implements SS_List, Filterable, Sortable, Limitable { + use UsesSearchFilters; /** * Holds the items in the list @@ -368,23 +373,6 @@ public function map($keyfield = 'ID', $titlefield = 'Title') return new Map($list, $keyfield, $titlefield); } - /** - * Find the first item of this list where the given key = value - * - * @param string $key - * @param string $value - * @return mixed - */ - public function find($key, $value) - { - foreach ($this->items as $item) { - if ($this->extractValue($item, $key) == $value) { - return $item; - } - } - return null; - } - /** * Returns an array of a single field value for all items in the list. * @@ -586,6 +574,18 @@ public function canFilterBy($by) return is_array($firstRecord) ? array_key_exists($by, $firstRecord) : property_exists($firstRecord, $by ?? ''); } + /** + * Find the first item of this list where the given key = value + * + * @param string $key + * @param string $value + * @return mixed + */ + public function find($key, $value) + { + return $this->filter($key, $value)->first(); + } + /** * Filter the list to include items with these characteristics * @@ -597,31 +597,15 @@ public function canFilterBy($by) * @example $list->filter(array('Name'=>'bob, 'Age'=>array(21, 43))); // bob with the Age 21 or 43 * @example $list->filter(array('Name'=>array('aziz','bob'), 'Age'=>array(21, 43))); * // aziz with the age 21 or 43 and bob with the Age 21 or 43 + * + * Also supports SearchFilter syntax + * @example // include anyone with "sam" anywhere in their name + * $list = $list->filter('Name:PartialMatch', 'sam'); */ public function filter() { - - $keepUs = call_user_func_array([$this, 'normaliseFilterArgs'], func_get_args()); - - $itemsToKeep = []; - foreach ($this->items as $item) { - $keepItem = true; - foreach ($keepUs as $column => $value) { - if ((is_array($value) && !in_array($this->extractValue($item, $column), $value ?? [])) - || (!is_array($value) && $this->extractValue($item, $column) != $value) - ) { - $keepItem = false; - break; - } - } - if ($keepItem) { - $itemsToKeep[] = $item; - } - } - - $list = clone $this; - $list->items = $itemsToKeep; - return $list; + $filters = call_user_func_array([$this, 'normaliseFilterArgs'], func_get_args()); + return $this->filterOrExclude($filters); } /** @@ -638,28 +622,102 @@ public function filter() * @example // all bobs, phils or anyone aged 21 or 43 in the list * $list = $list->filterAny(array('Name'=>array('bob','phil'), 'Age'=>array(21, 43))); * + * Also supports SearchFilter syntax + * @example // include anyone with "sam" anywhere in their name + * $list = $list->filterAny('Name:PartialMatch', 'sam'); + * * @param string|array See {@link filter()} * @return static */ public function filterAny() { - $keepUs = $this->normaliseFilterArgs(...func_get_args()); + $filters = call_user_func_array([$this, 'normaliseFilterArgs'], func_get_args()); + return $this->filterOrExclude($filters, true, true); + } + /** + * Exclude the list to not contain items with these characteristics + * + * @return ArrayList + * @see SS_List::exclude() + * @example $list->exclude('Name', 'bob'); // exclude bob from list + * @example $list->exclude('Name', array('aziz', 'bob'); // exclude aziz and bob from list + * @example $list->exclude(array('Name'=>'bob, 'Age'=>21)); // exclude bob that has Age 21 + * @example $list->exclude(array('Name'=>'bob, 'Age'=>array(21, 43))); // exclude bob with Age 21 or 43 + * @example $list->exclude(array('Name'=>array('bob','phil'), 'Age'=>array(21, 43))); + * // bob age 21 or 43, phil age 21 or 43 would be excluded + * + * Also supports SearchFilter syntax + * @example // everyone except anyone with "sam" anywhere in their name + * $list = $list->exclude('Name:PartialMatch', 'sam'); + */ + public function exclude() + { + $filters = call_user_func_array([$this, 'normaliseFilterArgs'], func_get_args()); + return $this->filterOrExclude($filters, false); + } + + /** + * Return a copy of the list excluding any items that have any of these characteristics + * + * @example // everyone except bob in the list + * $list = $list->excludeAny('Name', 'bob'); + * @example // everyone except azis or bob in the list + * $list = $list->excludeAny('Name', array('aziz', 'bob'); + * @example // everyone except bob or anyone aged 21 in the list + * $list = $list->excludeAny(array('Name'=>'bob, 'Age'=>21)); + * @example // everyone except bob or anyone aged 21 or 43 in the list + * $list = $list->excludeAny(array('Name'=>'bob, 'Age'=>array(21, 43))); + * @example // everyone except all bobs, phils or anyone aged 21 or 43 in the list + * $list = $list->excludeAny(array('Name'=>array('bob','phil'), 'Age'=>array(21, 43))); + * + * Also supports SearchFilter syntax + * @example // everyone except anyone with "sam" anywhere in their name + * $list = $list->excludeAny('Name:PartialMatch', 'sam'); + * + * @param string|array See {@link filter()} + */ + public function excludeAny(): static + { + $filters = call_user_func_array([$this, 'normaliseFilterArgs'], func_get_args()); + return $this->filterOrExclude($filters, false, true); + } + + /** + * Apply the appropriate filtering or excluding + */ + protected function filterOrExclude(array $filters, bool $inclusive = true, bool $any = false): static + { $itemsToKeep = []; + $searchFilters = []; + + foreach ($filters as $filterKey => $filterValue) { + $searchFilters[$filterKey] = $this->createSearchFilter($filterKey, $filterValue); + } foreach ($this->items as $item) { - foreach ($keepUs as $column => $value) { - $extractedValue = $this->extractValue($item, $column); - $matches = is_array($value) ? in_array($extractedValue, $value) : $extractedValue == $value; - if ($matches) { - $itemsToKeep[] = $item; + $matches = []; + foreach ($filters as $filterKey => $filterValue) { + /** @var SearchFilter $searchFilter */ + $searchFilter = $searchFilters[$filterKey]; + $hasMatch = $searchFilter->matches($this->extractValue($item, $searchFilter->getFullName())); + $matches[$hasMatch] = 1; + // If this is excludeAny or filterAny and we have a match, we can stop looking for matches. + if ($any && $hasMatch) { break; } } + // filterAny or excludeAny allow any true value to be a match; filter or exclude require any false value + // to be a mismatch. + $isMatch = $any ? isset($matches[true]) : !isset($matches[false]); + // If inclusive (filter) and we have a match, or exclusive (exclude) and there is NO match, keep the item. + if (($inclusive && $isMatch) || (!$inclusive && !$isMatch)) { + $itemsToKeep[] = $item; + } } $list = clone $this; - $list->items = array_unique($itemsToKeep ?? [], SORT_REGULAR); + $list->items = $itemsToKeep; return $list; } @@ -747,48 +805,6 @@ public function filterByCallback($callback) return $output; } - /** - * Exclude the list to not contain items with these characteristics - * - * @return ArrayList - * @see SS_List::exclude() - * @example $list->exclude('Name', 'bob'); // exclude bob from list - * @example $list->exclude('Name', array('aziz', 'bob'); // exclude aziz and bob from list - * @example $list->exclude(array('Name'=>'bob, 'Age'=>21)); // exclude bob that has Age 21 - * @example $list->exclude(array('Name'=>'bob, 'Age'=>array(21, 43))); // exclude bob with Age 21 or 43 - * @example $list->exclude(array('Name'=>array('bob','phil'), 'Age'=>array(21, 43))); - * // bob age 21 or 43, phil age 21 or 43 would be excluded - */ - public function exclude() - { - $removeUs = $this->normaliseFilterArgs(...func_get_args()); - - $hitsRequiredToRemove = count($removeUs ?? []); - $matches = []; - foreach ($removeUs as $column => $excludeValue) { - foreach ($this->items as $key => $item) { - if (!is_array($excludeValue) && $this->extractValue($item, $column) == $excludeValue) { - $matches[$key] = isset($matches[$key]) ? $matches[$key] + 1 : 1; - } elseif (is_array($excludeValue) && in_array($this->extractValue($item, $column), $excludeValue ?? [])) { - $matches[$key] = isset($matches[$key]) ? $matches[$key] + 1 : 1; - } - } - } - - $keysToRemove = array_keys($matches ?? [], $hitsRequiredToRemove); - - $itemsToKeep = []; - foreach ($this->items as $key => $value) { - if (!in_array($key, $keysToRemove ?? [])) { - $itemsToKeep[] = $value; - } - } - - $list = clone $this; - $list->items = $itemsToKeep; - return $list; - } - protected function shouldExclude($item, $args) { } diff --git a/src/ORM/DataList.php b/src/ORM/DataList.php index 3e784bb6d74..b6ff83ae6f9 100644 --- a/src/ORM/DataList.php +++ b/src/ORM/DataList.php @@ -4,7 +4,6 @@ use SilverStripe\Core\Injector\Injector; use SilverStripe\Dev\Debug; -use SilverStripe\ORM\Filters\SearchFilter; use SilverStripe\ORM\Queries\SQLConditionGroup; use SilverStripe\View\ViewableData; use Exception; @@ -15,6 +14,7 @@ use Traversable; use SilverStripe\ORM\DataQuery; use SilverStripe\ORM\ArrayList; +use SilverStripe\ORM\Filters\UsesSearchFilters; /** * Implements a "lazy loading" DataObjectSet. @@ -38,6 +38,8 @@ */ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Limitable { + use UsesSearchFilters; + /** * Whether to use placeholders for integer IDs on Primary and Foriegn keys during a WHERE IN query * It is significantly faster to not use placeholders @@ -665,44 +667,6 @@ protected function isValidRelationName($field) return preg_match('/^[A-Z0-9\._]+$/i', $field ?? ''); } - /** - * Given a filter expression and value construct a {@see SearchFilter} instance - * - * @param string $filter E.g. `Name:ExactMatch:not`, `Name:ExactMatch`, `Name:not`, `Name` - * @param mixed $value Value of the filter - * @return SearchFilter - */ - protected function createSearchFilter($filter, $value) - { - // Field name is always the first component - $fieldArgs = explode(':', $filter ?? ''); - $fieldName = array_shift($fieldArgs); - - // Inspect type of second argument to determine context - $secondArg = array_shift($fieldArgs); - $modifiers = $fieldArgs; - if (!$secondArg) { - // Use default filter if none specified. E.g. `->filter(['Name' => $myname])` - $filterServiceName = 'DataListFilter.default'; - } else { - // The presence of a second argument is by default ambiguous; We need to query - // Whether this is a valid modifier on the default filter, or a filter itself. - /** @var SearchFilter $defaultFilterInstance */ - $defaultFilterInstance = Injector::inst()->get('DataListFilter.default'); - if (in_array(strtolower($secondArg ?? ''), $defaultFilterInstance->getSupportedModifiers() ?? [])) { - // Treat second (and any subsequent) argument as modifiers, using default filter - $filterServiceName = 'DataListFilter.default'; - array_unshift($modifiers, $secondArg); - } else { - // Second argument isn't a valid modifier, so assume is filter identifier - $filterServiceName = "DataListFilter.{$secondArg}"; - } - } - - // Build instance - return Injector::inst()->create($filterServiceName, $fieldName, $value, $modifiers); - } - /** * Return a copy of this list which does not contain any items that match all params * diff --git a/src/ORM/EagerLoadedList.php b/src/ORM/EagerLoadedList.php index 6f2cf09cc53..ceb1d7dfd67 100644 --- a/src/ORM/EagerLoadedList.php +++ b/src/ORM/EagerLoadedList.php @@ -9,6 +9,7 @@ use BadMethodCallException; use InvalidArgumentException; use LogicException; +use SilverStripe\ORM\Filters\UsesSearchFilters; use Traversable; /** @@ -23,6 +24,8 @@ */ class EagerLoadedList extends ViewableData implements Relation, SS_List, Filterable, Sortable, Limitable { + use UsesSearchFilters; + /** * List responsible for instantiating the actual DataObject objects from eager-loaded data */ @@ -545,9 +548,9 @@ private function normaliseFilterArgs(array $arguments, string $function): array throw new InvalidArgumentException("Incorrect number of arguments passed to $function"); } foreach (array_keys($filter) as $column) { - if (!$this->canFilterBy($column)) { - throw new InvalidArgumentException("Can't filter by column '$column'"); - } + // if (!$this->canFilterBy($column)) { + // throw new InvalidArgumentException("Can't filter by column '$column'"); + // } } return $filter; @@ -561,12 +564,25 @@ private function normaliseFilterArgs(array $arguments, string $function): array private function getMatches($filters, bool $any = false): array { $matches = []; + $searchFilters = []; + + foreach ($filters as $filterKey => $filterValue) { + $searchFilters[$filterKey] = $this->createSearchFilter($filterKey, $filterValue); + } + foreach ($this->rows as $id => $row) { $doesMatch = true; foreach ($filters as $column => $value) { - $extractedValue = $this->extractValue($row, $this->standardiseColumn($column)); - $strict = $value === null || $extractedValue === null; - $doesMatch = $this->doesMatch($column, $value, $extractedValue, $strict); + // Throw exception for empty $value arrays to match ExactMatchFilter::manyFilter + if (is_array($value)) { + if (empty($value)) { + throw new InvalidArgumentException("Cannot filter $column against an empty set"); + } + } + /** @var SearchFilter $searchFilter */ + $searchFilter = $searchFilters[$column]; + $extractedValue = $this->extractValue($row, $this->standardiseColumn($searchFilter->getFullName())); + $doesMatch = $searchFilter->matches($extractedValue); if (!$any && !$doesMatch) { $doesMatch = false; break; @@ -582,23 +598,6 @@ private function getMatches($filters, bool $any = false): array return $matches; } - private function doesMatch(string $field, mixed $value1, mixed $value2, bool $strict): bool - { - if (is_array($value1)) { - if (empty($value1)) { - // mimics ExactMatchFilter::manyFilter - throw new InvalidArgumentException("Cannot filter $field against an empty set"); - } - return in_array($value2, $value1, $strict); - } - - if ($strict) { - return $value1 === $value2; - } - - return $value1 == $value2; - } - /** * Extracts a value from an item in the list, where the item is either an * object or array. diff --git a/src/ORM/Filters/ComparisonFilter.php b/src/ORM/Filters/ComparisonFilter.php index 96f716201f2..1e7b61dfa5b 100755 --- a/src/ORM/Filters/ComparisonFilter.php +++ b/src/ORM/Filters/ComparisonFilter.php @@ -2,6 +2,7 @@ namespace SilverStripe\ORM\Filters; +use BadMethodCallException; use SilverStripe\ORM\DataQuery; /** @@ -32,6 +33,40 @@ abstract protected function getOperator(); */ abstract protected function getInverseOperator(); + public function matches(mixed $toMatch): bool + { + $negated = in_array('not', $this->getModifiers()); + + // can't just cast to array, because that will convert null into an empty array + $values = $this->getValue(); + if (!is_array($values)) { + $values = [$values]; + } + + foreach ($values as $value) { + $doesMatch = $this->match($toMatch, $value); + + // Respect "not" modifier. + if ($negated) { + $doesMatch = !$doesMatch; + } + // If any value matches, then we consider the field to have matched. + if ($doesMatch) { + return true; + } + } + + return false; + } + + protected function match(mixed $objectValue, mixed $filterValue): bool + { + // We can't add an abstract method but we want to enforce the method signature for any subclasses + // which do implement this - therefore, throw an exception by default. + $actualClass = get_class($this); + throw new BadMethodCallException("matches is not implemented on $actualClass"); + } + /** * Applies a comparison filter to the query * Handles SQL escaping for both numeric and string values diff --git a/src/ORM/Filters/EndsWithFilter.php b/src/ORM/Filters/EndsWithFilter.php index bdcf8c05b0b..62192cf86c5 100644 --- a/src/ORM/Filters/EndsWithFilter.php +++ b/src/ORM/Filters/EndsWithFilter.php @@ -13,6 +13,7 @@ */ class EndsWithFilter extends PartialMatchFilter { + protected static $matchesEndsWith = true; protected function getMatchPattern($value) { diff --git a/src/ORM/Filters/ExactMatchFilter.php b/src/ORM/Filters/ExactMatchFilter.php index 7b2a47453eb..25aa00e938d 100644 --- a/src/ORM/Filters/ExactMatchFilter.php +++ b/src/ORM/Filters/ExactMatchFilter.php @@ -13,12 +13,50 @@ /** * Selects textual content with an exact match between columnname and keyword. * - * @todo case sensitivity switch * @todo documentation */ class ExactMatchFilter extends SearchFilter { + public function matches(mixed $toMatch): bool + { + $isCaseSensitive = $this->getCaseSensitive(); + if ($isCaseSensitive === null) { + $isCaseSensitive = $this->getCaseSensitivityByCollation(); + } + $caseSensitive = $isCaseSensitive ? '' : 'i'; + $negated = in_array('not', $this->getModifiers()); + + // Can't just cast to array, because that will convert null into an empty array + $values = $this->getValue(); + if (!is_array($values)) { + $values = [$values]; + } + + foreach ($values as $value) { + if (is_string($value) && is_string($toMatch)) { + $regexSafeValue = preg_quote($value, '/'); + $doesMatch = preg_match('/^' . $regexSafeValue . '$/u' . $caseSensitive, $toMatch); + } else { + // case sensitivity is meaningless if one or both values aren't strings, + // so fall back to a loose equivalency comparison. + $doesMatch = $value == $toMatch; + } + + // Respect "not" modifier. + if ($negated) { + $doesMatch = !$doesMatch; + } + // If any value matches, then we consider the field to have matched + if ($doesMatch) { + return true; + break; + } + } + + return false; + } + public function getSupportedModifiers() { return ['not', 'nocase', 'case']; @@ -81,7 +119,7 @@ protected function oneFilter(DataQuery $query, $inclusive) } $clause = [$where => $value]; - + return $this->aggregate ? $this->applyAggregate($query, $clause) : $query->where($clause); diff --git a/src/ORM/Filters/GreaterThanFilter.php b/src/ORM/Filters/GreaterThanFilter.php index 9b2460e2ff2..4e5eb009056 100755 --- a/src/ORM/Filters/GreaterThanFilter.php +++ b/src/ORM/Filters/GreaterThanFilter.php @@ -10,6 +10,10 @@ */ class GreaterThanFilter extends ComparisonFilter { + protected function match(mixed $objectValue, mixed $filterValue): bool + { + return $objectValue > $filterValue; + } protected function getOperator() { diff --git a/src/ORM/Filters/GreaterThanOrEqualFilter.php b/src/ORM/Filters/GreaterThanOrEqualFilter.php index fdbac87608a..9da4dfe8a00 100755 --- a/src/ORM/Filters/GreaterThanOrEqualFilter.php +++ b/src/ORM/Filters/GreaterThanOrEqualFilter.php @@ -10,6 +10,10 @@ */ class GreaterThanOrEqualFilter extends ComparisonFilter { + protected function match(mixed $objectValue, mixed $filterValue): bool + { + return $objectValue >= $filterValue; + } protected function getOperator() { diff --git a/src/ORM/Filters/LessThanFilter.php b/src/ORM/Filters/LessThanFilter.php index bd311845179..7852ee3e5d9 100755 --- a/src/ORM/Filters/LessThanFilter.php +++ b/src/ORM/Filters/LessThanFilter.php @@ -10,6 +10,10 @@ */ class LessThanFilter extends ComparisonFilter { + protected function match(mixed $objectValue, mixed $filterValue): bool + { + return $objectValue < $filterValue; + } protected function getOperator() { diff --git a/src/ORM/Filters/LessThanOrEqualFilter.php b/src/ORM/Filters/LessThanOrEqualFilter.php index 8f769eac9b7..b185b8b3509 100755 --- a/src/ORM/Filters/LessThanOrEqualFilter.php +++ b/src/ORM/Filters/LessThanOrEqualFilter.php @@ -10,6 +10,10 @@ */ class LessThanOrEqualFilter extends ComparisonFilter { + protected function match(mixed $objectValue, mixed $filterValue): bool + { + return $objectValue <= $filterValue; + } protected function getOperator() { diff --git a/src/ORM/Filters/PartialMatchFilter.php b/src/ORM/Filters/PartialMatchFilter.php index a93f457762a..c8522897954 100644 --- a/src/ORM/Filters/PartialMatchFilter.php +++ b/src/ORM/Filters/PartialMatchFilter.php @@ -11,6 +11,8 @@ */ class PartialMatchFilter extends SearchFilter { + protected static $matchesStartsWith = false; + protected static $matchesEndsWith = false; public function getSupportedModifiers() { @@ -28,6 +30,51 @@ protected function getMatchPattern($value) return "%$value%"; } + public function matches(mixed $toMatch): bool + { + $isCaseSensitive = $this->getCaseSensitive(); + if ($isCaseSensitive === null) { + $isCaseSensitive = $this->getCaseSensitivityByCollation(); + } + $caseSensitive = $isCaseSensitive ? '' : 'i'; + $negated = in_array('not', $this->getModifiers()); + $toMatchString = (string) $toMatch; + + // can't just cast to array, because that will convert null into an empty array + $values = $this->getValue(); + if (!is_array($values)) { + $values = [$values]; + } + + foreach ($values as $value) { + if (is_bool($toMatch)) { + if (static::$matchesStartsWith || static::$matchesEndsWith) { + // Nothing "starts" or "ends" with a boolean value, so automatically fail those matches. + $doesMatch = false; + } else { + // A partial boolean match should match truthy and falsy values. + $doesMatch = $toMatch == $value; + } + } else { + $value = (string) $value; + $regexSafeValue = preg_quote($value, '/'); + $start = static::$matchesStartsWith ? '^' : ''; + $end = static::$matchesEndsWith ? '$' : ''; + $doesMatch = preg_match('/' . $start . $regexSafeValue . $end . '/u' . $caseSensitive, $toMatchString); + } + // Respect "not" modifier. + if ($negated) { + $doesMatch = !$doesMatch; + } + // If any value matches, then we consider the field to have matched. + if ($doesMatch) { + return true; + } + } + + return false; + } + /** * Apply filter criteria to a SQL query. * diff --git a/src/ORM/Filters/SearchFilter.php b/src/ORM/Filters/SearchFilter.php index 59d9f1751d5..70e3d5b548e 100644 --- a/src/ORM/Filters/SearchFilter.php +++ b/src/ORM/Filters/SearchFilter.php @@ -2,11 +2,14 @@ namespace SilverStripe\ORM\Filters; +use BadMethodCallException; use SilverStripe\Core\ClassInfo; use SilverStripe\Core\Injector\Injectable; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataQuery; use InvalidArgumentException; +use SilverStripe\Core\Config\Configurable; +use SilverStripe\ORM\DB; use SilverStripe\ORM\FieldType\DBField; /** @@ -26,7 +29,19 @@ */ abstract class SearchFilter { - use Injectable; + use Injectable, Configurable; + + /** + * Whether the database uses case sensitive collation or not. + * @internal + */ + private static ?bool $caseSensitiveByCollation = null; + + /** + * Whether search filters should be case sensitive or not by default. + * If null, the database collation setting is used. + */ + private static ?bool $default_case_sensitive = null; /** * Classname of the inspected {@link DataObject}. @@ -345,6 +360,17 @@ public function applyAggregate(DataQuery $query, $having) ->groupby("\"{$baseTable}\".\"ID\""); } + /** + * Check whether this filter matches against a value. + */ + public function matches(mixed $value): bool + { + // We can't add an abstract method but we want to enforce the method signature for any subclasses + // which do implement this - therefore, throw an exception by default. + $actualClass = get_class($this); + throw new BadMethodCallException("matches is not implemented on $actualClass"); + } + /** * Apply filter criteria to a SQL query. * @@ -437,7 +463,7 @@ public function isEmpty() /** * Determines case sensitivity based on {@link getModifiers()}. * - * @return Mixed TRUE or FALSE to enforce sensitivity, NULL to use field collation. + * @return ?bool TRUE or FALSE to enforce sensitivity, NULL to use field collation. */ protected function getCaseSensitive() { @@ -447,7 +473,25 @@ protected function getCaseSensitive() } elseif (in_array('nocase', $modifiers ?? [])) { return false; } else { - return null; + $sensitive = self::config()->get('default_case_sensitive'); + if ($sensitive !== null) { + return $sensitive; + } } + return null; + } + + /** + * Find out whether the database is set to use case sensitive comparisons or not by default. + * Used for static comparisons in the matches() method. + */ + protected function getCaseSensitivityByCollation() + { + if (!self::$caseSensitiveByCollation) { + $whereClause = DB::get_conn()->comparisonClause("'CASE'", 'case', true); + self::$caseSensitiveByCollation = DB::query("SELECT '' WHERE $whereClause")->numRecords() === 0; + } + + return self::$caseSensitiveByCollation; } } diff --git a/src/ORM/Filters/StartsWithFilter.php b/src/ORM/Filters/StartsWithFilter.php index 7c9fb107120..fa8f7a7fbcc 100644 --- a/src/ORM/Filters/StartsWithFilter.php +++ b/src/ORM/Filters/StartsWithFilter.php @@ -13,6 +13,7 @@ */ class StartsWithFilter extends PartialMatchFilter { + protected static $matchesStartsWith = true; protected function getMatchPattern($value) { diff --git a/src/ORM/Filters/UsesSearchFilters.php b/src/ORM/Filters/UsesSearchFilters.php new file mode 100644 index 00000000000..9c028c9ef0a --- /dev/null +++ b/src/ORM/Filters/UsesSearchFilters.php @@ -0,0 +1,49 @@ +filter(['Name' => $myname])` + $filterServiceName = $default; + } else { + // The presence of a second argument is by default ambiguous; We need to query + // Whether this is a valid modifier on the default filter, or a filter itself. + /** @var SearchFilter $defaultFilterInstance */ + $defaultFilterInstance = Injector::inst()->get($default); + if (in_array(strtolower($secondArg), $defaultFilterInstance->getSupportedModifiers() ?? [])) { + // Treat second (and any subsequent) argument as modifiers, using default filter + $filterServiceName = $default; + array_unshift($modifiers, $secondArg); + } else { + // Second argument isn't a valid modifier, so assume is filter identifier + $filterServiceName = "DataListFilter.{$secondArg}"; + } + } + + // Build instance + $filter = Injector::inst()->create($filterServiceName, $fieldName, $value, $modifiers); + + return $filter; + } +} diff --git a/tests/php/ORM/Filters/EndsWithFilterTest.php b/tests/php/ORM/Filters/EndsWithFilterTest.php new file mode 100644 index 00000000000..ae7e21831cc --- /dev/null +++ b/tests/php/ORM/Filters/EndsWithFilterTest.php @@ -0,0 +1,239 @@ + [ + 'filterValue' => null, + 'objValue' => null, + 'modifiers' => [], + 'matches' => true, + ], + 'empty ends with null' => [ + 'filterValue' => null, + 'objValue' => '', + 'modifiers' => [], + 'matches' => true, + ], + 'null ends with empty' => [ + 'filterValue' => '', + 'objValue' => null, + 'modifiers' => [], + 'matches' => true, + ], + 'empty ends with empty' => [ + 'filterValue' => '', + 'objValue' => '', + 'modifiers' => [], + 'matches' => true, + ], + 'empty ends with false' => [ + 'filterValue' => false, + 'objValue' => '', + 'modifiers' => [], + 'matches' => true, + ], + 'true doesnt end with empty' => [ + 'filterValue' => true, + 'objValue' => '', + 'modifiers' => [], + 'matches' => false, + ], + 'false doesnt end with empty' => [ + 'filterValue' => '', + 'objValue' => false, + 'modifiers' => [], + 'matches' => false, + ], + 'true doesnt end with empty' => [ + 'filterValue' => '', + 'objValue' => true, + 'modifiers' => [], + 'matches' => false, + ], + 'null ends with false' => [ + 'filterValue' => false, + 'objValue' => null, + 'modifiers' => [], + 'matches' => true, + ], + 'false doesnt end with null' => [ + 'filterValue' => null, + 'objValue' => false, + 'modifiers' => [], + 'matches' => false, + ], + 'false doesnt end with true' => [ + 'filterValue' => true, + 'objValue' => false, + 'modifiers' => [], + 'matches' => false, + ], + 'true doesnt end with false' => [ + 'filterValue' => false, + 'objValue' => true, + 'modifiers' => [], + 'matches' => false, + ], + 'false doesnt end with false' => [ + 'filterValue' => false, + 'objValue' => false, + 'modifiers' => [], + 'matches' => false, + ], + 'true doesnt end with true' => [ + 'filterValue' => true, + 'objValue' => true, + 'modifiers' => [], + 'matches' => false, + ], + 'number is cast to string' => [ + 'filterValue' => 1, + 'objValue' => '1', + 'modifiers' => [], + 'matches' => true, + ], + '1 ends with 1' => [ + 'filterValue' => 1, + 'objValue' => 1, + 'modifiers' => [], + 'matches' => true, + ], + '100 doesnt end with 1' => [ + 'filterValue' => '1', + 'objValue' => 100, + 'modifiers' => [], + 'matches' => false, + ], + '100 ends with 0' => [ + 'filterValue' => '0', + 'objValue' => 100, + 'modifiers' => [], + 'matches' => true, + ], + '100 still ends with 0' => [ + 'filterValue' => 0, + 'objValue' => 100, + 'modifiers' => [], + 'matches' => true, + ], + 'SomeValue ends with SomeValue' => [ + 'filterValue' => 'SomeValue', + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => true, + ], + 'SomeValue doesnt end with somevalue' => [ + 'filterValue' => 'somevalue', + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => null, + ], + 'SomeValue doesnt end with meVal' => [ + 'filterValue' => 'meVal', + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => false, + ], + 'SomeValue ends with Value' => [ + 'filterValue' => 'Value', + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => true, + ], + 'SomeValue doesnt with vAlUe' => [ + 'filterValue' => 'vAlUe', + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => null, + ], + // These will both evaluate to true because the __toString() method just returns the class name. + // We're testing this scenario because ArrayList might contain arbitrary values + [ + 'filterValue' => new ArrayData(['SomeField' => 'some value']), + 'objValue' => new ArrayData(['SomeField' => 'some value']), + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => new ArrayData(['SomeField' => 'SoMe VaLuE']), + 'objValue' => new ArrayData(['SomeField' => 'some value']), + 'modifiers' => [], + 'matches' => true, + ], + // case insensitive + [ + 'filterValue' => 'somevalue', + 'objValue' => 'SomeValue', + 'modifiers' => ['nocase'], + 'matches' => true, + ], + [ + 'filterValue' => 'vAlUe', + 'objValue' => 'SomeValue', + 'modifiers' => ['nocase'], + 'matches' => true, + ], + [ + 'filterValue' => 'meval', + 'objValue' => 'SomeValue', + 'modifiers' => ['nocase'], + 'matches' => false, + ], + [ + 'filterValue' => 'different', + 'objValue' => 'SomeValue', + 'modifiers' => ['nocase'], + 'matches' => false, + ], + ]; + // negated + foreach ($scenarios as $scenario) { + $scenario['modifiers'][] = 'not'; + $scenario['matches'] = $scenario['matches'] === null ? null : !$scenario['matches']; + $scenarios[] = $scenario; + } + // explicit case sensitive + foreach ($scenarios as $scenario) { + if (!in_array('nocase', $scenario['modifiers'])) { + $scenario['modifiers'][] = 'case'; + $scenarios[] = $scenario; + } + } + return $scenarios; + } + + /** + * @dataProvider provideMatches + */ + public function testMatches(mixed $filterValue, mixed $matchValue, array $modifiers, ?bool $matches) + { + // Test with explicit default case sensitivity rather than relying on the collation, so that database + // settings don't interfere with the test + foreach ([true, false] as $caseSensitive) { + // Handle cases where the expected value can depend on the default case sensitivity + if ($matches === null) { + $nullMatch = !(in_array('case', $modifiers) ?: $caseSensitive); + if (in_array('not', $modifiers)) { + $nullMatch = !$nullMatch; + } + } + + EndsWithFilter::config()->set('default_case_sensitive', $caseSensitive); + $filter = new EndsWithFilter(); + $filter->setValue($filterValue); + $filter->setModifiers($modifiers); + $this->assertSame($matches ?? $nullMatch, $filter->matches($matchValue)); + } + } +} diff --git a/tests/php/ORM/Filters/ExactMatchFilterTest.php b/tests/php/ORM/Filters/ExactMatchFilterTest.php index 25562cc84be..b292742da46 100644 --- a/tests/php/ORM/Filters/ExactMatchFilterTest.php +++ b/tests/php/ORM/Filters/ExactMatchFilterTest.php @@ -8,6 +8,7 @@ use SilverStripe\ORM\Tests\Filters\ExactMatchFilterTest\Task; use SilverStripe\ORM\Tests\Filters\ExactMatchFilterTest\Project; use SilverStripe\ORM\DataList; +use SilverStripe\View\ArrayData; class ExactMatchFilterTest extends SapphireTest { @@ -93,4 +94,186 @@ private function usesPlaceholders(callable $fn): array $titleQueryUsesPlaceholders = isset($matches[1]) ? $matches[1] === '?, ?, ?' : null; return [$idQueryUsesPlaceholders, $titleQueryUsesPlaceholders]; } + + public function provideMatches() + { + $scenarios = [ + // without modifiers + [ + 'filterValue' => null, + 'objValue' => null, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => null, + 'objValue' => '', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => '', + 'objValue' => null, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => '', + 'objValue' => '', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => false, + 'objValue' => '', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => true, + 'objValue' => '', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => '', + 'objValue' => false, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => '', + 'objValue' => true, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => false, + 'objValue' => null, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => null, + 'objValue' => false, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => true, + 'objValue' => false, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => false, + 'objValue' => false, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => true, + 'objValue' => true, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 'SomeValue', + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 'somevalue', + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => null, + ], + [ + 'filterValue' => 'SomeValue', + 'objValue' => 'Some', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 1, + 'objValue' => '1', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 1, + 'objValue' => 1, + 'modifiers' => [], + 'matches' => true, + ], + // test something that is clearly not strings, since exact match + // is the default for ArrayList filtering which can have basically + // anything as its value + [ + 'filterValue' => new ArrayData(['SomeField' => 'some value']), + 'objValue' => new ArrayData(['SomeField' => 'some value']), + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => new ArrayData(['SomeField' => 'SoMe VaLuE']), + 'objValue' => new ArrayData(['SomeField' => 'some value']), + 'modifiers' => [], + 'matches' => false, + ], + // case insensitive + [ + 'filterValue' => 'somevalue', + 'objValue' => 'SomeValue', + 'modifiers' => ['nocase'], + 'matches' => true, + ], + // doesn't do partial matching even when case insensitive + [ + 'filterValue' => 'some', + 'objValue' => 'SomeValue', + 'modifiers' => ['nocase'], + 'matches' => false, + ], + ]; + // negated + foreach ($scenarios as $scenario) { + $scenario['modifiers'][] = 'not'; + $scenario['matches'] = $scenario['matches'] === null ? null : !$scenario['matches']; + $scenarios[] = $scenario; + } + // explicitly case sensitive + foreach ($scenarios as $scenario) { + if (!in_array('nocase', $scenario['modifiers'])) { + $scenario['modifiers'][] = 'case'; + $scenarios[] = $scenario; + } + } + return $scenarios; + } + + /** + * @dataProvider provideMatches + */ + public function testMatches(mixed $filterValue, mixed $objValue, array $modifiers, ?bool $matches) + { + // Test with explicit default case sensitivity rather than relying on the collation, so that database + // settings don't interfere with the test + foreach ([true, false] as $caseSensitive) { + // Handle cases where the expected value can depend on the default case sensitivity + if ($matches === null) { + $nullMatch = !(in_array('case', $modifiers) ?: $caseSensitive); + if (in_array('not', $modifiers)) { + $nullMatch = !$nullMatch; + } + } + + ExactMatchFilter::config()->set('default_case_sensitive', $caseSensitive); + $filter = new ExactMatchFilter(); + $filter->setValue($filterValue); + $filter->setModifiers($modifiers); + $this->assertSame($matches ?? $nullMatch, $filter->matches($objValue)); + } + } } diff --git a/tests/php/ORM/Filters/GreaterThanFilterTest.php b/tests/php/ORM/Filters/GreaterThanFilterTest.php new file mode 100644 index 00000000000..cb1be336a2b --- /dev/null +++ b/tests/php/ORM/Filters/GreaterThanFilterTest.php @@ -0,0 +1,193 @@ + true, + 'matchValue' => null, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => false, + 'matchValue' => null, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => null, + 'matchValue' => true, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => null, + 'matchValue' => false, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => true, + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => false, + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 1, + 'matchValue' => true, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 1, + 'matchValue' => false, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => null, + 'matchValue' => null, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => '', + 'matchValue' => null, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => null, + 'matchValue' => '', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => '', + 'matchValue' => '', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 'SomeValue', + 'matchValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 'SomeValue', + 'matchValue' => 'somevalue', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => '1', + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 1, + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 2, + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 1, + 'matchValue' => 2, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => '2', + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 2, + 'matchValue' => '1', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => '1', + 'matchValue' => 2, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 1, + 'matchValue' => '2', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => '12', + 'matchValue' => 2, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 12, + 'matchValue' => '2', + 'modifiers' => [], + 'matches' => false, + ], + // We're testing this scenario because ArrayList might contain arbitrary values + [ + 'filterValue' => new ArrayData(['SomeField' => 'some value']), + 'matchValue' => new ArrayData(['SomeField' => 'some value']), + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => new ArrayData(['SomeField' => 'some value']), + 'matchValue' => new ArrayData(['SomeField' => 'SoMe VaLuE']), + 'modifiers' => [], + 'matches' => false, + ], + ]; + // negated + foreach ($scenarios as $scenario) { + $scenario['modifiers'][] = 'not'; + $scenario['matches'] = !$scenario['matches']; + $scenarios[] = $scenario; + } + return $scenarios; + } + + /** + * @dataProvider provideMatches + */ + public function testMatches(mixed $filterValue, mixed $matchValue, array $modifiers, bool $matches) + { + $filter = new GreaterThanFilter(); + $filter->setValue($filterValue); + $filter->setModifiers($modifiers); + $this->assertSame($matches, $filter->matches($matchValue)); + } +} diff --git a/tests/php/ORM/Filters/GreaterThanOrEqualFilterTest.php b/tests/php/ORM/Filters/GreaterThanOrEqualFilterTest.php new file mode 100644 index 00000000000..465c4492a7c --- /dev/null +++ b/tests/php/ORM/Filters/GreaterThanOrEqualFilterTest.php @@ -0,0 +1,193 @@ + true, + 'matchValue' => null, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => false, + 'matchValue' => null, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => null, + 'matchValue' => true, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => null, + 'matchValue' => false, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => true, + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => false, + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 1, + 'matchValue' => true, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 1, + 'matchValue' => false, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => null, + 'matchValue' => null, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => '', + 'matchValue' => null, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => null, + 'matchValue' => '', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => '', + 'matchValue' => '', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 'SomeValue', + 'matchValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 'SomeValue', + 'matchValue' => 'somevalue', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => '1', + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 1, + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 2, + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 1, + 'matchValue' => 2, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => '2', + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 2, + 'matchValue' => '1', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => '1', + 'matchValue' => 2, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 1, + 'matchValue' => '2', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => '12', + 'matchValue' => 2, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 12, + 'matchValue' => '2', + 'modifiers' => [], + 'matches' => false, + ], + // We're testing this scenario because ArrayList might contain arbitrary values + [ + 'filterValue' => new ArrayData(['SomeField' => 'some value']), + 'matchValue' => new ArrayData(['SomeField' => 'some value']), + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => new ArrayData(['SomeField' => 'some value']), + 'matchValue' => new ArrayData(['SomeField' => 'SoMe VaLuE']), + 'modifiers' => [], + 'matches' => false, + ], + ]; + // negated + foreach ($scenarios as $scenario) { + $scenario['modifiers'][] = 'not'; + $scenario['matches'] = !$scenario['matches']; + $scenarios[] = $scenario; + } + return $scenarios; + } + + /** + * @dataProvider provideMatches + */ + public function testMatches(mixed $filterValue, mixed $matchValue, array $modifiers, bool $matches) + { + $filter = new GreaterThanOrEqualFilter(); + $filter->setValue($filterValue); + $filter->setModifiers($modifiers); + $this->assertSame($matches, $filter->matches($matchValue)); + } +} diff --git a/tests/php/ORM/Filters/LessThanFilterTest.php b/tests/php/ORM/Filters/LessThanFilterTest.php new file mode 100644 index 00000000000..dc8ebd0a425 --- /dev/null +++ b/tests/php/ORM/Filters/LessThanFilterTest.php @@ -0,0 +1,193 @@ + true, + 'matchValue' => null, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => false, + 'matchValue' => null, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => null, + 'matchValue' => true, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => null, + 'matchValue' => false, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => true, + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => false, + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 1, + 'matchValue' => true, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 1, + 'matchValue' => false, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => null, + 'matchValue' => null, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => '', + 'matchValue' => null, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => null, + 'matchValue' => '', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => '', + 'matchValue' => '', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 'SomeValue', + 'matchValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 'SomeValue', + 'matchValue' => 'somevalue', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => '1', + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 1, + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 2, + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 1, + 'matchValue' => 2, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => '2', + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 2, + 'matchValue' => '1', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => '1', + 'matchValue' => 2, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 1, + 'matchValue' => '2', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => '12', + 'matchValue' => 2, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 12, + 'matchValue' => '2', + 'modifiers' => [], + 'matches' => true, + ], + // We're testing this scenario because ArrayList might contain arbitrary values + [ + 'filterValue' => new ArrayData(['SomeField' => 'some value']), + 'matchValue' => new ArrayData(['SomeField' => 'some value']), + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => new ArrayData(['SomeField' => 'some value']), + 'matchValue' => new ArrayData(['SomeField' => 'SoMe VaLuE']), + 'modifiers' => [], + 'matches' => true, + ], + ]; + // negated + foreach ($scenarios as $scenario) { + $scenario['modifiers'][] = 'not'; + $scenario['matches'] = !$scenario['matches']; + $scenarios[] = $scenario; + } + return $scenarios; + } + + /** + * @dataProvider provideMatches + */ + public function testMatches(mixed $filterValue, mixed $matchValue, array $modifiers, bool $matches) + { + $filter = new LessThanFilter(); + $filter->setValue($filterValue); + $filter->setModifiers($modifiers); + $this->assertSame($matches, $filter->matches($matchValue)); + } +} diff --git a/tests/php/ORM/Filters/LessThanOrEqualFilterTest.php b/tests/php/ORM/Filters/LessThanOrEqualFilterTest.php new file mode 100644 index 00000000000..8511adbee2a --- /dev/null +++ b/tests/php/ORM/Filters/LessThanOrEqualFilterTest.php @@ -0,0 +1,193 @@ + true, + 'matchValue' => null, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => false, + 'matchValue' => null, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => null, + 'matchValue' => true, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => null, + 'matchValue' => false, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => true, + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => false, + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 1, + 'matchValue' => true, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 1, + 'matchValue' => false, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => null, + 'matchValue' => null, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => '', + 'matchValue' => null, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => null, + 'matchValue' => '', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => '', + 'matchValue' => '', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 'SomeValue', + 'matchValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 'SomeValue', + 'matchValue' => 'somevalue', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => '1', + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 1, + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 2, + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 1, + 'matchValue' => 2, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => '2', + 'matchValue' => 1, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 2, + 'matchValue' => '1', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => '1', + 'matchValue' => 2, + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 1, + 'matchValue' => '2', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => '12', + 'matchValue' => 2, + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 12, + 'matchValue' => '2', + 'modifiers' => [], + 'matches' => true, + ], + // We're testing this scenario because ArrayList might contain arbitrary values + [ + 'filterValue' => new ArrayData(['SomeField' => 'some value']), + 'matchValue' => new ArrayData(['SomeField' => 'some value']), + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => new ArrayData(['SomeField' => 'some value']), + 'matchValue' => new ArrayData(['SomeField' => 'SoMe VaLuE']), + 'modifiers' => [], + 'matches' => true, + ], + ]; + // negated + foreach ($scenarios as $scenario) { + $scenario['modifiers'][] = 'not'; + $scenario['matches'] = !$scenario['matches']; + $scenarios[] = $scenario; + } + return $scenarios; + } + + /** + * @dataProvider provideMatches + */ + public function testMatches(mixed $filterValue, mixed $matchValue, array $modifiers, bool $matches) + { + $filter = new LessThanOrEqualFilter(); + $filter->setValue($filterValue); + $filter->setModifiers($modifiers); + $this->assertSame($matches, $filter->matches($matchValue)); + } +} diff --git a/tests/php/ORM/Filters/PartialMatchFilterTest.php b/tests/php/ORM/Filters/PartialMatchFilterTest.php new file mode 100644 index 00000000000..d5ade81dd2a --- /dev/null +++ b/tests/php/ORM/Filters/PartialMatchFilterTest.php @@ -0,0 +1,233 @@ + [ + 'filterValue' => null, + 'objValue' => null, + 'modifiers' => [], + 'matches' => true, + ], + 'null partially matches empty' => [ + 'filterValue' => null, + 'objValue' => '', + 'modifiers' => [], + 'matches' => true, + ], + 'empty partially matches null' => [ + 'filterValue' => '', + 'objValue' => null, + 'modifiers' => [], + 'matches' => true, + ], + 'empty partially matches empty' => [ + 'filterValue' => '', + 'objValue' => '', + 'modifiers' => [], + 'matches' => true, + ], + 'false partially matches empty' => [ + 'filterValue' => false, + 'objValue' => '', + 'modifiers' => [], + 'matches' => true, + ], + 'true doesnt partially match empty' => [ + 'filterValue' => true, + 'objValue' => '', + 'modifiers' => [], + 'matches' => false, + ], + 'empty partially matches false' => [ + 'filterValue' => '', + 'objValue' => false, + 'modifiers' => [], + 'matches' => true, + ], + 'empty doesnt partially match true' => [ + 'filterValue' => '', + 'objValue' => true, + 'modifiers' => [], + 'matches' => false, + ], + 'false partially matches null' => [ + 'filterValue' => false, + 'objValue' => null, + 'modifiers' => [], + 'matches' => true, + ], + 'null partially matches false' => [ + 'filterValue' => null, + 'objValue' => false, + 'modifiers' => [], + 'matches' => true, + ], + 'true doesnt partially match false' => [ + 'filterValue' => true, + 'objValue' => false, + 'modifiers' => [], + 'matches' => false, + ], + 'false doesnt partially match true' => [ + 'filterValue' => false, + 'objValue' => true, + 'modifiers' => [], + 'matches' => false, + ], + 'false partially matches false' => [ + 'filterValue' => false, + 'objValue' => false, + 'modifiers' => [], + 'matches' => true, + ], + 'true partially matches true' => [ + 'filterValue' => true, + 'objValue' => true, + 'modifiers' => [], + 'matches' => true, + ], + 'number is cast to string' => [ + 'filterValue' => 1, + 'objValue' => '1', + 'modifiers' => [], + 'matches' => true, + ], + 'numeric match' => [ + 'filterValue' => 1, + 'objValue' => 1, + 'modifiers' => [], + 'matches' => true, + ], + 'partial numeric match' => [ + 'filterValue' => '1', + 'objValue' => 100, + 'modifiers' => [], + 'matches' => true, + ], + 'partial numeric match2' => [ + 'filterValue' => 1, + 'objValue' => 100, + 'modifiers' => [], + 'matches' => true, + ], + 'partial numeric match3' => [ + 'filterValue' => 0, + 'objValue' => 100, + 'modifiers' => [], + 'matches' => true, + ], + 'case sensitive match' => [ + 'filterValue' => 'SomeValue', + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => true, + ], + 'case sensitive mismatch' => [ + 'filterValue' => 'somevalue', + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => null, + ], + 'case sensitive partial match' => [ + 'filterValue' => 'meVal', + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => true, + ], + 'case sensitive partial mismatch' => [ + 'filterValue' => 'meval', + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => null, + ], + // These will both evaluate to true because the __toString() method just returns the class name. + // We're testing this scenario because ArrayList might contain arbitrary values + [ + 'filterValue' => new ArrayData(['SomeField' => 'some value']), + 'objValue' => new ArrayData(['SomeField' => 'some value']), + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => new ArrayData(['SomeField' => 'SoMe VaLuE']), + 'objValue' => new ArrayData(['SomeField' => 'some value']), + 'modifiers' => [], + 'matches' => true, + ], + // case insensitive + [ + 'filterValue' => 'somevalue', + 'objValue' => 'SomeValue', + 'modifiers' => ['nocase'], + 'matches' => true, + ], + [ + 'filterValue' => 'some', + 'objValue' => 'SomeValue', + 'modifiers' => ['nocase'], + 'matches' => true, + ], + [ + 'filterValue' => 'meval', + 'objValue' => 'SomeValue', + 'modifiers' => ['nocase'], + 'matches' => true, + ], + [ + 'filterValue' => 'different', + 'objValue' => 'SomeValue', + 'modifiers' => ['nocase'], + 'matches' => false, + ], + ]; + // negated + foreach ($scenarios as $scenario) { + $scenario['modifiers'][] = 'not'; + $scenario['matches'] = $scenario['matches'] === null ? null : !$scenario['matches']; + $scenarios[] = $scenario; + } + // explicit case sensitive + foreach ($scenarios as $scenario) { + if (!in_array('nocase', $scenario['modifiers'])) { + $scenario['modifiers'][] = 'case'; + $scenarios[] = $scenario; + } + } + return $scenarios; + } + + /** + * @dataProvider provideMatches + */ + public function testMatches(mixed $filterValue, mixed $objValue, array $modifiers, ?bool $matches) + { + // Test with explicit default case sensitivity rather than relying on the collation, so that database + // settings don't interfere with the test + foreach ([true, false] as $caseSensitive) { + // Handle cases where the expected value can depend on the default case sensitivity + if ($matches === null) { + $nullMatch = !(in_array('case', $modifiers) ?: $caseSensitive); + if (in_array('not', $modifiers)) { + $nullMatch = !$nullMatch; + } + } + + PartialMatchFilter::config()->set('default_case_sensitive', $caseSensitive); + $filter = new PartialMatchFilter(); + $filter->setValue($filterValue); + $filter->setModifiers($modifiers); + $this->assertSame($matches ?? $nullMatch, $filter->matches($objValue)); + } + } +} diff --git a/tests/php/ORM/Filters/StartsWithFilterTest.php b/tests/php/ORM/Filters/StartsWithFilterTest.php new file mode 100644 index 00000000000..bc517c27f2a --- /dev/null +++ b/tests/php/ORM/Filters/StartsWithFilterTest.php @@ -0,0 +1,239 @@ + [ + 'filterValue' => null, + 'objValue' => null, + 'modifiers' => [], + 'matches' => true, + ], + 'empty starts with null' => [ + 'filterValue' => null, + 'objValue' => '', + 'modifiers' => [], + 'matches' => true, + ], + 'null starts with empty' => [ + 'filterValue' => '', + 'objValue' => null, + 'modifiers' => [], + 'matches' => true, + ], + 'empty starts with empty' => [ + 'filterValue' => '', + 'objValue' => '', + 'modifiers' => [], + 'matches' => true, + ], + 'empty starts with false' => [ + 'filterValue' => false, + 'objValue' => '', + 'modifiers' => [], + 'matches' => true, + ], + 'true doesnt start with empty' => [ + 'filterValue' => true, + 'objValue' => '', + 'modifiers' => [], + 'matches' => false, + ], + 'false doesnt start with empty' => [ + 'filterValue' => '', + 'objValue' => false, + 'modifiers' => [], + 'matches' => false, + ], + 'true doesnt start with empty' => [ + 'filterValue' => '', + 'objValue' => true, + 'modifiers' => [], + 'matches' => false, + ], + 'null starts with false' => [ + 'filterValue' => false, + 'objValue' => null, + 'modifiers' => [], + 'matches' => true, + ], + 'false doesnt start with null' => [ + 'filterValue' => null, + 'objValue' => false, + 'modifiers' => [], + 'matches' => false, + ], + 'false doesnt start with true' => [ + 'filterValue' => true, + 'objValue' => false, + 'modifiers' => [], + 'matches' => false, + ], + 'true doesnt start with false' => [ + 'filterValue' => false, + 'objValue' => true, + 'modifiers' => [], + 'matches' => false, + ], + 'false doesnt start with false' => [ + 'filterValue' => false, + 'objValue' => false, + 'modifiers' => [], + 'matches' => false, + ], + 'true doesnt start with true' => [ + 'filterValue' => true, + 'objValue' => true, + 'modifiers' => [], + 'matches' => false, + ], + 'number is cast to string' => [ + 'filterValue' => 1, + 'objValue' => '1', + 'modifiers' => [], + 'matches' => true, + ], + '1 starts with 1' => [ + 'filterValue' => 1, + 'objValue' => 1, + 'modifiers' => [], + 'matches' => true, + ], + '100 starts with 1' => [ + 'filterValue' => '1', + 'objValue' => 100, + 'modifiers' => [], + 'matches' => true, + ], + '100 still starts with 1' => [ + 'filterValue' => 1, + 'objValue' => 100, + 'modifiers' => [], + 'matches' => true, + ], + '100 doesnt start with 0' => [ + 'filterValue' => 0, + 'objValue' => 100, + 'modifiers' => [], + 'matches' => false, + ], + 'SomeValue starts with SomeValue' => [ + 'filterValue' => 'SomeValue', + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => true, + ], + 'SomeValue doesnt start with somevalue' => [ + 'filterValue' => 'somevalue', + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => null, + ], + 'SomeValue doesnt start with meVal' => [ + 'filterValue' => 'meVal', + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => false, + ], + 'SomeValue starts with Some' => [ + 'filterValue' => 'Some', + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => true, + ], + 'SomeValue doesnt with sOmE' => [ + 'filterValue' => 'sOmE', + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => null, + ], + // These will both evaluate to true because the __toString() method just returns the class name. + // We're testing this scenario because ArrayList might contain arbitrary values + [ + 'filterValue' => new ArrayData(['SomeField' => 'some value']), + 'objValue' => new ArrayData(['SomeField' => 'some value']), + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => new ArrayData(['SomeField' => 'SoMe VaLuE']), + 'objValue' => new ArrayData(['SomeField' => 'some value']), + 'modifiers' => [], + 'matches' => true, + ], + // case insensitive + [ + 'filterValue' => 'somevalue', + 'objValue' => 'SomeValue', + 'modifiers' => ['nocase'], + 'matches' => true, + ], + [ + 'filterValue' => 'sOmE', + 'objValue' => 'SomeValue', + 'modifiers' => ['nocase'], + 'matches' => true, + ], + [ + 'filterValue' => 'meval', + 'objValue' => 'SomeValue', + 'modifiers' => ['nocase'], + 'matches' => false, + ], + [ + 'filterValue' => 'different', + 'objValue' => 'SomeValue', + 'modifiers' => ['nocase'], + 'matches' => false, + ], + ]; + // negated + foreach ($scenarios as $scenario) { + $scenario['modifiers'][] = 'not'; + $scenario['matches'] = $scenario['matches'] === null ? null : !$scenario['matches']; + $scenarios[] = $scenario; + } + // explicit case sensitive + foreach ($scenarios as $scenario) { + if (!in_array('nocase', $scenario['modifiers'])) { + $scenario['modifiers'][] = 'case'; + $scenarios[] = $scenario; + } + } + return $scenarios; + } + + /** + * @dataProvider provideMatches + */ + public function testMatches(mixed $filterValue, mixed $matchValue, array $modifiers, ?bool $matches) + { + // Test with explicit default case sensitivity rather than relying on the collation, so that database + // settings don't interfere with the test + foreach ([true, false] as $caseSensitive) { + // Handle cases where the expected value can depend on the default case sensitivity + if ($matches === null) { + $nullMatch = !(in_array('case', $modifiers) ?: $caseSensitive); + if (in_array('not', $modifiers)) { + $nullMatch = !$nullMatch; + } + } + + StartsWithFilter::config()->set('default_case_sensitive', $caseSensitive); + $filter = new StartsWithFilter(); + $filter->setValue($filterValue); + $filter->setModifiers($modifiers); + $this->assertSame($matches ?? $nullMatch, $filter->matches($matchValue)); + } + } +}