artabro/wire/core/Selector.php

1428 lines
46 KiB
PHP
Raw Permalink Normal View History

2024-08-27 11:35:37 +02:00
<?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)); }
}