Source of file SearchFilterableArrayList.php
Size: 12,530 Bytes - Last Modified: 2021-12-24T07:08:25+00:00
/var/www/docs.ssmods.com/process/src/src/SearchFilterableArrayList.php
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307 | <?php namespace Signify\SearchFilterArrayList; use LogicException; use SilverStripe\Core\Injector\Injector; use SilverStripe\ORM\ArrayList; use SilverStripe\ORM\Filters\EndsWithFilter; use SilverStripe\ORM\Filters\ExactMatchFilter; use SilverStripe\ORM\Filters\GreaterThanFilter; use SilverStripe\ORM\Filters\GreaterThanOrEqualFilter; use SilverStripe\ORM\Filters\LessThanFilter; use SilverStripe\ORM\Filters\LessThanOrEqualFilter; use SilverStripe\ORM\Filters\PartialMatchFilter; use SilverStripe\ORM\Filters\SearchFilter; use SilverStripe\ORM\Filters\StartsWithFilter; class SearchFilterableArrayList extends ArrayList { /** * Find the first item of this list where the given key = value * Note that search filters can also be used, but dot notation is not respected. * * @inheritdoc * @link https://docs.silverstripe.org/en/4/developer_guides/model/searchfilters/ */ public function find($key, $value) { return $this->filter($key, $value)->first(); } /** * Filter the list to include items with these characteristics. * Note that search filters can also be used, but dot notation is not respected. * * @inheritdoc * @link https://docs.silverstripe.org/en/4/developer_guides/model/searchfilters/ */ public function filter() { $filters = call_user_func_array([$this, 'normaliseFilterArgs'], func_get_args()); return $this->filterOrExclude($filters); } /** * Return a copy of this list which contains items matching any of these characteristics. * Note that search filters can also be used, but dot notation is not respected. * * @inheritdoc * @link https://docs.silverstripe.org/en/4/developer_guides/model/searchfilters/ */ public function filterAny() { $filters = call_user_func_array([$this, 'normaliseFilterArgs'], func_get_args()); return $this->filterOrExclude($filters, true, true); } /** * Return a copy of the list excluding any items that have all of these characteristics * Note that search filters can also be used, but dot notation is not respected. * * @inheritdoc * @link https://docs.silverstripe.org/en/4/developer_guides/model/searchfilters/ */ public function exclude() { $filters = call_user_func_array([$this, 'normaliseFilterArgs'], func_get_args()); return $this->filterOrExclude($filters, false); } /** * Return a copy of the list excluding any items that have any of these characteristics * Note that search filters can also be used, but dot notation is not respected. * * @link https://docs.silverstripe.org/en/4/developer_guides/model/searchfilters/ */ public function excludeAny() { $filters = call_user_func_array([$this, 'normaliseFilterArgs'], func_get_args()); return $this->filterOrExclude($filters, false, true); } /** * Apply the appropriate filtering or excluding * * @param array $filters * @return static */ protected function filterOrExclude($filters, $inclusive = true, $any = false) { $itemsToKeep = []; $searchFilters = []; foreach ($filters as $filterKey => $filterValue) { $searchFilter = $this->createSearchFilter($filterKey, $filterValue); $searchFilters[$filterKey] = $searchFilter; } foreach ($this->items as $item) { $matches = []; foreach ($filters as $filterKey => $filterValue) { $searchFilter = $searchFilters[$filterKey]; $hasMatch = $this->checkValueMatchesSearchFilter($searchFilter, $item); $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; } } return static::create($itemsToKeep); } /** * Determine if an item is matched by a given SearchFilter. * * Regex with explicitly casted strings is used for many of these checks, which allows for things like * '1' to match true, without 'abcd' matching true. This can be useful for things like checkboxes which * will often return '1' or '0', but we don't want 'abcd' to match against the truthy '1', nor a raw true * value. * * Dot notation is not respected (if you try to filter against "Field.Count", it will be searching for a * field or array key literally called "Field.Count". This is consistent with the behaviour of ArrayList). * * @todo: Consider respecting dot notation in the future. * * @param SearchFilter $searchFilter * @param mixed $item * @return bool */ protected function checkValueMatchesSearchFilter(SearchFilter $searchFilter, $item): bool { $modifiers = $searchFilter->getModifiers(); $regexSensitivity = in_array('nocase', $modifiers) ? 'i' : ''; $negated = in_array('not', $modifiers); $field = $searchFilter->getFullName(); $extractedValue = $this->extractValue($item, $field); $extractedValueString = (string)$extractedValue; $values = $searchFilter->getValue(); if (!is_array($values)) { $values = [$values]; } $fieldMatches = false; foreach ($values as $value) { $value = (string)$value; $regexSafeValue = preg_quote($value, '/'); switch (get_class($searchFilter)) { case EndsWithFilter::class: if (is_bool($extractedValue)) { $doesMatch = false; } else { $doesMatch = preg_match( '/' . $regexSafeValue . '$/u' . $regexSensitivity, $extractedValueString ); } break; case ExactMatchFilter::class: $doesMatch = preg_match( '/^' . $regexSafeValue . '$/u' . $regexSensitivity, $extractedValueString ); break; case GreaterThanFilter::class: $doesMatch = $extractedValueString > $value; break; case GreaterThanOrEqualFilter::class: $doesMatch = $extractedValueString >= $value; break; case LessThanFilter::class: $doesMatch = $extractedValueString < $value; break; case LessThanOrEqualFilter::class: $doesMatch = $extractedValueString <= $value; break; case PartialMatchFilter::class: $doesMatch = preg_match( '/' . $regexSafeValue . '/u' . $regexSensitivity, $extractedValueString ); break; case StartsWithFilter::class: if (is_bool($extractedValue)) { $doesMatch = false; } else { $doesMatch = preg_match( '/^' . $regexSafeValue . '/u' . $regexSensitivity, $extractedValueString ); } break; default: // This will only be reached if an Extension class added classes to // getSupportedSearchFilterClasses(). We will let them handle matching // against it in their implementation of updateFilterMatch. continue 2; // continue the loop } // Respect "not" modifier. if ($negated) { $doesMatch = !$doesMatch; } // If any value matches, then we consider the field to have matched. if ($doesMatch) { $fieldMatches = true; break; } } // Allow developers to make their own changes (e.g. for unsupported SearchFilters or modifiers). $this->extend('updateFilterMatch', $fieldMatches, $extractedValue, $searchFilter); return $fieldMatches; } /** * Given a filter expression and value construct a {@see SearchFilter} instance * * @param string $filter E.g. `Name:ExactMatch:not:nocase`, `Name:ExactMatch`, `Name:not`, `Name`, etc... * @param mixed $value Value of the filter * @return SearchFilter * @see \SilverStripe\ORM\DataList::createSearchFilter */ public function createSearchFilter(string $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}"; } } // Explicitly don't allow unsupported modifiers instead of silently ignoring them. if (!empty($invalid = array_diff($modifiers, $this->getSupportedModifiers()))) { throw new LogicException('Unsupported SearchFilter modifier(s): ' . implode(', ', $invalid)); } // Build instance $filter = Injector::inst()->create($filterServiceName, $fieldName, $value, $modifiers); // Explicitly don't allow unsupported SearchFilters instead of silently ignoring them. if (!in_array(get_class($filter), $this->getSupportedSearchFilterClasses())) { throw new LogicException('Unsupported SearchFilter class: ' . get_class($filter)); } return $filter; } /** * Get the SearchFilter classes supported by this class. * * @return string[] */ protected function getSupportedSearchFilterClasses(): array { $supportedClasses = [ EndsWithFilter::class, ExactMatchFilter::class, GreaterThanFilter::class, GreaterThanOrEqualFilter::class, LessThanFilter::class, LessThanOrEqualFilter::class, PartialMatchFilter::class, StartsWithFilter::class, ]; // Allow developers to add their own SearchFilter classes. $this->extend('updateSupportedSearchFilterClasses', $supportedClasses); return $supportedClasses; } /** * Get the SearchFilter modifiers supported by this class. * * @return string[] */ protected function getSupportedModifiers(): array { $supportedModifiers = ['not', 'nocase', 'case']; // Allow developers to add their own modifiers. $this->extend('updateSupportedModifiers', $supportedModifiers); return $supportedModifiers; } } |