artabro/wire/core/Selector.php
2024-08-27 11:35:37 +02:00

1427 lines
46 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php namespace ProcessWire;
/**
* ProcessWire Selector base type and implementation for various Selector types
*
* Selectors hold a field, operator and value and are used in finding things
*
* This file provides the base implementation for a Selector, as well as implementation
* for several actual Selector types under the main Selector class.
*
* ProcessWire 3.x, Copyright 2020 by Ryan Cramer
* https://processwire.com
*
* #pw-summary Selector maintains a single selector consisting of field name, operator, and value.
*
* #pw-body =
* - Serves as the base class for the different Selector types (`SelectorEqual`, `SelectorNotEqual`, `SelectorLessThan`, etc.)
* - The constructor requires `$field` and `$value` properties which may either be an array or string.
* An array indicates multiple items in an OR condition. Multiple items may also be specified by
* pipe “|” separated strings.
* - Operator is determined by the Selector class name, and thus may not be changed without replacing
* the entire Selector.
*
* ~~~~~
* // very basic usage example
* // constructor takes ($field, $value) which can be strings or arrays
* $s = new SelectorEqual('title', 'About Us');
* // $page can be any kind of Wire-derived object
* if($s->matches($page)) {
* // $page has title "About Us"
* }
* ~~~~~
* ~~~~~
* // another usage example
* $s = new SelectorContains('title|body|summary', 'foo|bar');
* if($s->matches($page)) {
* // the title, body or summary properties of $page contain either the text "foo" or "bar"
* }
* ~~~~~
*
* ### List of core selector-derived classes
*
* - `SelectorEqual`
* - `SelectorNotEqual`
* - `SelectorGreaterThan`
* - `SelectorLessThan`
* - `SelectorGreaterThanEqual`
* - `SelectorLessThanEqual`
* - `SelectorContains`
* - `SelectorContainsLike`
* - `SelectorContainsWords`
* - `SelectorContainsWordsPartial` (3.0.160+)
* - `SelectorContainsWordsLive` (3.0.160)
* - `SelectorContainsWordsLike` (3.0.160)
* - `SelectorContainsWordsExpand` (3.0.160)
* - `SelectorContainsAnyWords` (3.0.160)
* - `SelectorContainsAnyWordsPartial` (3.0.160)
* - `SelectorContainsAnyWordsLike` (3.0.160)
* - `SelectorContainsExpand` (3.0.160)
* - `SelectorContainsMatch` (3.0.160)
* - `SelectorContainsMatchExpand` (3.0.160)
* - `SelectorContainsAdvanced` (3.0.160)
* - `SelectorStarts`
* - `SelectorStartsLike`
* - `SelectorEnds`
* - `SelectorEndsLike`
* - `SelectorBitwiseAnd`
*
*
* #pw-body
*
* @property array $fields Fields that were present in selector (same as $field, but always an array).
* @property string|array $field Field or fields present in the selector (string if single, or array of strings if multiple). Preferable to use $fields property instead.
* @property-read string $operator Operator used by the selector.
* @property array $values Values that were present in selector (same as $value, but always array).
* @property string|array $value Value or values present in the selector (string if single, or array of strings if multiple). Preferable to use $values property instead.
* @property bool $not Is this a NOT selector? Indicates the selector returns the opposite if what it would otherwise. #pw-group-properties
* @property string|null $group Group name for this selector (if field was prepended with a "group_name@"). #pw-group-properties
* @property string $quote Type of quotes value was in, or blank if it was not quoted. One of: '"[{( #pw-group-properties
* @property-read string $str String value of selector, i.e. “a=b”. #pw-group-properties
* @property null|bool $forceMatch When boolean, forces match (true) or force non-match (false). (default=null) #pw-group-properties
* @property array $altOperators Alternate operators to use when primary fails match, supported only by compareTypeFind. Since 3.0.161 (default=[]) #pw-group-properties
*
*/
abstract class Selector extends WireData {
/**
* Comparison type: Exact (value equals this or value does not equal this)
*
*/
const compareTypeExact = 1;
/**
* Comparison type: Sort (matches based on how it would sort among given value)
*
*/
const compareTypeSort = 2;
/**
* Comparison type: Find (text value is found within another text value)
*
*/
const compareTypeFind = 4;
/**
* Comparison type: Like (text value is like another, combined with compareTypeFind)
*
*/
const compareTypeLike = 8;
/**
* Comparison type: Bitwise
*
*/
const compareTypeBitwise = 16;
/**
* Comparison type: Expand (value can be expanded to include other results when supported)
*
*/
const compareTypeExpand = 32;
/**
* Comparison type: Command (value can contain additional commands interpreted by the Selector)
*
*/
const compareTypeCommand = 64;
/**
* Comparison type: Database (Selector is only applicable for database-driven comparisons)
*
*/
const compareTypeDatabase = 128;
/**
* Comparison type: Fulltext index required when used with database queries
*
*/
const compareTypeFulltext = 256;
/**
* Comparison type: Perform phrase match (1+ words in order)
*/
const compareTypePhrase = 512;
/**
* Comparison type: Match as words independent of order (opposite of phrase)
*
*/
const compareTypeWords = 1024;
/**
* Comparison type: Partial matches allowed, such as partial words or phrases
*
*/
const compareTypePartial = 2048;
/**
* Comparison type: If multiple items in query, ANY of them may match
*
*/
const compareTypeAny = 4096;
/**
* Comparison type: If multiple items in query, ALL of them may match
*
*/
const compareTypeAll = 8192;
/**
* Comparison type: Matches at boundary (start or end)
*
*/
const compareTypeBoundary = 16384;
/**
* Given a field name and value, construct the Selector.
*
* If the provided $field is an array or pipe "|" separated string, Selector may match any of them (OR field condition)
* If the provided $value is an array of pipe "|" separated string, Selector may match any one of them (OR value condition).
*
* If only one field is provided as a string, and that field is prepended by an exclamation point, i.e. !field=something
* then the condition is reversed.
*
* @param string|array $field
* @param string|int|array $value
*
*/
public function __construct($field, $value) {
parent::__construct();
$this->set('not', false);
$this->set('group', null); // group name identified with 'group_name@' before a field name
$this->set('quote', ''); // if $value in quotes, this contains either: ', ", [, {, or (, indicating quote type (set by Selectors class)
$this->set('forceMatch', null); // boolean true to force match, false to force non-match
parent::set('altOperators', array()); // optional alternate operators
$this->setField($field);
$this->setValue($value);
}
/**
* Return the operator used by this Selector
*
* @return string
* @since 3.0.42 Prior versions just supported the 'operator' property.
*
*/
public function operator() {
return static::getOperator();
}
/**
* Get the field(s) of this Selector
*
* Note that if calling this as a property (rather than a method) it can return either a string or an array.
*
* @param bool|int $forceString Specify one of the following:
* - `true` (bool): to only return a string, where multiple-fields will be split by pipe "|". (default)
* - `false` (bool): to return string if 1 field, or array of multiple fields (same behavior as field property).
* - `1` (int): to return only the first value (string).
* @return string|array|null
* @since 3.0.42 Prior versions only supported the 'field' property.
* @see Selector::fields()
*
*/
public function field($forceString = true) {
$field = parent::get('field');
if($forceString && is_array($field)) {
if($forceString === 1) {
$field = reset($field);
} else {
$field = implode('|', $field);
}
}
return $field;
}
/**
* Return array of field(s) for this Selector
*
* @return array
* @see Selector::field()
* @since 3.0.42 Prior versions just supported the 'fields' property.
*
*/
public function fields() {
$field = parent::get('field');
if(is_array($field)) return $field;
if(!strlen($field)) return array();
return array($field);
}
/**
* Get the value(s) of this Selector
*
* Note that if calling this as a property (rather than a method) it can return either a string or an array.
*
* @param bool|int $forceString Specify one of the following:
* - `true` (bool): to only return a string, where multiple-values will be split by pipe "|". (default)
* - `false` (bool): to return string if 1 value, or array of multiple values (same behavior as value property).
* - `1` (int): to return only the first value (string).
* @return string|array|null
* @since 3.0.42 Prior versions only supported the 'value' property.
* @see Selector::values()
*
*/
public function value($forceString = true) {
$value = parent::get('value');
if($forceString && is_array($value)) {
if($forceString === 1) {
$value = reset($value);
} else {
$value = $this->wire()->sanitizer->selectorValue($value);
}
}
return $value;
}
/**
* Return array of value(s) for this Selector
*
* @param bool $nonEmpty If empty array will be returned, forces it to return array with one blank item instead (default=false).
* @return array
* @see Selector::value()
* @since 3.0.42 Prior versions just supported the 'values' property.
*
*/
public function values($nonEmpty = false) {
$values = parent::get('value');
if(is_array($values)) {
// ok
} else if(is_string($values)) {
$values = strlen($values) ? array($values) : array();
} else if(is_object($values)) {
$values = $values instanceof WireArray ? $values->getArray() : array($values);
} else if($values) {
$values = array($values);
} else {
$values = array();
}
if($nonEmpty && !count($values)) $values = array('');
return $values;
}
/**
* Get a property
*
* @param string $key Property name
* @return array|mixed|null|string Property value
*
*/
public function get($key) {
if($key === 'operator') return $this->operator();
if($key === 'str') return $this->__toString();
if($key === 'values') return $this->values();
if($key === 'fields') return $this->fields();
if($key === 'label') return $this->getLabel();
return parent::get($key);
}
/**
* Returns the selector field(s), optionally forcing as string or array
*
* #pw-internal
*
* @param string $type Omit for automatic, or specify 'string' or 'array' to force return in that type
* @return string|array
* @throws WireException if given invalid type
*
*/
public function getField($type = '') {
$field = $this->field;
if($type == 'string') {
if(is_array($field)) $field = implode('|', $field);
} else if($type == 'array') {
if(!is_array($field)) $field = array($field);
} else if($type) {
throw new WireException("Unknown type '$type' specified to getField()");
}
return $field;
}
/**
* Set field or fields
*
* @param string|array $field
* @return self
* @since 3.0.160
*
*/
public function setField($field) {
if(is_array($field)) $field = implode('|', $field);
$field = (string) $field;
$not = strpos($field, '!') === 0;
if($not) $field = ltrim($field, '!');
if(strpos($field, '|') !== false) $field = explode('|', $field);
parent::set('field', $field);
parent::set('not', $not);
return $this;
}
/**
* Returns the selector value(s) with additional processing and forced type options
*
* When the $type argument is not specified, this method may return a string, array or Selectors object.
* A Selectors object is only returned if the value happens to contain an embedded selector.
*
* #pw-internal
*
* @param string $type Omit for automatic, or specify 'string' or 'array' to force return in that type
* @return string|array|Selectors
* @throws WireException if given invalid type
*
*/
public function getValue($type = '') {
$value = $this->value;
if($type == 'string') {
if(is_array($value)) $value = $this->wire()->sanitizer->selectorValue($value);
} else if($type == 'array') {
if(!is_array($value)) $value = array($value);
} else if($this->quote == '[') {
if(is_string($value) && Selectors::stringHasSelector($value)) {
$value = $this->wire(new Selectors($value));
} else if($value instanceof Selectors) {
// okay
}
} else if($type) {
throw new WireException("Unknown type '$type' specified to getValue()");
}
return $value;
}
/**
* Set selector value(s)
*
* @param string|int|array|mixed $value
* @return self
* @since 3.0.160
*
*/
public function setValue($value) {
parent::set('value', $value);
return $this;
}
/**
* Set a property of the Selector
*
* @param string $key
* @param mixed $value
* @return Selector|WireData
*
*/
public function set($key, $value) {
if($key === 'fields' || $key === 'field') return $this->setField($value);
if($key === 'values' || $key === 'value') return $this->setValue($value);
if($key === 'operator') {
$this->error("You cannot set the operator on a Selector: $this");
return $this;
}
if($key === 'altOperators') {
if(!is_array($value)) $value = array();
$operator = $this->operator();
foreach($value as $k => $v) {
// dont allow current operator to be an altOperator
if($operator === $v) unset($value[$k]);
}
}
return parent::set($key, $value);
}
/**
* Return the operator used by this Selector
*
* Strict standards don't let us make static abstract methods, so this one throws an exception if it's not reimplemented.
*
* #pw-internal
*
* @return string
* @throws WireException
*
*/
public static function getOperator() {
throw new WireException("This getOperator method must be implemented");
}
/**
* What type of comparson does Selector perform?
*
* @return int Returns a Selector::compareType* constant or 0 if not defined
* @since 3.0.154
*
*/
public static function getCompareType() {
return 0;
}
/*
public static function getCompareTypeArray() {
$types = array(
self::compareTypeExact => 'exact',
self::compareTypeSort => 'sort',
self::compareTypeFind => 'find',
self::compareTypeLike => 'like',
self::compareTypeBitwise => 'bitwise',
self::compareTypeExpand => 'expand',
self::compareTypeCommand => 'command',
self::compareTypeDatabase => 'database',
self::compareTypeFulltext => 'fulltext',
self::compareTypePhrase => 'phrase',
self::compareTypeWords => 'words',
self::compareTypePartial => 'partial',
self::compareTypeAny => 'any',
self::compareTypeAll => 'all',
self::compareTypeBoundary => 'boundary',
);
$compareType = self::getCompareType();
$compareTypes = array();
foreach($types as $flag => $type) {
if($compareType & $flag;
}
}
*/
/**
* Get short label that describes this Selector
*
* @return string
* @since 3.0.160
*
*/
public static function getLabel() {
return '';
}
/**
* Get longer description that describes this Selector
*
* @return string
* @since 3.0.160
*
*/
public static function getDescription() {
return '';
}
/**
* Does $value1 match $value2?
*
* @param mixed $value1 Dynamic comparison value
* @param string $value2 User-supplied value to compare against
* @return bool
*
*/
abstract protected function match($value1, $value2);
/**
* Does this Selector match the given value?
*
* If the value held by this Selector is an array of values, it will check if any one of them matches the value supplied here.
*
* @param string|int|Wire|array $value If given a Wire, then matches will also operate on OR field=value type selectors, where present
* @return bool
*
*/
public function matches($value) {
$forceMatch = $this->get('forceMatch');
if(is_bool($forceMatch)) return $forceMatch;
$matches = false;
$values1 = is_array($this->value) ? $this->value : array($this->value);
$field = $this->field;
$operator = $this->operator();
// prepare the value we are comparing
if(is_object($value)) {
if($this->wire()->languages && $value instanceof LanguagesValueInterface) {
$value = (string) $value;
} else if($value instanceof WireData) {
$value = $value->get($field);
} else if($value instanceof WireArray && is_string($field) && !strpos($field, '.')) {
$value = (string) $value; // 123|456|789, etc.
} else if($value instanceof Wire) {
$value = $value->$field;
}
$value = (string) $value;
}
if(is_string($value) && strpos($value, '|') !== false) $value = explode('|', $value);
if(!is_array($value)) $value = array($value);
$values2 = $value;
unset($value);
// now we're just dealing with 2 arrays: $values1 and $values2
// $values1 is the value stored by the selector
// $values2 is the value passed into the matches() function
$numMatches = 0;
$numMatchesRequired = 1;
if(($operator === '!=' && !$this->not) || ($this->not && $operator !== '!=')) {
$numMatchesRequired = count($values1) * count($values2);
}
$fields = is_array($field) ? $field : array($field);
foreach($fields as $field) {
foreach($values1 as $v1) {
if(is_object($v1)) {
if($v1 instanceof WireData) $v1 = $v1->get($field);
else if($v1 instanceof Wire) $v1 = $v1->$field;
}
foreach($values2 as $v2) {
if(empty($v2) && empty($v1)) {
// normalize empty values so that they will match if both considered "empty"
$v2 = '';
$v1 = '';
}
if($this->match($v2, $v1)) {
$numMatches++;
}
}
if($numMatches >= $numMatchesRequired) {
$matches = true;
break;
}
}
if($matches) break;
}
return $matches;
}
/**
* Provides the opportunity to override or NOT the condition
*
* Selectors should include a call to this in their matches function
*
* @param bool $matches
* @return bool
*
*/
protected function evaluate($matches) {
$forceMatch = $this->get('forceMatch');
if(is_bool($forceMatch)) $matches = $forceMatch;
if($this->not) return !$matches;
return $matches;
}
/**
* Copy all data from this selector to another
*
* #pw-internal
*
* @param Selector $selector
* @since 3.0.161
*
*/
public function copyTo(Selector $selector) {
$selector->setField($this->field);
$selector->setValue($this->value);
$selector->not = $this->not;
if($this->group) $selector->group = $this->group;
if($this->quote) $selector->quote = $this->quote;
if(is_bool($this->forceMatch)) $selector->forceMatch = $this->forceMatch;
if(count($this->altOperators)) $selector->altOperators = $this->altOperators;
}
/**
* Sanitize field name
*
* @param string|array $fieldName
* @return string|array
* @todo This needs testing and then to be used by this class
*
*/
protected function sanitizeFieldName($fieldName) {
if(strpos($fieldName, '|') !== false) {
$fieldName = explode('|', $fieldName);
}
if(is_array($fieldName)) {
$fieldNames = array();
foreach($fieldName as $name) {
$name = $this->sanitizeFieldName($name);
if($name !== '') $fieldNames[] = $name;
}
return $fieldNames;
}
$fieldName = trim($fieldName, '. ');
if($fieldName === '') return $fieldName;
if(ctype_alnum($fieldName)) return $fieldName;
if(ctype_alnum(str_replace(array('.', '_'), '', $fieldName))) return $fieldName;
return '';
}
/**
* The string value of Selector is always the selector string that it originated from
*
*/
public function __toString() {
$openingQuote = $this->quote;
$closingQuote = $openingQuote;
if($openingQuote) {
if($openingQuote == '[') $closingQuote = ']';
else if($openingQuote == '{') $closingQuote = '}';
else if($openingQuote == '(') $closingQuote = ')';
}
$value = $this->value();
if($openingQuote) $value = trim($value, $openingQuote . $closingQuote);
$value = $openingQuote . $value . $closingQuote;
$str =
($this->not ? '!' : '') .
(is_null($this->group) ? '' : $this->group . '@') .
(is_array($this->field) ? implode('|', $this->field) : $this->field) .
$this->operator() . $value;
return $str;
}
/**
* Debug info
*
* #pw-internal
*
* @return array
*
*/
public function __debugInfo() {
$info = array(
'field' => $this->field,
'operator' => $this->operator,
'value' => $this->value,
);
if($this->not) $info['not'] = true;
if($this->forceMatch) $info['forceMatch'] = true;
if($this->group) $info['group'] = $this->group;
if($this->quote) $info['quote'] = $this->quote;
$info['string'] = $this->__toString();
return $info;
}
/**
* Add all individual selector types to the runtime Selectors
*
* #pw-internal
*
*/
static public function loadSelectorTypes() {
$types = array(
'Equal',
'NotEqual',
'GreaterThan',
'LessThan',
'GreaterThanEqual',
'LessThanEqual',
'Contains',
'ContainsLike',
'ContainsWords',
'ContainsWordsPartial',
'ContainsWordsLive',
'ContainsWordsLike',
'ContainsWordsExpand',
'ContainsAnyWords',
'ContainsAnyWordsPartial',
'ContainsAnyWordsLike',
'ContainsAnyWordsExpand',
'ContainsExpand',
'ContainsMatch',
'ContainsMatchExpand',
'ContainsAdvanced',
'Starts',
'StartsLike',
'Ends',
'EndsLike',
'BitwiseAnd',
);
foreach($types as $type) {
$class = "Selector$type";
/** @var Selector $className */
$className = __NAMESPACE__ . "\\$class";
$operator = $className::getOperator();
Selectors::addType($operator, $class);
}
}
}
/**
* Selector that matches equality between two values
*
*/
class SelectorEqual extends Selector {
public static function getOperator() { return '='; }
public static function getCompareType() { return Selector::compareTypeExact; }
public static function getLabel() { return __('Equals', __FILE__); }
public static function getDescription() { return __('Given value is the same as value compared to.', __FILE__); }
protected function match($value1, $value2) { return $this->evaluate($value1 == $value2); }
}
/**
* Selector that matches two values that aren't equal
*
*/
class SelectorNotEqual extends Selector {
public static function getOperator() { return '!='; }
public static function getCompareType() { return Selector::compareTypeExact; }
public static function getLabel() { return __('Not equals', __FILE__); }
public static function getDescription() { return __('Given value is not the same as value compared to.', __FILE__); }
protected function match($value1, $value2) { return $this->evaluate($value1 != $value2); }
}
/**
* Selector that matches one value greater than another
*
*/
class SelectorGreaterThan extends Selector {
public static function getOperator() { return '>'; }
public static function getCompareType() { return Selector::compareTypeSort; }
public static function getLabel() { return __('Greater than', __FILE__); }
public static function getDescription() { return __('Compared value is greater than given value.', __FILE__); }
protected function match($value1, $value2) { return $this->evaluate($value1 > $value2); }
}
/**
* Selector that matches one value less than another
*
*/
class SelectorLessThan extends Selector {
public static function getOperator() { return '<'; }
public static function getCompareType() { return Selector::compareTypeSort; }
public static function getLabel() { return __('Less than', __FILE__); }
public static function getDescription() { return __('Compared value is less than given value.', __FILE__); }
protected function match($value1, $value2) { return $this->evaluate($value1 < $value2); }
}
/**
* Selector that matches one value greater than or equal to another
*
*/
class SelectorGreaterThanEqual extends Selector {
public static function getOperator() { return '>='; }
public static function getCompareType() { return Selector::compareTypeSort; }
public static function getLabel() { return __('Greater than or equal', __FILE__); }
public static function getDescription() { return __('Compared value is greater than or equal to given value.', __FILE__); }
protected function match($value1, $value2) { return $this->evaluate($value1 >= $value2); }
}
/**
* Selector that matches one value less than or equal to another
*
*/
class SelectorLessThanEqual extends Selector {
public static function getOperator() { return '<='; }
public static function getCompareType() { return Selector::compareTypeSort; }
public static function getLabel() { return __('Less than or equal', __FILE__); }
public static function getDescription() { return __('Compared value is less than or equal to given value.', __FILE__); }
protected function match($value1, $value2) { return $this->evaluate($value1 <= $value2); }
}
/**
* Selector that matches one string value (phrase) that happens to be present in another string value
*
*/
class SelectorContains extends Selector {
public static function getOperator() { return '*='; }
public static function getCompareType() {
return
Selector::compareTypeFind |
Selector::compareTypePartial |
Selector::compareTypePhrase |
Selector::compareTypeFulltext;
}
public static function getLabel() { return __('Contains phrase', __FILE__); }
public static function getDescription() { return SelectorContains::buildDescription('phrase fulltext'); }
protected function match($value1, $value2) {
$matches = stripos($value1, $value2) !== false && preg_match('/\b' . preg_quote($value2) . '/i', $value1);
return $this->evaluate($matches);
}
/**
* Build description from predefined keys for SelectorContains* classes
*
* @param array|string $keys
* @return string
*
*/
public static function buildDescription($keys) {
$a = array();
if(!is_array($keys)) $keys = explode(' ', $keys);
foreach($keys as $key) {
switch($key) {
case 'text': $a[] = __('Given text appears in value compared to.', __FILE__); break;
case 'phrase': $a[] = __('Given phrase or word appears in value compared to.', __FILE__); break;
case 'phrase-start': $a[] = __('Given word or phrase appears at beginning of compared value.', __FILE__); break;
case 'phrase-end': $a[] = __('Given word or phrase appears at end of compared value.', __FILE__); break;
case 'expand': $a[] = __('Expand to include potentially related terms and word variations.', __FILE__); break;
case 'words-all': $a[] = __('All given words appear in compared value, in any order.', __FILE__); break;
case 'words-any': $a[] = __('Any given words appear in compared value, in any order.', __FILE__); break;
case 'words-match': $a[] = __('Any given words match against compared value.', __FILE__); break;
case 'words-whole': $a[] = __('Matches whole words.', __FILE__); break;
case 'words-partial': $a[] = __('Matches whole or partial words.', __FILE__); break;
case 'words-partial-any': $a[] = __('Partial matches anywhere within words.', __FILE__); break;
case 'words-partial-begin': $a[] = __('Partial matches from beginning of words.', __FILE__); break;
case 'words-partial-last': $a[] = __('Partial matches last word in given value.', __FILE__); break;
case 'fulltext': $a[] = __('Uses “fulltext” index.', __FILE__); break;
case 'like': $a[] = __('Matches using “like”.', __FILE__); break;
case 'like-words': $a[] = __('Matches without regard to word boundaries (using “like”).', __FILE__); break;
default: $a[] = "UNKNOWN:$key";
}
}
return implode(' ', $a);
}
}
/**
* Same as SelectorContains but query expansion when used for database searching
*
*/
class SelectorContainsExpand extends SelectorContains {
public static function getOperator() { return '*+='; }
public static function getCompareType() {
return
Selector::compareTypeFind |
Selector::compareTypePartial |
Selector::compareTypePhrase |
Selector::compareTypeExpand |
Selector::compareTypeDatabase |
Selector::compareTypeFulltext;
}
public static function getLabel() { return __('Contains phrase expand', __FILE__); }
public static function getDescription() { return SelectorContains::buildDescription('phrase expand fulltext'); }
}
/**
* Same as SelectorContains but serves as operator placeholder for SQL LIKE operations
*
*/
class SelectorContainsLike extends SelectorContains {
public static function getOperator() { return '%='; }
public static function getCompareType() {
return
Selector::compareTypeFind |
Selector::compareTypePartial |
Selector::compareTypePhrase |
Selector::compareTypeLike;
}
public static function getLabel() { return __('Contains text like', __FILE__); }
public static function getDescription() { return SelectorContains::buildDescription('phrase like'); }
protected function match($value1, $value2) { return $this->evaluate(stripos($value1, $value2) !== false); }
}
/**
* Selector that matches one string value that happens to have all of its words present in another string value (regardless of individual word location)
*
*/
class SelectorContainsWords extends Selector {
public static function getOperator() { return '~='; }
public static function getCompareType() {
return
Selector::compareTypeFind |
Selector::compareTypeAll |
Selector::compareTypeWords |
Selector::compareTypeFulltext;
}
public static function getLabel() { return __('Contains all words', __FILE__); }
public static function getDescription() { return SelectorContains::buildDescription('words-all words-whole fulltext'); }
protected function match($value1, $value2) {
$hasAll = true;
$words = $this->wire()->sanitizer->wordsArray($value2);
foreach($words as $word) if(!preg_match('/\b' . preg_quote($word) . '\b/i', $value1)) {
$hasAll = false;
break;
}
return $this->evaluate($hasAll);
}
}
/**
* Selector that matches all given words in whole or in part starting with
*
*/
class SelectorContainsWordsPartial extends Selector {
public static function getOperator() { return '~*='; }
public static function getCompareType() {
return
Selector::compareTypeFind |
Selector::compareTypeAll |
Selector::compareTypePartial |
Selector::compareTypeWords |
Selector::compareTypeFulltext;
}
public static function getLabel() { return __('Contains all partial words', __FILE__); }
public static function getDescription() { return SelectorContains::buildDescription('words-all words-partial words-partial-begin fulltext'); }
protected function match($value1, $value2) {
$hasAll = true;
$words = $this->wire()->sanitizer->wordsArray($value2);
foreach($words as $word) {
if(!preg_match('/\b' . preg_quote($word) . '/i', $value1)) {
$hasAll = false;
break;
}
}
return $this->evaluate($hasAll);
}
}
/**
* Selector that matches partial words at either beginning or ending
*
*/
class SelectorContainsWordsLike extends Selector {
public static function getOperator() { return '~%='; }
public static function getCompareType() {
return
Selector::compareTypeFind |
Selector::compareTypeAll |
Selector::compareTypePartial |
Selector::compareTypeWords |
Selector::compareTypeLike;
}
public static function getLabel() { return __('Contains all words like', __FILE__); }
public static function getDescription() { return SelectorContains::buildDescription('words-all words-partial words-partial-any like'); }
protected function match($value1, $value2) {
$hasAll = true;
$words = $this->wire()->sanitizer->wordsArray($value2);
foreach($words as $word) {
if(stripos($value1, $word) === false) {
$hasAll = false;
break;
}
}
return $this->evaluate($hasAll);
}
}
/**
* Selector that matches entire words except for last word, which must start with
*
* Useful in matching "live" search results where someone is typing and last word may be partial.
*
*/
class SelectorContainsWordsLive extends Selector {
public static function getOperator() { return '~~='; }
public static function getCompareType() {
return
Selector::compareTypeFind |
Selector::compareTypeAll |
Selector::compareTypeWords |
Selector::compareTypePartial |
Selector::compareTypeFulltext;
}
public static function getLabel() { return __('Contains all words live', __FILE__); }
public static function getDescription() { return SelectorContains::buildDescription('words-all words-partial-last fulltext'); }
protected function match($value1, $value2) {
$hasAll = true;
$words = $this->wire()->sanitizer->wordsArray($value2);
$lastWord = array_pop($words);
foreach($words as $word) {
if(!preg_match('/\b' . preg_quote($word) . '\b/i', $value1)) {
// full-word match
$hasAll = false;
break;
}
}
// last word only needs to match beginning of word
$hasAll = $hasAll && preg_match('\b' . preg_quote($lastWord) . '/i', $value1);
return $this->evaluate($hasAll);
}
}
/**
* Selector that matches all words with query expansion
*
*/
class SelectorContainsWordsExpand extends SelectorContainsWords {
public static function getOperator() { return '~+='; }
public static function getCompareType() {
return
Selector::compareTypeFind |
Selector::compareTypeAll |
Selector::compareTypeWords |
Selector::compareTypeExpand |
Selector::compareTypeFulltext;
}
public static function getLabel() { return __('Contains all words expand', __FILE__); }
public static function getDescription() { return SelectorContains::buildDescription('words-all words-whole expand fulltext'); }
}
/**
* Selector that has any of the given whole words (only 1 needs to match)
*
*/
class SelectorContainsAnyWords extends Selector {
public static function getOperator() { return '~|='; }
public static function getCompareType() {
return
Selector::compareTypeFind |
Selector::compareTypeAny |
Selector::compareTypeWords |
Selector::compareTypeFulltext;
}
public static function getLabel() { return __('Contains any words', __FILE__); }
public static function getDescription() { return SelectorContains::buildDescription('words-any words-whole fulltext'); }
protected function match($value1, $value2) {
$hasAny = false;
$words = $this->wire()->sanitizer->wordsArray($value2);
foreach($words as $word) {
if(stripos($value1, $word) !== false) {
if(preg_match('!\b' . preg_quote($word) . '\b!i', $value1)) {
$hasAny = true;
break;
}
}
}
return $this->evaluate($hasAny);
}
}
/**
* Selector that has any of the given partial words (starting with, only 1 needs to match)
*
*/
class SelectorContainsAnyWordsPartial extends Selector {
public static function getOperator() { return '~|*='; }
public static function getCompareType() {
return
Selector::compareTypeFind |
Selector::compareTypeAny |
Selector::compareTypePartial |
Selector::compareTypeWords |
Selector::compareTypeFulltext;
}
public static function getLabel() { return __('Contains any partial words', __FILE__); }
public static function getDescription() { return SelectorContains::buildDescription('words-any words-partial words-partial-begin fulltext'); }
protected function match($value1, $value2) {
$hasAny = false;
$words = $this->wire()->sanitizer->wordsArray($value2);
foreach($words as $word) {
if(stripos($value1, $word) !== false) {
if(preg_match('!\b' . preg_quote($word) . '!i', $value1)) {
$hasAny = true;
break;
}
}
}
return $this->evaluate($hasAny);
}
}
/**
* Selector that has any words like any of those given (only 1 needs to match)
*
*/
class SelectorContainsAnyWordsLike extends Selector {
public static function getOperator() { return '~|%='; }
public static function getCompareType() {
return
Selector::compareTypeFind |
Selector::compareTypeAny |
Selector::compareTypePartial |
Selector::compareTypeWords |
Selector::compareTypeLike;
}
public static function getLabel() { return __('Contains any words like', __FILE__); }
public static function getDescription() { return SelectorContains::buildDescription('words-any words-partial words-partial-any like'); }
protected function match($value1, $value2) {
$hasAny = false;
$words = $this->wire()->sanitizer->wordsArray($value2);
foreach($words as $word) {
if(stripos($value1, $word) !== false) {
$hasAny = true;
break;
}
}
return $this->evaluate($hasAny);
}
}
/**
* Selector that matches any words with query expansion
*
*/
class SelectorContainsAnyWordsExpand extends SelectorContainsAnyWords {
public static function getOperator() { return '~|+='; }
public static function getCompareType() {
return
Selector::compareTypeFind |
Selector::compareTypeAny |
Selector::compareTypeWords |
Selector::compareTypeExpand |
Selector::compareTypeFulltext;
}
public static function getLabel() { return __('Contains any words expand', __FILE__); }
public static function getDescription() { return SelectorContains::buildDescription('words-any expand fulltext'); }
protected function match($value1, $value2) {
$hasAny = false;
$textTools = $this->wire()->sanitizer->getTextTools();
$words = $this->wire()->sanitizer->wordsArray($value2);
foreach($words as $word) {
if(stripos($value1, $word) !== false && preg_match('/\b' . preg_quote($word) . '\b/i', $value1)) {
$hasAny = true;
break;
}
$alternates = $textTools->getWordAlternates($word);
foreach($alternates as $alternate) {
if(stripos($value1, $alternate) && preg_match('/\b' . preg_quote($alternate) . '\b/i', $value1)) {
$hasAny = true;
break;
}
}
if($hasAny) break;
}
return $this->evaluate($hasAny);
}
}
/**
* Selector that uses standard MySQL MATCH/AGAINST behavior with implied DB-score sorting
*
* This selector is only useful for database $pages->find() queries.
*
*/
class SelectorContainsMatch extends SelectorContainsAnyWords {
public static function getOperator() { return '**='; }
public static function getCompareType() {
return
Selector::compareTypeFind |
Selector::compareTypeAny |
Selector::compareTypeWords |
Selector::compareTypeDatabase |
Selector::compareTypeFulltext;
}
public static function getLabel() { return __('Contains match', __FILE__); }
public static function getDescription() { return SelectorContains::buildDescription('words-match words-whole fulltext'); }
}
/**
* Selector that uses standard MySQL MATCH/AGAINST behavior with implied DB-score sorting
*
* This selector is only useful for database $pages->find() queries.
*
*/
class SelectorContainsMatchExpand extends SelectorContainsMatch {
public static function getOperator() { return '**+='; }
public static function getCompareType() {
return
Selector::compareTypeFind |
Selector::compareTypeAny |
Selector::compareTypeWords |
Selector::compareTypeExpand |
Selector::compareTypeDatabase |
Selector::compareTypeFulltext;
}
public static function getLabel() { return __('Contains match expand', __FILE__); }
public static function getDescription() { return SelectorContains::buildDescription('words-match words-whole expand fulltext'); }
}
/**
* Selector for advanced text searches that interprets specific search commands
*
* - `foo` Optional word has no prefix.
* - `+foo` Required word has a "+" prefix.
* - `+foo*` Required words starting with "foo" (i.e. "fool", "foobar", etc.) has "+" prefix and "*" wildcard suffix.
* - `-bar` Disallowed word has a "-" prefix.
* - `-bar*` Disallowed words starting with "bar" (i.e. "barn", "barbell", etc.) has "-" prefix and "*" wildcard suffix.
* - `"foo bar baz"` Optional phrase surrounded in quotes.
* - `+"foo bar baz"` Required phrase with "+" prefix followed by double-quoted value.
* - `-"foo bar baz"` Disallowed phrase with "-" prefix followed by double-quoted value.
*
* Note that to designate a phrase, it must be in "double quotes" (not 'single quotes').
*
*/
class SelectorContainsAdvanced extends SelectorContains {
public static function getOperator() { return '#='; }
public static function getCompareType() {
return
Selector::compareTypeFind |
Selector::compareTypeAny |
Selector::compareTypeWords |
Selector::compareTypePhrase |
Selector::compareTypeCommand |
Selector::compareTypeFulltext;
}
public static function getLabel() { return __('Advanced text search', __FILE__); }
public static function getDescription() {
return
__('Match values with commands: +Word MUST appear, -Word MUST NOT appear, and unprefixed Word may appear.', __FILE__) . ' ' .
__('Add asterisk for partial match: Bar* or +Bar* matches bar, barn, barge; while -Bar* prevents matching them.') . ' ' .
__('Use quotes to match phrases: +(Must Match), -(Must Not Match), or (May Match).');
}
/**
* Return array of advanced search commands from given value
*
* @param string $value
* @return array
*
*/
public function valueToCommands($value) {
$commands = array();
$hasQuotes = strrpos($value, '"') || strrpos($value, '”') || strrpos($value, ')') || strrpos($value, '}');
$substr = function_exists('\\mb_substr') ? '\\mb_substr' : '\\substr';
$re = '/[-+]?("[^"]+"|\([^)]+\))\*?/';
if($hasQuotes && preg_match_all($re, $value, $matches)) {
// find all quoted phrases
foreach($matches[0] as $key => $fullMatch) {
$type = substr($fullMatch, 0, 1);
$partial = substr($fullMatch, -1) === '*';
if($type !== '+' && $type !== '-') $type = '';
$phrase = $matches[1][$key];
$phrase = trim($phrase, $substr($phrase, 0, 1) . $substr($phrase, -1)); // remove quotes
$phrase = str_replace('+', '', trim($phrase, '-'));
if(strpos($phrase, '-') !== false) $phrase = preg_replace('/([^\w\d])-(.)/', '$1 $2', $phrase);
$value = str_replace($fullMatch, ' ', $value);
while(strpos($phrase, ' ') !== false) $phrase = str_replace(' ', ' ', $phrase);
if(!strlen($phrase)) continue;
$phrase = str_replace('"', '', $phrase);
$query = $type . '"' . $phrase . '"' . ($partial ? '*' : '');
$a = array('type' => $type, 'value' => $phrase, 'query' => $query, 'partial' => $partial, 'phrase' => true);
$commands[] = $a;
}
}
$words = $this->wire()->sanitizer->wordsArray($value, array(
'keepChars' => array('+', '-', '*')
));
foreach($words as $word) {
$type = substr($word, 0, 1);
$partial = substr($word, -1) === '*';
if($type !== '+' && $type !== '-') $type = '';
$word = trim($word, '+-*');
$query = $type . $word . ($partial ? '*' : '');
$a = array('type' => $type, 'value' => $word, 'query' => $query, 'partial' => $partial, 'phrase' => false);
$commands[] = $a;
}
return $commands;
}
protected function match($value1, $value2) {
$fail = false;
$numMatch = 0;
$numOptional = 0;
$commands = $this->valueToCommands($value2);
foreach($commands as $command) {
$re = '/\b' . preg_quote($command['value']) . ($command['partial'] ? '' : '\b') . '/i';
$match = preg_match($re, $value1);
if($command['type'] === '+') {
// value must be present (+)
if(!$match) $fail = true;
if(!$fail) $numMatch++;
} else if($command['type'] === '-') {
// value must not be present (-)
if($match) $fail = true;
if(!$fail) $numMatch++;
} else {
// value may be present (blank type)
if($match) $numMatch++;
$numOptional++;
}
if($fail) break;
}
if(!$fail && $numOptional && !$numMatch) $fail = true;
return $this->evaluate(!$fail);
}
}
/**
* Selector that matches if the value exists at the beginning of another value
*
*/
class SelectorStarts extends Selector {
public static function getOperator() { return '^='; }
public static function getCompareType() {
return
Selector::compareTypeFind |
Selector::compareTypeAll |
Selector::compareTypePhrase |
Selector::compareTypeBoundary |
Selector::compareTypeFulltext;
}
public static function getLabel() { return __('Starts with', __FILE__); }
public static function getDescription() { return SelectorContains::buildDescription('phrase-start fulltext'); }
protected function match($value1, $value2) {
return $this->evaluate(stripos(trim($value1), $value2) === 0);
}
}
/**
* Selector that matches if the value exists at the beginning of another value (specific to SQL LIKE)
*
*/
class SelectorStartsLike extends SelectorStarts {
public static function getOperator() { return '%^='; }
public static function getCompareType() {
return
Selector::compareTypeFind |
Selector::compareTypeAll |
Selector::compareTypePhrase |
Selector::compareTypeBoundary |
Selector::compareTypeLike;
}
public static function getLabel() { return __('Starts like', __FILE__); }
public static function getDescription() { return SelectorContains::buildDescription('phrase-start like'); }
}
/**
* Selector that matches if the value exists at the end of another value
*
*/
class SelectorEnds extends Selector {
public static function getOperator() { return '$='; }
public static function getCompareType() {
return
Selector::compareTypeFind |
Selector::compareTypeAll |
Selector::compareTypePhrase |
Selector::compareTypeBoundary |
Selector::compareTypeFulltext;
}
public static function getLabel() { return __('Ends with', __FILE__); }
public static function getDescription() { return SelectorContains::buildDescription('phrase-end fulltext'); }
protected function match($value1, $value2) {
$value2 = trim($value2);
$value1 = substr($value1, -1 * strlen($value2));
return $this->evaluate(strcasecmp($value1, $value2) == 0);
}
}
/**
* Selector that matches if the value exists at the end of another value (specific to SQL LIKE)
*
*/
class SelectorEndsLike extends SelectorEnds {
public static function getOperator() { return '%$='; }
public static function getCompareType() {
return
Selector::compareTypeFind |
Selector::compareTypeAll |
Selector::compareTypePhrase |
Selector::compareTypeBoundary |
Selector::compareTypeLike;
}
public static function getLabel() { return __('Ends like', __FILE__); }
public static function getDescription() { return SelectorContains::buildDescription('phrase-end like'); }
}
/**
* Selector that matches a bitwise AND '&'
*
*/
class SelectorBitwiseAnd extends Selector {
public static function getOperator() { return '&'; }
public static function getCompareType() { return Selector::compareTypeBitwise; }
public static function getLabel() { return __('Bitwise AND', __FILE__); }
public static function getDescription() {
return __('Given integer value matches bitwise AND with compared integer value.', __FILE__);
}
protected function match($value1, $value2) { return $this->evaluate(((int) $value1) & ((int) $value2)); }
}