NEW Enable ArrayList and EagerLoadedList to use search filters (#10925)

This commit is contained in:
Guy Sartorelli 2023-08-29 15:40:19 +12:00 committed by GitHub
parent c17138b6f5
commit b4463d9050
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 3287 additions and 215 deletions

View File

@ -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)
{
}

View File

@ -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
*

View File

@ -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.

View File

@ -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

View File

@ -13,6 +13,7 @@ namespace SilverStripe\ORM\Filters;
*/
class EndsWithFilter extends PartialMatchFilter
{
protected static $matchesEndsWith = true;
protected function getMatchPattern($value)
{

View File

@ -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);

View File

@ -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()
{

View File

@ -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()
{

View File

@ -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()
{

View File

@ -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()
{

View File

@ -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.
*

View File

@ -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;
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace SilverStripe\ORM\Filters;
use SilverStripe\Core\Injector\Injector;
trait SearchFilterable
{
/**
* 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);
$default = 'DataListFilter.default';
// Inspect type of second argument to determine context
$secondArg = array_shift($fieldArgs);
$modifiers = $fieldArgs;
if (!$secondArg) {
// Use default SearchFilter if none specified. E.g. `->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);
}
}

View File

@ -13,6 +13,7 @@ namespace SilverStripe\ORM\Filters;
*/
class StartsWithFilter extends PartialMatchFilter
{
protected static $matchesStartsWith = true;
protected function getMatchPattern($value)
{

View File

@ -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(

View File

@ -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);

View File

@ -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)
*/

View File

@ -0,0 +1,277 @@
<?php
namespace SilverStripe\ORM\Tests\Filters;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\ORM\Filters\EndsWithFilter;
use SilverStripe\View\ArrayData;
class EndsWithFilterTest extends SapphireTest
{
public function provideMatches()
{
$scenarios = [
// without modifiers
'null ends with null' => [
'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));
}
}
}

View File

@ -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));
}
}
}

View File

@ -0,0 +1,225 @@
<?php
namespace SilverStripe\ORM\Tests\Filters;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\ORM\Filters\GreaterThanFilter;
use SilverStripe\View\ArrayData;
class GreaterThanFilterTest extends SapphireTest
{
public function provideMatches()
{
$scenarios = [
// without modifiers
[
'filterValue' => 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));
}
}

View File

@ -0,0 +1,225 @@
<?php
namespace SilverStripe\ORM\Tests\Filters;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\ORM\Filters\GreaterThanOrEqualFilter;
use SilverStripe\View\ArrayData;
class GreaterThanOrEqualFilterTest extends SapphireTest
{
public function provideMatches()
{
$scenarios = [
// without modifiers
[
'filterValue' => 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));
}
}

View File

@ -0,0 +1,225 @@
<?php
namespace SilverStripe\ORM\Tests\Filters;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\ORM\Filters\LessThanFilter;
use SilverStripe\View\ArrayData;
class LessThanFilterTest extends SapphireTest
{
public function provideMatches()
{
$scenarios = [
// without modifiers
[
'filterValue' => 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));
}
}

View File

@ -0,0 +1,225 @@
<?php
namespace SilverStripe\ORM\Tests\Filters;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\ORM\Filters\LessThanOrEqualFilter;
use SilverStripe\View\ArrayData;
class LessThanOrEqualFilterTest extends SapphireTest
{
public function provideMatches()
{
$scenarios = [
// without modifiers
[
'filterValue' => 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));
}
}

View File

@ -0,0 +1,277 @@
<?php
namespace SilverStripe\ORM\Tests\Filters;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\ORM\Filters\PartialMatchFilter;
use SilverStripe\View\ArrayData;
class PartialMatchFilterTest extends SapphireTest
{
public function provideMatches()
{
$scenarios = [
// without modifiers
'null partially matches null' => [
'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));
}
}
}

View File

@ -0,0 +1,277 @@
<?php
namespace SilverStripe\ORM\Tests\Filters;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\ORM\Filters\StartsWithFilter;
use SilverStripe\View\ArrayData;
class StartsWithFilterTest extends SapphireTest
{
public function provideMatches()
{
$scenarios = [
// without modifiers
'null starts with null' => [
'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));
}
}
}