From b4463d90509fc1602714f949fbf7589a8b1fbf72 Mon Sep 17 00:00:00 2001 From: Guy Sartorelli <36352093+GuySartorelli@users.noreply.github.com> Date: Tue, 29 Aug 2023 15:40:19 +1200 Subject: [PATCH] NEW Enable ArrayList and EagerLoadedList to use search filters (#10925) --- src/ORM/ArrayList.php | 217 ++++--- src/ORM/DataList.php | 42 +- src/ORM/EagerLoadedList.php | 39 +- src/ORM/Filters/ComparisonFilter.php | 41 ++ src/ORM/Filters/EndsWithFilter.php | 1 + src/ORM/Filters/ExactMatchFilter.php | 46 +- src/ORM/Filters/GreaterThanFilter.php | 4 + src/ORM/Filters/GreaterThanOrEqualFilter.php | 4 + src/ORM/Filters/LessThanFilter.php | 4 + src/ORM/Filters/LessThanOrEqualFilter.php | 4 + src/ORM/Filters/PartialMatchFilter.php | 51 ++ src/ORM/Filters/SearchFilter.php | 54 +- src/ORM/Filters/SearchFilterable.php | 47 ++ src/ORM/Filters/StartsWithFilter.php | 1 + tests/php/ORM/ArrayListTest.php | 554 ++++++++++++++++-- tests/php/ORM/DataListTest.php | 57 ++ tests/php/ORM/EagerLoadedListTest.php | 378 +++++++++++- tests/php/ORM/Filters/EndsWithFilterTest.php | 277 +++++++++ .../php/ORM/Filters/ExactMatchFilterTest.php | 227 +++++++ .../php/ORM/Filters/GreaterThanFilterTest.php | 225 +++++++ .../Filters/GreaterThanOrEqualFilterTest.php | 225 +++++++ tests/php/ORM/Filters/LessThanFilterTest.php | 225 +++++++ .../ORM/Filters/LessThanOrEqualFilterTest.php | 225 +++++++ .../ORM/Filters/PartialMatchFilterTest.php | 277 +++++++++ .../php/ORM/Filters/StartsWithFilterTest.php | 277 +++++++++ 25 files changed, 3287 insertions(+), 215 deletions(-) create mode 100644 src/ORM/Filters/SearchFilterable.php create mode 100644 tests/php/ORM/Filters/EndsWithFilterTest.php create mode 100644 tests/php/ORM/Filters/GreaterThanFilterTest.php create mode 100644 tests/php/ORM/Filters/GreaterThanOrEqualFilterTest.php create mode 100644 tests/php/ORM/Filters/LessThanFilterTest.php create mode 100644 tests/php/ORM/Filters/LessThanOrEqualFilterTest.php create mode 100644 tests/php/ORM/Filters/PartialMatchFilterTest.php create mode 100644 tests/php/ORM/Filters/StartsWithFilterTest.php diff --git a/src/ORM/ArrayList.php b/src/ORM/ArrayList.php index ae0a8e865..f9031c3dd 100644 --- a/src/ORM/ArrayList.php +++ b/src/ORM/ArrayList.php @@ -5,7 +5,12 @@ namespace SilverStripe\ORM; use ArrayIterator; use InvalidArgumentException; use LogicException; +use SilverStripe\Core\ClassInfo; +use SilverStripe\Core\Injector\Injector; use SilverStripe\Dev\Debug; +use SilverStripe\Dev\Deprecation; +use SilverStripe\ORM\Filters\SearchFilter; +use SilverStripe\ORM\Filters\SearchFilterable; use SilverStripe\View\ArrayData; use SilverStripe\View\ViewableData; use Traversable; @@ -26,6 +31,15 @@ use Traversable; */ class ArrayList extends ViewableData implements SS_List, Filterable, Sortable, Limitable { + use SearchFilterable; + + /** + * Whether filter and exclude calls should be case sensitive by default or not. + * This configuration property is here for backwards compatability. + * + * @deprecated 5.1.0 use SearchFilter.default_case_sensitive instead + */ + private static bool $default_case_sensitive = true; /** * Holds the items in the list @@ -368,23 +382,6 @@ class ArrayList extends ViewableData implements SS_List, Filterable, Sortable, L 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. * @@ -594,6 +591,18 @@ class ArrayList extends ViewableData implements SS_List, Filterable, Sortable, L return 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 * @@ -605,31 +614,15 @@ class ArrayList extends ViewableData implements SS_List, Filterable, Sortable, L * @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 = $this->normaliseFilterArgs(...func_get_args()); + return $this->filterOrExclude($filters); } /** @@ -646,28 +639,118 @@ class ArrayList extends ViewableData implements SS_List, Filterable, Sortable, L * @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 = $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 = $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 = $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) { + // Convert null to an empty string for backwards compatability, since nulls are treated specially + // in the ExactMatchFilter + $searchFilter = $this->createSearchFilter($filterKey, $filterValue ?? ''); + + // Apply default case sensitivity for backwards compatability + if (!str_contains($filterKey, ':case') && !str_contains($filterKey, ':nocase')) { + $caseSensitive = Deprecation::withNoReplacement(fn() => static::config()->get('default_case_sensitive')); + if ($caseSensitive && in_array('case', $searchFilter->getSupportedModifiers())) { + $searchFilter->setModifiers($searchFilter->getModifiers() + ['case']); + } elseif (!$caseSensitive && in_array('nocase', $searchFilter->getSupportedModifiers())) { + $searchFilter->setModifiers($searchFilter->getModifiers() + ['nocase']); + } + } + + $searchFilters[$filterKey] = $searchFilter; + } 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; } @@ -755,48 +838,6 @@ class ArrayList extends ViewableData implements SS_List, Filterable, Sortable, L 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 3e784bb6d..1392933c4 100644 --- a/src/ORM/DataList.php +++ b/src/ORM/DataList.php @@ -4,7 +4,6 @@ namespace SilverStripe\ORM; 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 SilverStripe\ORM\Connect\Query; use Traversable; use SilverStripe\ORM\DataQuery; use SilverStripe\ORM\ArrayList; +use SilverStripe\ORM\Filters\SearchFilterable; /** * Implements a "lazy loading" DataObjectSet. @@ -38,6 +38,8 @@ use SilverStripe\ORM\ArrayList; */ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Limitable { + use SearchFilterable; + /** * 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 @@ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Li 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 6f2cf09cc..6464aea80 100644 --- a/src/ORM/EagerLoadedList.php +++ b/src/ORM/EagerLoadedList.php @@ -9,6 +9,7 @@ use SilverStripe\ORM\FieldType\DBField; use BadMethodCallException; use InvalidArgumentException; use LogicException; +use SilverStripe\ORM\Filters\SearchFilterable; use Traversable; /** @@ -23,6 +24,8 @@ use Traversable; */ class EagerLoadedList extends ViewableData implements Relation, SS_List, Filterable, Sortable, Limitable { + use SearchFilterable; + /** * List responsible for instantiating the actual DataObject objects from eager-loaded data */ @@ -545,7 +548,7 @@ class EagerLoadedList extends ViewableData implements Relation, SS_List, Filtera throw new InvalidArgumentException("Incorrect number of arguments passed to $function"); } foreach (array_keys($filter) as $column) { - if (!$this->canFilterBy($column)) { + if (!$this->canFilterBy(explode(':', $column)[0])) { throw new InvalidArgumentException("Can't filter by column '$column'"); } } @@ -561,12 +564,23 @@ class EagerLoadedList extends ViewableData implements Relation, SS_List, Filtera 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) && 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 +596,6 @@ class EagerLoadedList extends ViewableData implements Relation, SS_List, Filtera 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 96f716201..e90d05939 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,46 @@ abstract class ComparisonFilter extends SearchFilter */ abstract protected function getInverseOperator(); + public function matches(mixed $objectValue): bool + { + $negated = in_array('not', $this->getModifiers()); + + // can't just cast to array, because that will convert null into an empty array + $filterValues = $this->getValue(); + if (!is_array($filterValues)) { + $filterValues = [$filterValues]; + } + + // This is essentially a in_array($objectValue, $filterValues) check, with some special handling. + $hasMatch = false; + foreach ($filterValues as $filterValue) { + $doesMatch = $this->match($objectValue, $filterValue); + + // Any match is a match + if ($doesMatch) { + $hasMatch = true; + break; + } + } + + // Respect "not" modifier. + if ($negated) { + $hasMatch = !$hasMatch; + } + + return $hasMatch; + } + + protected function match(mixed $objectValue, mixed $filterValue): bool + { + // We can't add an abstract method because that will mean custom subclasses would need to + // implement this new method which makes it a breaking change - 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 bdcf8c05b..62192cf86 100644 --- a/src/ORM/Filters/EndsWithFilter.php +++ b/src/ORM/Filters/EndsWithFilter.php @@ -13,6 +13,7 @@ namespace SilverStripe\ORM\Filters; */ 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 7b2a47453..5f4d1dfc3 100644 --- a/src/ORM/Filters/ExactMatchFilter.php +++ b/src/ORM/Filters/ExactMatchFilter.php @@ -13,12 +13,54 @@ use SilverStripe\ORM\DataList; /** * 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 $objectValue): bool + { + $isCaseSensitive = $this->getCaseSensitive(); + if ($isCaseSensitive === null) { + $isCaseSensitive = $this->getCaseSensitiveByCollation(); + } + $caseSensitive = $isCaseSensitive ? '' : 'i'; + $negated = in_array('not', $this->getModifiers()); + + // Can't just cast to array, because that will convert null into an empty array + $filterValues = $this->getValue(); + if (!is_array($filterValues)) { + $filterValues = [$filterValues]; + } + + // This is essentially a in_array($objectValue, $filterValues) check, with some special handling. + $hasMatch = false; + foreach ($filterValues as $filterValue) { + if (is_string($filterValue) && is_string($objectValue)) { + $regexSafeFilterValue = preg_quote($filterValue, '/'); + $doesMatch = preg_match('/^' . $regexSafeFilterValue . '$/u' . $caseSensitive, $objectValue); + } elseif ($filterValue === null || $objectValue === null) { + $doesMatch = $filterValue === $objectValue; + } else { + // case sensitivity is meaningless if one or both values aren't strings, + // so fall back to a loose equivalency comparison. + $doesMatch = $filterValue == $objectValue; + } + // Any match is a match + if ($doesMatch) { + $hasMatch = true; + break; + } + } + + // Respect "not" modifier. + if ($negated) { + $hasMatch = !$hasMatch; + } + + return $hasMatch; + } + public function getSupportedModifiers() { return ['not', 'nocase', 'case']; @@ -81,7 +123,7 @@ class ExactMatchFilter extends SearchFilter } $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 9b2460e2f..4e5eb0090 100755 --- a/src/ORM/Filters/GreaterThanFilter.php +++ b/src/ORM/Filters/GreaterThanFilter.php @@ -10,6 +10,10 @@ namespace SilverStripe\ORM\Filters; */ 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 fdbac8760..9da4dfe8a 100755 --- a/src/ORM/Filters/GreaterThanOrEqualFilter.php +++ b/src/ORM/Filters/GreaterThanOrEqualFilter.php @@ -10,6 +10,10 @@ namespace SilverStripe\ORM\Filters; */ 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 bd3118451..7852ee3e5 100755 --- a/src/ORM/Filters/LessThanFilter.php +++ b/src/ORM/Filters/LessThanFilter.php @@ -10,6 +10,10 @@ namespace SilverStripe\ORM\Filters; */ 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 8f769eac9..b185b8b35 100755 --- a/src/ORM/Filters/LessThanOrEqualFilter.php +++ b/src/ORM/Filters/LessThanOrEqualFilter.php @@ -10,6 +10,10 @@ namespace SilverStripe\ORM\Filters; */ 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 a93f45776..a2d3159ee 100644 --- a/src/ORM/Filters/PartialMatchFilter.php +++ b/src/ORM/Filters/PartialMatchFilter.php @@ -11,6 +11,8 @@ use InvalidArgumentException; */ class PartialMatchFilter extends SearchFilter { + protected static $matchesStartsWith = false; + protected static $matchesEndsWith = false; public function getSupportedModifiers() { @@ -28,6 +30,55 @@ class PartialMatchFilter extends SearchFilter return "%$value%"; } + public function matches(mixed $objectValue): bool + { + $isCaseSensitive = $this->getCaseSensitive(); + if ($isCaseSensitive === null) { + $isCaseSensitive = $this->getCaseSensitiveByCollation(); + } + $caseSensitive = $isCaseSensitive ? '' : 'i'; + $negated = in_array('not', $this->getModifiers()); + $objectValueString = (string) $objectValue; + + // can't just cast to array, because that will convert null into an empty array + $filterValues = $this->getValue(); + if (!is_array($filterValues)) { + $filterValues = [$filterValues]; + } + + // This is essentially a in_array($objectValue, $filterValues) check, with some special handling. + $hasMatch = false; + foreach ($filterValues as $filterValue) { + if (is_bool($objectValue)) { + 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 = $objectValue == $filterValue; + } + } else { + $filterValue = (string) $filterValue; + $regexSafeFilterValue = preg_quote($filterValue, '/'); + $start = static::$matchesStartsWith ? '^' : ''; + $end = static::$matchesEndsWith ? '$' : ''; + $doesMatch = preg_match('/' . $start . $regexSafeFilterValue . $end . '/u' . $caseSensitive, $objectValueString); + } + // Any match is a match + if ($doesMatch) { + $hasMatch = true; + break; + } + } + + // Respect "not" modifier. + if ($negated) { + $hasMatch = !$hasMatch; + } + + return $hasMatch; + } + /** * Apply filter criteria to a SQL query. * diff --git a/src/ORM/Filters/SearchFilter.php b/src/ORM/Filters/SearchFilter.php index 59d9f1751..204a11159 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 @@ use SilverStripe\ORM\FieldType\DBField; */ 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,19 @@ abstract class SearchFilter ->groupby("\"{$baseTable}\".\"ID\""); } + /** + * Check whether this filter matches against a value. + */ + public function matches(mixed $objectValue): bool + { + // We can't add an abstract method because that will mean custom subclasses would need to + // implement this new method which makes it a breaking change - 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 +465,7 @@ abstract class SearchFilter /** * 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 +475,27 @@ abstract class SearchFilter } 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 getCaseSensitiveByCollation(): ?bool + { + if (!self::$caseSensitiveByCollation) { + if (!DB::is_active()) { + return null; + } + self::$caseSensitiveByCollation = DB::query("SELECT 'CASE' = 'case'")->record() === 0; + } + + return self::$caseSensitiveByCollation; } } diff --git a/src/ORM/Filters/SearchFilterable.php b/src/ORM/Filters/SearchFilterable.php new file mode 100644 index 000000000..c736eee5b --- /dev/null +++ b/src/ORM/Filters/SearchFilterable.php @@ -0,0 +1,47 @@ +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 + return Injector::inst()->create($filterServiceName, $fieldName, $value, $modifiers); + } +} diff --git a/src/ORM/Filters/StartsWithFilter.php b/src/ORM/Filters/StartsWithFilter.php index 7c9fb1071..fa8f7a7fb 100644 --- a/src/ORM/Filters/StartsWithFilter.php +++ b/src/ORM/Filters/StartsWithFilter.php @@ -13,6 +13,7 @@ namespace SilverStripe\ORM\Filters; */ class StartsWithFilter extends PartialMatchFilter { + protected static $matchesStartsWith = true; protected function getMatchPattern($value) { diff --git a/tests/php/ORM/ArrayListTest.php b/tests/php/ORM/ArrayListTest.php index dc8a1ef05..4088e9d21 100644 --- a/tests/php/ORM/ArrayListTest.php +++ b/tests/php/ORM/ArrayListTest.php @@ -343,6 +343,59 @@ class ArrayListTest extends SapphireTest ); } + public function provideFindWithSearchfilters() + { + $objects = $this->getFilterWithSearchfiltersObjects(); + return [ + // test a couple of search filters + // don't need to be as explicit as the filter tests, just check the syntax works + 'exact match not case sensitive' => [ + 'args' => ['NoCase:nocase', 'case sensitive'], + 'objects' => $objects, + 'expected' => $objects[0], + ], + 'startswith match' => [ + 'args' => ['StartsWithTest:StartsWith', 'test'], + 'objects' => $objects, + 'expected' => $objects[3], + ], + 'startswith match no case' => [ + 'args' => ['StartsWithTest:StartsWith:nocase', 'test'], + 'objects' => $objects, + 'expected' => $objects[0], + ], + 'startswith match negated' => [ + 'args' => ['StartsWithTest:StartsWith:not', 'Test'], + 'objects' => $objects, + 'expected' => $objects[1], + ], + 'lessthan match' => [ + 'args' => ['GreaterThan100:LessThan', '100'], + 'objects' => $objects, + 'expected' => $objects[2], + ], + 'nomatch greaterthan' => [ + 'args' => ['LessThan100:GreaterThan', 1000], + 'objects' => $objects, + 'expected' => null, + ], + 'nomatch lessthan' => [ + 'args' => ['LessThan100:LessThan:not', 1000], + 'objects' => $objects, + 'expected' => null, + ], + ]; + } + + /** + * @dataProvider provideFindWithSearchfilters + */ + public function testFindWithSearchfilters(array $args, array $objects, object|array|null $expected) + { + $list = new ArrayList($objects); + $this->assertEquals($expected, $list->find(...$args)); + } + public function testFind() { $list = new ArrayList( @@ -917,73 +970,354 @@ class ArrayListTest extends SapphireTest $this->assertEquals($expected, $list->toArray(), 'List should only contain Steve and Steve and Clair'); } - public function testFilterAny() + private function getFilterWithSearchfiltersObjects() { + return [ + [ + 'ID' => 1, + 'Name' => 'Steve', + 'Age' => 21, + 'Title' => 'First Object', + 'NoCase' => 'CaSe SeNsItIvE', + 'CaseSensitive' => 'Case Sensitive', + 'StartsWithTest' => 'Test Value', + 'GreaterThan100' => 300, + 'LessThan100' => 50, + 'SomeField' => 'Some Value', + ], + [ + 'ID' => 2, + 'Name' => 'Steve', + 'Age' => 18, + 'Title' => 'Second Object', + 'NoCase' => 'case sensitive', + 'CaseSensitive' => 'case sensitive', + 'StartsWithTest' => 'Not Starts With Test', + 'GreaterThan100' => 101, + 'LessThan100' => 99, + 'SomeField' => 'Another Value', + ], + [ + 'ID' => 3, + 'Name' => 'Steve', + 'Age' => 43, + 'Title' => 'Third Object', + 'NoCase' => null, + 'CaseSensitive' => '', + 'StartsWithTest' => 'Does not start with test', + 'GreaterThan100' => 99, + 'LessThan100' => 99, + 'SomeField' => 'Some Value', + ], + [ + 'ID' => 4, + 'Name' => 'Clair', + 'Age' => 21, + 'Title' => 'Fourth Object', + 'StartsWithTest' => 'test value, but lower case', + 'GreaterThan100' => 100, + 'LessThan100' => 100, + 'SomeField' => 'some value', + ], + [ + 'ID' => 5, + 'Name' => 'Clair', + 'Age' => 52, + 'Title' => '', + ], + ]; + } + public function provideFilterWithSearchfilters() + { + // Note that search filter tests here are to test syntax and to ensure all supported search filters + // work with arraylist - but we don't need to test every possible edge case here, + // we can rely on individual searchfilter unit tests for many edge cases + $objects = $this->getFilterWithSearchfiltersObjects(); + return [ + // exact match filter tests + 'exact match - negate' => [ + 'args' => ['Title:not', 'First Object'], + 'objects' => $objects, + 'expected' => [$objects[1], $objects[2], $objects[3], $objects[4]], + ], + 'exact match - negate two different ways' => [ + 'args' => [[ + 'Title:not' => 'First Object', + 'Title:ExactMatch:not' => 'Third Object', + ]], + 'objects' => $objects, + 'expected' => [$objects[1], $objects[3], $objects[4]], + ], + 'exact match negated - nothing gets filtered out' => [ + 'filter' => ['Title:not', 'No object has this title - we should have all objects'], + 'objects' => $objects, + 'expected' => $objects, + ], + 'exact match negated against null - only last item gets filtered out' => [ + 'args' => ['SomeField:not', null], + 'objects' => $objects, + 'expected' => [$objects[0], $objects[1], $objects[2], $objects[3]], + ], + 'exact match with a few items' => [ + 'args' => ['Title', ['First Object', 'Second Object', 'Third Object']], + 'objects' => $objects, + 'expected' => [$objects[0], $objects[1], $objects[2]], + ], + 'negate the above test' => [ + 'args' => ['Title:not', ['First Object', 'Second Object', 'Third Object']], + 'objects' => $objects, + 'expected' => [$objects[3], $objects[4]], + ], + // case sensitivity checks + 'exact match case sensitive' => [ + 'args' => [['NoCase' => 'case sensitive']], + 'objects' => $objects, + 'expected' => [$objects[1]], + ], + 'exact match case insensitive' => [ + 'args' => ['NoCase:nocase', 'case sensitive'], + 'objects' => $objects, + 'expected' => [$objects[0], $objects[1]], + ], + 'exact match mixed case filters' => [ + 'args' => [[ + 'NoCase:nocase' => 'case sensitive', + 'CaseSensitive' => 'case sensitive', + ]], + 'objects' => $objects, + 'expected' => [$objects[1]], + ], + // explicit exact match + 'exact match explicit' => [ + 'args' => ['Title:ExactMatch', 'Third Object'], + 'objects' => $objects, + 'expected' => [$objects[2]], + ], + 'exact match explicit with modifier' => [ + 'args' => [['Title:ExactMatch:nocase' => 'third object']], + 'objects' => $objects, + 'expected' => [$objects[2]], + ], + // partialmatch filter + 'partial match' => [ + 'args' => ['StartsWithTest:PartialMatch', 'start'], + 'objects' => $objects, + 'expected' => [$objects[2]], + ], + 'partial match with modifier' => [ + 'args' => [['StartsWithTest:PartialMatch:nocase' => 'start']], + 'objects' => $objects, + 'expected' => [$objects[1], $objects[2]], + ], + // greaterthan filter + 'greaterthan match' => [ + 'args' => ['GreaterThan100:GreaterThan', 100], + 'objects' => $objects, + 'expected' => [$objects[0], $objects[1]], + ], + 'greaterthan match with modifier' => [ + 'args' => [['GreaterThan100:GreaterThan:not' => 100]], + 'objects' => $objects, + 'expected' => [$objects[2], $objects[3], $objects[4]], + ], + // greaterthanorequal filter + 'greaterthanorequal match' => [ + 'args' => ['GreaterThan100:GreaterThanOrEqual', 100], + 'objects' => $objects, + 'expected' => [$objects[0], $objects[1], $objects[3]], + ], + 'greaterthanorequal match with modifier' => [ + 'args' => [['GreaterThan100:GreaterThanOrEqual:not' => 100]], + 'objects' => $objects, + 'expected' => [$objects[2], $objects[4]], + ], + // lessthan filter + 'lessthan match' => [ + 'args' => ['LessThan100:LessThan', 100], + 'objects' => $objects, + 'expected' => [$objects[0], $objects[1], $objects[2], $objects[4]], + ], + 'lessthan match with modifier' => [ + 'args' => [['LessThan100:LessThan:not' => 100]], + 'objects' => $objects, + 'expected' => [$objects[3]], + ], + // lessthanorequal filter + 'lessthanorequal match' => [ + 'args' => ['LessThan100:LessThanOrEqual', 99], + 'objects' => $objects, + 'expected' => [$objects[0], $objects[1], $objects[2], $objects[4]], + ], + 'lessthanorequal match with modifier' => [ + 'args' => [['LessThan100:LessThanOrEqual:not' => 99]], + 'objects' => $objects, + 'expected' => [$objects[3]], + ], + // various more complex filters/combinations and extra scenarios + 'complex1' => [ + 'args' => [[ + 'NoCase:nocase' => 'CASE SENSITIVE', + 'StartsWithTest:StartsWith' => 'Not', + ]], + 'objects' => $objects, + 'expected' => [$objects[1]], + ], + 'complex2' => [ + 'args' => [[ + 'NoCase:case' => 'CASE SENSITIVE', + 'StartsWithTest:StartsWith' => 'Not', + ]], + 'objects' => $objects, + 'expected' => [], + ], + 'complex3' => [ + 'args' => [[ + 'LessThan100:LessThan' => 100, + 'GreaterThan100:GreaterThan:not' => 100, + ]], + 'objects' => $objects, + 'expected' => [$objects[2], $objects[4]], + ], + 'complex4' => [ + 'args' => [[ + 'LessThan100:LessThan' => 1, + 'GreaterThan100:GreaterThan' => 100, + ]], + 'objects' => $objects, + 'expected' => [], + ], + ]; + } + + /** + * @dataProvider provideFilterWithSearchfilters + */ + public function testFilterWithSearchfilters(array $args, array $objects, array $expected) + { + $list = new ArrayList($objects); + $list = $list->filter(...$args); + $this->assertEquals(array_column($expected, 'ID'), $list->column('ID')); + } + + public function provideFilterAnyWithSearchfilters() + { + $objects = $this->getFilterWithSearchfiltersObjects(); + return [ + // test a couple of search filters + // don't need to be as explicit as the filter tests, just check the syntax works + 'partial match' => [ + 'args' => ['StartsWithTest:PartialMatch', 'start'], + 'objects' => $objects, + 'expected' => [$objects[2]], + ], + 'partial match with modifier' => [ + 'args' => ['StartsWithTest:PartialMatch:nocase', 'start'], + 'objects' => $objects, + 'expected' => [$objects[1], $objects[2]], + ], + 'greaterthan match' => [ + 'args' => ['GreaterThan100:GreaterThan', 100], + 'objects' => $objects, + 'expected' => [$objects[0], $objects[1]], + ], + 'greaterthan match with modifier' => [ + 'args' => ['GreaterThan100:GreaterThan:not', 100], + 'objects' => $objects, + 'expected' => [$objects[2], $objects[3], $objects[4]], + ], + 'multiple filters match' => [ + 'args' => [[ + 'StartsWithTest:PartialMatch:nocase' => 'start', + 'Age:GreaterThanOrEqual' => 43, + ]], + 'objects' => $objects, + 'expected' => [$objects[1], $objects[2], $objects[4]], + ], + 'partial match with a few items' => [ + 'args' => ['Title:PartialMatch', ['First Object', 'Second Object', 'Third Object']], + 'objects' => $objects, + 'expected' => [$objects[0], $objects[1], $objects[2]], + ], + 'negate the above test' => [ + 'args' => ['Title:PartialMatch:not', ['First Object', 'Second Object', 'Third Object']], + 'objects' => $objects, + 'expected' => [$objects[3], $objects[4]], + ], + ]; + } + + /** + * @dataProvider provideFilterAnyWithSearchfilters + */ + public function testFilterAnyWithSearchfilters(array $args, array $objects, array $expected) + { + $list = new ArrayList($objects); + $list = $list->filterAny(...$args); + $this->assertEquals(array_column($expected, 'ID'), $list->column('ID')); + } + + public function provideFilterAny() + { $list = new ArrayList( [ - $steve = ['Name' => 'Steve', 'ID' => 1, 'Age' => 21], - $bob = ['Name' => 'Bob', 'ID' => 2, 'Age' => 18], - $clair = ['Name' => 'Clair', 'ID' => 3, 'Age' => 21], - $phil = ['Name' => 'Phil', 'ID' => 4, 'Age' => 21], - $oscar = ['Name' => 'Oscar', 'ID' => 5, 'Age' => 52], - $mike = ['Name' => 'Mike', 'ID' => 6, 'Age' => 43], + $steve = ['Name' => 'Steve', 'ID' => 1, 'Age' => 21], + $bob = ['Name' => 'Bob', 'ID' => 2, 'Age' => 18], + $clair = ['Name' => 'Clair', 'ID' => 3, 'Age' => 21], + $phil = ['Name' => 'Phil', 'ID' => 4, 'Age' => 21], + $oscar = ['Name' => 'Oscar', 'ID' => 5, 'Age' => 52], + $mike = ['Name' => 'Mike', 'ID' => 6, 'Age' => 43], ] ); + return [ + [ + 'list' => $list, + 'args' => ['Name', 'Bob'], + 'contains' => [$bob], + ], + [ + 'list' => $list, + 'args' => ['Name', ['Aziz', 'Bob']], + 'contains' => [$bob], + ], + [ + 'list' => $list, + 'args' => ['Name', ['Steve', 'Bob']], + 'contains' => [$steve, $bob], + ], + [ + 'list' => $list, + 'args' => [['Name' => 'Bob', 'Age' => 21]], + 'contains' => [$bob, $steve, $clair, $phil], + ], + [ + 'list' => $list, + 'args' => [['Name' => 'Bob', 'Age' => [21, 43]]], + 'contains' => [$bob, $steve, $clair, $mike, $phil], + ], + [ + 'list' => $list, + 'args' => [['Name' => ['Bob', 'Phil'], 'Age' => [21, 43]]], + 'contains' => [$bob, $steve, $clair, $mike, $phil], + ], + [ + 'list' => $list, + 'args' => [['Name' => ['Bob', 'Nobody'], 'Age' => [21, 43]]], + 'contains' => [$bob, $steve, $clair, $mike, $phil], + ], + ]; + } - // only bob in the list - //$list = $list->filterAny('Name', 'bob'); - $filteredList = $list->filterAny('Name', 'Bob')->toArray(); - $this->assertCount(1, $filteredList); - $this->assertContains($bob, $filteredList); - - // azis or bob in the list - //$list = $list->filterAny('Name', ['aziz', 'bob']); - $filteredList = $list->filterAny('Name', ['Aziz', 'Bob'])->toArray(); - $this->assertCount(1, $filteredList); - $this->assertContains($bob, $filteredList); - - $filteredList = $list->filterAny('Name', ['Steve', 'Bob'])->toArray(); - $this->assertCount(2, $filteredList); - $this->assertContains($steve, $filteredList); - $this->assertContains($bob, $filteredList); - - // bob or anyone aged 21 in the list - //$list = $list->filterAny(['Name'=>'bob, 'Age'=>21]); - $filteredList = $list->filterAny(['Name' => 'Bob', 'Age' => 21])->toArray(); - $this->assertCount(4, $filteredList); - $this->assertContains($bob, $filteredList); - $this->assertContains($steve, $filteredList); - $this->assertContains($clair, $filteredList); - $this->assertContains($phil, $filteredList); - - // bob or anyone aged 21 or 43 in the list - // $list = $list->filterAny(['Name'=>'bob, 'Age'=>[21, 43]]); - $filteredList = $list->filterAny(['Name' => 'Bob', 'Age' => [21, 43]])->toArray(); - $this->assertCount(5, $filteredList); - $this->assertContains($bob, $filteredList); - $this->assertContains($steve, $filteredList); - $this->assertContains($clair, $filteredList); - $this->assertContains($mike, $filteredList); - $this->assertContains($phil, $filteredList); - - // all bobs, phils or anyone aged 21 or 43 in the list - //$list = $list->filterAny(['Name'=>['bob','phil'], 'Age'=>[21, 43]]); - $filteredList = $list->filterAny(['Name' => ['Bob', 'Phil'], 'Age' => [21, 43]])->toArray(); - $this->assertCount(5, $filteredList); - $this->assertContains($bob, $filteredList); - $this->assertContains($steve, $filteredList); - $this->assertContains($clair, $filteredList); - $this->assertContains($mike, $filteredList); - $this->assertContains($phil, $filteredList); - - $filteredList = $list->filterAny(['Name' => ['Bob', 'Nobody'], 'Age' => [21, 43]])->toArray(); - $this->assertCount(5, $filteredList); - $this->assertContains($bob, $filteredList); - $this->assertContains($steve, $filteredList); - $this->assertContains($clair, $filteredList); - $this->assertContains($mike, $filteredList); - $this->assertContains($phil, $filteredList); + /** + * @dataProvider provideFilterAny + */ + public function testFilterAny(ArrayList $list, array $args, array $contains) + { + $filteredList = $list->filterAny(...$args)->toArray(); + $this->assertCount(count($contains), $filteredList); + foreach ($contains as $item) { + $this->assertContains($item, $filteredList); + } } /** @@ -1195,6 +1529,106 @@ class ArrayListTest extends SapphireTest $this->assertEquals($expected, $list->toArray()); } + public function provideExcludeWithSearchfilters() + { + // If it's included in the filter test, then it's excluded in the exclude test, + // so we can just use the same scenarios and reverse the expected results. + $objects = $this->getFilterWithSearchfiltersObjects(); + $scenarios = $this->provideFilterWithSearchfilters(); + foreach ($scenarios as $name => $scenario) { + $kept = []; + $excluded = []; + foreach ($scenario['expected'] as $item) { + $kept[] = $item['ID']; + } + foreach ($objects as $item) { + if (!in_array($item['ID'], $kept)) { + $excluded[] = $item; + } + } + $scenarios[$name]['expected'] = $excluded; + } + return $scenarios; + } + + /** + * @dataProvider provideExcludeWithSearchfilters + */ + public function testExcludeWithSearchfilters(array $args, array $objects, array $expected) + { + $list = new ArrayList($objects); + $list = $list->exclude(...$args); + $this->assertEquals($expected, $list->toArray()); + } + + public function provideExcludeAnyWithSearchfilters() + { + // If it's included in the filterAny test, then it's excluded in the excludeAny test, + // so we can just use the same scenarios and reverse the expected results. + $objects = $this->getFilterWithSearchfiltersObjects(); + $scenarios = $this->provideFilterAnyWithSearchfilters(); + foreach ($scenarios as $name => $scenario) { + $kept = []; + $excluded = []; + foreach ($scenario['expected'] as $item) { + $kept[] = $item['ID']; + } + foreach ($objects as $item) { + if (!in_array($item['ID'], $kept)) { + $excluded[] = $item; + } + } + $scenarios[$name]['expected'] = $excluded; + } + return $scenarios; + } + + /** + * @dataProvider provideExcludeAnyWithSearchfilters + */ + public function testExcludeAnyWithSearchfilters(array $args, array $objects, array $expected) + { + $list = new ArrayList($objects); + $list = $list->excludeAny(...$args); + $this->assertEquals($expected, $list->toArray()); + } + + public function provideExcludeAny() + { + // If it's included in the filterAny test, then it's excluded in the excludeAny test, + // so we can just use the same scenarios and reverse the expected results. + $scenarios = $this->provideFilterAny(); + foreach ($scenarios as $name => $scenario) { + $kept = []; + $excluded = []; + /** @var array $item */ + foreach ($scenario['contains'] as $item) { + $kept[] = $item['ID']; + } + /** @var ArrayData $item */ + foreach ($scenario['list'] as $item) { + $itemAsArray = $item->toMap(); + if (!in_array($itemAsArray['ID'], $kept)) { + $excluded[] = $itemAsArray; + } + } + $scenarios[$name]['contains'] = $excluded; + } + return $scenarios; + } + + /** + * @dataProvider provideExcludeAny + */ + public function testExcludeAny(ArrayList $list, array $args, array $contains) + { + $filteredList = $list->excludeAny(...$args)->toArray(); + $this->assertCount(count($contains), $filteredList); + foreach ($contains as $item) { + $this->assertContains($item, $filteredList); + } + } + public function testCanFilterBy() { $list = new ArrayList( diff --git a/tests/php/ORM/DataListTest.php b/tests/php/ORM/DataListTest.php index a582517bc..d046c6cba 100755 --- a/tests/php/ORM/DataListTest.php +++ b/tests/php/ORM/DataListTest.php @@ -31,6 +31,7 @@ use SilverStripe\ORM\Connect\DatabaseException; use SilverStripe\ORM\FieldType\DBPrimaryKey; use SilverStripe\ORM\FieldType\DBText; use SilverStripe\ORM\FieldType\DBVarchar; +use SilverStripe\ORM\Filters\SearchFilter; use SilverStripe\ORM\Tests\DataObjectTest\RelationChildFirst; use SilverStripe\ORM\Tests\DataObjectTest\RelationChildSecond; @@ -88,6 +89,62 @@ class DataListTest extends SapphireTest $this->assertEquals(2, count($list ?? [])); } + public function provideDefaultCaseSensitivity() + { + return [ + [ + 'caseSensitive' => true, + 'filter' => ['FirstName' => 'captain'], + 'expectedCount' => 0, + ], + [ + 'caseSensitive' => false, + 'filter' => ['FirstName' => 'captain'], + 'expectedCount' => 1, + ], + [ + 'caseSensitive' => true, + 'filter' => ['FirstName:PartialMatch' => 'captain'], + 'expectedCount' => 0, + ], + [ + 'caseSensitive' => false, + 'filter' => ['FirstName:PartialMatch' => 'captain'], + 'expectedCount' => 2, + ], + [ + 'caseSensitive' => true, + 'filter' => ['FirstName:StartsWith' => 'captain'], + 'expectedCount' => 0, + ], + [ + 'caseSensitive' => false, + 'filter' => ['FirstName:StartsWith' => 'captain'], + 'expectedCount' => 2, + ], + [ + 'caseSensitive' => true, + 'filter' => ['Surname:EndsWith' => 'Keeper'], + 'expectedCount' => 0, + ], + [ + 'caseSensitive' => false, + 'filter' => ['Surname:EndsWith' => 'Keeper'], + 'expectedCount' => 1, + ], + ]; + } + + /** + * @dataProvider provideDefaultCaseSensitivity + */ + public function testDefaultCaseSensitivity(bool $caseSensitive, array $filter, int $expectedCount) + { + SearchFilter::config()->set('default_case_sensitive', $caseSensitive); + $list = Player::get()->filter($filter); + $this->assertCount($expectedCount, $list); + } + public function testCount() { $list = new DataList(Team::class); diff --git a/tests/php/ORM/EagerLoadedListTest.php b/tests/php/ORM/EagerLoadedListTest.php index da72a706c..c9b717d06 100644 --- a/tests/php/ORM/EagerLoadedListTest.php +++ b/tests/php/ORM/EagerLoadedListTest.php @@ -49,16 +49,19 @@ class EagerLoadedListTest extends SapphireTest 'ID' => 1, 'Name' => 'test obj 1', 'Created' => '2013-01-01 00:00:00', + 'SomeField' => 'VaLuE', ], [ 'ID' => 2, 'Name' => 'test obj 2', 'Created' => '2023-01-01 00:00:00', + 'SomeField' => 'value', ], [ 'ID' => 3, 'Name' => 'test obj 3', 'Created' => '2023-01-01 00:00:00', + 'SomeField' => null, ], ]; } @@ -316,6 +319,7 @@ class EagerLoadedListTest extends SapphireTest /** * @dataProvider provideFilter + * @dataProvider provideFilterWithSearchFilters */ public function testFilter( string $dataListClass, @@ -417,7 +421,6 @@ class EagerLoadedListTest extends SapphireTest 'dataListClass' => ManyManyThroughList::class, 'eagerloadedDataClass' => ValidatedObject::class, $rows, - // Filter by ID is handled slightly differently than other fields 'filter' => [ 'ID' => [1, 2], ], @@ -426,6 +429,284 @@ class EagerLoadedListTest extends SapphireTest ]; } + public function provideFilterWithSearchFilters() + { + $rows = $this->getBasicRecordRows(); + $scenarios = [ + // exact match filter tests + 'exact match - negate' => [ + 'filter' => ['Name:not' => 'test obj 1'], + 'expected' => [2, 3], + ], + 'exact match - negate two different ways' => [ + 'filter' => [ + 'Name:not' => 'test obj 1', + 'Name:ExactMatch:not' => 'test obj 3', + ], + 'expected' => [2], + ], + 'exact match negated - nothing gets filtered out' => [ + 'filter' => ['Name:not' => 'No row has this name - we should have all rows'], + 'expected' => array_column($rows, 'ID'), + ], + 'exact match negated against null - only last item gets filtered out' => [ + 'filter' => ['SomeField:not' => null], + 'expected' => [1, 2], + ], + 'exact match negated with a few items' => [ + 'filter' => [ + 'Name:not' => ['test obj 1', 'test obj 3', 'not there'], + ], + 'expected' => [2], + ], + // case sensitivity checks + 'exact match case sensitive' => [ + 'filter' => ['SomeField:case' => 'value'], + 'expected' => [2], + ], + 'exact match case insensitive' => [ + 'filter' => ['SomeField:nocase' => 'value'], + 'expected' => [1, 2], + ], + // explicit exact match + 'exact match explicit' => [ + 'filter' => ['Name:ExactMatch' => 'test obj 2'], + 'expected' => [2], + ], + 'exact match explicit with modifier' => [ + 'filter' => ['Name:ExactMatch:nocase' => 'Test Obj 2'], + 'expected' => [2], + ], + // partialmatch filter + 'partial match' => [ + 'filter' => ['SomeField:PartialMatch:case' => 'alu'], + 'expected' => [2], + ], + 'partial match with modifier' => [ + 'filter' => ['SomeField:PartialMatch:nocase' => 'alu'], + 'expected' => [1, 2], + ], + // greaterthan filter + 'greaterthan match' => [ + 'filter' => ['ID:GreaterThan' => 2], + 'expected' => [3], + ], + 'greaterthan match with modifier' => [ + 'filter' => ['ID:GreaterThan:not' => 2], + 'expected' => [1, 2], + ], + // greaterthanorequal filter + 'greaterthanorequal match' => [ + 'filter' => ['ID:GreaterThanOrEqual' => 2], + 'expected' => [2, 3], + ], + 'greaterthanorequal match with modifier' => [ + 'filter' => ['ID:GreaterThanOrEqual:not' => 2], + 'expected' => [1], + ], + // lessthan filter + 'lessthan match' => [ + 'filter' => ['ID:LessThan' => 2], + 'expected' => [1], + ], + 'lessthan match with modifier' => [ + 'filter' => ['ID:LessThan:not' => 2], + 'expected' => [2, 3], + ], + // lessthanorequal filter + 'lessthanorequal match' => [ + 'filter' => ['ID:LessThanOrEqual' => 2], + 'expected' => [1, 2], + ], + 'lessthanorequal match with modifier' => [ + 'filter' => ['ID:LessThanOrEqual:not' => 2], + 'expected' => [3], + ], + // various more complex filters/combinations and extra scenarios + 'complex1' => [ + 'filter' => [ + 'SomeField:nocase' => 'value', + 'Name:StartsWith' => 'test', + ], + 'expected' => [1, 2], + ], + 'complex2' => [ + 'filter' => [ + 'ID:LessThan' => 3, + 'ID:GreaterThan:not' => 1, + ], + 'expected' => [1], + ], + 'complex3' => [ + 'filter' => [ + 'ID:LessThan' => 3, + 'ID:GreaterThan' => 1, + ], + 'expected' => [2], + ], + ]; + // No need to vary these between scenarios, we're just checking search filter + // syntax works as expected. + foreach (array_keys($scenarios) as $key) { + array_unshift($scenarios[$key], $rows); + array_unshift($scenarios[$key], ValidatedObject::class); + array_unshift($scenarios[$key], DataList::class); + } + return $scenarios; + } + + /** + * @dataProvider provideFilterAnyWithSearchFilters + */ + public function testFilterAnyWithSearchfilters(array $filter, array $expectedIDs): void + { + $rows = $this->getBasicRecordRows(); + $list = new EagerLoadedList(ValidatedObject::class, DataList::class); + foreach ($rows as $row) { + $list->addRow($row); + } + $filteredList = $list->filterAny($filter); + + // Validate that the unfiltered list still has all records, and the filtered list has the expected amount + $this->assertCount(count($rows), $list); + $this->assertCount(count($expectedIDs), $filteredList); + + // Validate that the filtered list has the CORRECT records + $this->iterate($list, $rows, array_column($rows, 'ID')); + } + + public function provideFilterAnyWithSearchFilters() + { + return [ + // test a couple of search filters + // don't need to be as explicit as the filter tests, just check the syntax works + 'partial match' => [ + 'filter' => ['Name:PartialMatch' => 'test obj'], + 'expected' => [1, 2, 3], + ], + 'partial match2' => [ + 'filter' => ['Name:PartialMatch' => 3], + 'expected' => [3], + ], + 'partial match with modifier' => [ + 'filter' => ['SomeField:PartialMatch:nocase' => 'alu'], + 'expected' => [1, 2], + ], + 'greaterthan match' => [ + 'filter' => ['ID:GreaterThan'=> 2], + 'expected' => [3], + ], + 'greaterthan match with modifier' => [ + 'filter' => ['ID:GreaterThan:not' => 2], + 'expected' => [1, 2], + ], + 'multiple filters match' => [ + 'filter' => [ + 'SomeField:PartialMatch:case' => 'val', + 'ID:GreaterThanOrEqual' => 2, + ], + 'expected' => [2, 3], + ], + 'exact match with a few items' => [ + 'filter' => ['Name:ExactMatch' => ['test obj 1', 'test obj 2']], + 'expected' => [1, 2], + ], + 'negate the above test' => [ + 'filter' => ['Name:ExactMatch:not' => ['test obj 1', 'test obj 2']], + 'expected' => [3], + ], + ]; + } + + public function provideExcludeWithSearchfilters() + { + // If it's included in the filter test, then it's excluded in the exclude test, + // so we can just use the same scenarios and reverse the expected results. + $rows = $this->getBasicRecordRows(); + $scenarios = $this->provideFilterWithSearchfilters(); + foreach ($scenarios as $name => $scenario) { + $kept = []; + $excluded = []; + foreach ($scenario['expected'] as $id) { + $kept[] = $id; + } + foreach ($rows as $row) { + if (!in_array($row['ID'], $kept)) { + $excluded[] = $row['ID']; + } + } + $scenarios[$name]['expected'] = $excluded; + + // Remove args we won't be using for this test + foreach (['dataListClass', 'eagerloadedDataClass', 'rows'] as $removeFromScenario) { + array_shift($scenarios[$name]); + } + } + return $scenarios; + } + + /** + * @dataProvider provideExcludeWithSearchfilters + */ + public function testExcludeWithSearchfilters(array $filter, array $expectedIDs): void + { + $rows = $this->getBasicRecordRows(); + $list = new EagerLoadedList(ValidatedObject::class, DataList::class); + foreach ($rows as $row) { + $list->addRow($row); + } + $filteredList = $list->exclude($filter); + + // Validate that the unfiltered list still has all records, and the filtered list has the expected amount + $this->assertCount(count($rows), $list); + $this->assertCount(count($expectedIDs), $filteredList); + + // Validate that the filtered list has the CORRECT records + $this->iterate($list, $rows, array_column($rows, 'ID')); + } + + public function provideExcludeAnyWithSearchfilters() + { + // If it's included in the filterAny test, then it's excluded in the excludeAny test, + // so we can just use the same scenarios and reverse the expected results. + $rows = $this->getBasicRecordRows(); + $scenarios = $this->provideFilterAnyWithSearchfilters(); + foreach ($scenarios as $name => $scenario) { + $kept = []; + $excluded = []; + foreach ($scenario['expected'] as $id) { + $kept[] = $id; + } + foreach ($rows as $row) { + if (!in_array($row['ID'], $kept)) { + $excluded[] = $row['ID']; + } + } + $scenarios[$name]['expected'] = $excluded; + } + return $scenarios; + } + + /** + * @dataProvider provideExcludeAnyWithSearchfilters + */ + public function testExcludeAnyWithSearchfilters(array $filter, array $expectedIDs): void + { + $rows = $this->getBasicRecordRows(); + $list = new EagerLoadedList(ValidatedObject::class, DataList::class); + foreach ($rows as $row) { + $list->addRow($row); + } + $filteredList = $list->excludeAny($filter); + + // Validate that the unfiltered list still has all records, and the filtered list has the expected amount + $this->assertCount(count($rows), $list); + $this->assertCount(count($expectedIDs), $filteredList); + + // Validate that the filtered list has the CORRECT records + $this->iterate($list, $rows, array_column($rows, 'ID')); + } + public function testFilterByInvalidColumn() { $list = new EagerLoadedList(ValidatedObject::class, DataList::class); @@ -1189,6 +1470,23 @@ class EagerLoadedListTest extends SapphireTest ] ], ], + 'Filter by non-null' => [ + 'filterMethod' => 'filter', + 'filter' => ['Email:not' => null], + 'expected' => [ + [ + 'Name' => 'Damian', + 'Email' => 'damian@thefans.com', + ], + [ + 'Name' => 'Richard', + 'Email' => 'richie@richers.com', + ], + [ + 'Name' => 'Hamish', + ] + ], + ], 'Filter by empty only' => [ 'filterMethod' => 'filter', 'filter' => ['Email' => ''], @@ -1198,6 +1496,27 @@ class EagerLoadedListTest extends SapphireTest ] ], ], + // This should include null values, matching the behaviour in DataList + 'Non-empty only' => [ + 'filterMethod' => 'filter', + 'filter' => ['Email:not' => ''], + 'expected' => [ + [ + 'Name' => 'Damian', + 'Email' => 'damian@thefans.com', + ], + [ + 'Name' => 'Richard', + 'Email' => 'richie@richers.com', + ], + [ + 'Name' => 'Stephen', + ], + [ + 'Name' => 'Mitch', + ] + ], + ], 'Filter by null or empty values' => [ 'filterMethod' => 'filter', 'filter' => ['Email' => [null, '']], @@ -1232,7 +1551,17 @@ class EagerLoadedListTest extends SapphireTest ] ], ], - 'Filter by many including empty string and non-empty' => [ + 'Filter exclusion of above list' => [ + 'filterMethod' => 'filter', + 'filter' => ['Email:not' => [null, '', 'damian@thefans.com']], + 'expected' => [ + [ + 'Name' => 'Richard', + 'Email' => 'richie@richers.com', + ], + ], + ], + 'Filter by many including empty string and non-empty 1' => [ 'filterMethod' => 'filter', 'filter' => ['Email' => ['', 'damian@thefans.com']], 'expected' => [ @@ -1245,6 +1574,41 @@ class EagerLoadedListTest extends SapphireTest ] ], ], + 'Filter by many including empty string and non-empty 2' => [ + 'filterMethod' => 'filter', + 'filter' => ['Email:not' => ['', 'damian@thefans.com']], + 'expected' => [ + [ + 'Name' => 'Richard', + 'Email' => 'richie@richers.com', + ], + [ + 'Name' => 'Stephen', + ], + [ + 'Name' => 'Mitch', + ] + ], + ], + 'Filter by many including empty string and non-empty 3' => [ + 'filterMethod' => 'filterAny', + 'filter' => [ + 'Email:not' => ['', 'damian@thefans.com'], + 'Email' => null + ], + 'expected' => [ + [ + 'Name' => 'Richard', + 'Email' => 'richie@richers.com', + ], + [ + 'Name' => 'Stephen', + ], + [ + 'Name' => 'Mitch', + ] + ], + ], ]; } @@ -1390,6 +1754,16 @@ class EagerLoadedListTest extends SapphireTest ]; } + public function testExcludeWithSearchFilter() + { + $list = $this->getListWithRecords(TeamComment::class); + $list = $list->exclude('Comment:PartialMatch', 'Bob'); + $this->assertListEquals([ + ['Name' => 'Joe'], + ['Name' => 'Phil'], + ], $list); + } + /** * Test that Bob and Phil are excluded (one match each) */ diff --git a/tests/php/ORM/Filters/EndsWithFilterTest.php b/tests/php/ORM/Filters/EndsWithFilterTest.php new file mode 100644 index 000000000..ea5d51d4d --- /dev/null +++ b/tests/php/ORM/Filters/EndsWithFilterTest.php @@ -0,0 +1,277 @@ + [ + '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, + ], + // unicode matches + [ + 'filterValue' => 'tohutō', + 'matchValue' => 'tohuto', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 'tohuto', + 'matchValue' => 'tohutō', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 'tohutō', + 'matchValue' => 'tohutō', + 'modifiers' => [], + 'matches' => true, + ], + // Some multi-value tests + [ + 'filterValue' => [123, 'somevalue', 'abc'], + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => null, + ], + [ + 'filterValue' => [123, 'Value', 'abc'], + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => [123, 'meVal', 'abc'], + 'objValue' => 'Some', + 'modifiers' => [], + 'matches' => false, + ], + // 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 25562cc84..aa66b2641 100644 --- a/tests/php/ORM/Filters/ExactMatchFilterTest.php +++ b/tests/php/ORM/Filters/ExactMatchFilterTest.php @@ -8,6 +8,7 @@ use SilverStripe\ORM\Filters\ExactMatchFilter; 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,230 @@ class ExactMatchFilterTest extends SapphireTest $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' => false, + ], + [ + 'filterValue' => '', + 'objValue' => null, + 'modifiers' => [], + 'matches' => false, + ], + [ + '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' => false, + ], + [ + 'filterValue' => null, + 'objValue' => false, + 'modifiers' => [], + 'matches' => false, + ], + [ + '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, + ], + // unicode matches + [ + 'filterValue' => 'tohutō', + 'matchValue' => 'tohuto', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 'tohuto', + 'matchValue' => 'tohutō', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 'tohutō', + 'matchValue' => 'tohutō', + 'modifiers' => [], + 'matches' => true, + ], + // Some multi-value tests + [ + 'filterValue' => [123, 'somevalue', 'abc'], + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => null, + ], + [ + 'filterValue' => [123, 'SomeValue', 'abc'], + 'objValue' => 'Some', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => [1, 2, 3], + 'objValue' => '1', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => [4, 5, 6], + 'objValue' => 1, + 'modifiers' => [], + 'matches' => false, + ], + // 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 000000000..6a7bcf284 --- /dev/null +++ b/tests/php/ORM/Filters/GreaterThanFilterTest.php @@ -0,0 +1,225 @@ + 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, + ], + // unicode matches - macrons are "greater" than their non-macron equivalent + [ + 'filterValue' => 'tohutō', + 'matchValue' => 'tohuto', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 'tohuto', + 'matchValue' => 'tohutō', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 'tohutō', + 'matchValue' => 'tohutō', + 'modifiers' => [], + 'matches' => false, + ], + // Some multi-value tests + [ + 'filterValue' => [123, '99', '123456'], + 'matchValue' => '2', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => [123, '0', '123456'], + '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' => 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 000000000..c93f3a8a9 --- /dev/null +++ b/tests/php/ORM/Filters/GreaterThanOrEqualFilterTest.php @@ -0,0 +1,225 @@ + 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, + ], + // unicode matches - macrons are "greater" than their non-macron equivalent + [ + 'filterValue' => 'tohutō', + 'matchValue' => 'tohuto', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 'tohuto', + 'matchValue' => 'tohutō', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 'tohutō', + 'matchValue' => 'tohutō', + 'modifiers' => [], + 'matches' => true, + ], + // Some multi-value tests + [ + 'filterValue' => [123, '99', '123456'], + 'matchValue' => '2', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => [123, '0', '123456'], + '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' => 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 000000000..ac8f5fa4e --- /dev/null +++ b/tests/php/ORM/Filters/LessThanFilterTest.php @@ -0,0 +1,225 @@ + 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, + ], + // unicode matches - macrons are "greater" than their non-macron equivalent + [ + 'filterValue' => 'tohutō', + 'matchValue' => 'tohuto', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 'tohuto', + 'matchValue' => 'tohutō', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 'tohutō', + 'matchValue' => 'tohutō', + 'modifiers' => [], + 'matches' => false, + ], + // Some multi-value tests + [ + 'filterValue' => [123, '99', '50'], + 'matchValue' => '200', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => [123, '250', '50'], + 'matchValue' => 200, + '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 000000000..30a0f3078 --- /dev/null +++ b/tests/php/ORM/Filters/LessThanOrEqualFilterTest.php @@ -0,0 +1,225 @@ + 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, + ], + // unicode matches - macrons are "greater" than their non-macron equivalent + [ + 'filterValue' => 'tohutō', + 'matchValue' => 'tohuto', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => 'tohuto', + 'matchValue' => 'tohutō', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 'tohutō', + 'matchValue' => 'tohutō', + 'modifiers' => [], + 'matches' => true, + ], + // Some multi-value tests + [ + 'filterValue' => [123, '99', '50'], + 'matchValue' => '200', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => [123, '250', '50'], + 'matchValue' => 200, + '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 000000000..8f39248b3 --- /dev/null +++ b/tests/php/ORM/Filters/PartialMatchFilterTest.php @@ -0,0 +1,277 @@ + [ + '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, + ], + // unicode matches + [ + 'filterValue' => 'tohutō', + 'matchValue' => 'tohuto', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 'tohuto', + 'matchValue' => 'tohutō', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 'tohutō', + 'matchValue' => 'tohutō', + 'modifiers' => [], + 'matches' => true, + ], + // Some multi-value tests + [ + 'filterValue' => [123, 'somevalue', 'abc'], + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => null, + ], + [ + 'filterValue' => [123, 'meVal', 'abc'], + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => [123, 'meval', 'abc'], + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => null, + ], + [ + 'filterValue' => [4, 5, 6], + 'objValue' => 1, + 'modifiers' => [], + 'matches' => false, + ], + // 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 000000000..5d4cac344 --- /dev/null +++ b/tests/php/ORM/Filters/StartsWithFilterTest.php @@ -0,0 +1,277 @@ + [ + '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 start with with sOmE' => [ + 'filterValue' => 'sOmE', + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => null, + ], + // unicode matches + [ + 'filterValue' => 'tohutō', + 'matchValue' => 'tohuto', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 'tohuto', + 'matchValue' => 'tohutō', + 'modifiers' => [], + 'matches' => false, + ], + [ + 'filterValue' => 'tohutō', + 'matchValue' => 'tohutō', + 'modifiers' => [], + 'matches' => true, + ], + // Some multi-value tests + [ + 'filterValue' => [123, 'somevalue', 'abc'], + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => null, + ], + [ + 'filterValue' => [123, 'Some', 'abc'], + 'objValue' => 'SomeValue', + 'modifiers' => [], + 'matches' => true, + ], + [ + 'filterValue' => [123, 'meVal', 'abc'], + 'objValue' => 'Some', + 'modifiers' => [], + 'matches' => false, + ], + // 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)); + } + } +}