artabro/wire/core/Selectors.php

1819 lines
53 KiB
PHP
Raw Permalink Normal View History

2024-08-27 11:35:37 +02:00
<?php namespace ProcessWire;
require_once(PROCESSWIRE_CORE_PATH . "Selector.php");
/**
* ProcessWire Selectors
*
* #pw-summary Processes a selector string into a WireArray of Selector objects.
* #pw-summary-static-helpers Static helper methods useful in analyzing selector strings outside of this class.
* #pw-body =
* This Selectors class is used internally by ProcessWire to provide selector string (and array) matching throughout the core.
*
* ~~~~~
* $selectors = new Selectors();
* $selectors->init("sale_price|retail_price>100, currency=USD|EUR");
* if($selectors->matches($page)) {
* // selector string matches the given $page (which can be any Wire-derived item)
* }
* ~~~~~
* ~~~~~
* // iterate and display what's in this Selectors object
* foreach($selectors as $selector) {
* echo "<p>";
* echo "Field(s): " . implode('|', $selector->fields) . "<br>";
* echo "Operator: " . $selector->operator . "<br>";
* echo "Value(s): " . implode('|', $selector->values) . "<br>";
* echo "</p>";
* }
* ~~~~~
* #pw-body
*
* @link https://processwire.com/api/selectors/ Official Selectors Documentation
* @method Selector[] getIterator()
*
* ProcessWire 3.x, Copyright 2022 by Ryan Cramer
* https://processwire.com
*
* @todo Move static helper methods to dedicated API var/class so this class can be more focused
* @todo Determine whether Selector array handling methods would be better in separate/descending class
*
*/
class Selectors extends WireArray {
/**
* Maximum length for a selector operator
*
*/
const maxOperatorLength = 10;
/**
* Static array of Selector types of $operator => $className
*
*/
static $selectorTypes = array();
/**
* Array of all individual characters used by operators
*
*/
static $operatorChars = array();
/**
* Original saved selector string, used for debugging purposes
*
*/
protected $selectorStr = '';
/**
* Whether or not variables like [user.id] should be converted to actual value
*
* In most cases this should be true.
*
* @var bool
*
*/
protected $parseVars = true;
/**
* API variable names that are allowed to be parsed
*
* @var array
*
*/
protected $allowedParseVars = array(
'session',
'page',
'user',
);
/**
* Types of quotes selector values may be surrounded in
*
*/
protected $quotes = array(
// opening => closing
'"' => '"',
"'" => "'",
'[' => ']',
'{' => '}',
'(' => ')',
);
/**
* Given a selector string, extract it into one or more corresponding Selector objects, iterable in this object.
*
* @param string|null|array $selector Please omit this argument and use a separate init($selector) call instead.
*
*/
public function __construct($selector = null) {
parent::__construct();
if(!is_null($selector)) $this->init($selector);
}
/**
* Set the selector string or array (if not set already from the constructor)
*
* ~~~~~
* $selectors = new Selectors();
* $selectors->init("sale_price|retail_price>100, currency=USD|EUR");
* ~~~~~
*
* @param string|array $selector
*
*/
public function init($selector) {
if(is_array($selector)) {
$this->setSelectorArray($selector);
} else if($selector instanceof Selector) {
$this->add($selector);
} else {
$this->setSelectorString($selector);
}
}
/**
* Set the selector string
*
* #pw-internal
*
* @param string $selectorStr
*
*/
public function setSelectorString($selectorStr) {
$selectorStr = (string) $selectorStr;
$this->selectorStr = $selectorStr;
$this->extractString(trim($selectorStr));
}
/**
* Import items into this WireArray.
*
* #pw-internal
*
* @throws WireException
* @param string|WireArray $items Items to import.
* @return WireArray This instance.
*
*/
public function import($items) {
if(is_string($items)) {
$this->extractString($items);
return $this;
} else {
return parent::import($items);
}
}
/**
* Per WireArray interface, return true if the item is a Selector instance
*
* #pw-internal
*
* @param Selector $item
* @return bool
*
*/
public function isValidItem($item) {
return $item instanceof Selector;
}
/**
* Per WireArray interface, return a blank Selector
*
* #pw-internal
*
* @return Selector
*
*/
public function makeBlankItem() {
return $this->wire(new SelectorEqual('',''));
}
/**
* Create a new Selector object from a field name, operator, and value
*
* This is mostly for internal use, as the Selectors object already does this when you pass it
* a selector string in the constructor or init() method.
*
* #pw-group-advanced
*
* @param string $field Field name or names (separated by a pipe)
* @param string $operator Operator, i.e. "="
* @param string|array $value Value or values (separated by a pipe)
* @return Selector Returns the correct type of `Selector` object that corresponds to the given `$operator`.
* @throws WireException
*
*/
public function create($field, $operator, $value) {
$not = false;
if(!isset(self::$selectorTypes[$operator])) {
// unrecognized operator, see if it's an alternate placement for NOT "!" statement
$op = ltrim("$operator", '!');
if(isset(self::$selectorTypes[$op])) {
$operator = $op;
$not = true;
} else {
if(is_array($value)) $value = implode('|', $value);
if(is_array($field)) $field = implode('|', $field);
$debug = $this->wire()->config->debug ? "field='$field', value='$value', selector: '$this->selectorStr'" : "";
if(empty($operator)) $operator = '[empty]';
throw new WireException("Unknown Selector operator: '$operator' -- was your selector value properly escaped? $debug");
}
}
$class = wireClassName(self::$selectorTypes[$operator], true);
/** @var Selector $selector */
$selector = $this->wire(new $class($field, $value));
if($not) $selector->not = true;
return $selector;
}
/**
* Given a selector string, populate to Selector objects in this Selectors instance
*
* @param string $str The string containing a selector (or multiple selectors, separated by commas)
*
*/
protected function extractString($str) {
while(strlen($str)) {
$not = false;
$quote = '';
if(strpos($str, '!') === 0) {
$str = ltrim($str, '!');
$not = true;
}
$group = $this->extractGroup($str);
$field = $this->extractField($str);
$operators = $this->extractOperators($str);
$operator = array_shift($operators);
$value = $this->extractValue($str, $quote);
if($this->parseVars && $quote === '[' && $this->valueHasVar($value)) {
// parse an API variable property to a string value
$v = $this->parseValue($value);
if($v !== null) {
$value = $v;
$quote = '';
}
}
if($field || $value || strlen("$value")) {
$selector = $this->create($field, $operator, $value);
if(!is_null($group)) $selector->group = $group;
if($quote) $selector->quote = $quote;
if($not) $selector->not = true;
if(count($operators)) $selector->altOperators = $operators;
$this->add($selector);
}
}
}
/**
* Given a string like name@field=... or @field=... extract the part that comes before the @
*
* This part indicates the group name, which may also be blank to indicate grouping with other blank grouped items
*
* @param string $str
* @return null|string
*
*/
protected function extractGroup(&$str) {
$group = null;
$pos = strpos($str, '@');
if($pos === false) return null;
if($pos === 0) {
$group = '';
$str = substr($str, 1);
} else if(preg_match('/^([-_a-zA-Z0-9]*)@(.*)/', $str, $matches)) {
$group = $matches[1];
$str = $matches[2];
}
return $group;
}
/**
* Given a string starting with a field, return that field, and remove it from $str.
*
* @param string $str
* @return string
*
*/
protected function extractField(&$str) {
$field = '';
if(strpos($str, '(') === 0) {
// OR selector where specification of field name is optional and = operator is assumed
$str = '=(' . substr($str, 1);
return $field;
}
if(preg_match('/^(!?[_|.a-zA-Z0-9]+)(.*)/', $str, $matches)) {
$field = trim($matches[1], '|');
$str = $matches[2];
if(strpos($field, '|')) {
$field = explode('|', $field);
}
}
return $field;
}
/**
* Given a string starting with an operator, return that operator, and remove it from $str.
*
* @param string $str
* @param array $operatorChars
* @return string
* @deprecated Replaced by extractOperators()
* @todo this method can be removed once confirmed nothing else uses it
*
*/
protected function extractOperator(&$str, array $operatorChars) {
$n = 0;
$operator = '';
$lastOperator = '';
while(isset($str[$n]) && in_array($str[$n], $operatorChars) && $n < self::maxOperatorLength) {
$operator .= $str[$n];
if(self::isOperator($operator)) {
$lastOperator = $operator;
} else if($lastOperator) {
$operator = $lastOperator;
break;
}
$n++;
}
if($operator) $str = substr($str, $n);
return $operator;
}
/**
* Given a string starting with an operator, return that operator, and remove it from $str.
*
* @param string $str
* @return array
*
*/
protected function extractOperators(&$str) {
$n = 0;
$not = false;
$operator = '';
$lastOperator = '';
$operators = array();
$operatorChars = self::getOperatorChars();
while(isset($str[$n]) && isset($operatorChars[$str[$n]])) {
$c = $str[$n];
if($operator === '!' && $c !== '=') {
// beginning of operator negation thats not "!="
$not = true;
$operator = ltrim($operator, '!');
}
$operator .= $c;
if(self::isOperator($operator)) {
$lastOperator = $operator;
} else if($lastOperator) {
if($not) $lastOperator = "!$lastOperator";
$operators[$lastOperator] = $lastOperator;
$lastOperator = '';
$operator = $c;
$not = false;
}
$n++;
}
if($lastOperator) {
if($not) $lastOperator = "!$lastOperator";
$operators[$lastOperator] = $lastOperator;
}
if(count($operators)) {
$str = substr($str, $n);
}
if($operator && !isset($operators[$lastOperator])) {
// leftover characters in $operator, maybe from operator in wrong order
$fail = true;
if(!count($operators)) {
// check if operator has a typo we can fix
// isOperator with 2nd argument true allows for and corrects some order mixups
$op = self::isOperator($operator, true);
if($op) {
if($not) $op = "!$op";
$operators[$op] = $op;
$str = substr($str, $n);
$fail = false;
}
}
if($fail) {
throw new WireException("Unrecognized operator: $operator");
}
}
return $operators;
}
/**
* Early-exit optimizations for extractValue
*
* @param string $str String to extract value from, $str will be modified if extraction successful
* @param string $openingQuote Opening quote character, if string has them, blank string otherwise
* @param string $closingQuote Closing quote character, if string has them, blank string otherwise
* @return false|string|string[] Returns found value if successful, boolean false if not
*
*/
protected function extractValueQuick(&$str, $openingQuote, $closingQuote) {
// determine where value ends
$offset = 0;
if($openingQuote) $offset++; // skip over leading quote
$commaPos = strpos("$str,", $closingQuote . ',', $offset); // "$str," just in case value is last and no trailing comma
if($commaPos === false && $closingQuote) {
// if closing quote and comma didn't match, try to match just comma in case of "something"<space>,
$str1 = substr($str, 1);
$commaPos = strpos($str1, ',');
if($commaPos !== false) {
$closingQuotePos = strpos($str1, $closingQuote);
if($closingQuotePos > $commaPos) {
// comma is in quotes and thus not one we want to work with
return false;
} else {
// increment by 1 since it was derived from a string at position 1 (rather than 0)
$commaPos++;
}
}
}
if($commaPos === false) {
// value is the last one in $str
$commaPos = strlen($str);
} else if($commaPos && $str[$commaPos-1] === '//') {
// escaped comma or closing quote means no optimization possible here
return false;
}
// extract the value for testing
$value = substr($str, 0, $commaPos);
// if there is an operator present, it might be a subselector or OR-group
if(self::stringHasOperator($value)) return false;
if($openingQuote) {
// if there were quotes, trim them out
$value = trim($value, $openingQuote . $closingQuote);
}
// determine if there are any embedded quotes in the value
$hasEmbeddedQuotes = false;
foreach($this->quotes as $open => $close) {
if(strpos($value, $open)) $hasEmbeddedQuotes = true;
}
// if value contains quotes anywhere inside of it, abort optimization
if($hasEmbeddedQuotes) return false;
// does the value contain possible OR conditions?
if(strpos($value, '|') !== false) {
// if there is an escaped pipe, abort optimization attempt
if(strpos($value, '\\' . '|') !== false) return false;
// if value was surrounded in "quotes" or 'quotes' abort optimization attempt
// as the pipe is a literal value rather than an OR
if($openingQuote == '"' || $openingQuote == "'") return false;
// we have valid OR conditions, so convert to an array
$value = explode('|', $value);
}
// if we reach this point we have a successful extraction and can remove value from str
// $str = $commaPos ? trim(substr($str, $commaPos+1)) : '';
$str = trim(substr($str, $commaPos+1));
// successful optimization
return $value;
}
/**
* Given a string starting with a value, return that value, and remove it from $str.
*
* @param string $str String to extract value from
* @param string $quote Automatically populated with quote type, if found
* @return array|string Found values or value (excluding quotes)
*
*/
protected function extractValue(&$str, &$quote) {
$sanitizer = $this->wire()->sanitizer;
$str = trim($str);
if(!strlen($str)) return '';
if(isset($this->quotes[$str[0]])) {
$openingQuote = $str[0];
$closingQuote = $this->quotes[$openingQuote];
$quote = $openingQuote;
$n = 1;
} else {
$openingQuote = '';
$closingQuote = '';
$n = 0;
}
$value = $this->extractValueQuick($str, $openingQuote, $closingQuote); // see if we can do a quick exit
if($value !== false) return $value;
$value = '';
$lastc = '';
$quoteDepth = 0;
$inDoubleQuote = false; // applies only if openingQuote is populated and not itself a double quote
do {
if(!isset($str[$n])) break;
$c = $str[$n];
if($openingQuote) {
// we are in a quoted value string
if($c === $closingQuote && !$inDoubleQuote) {
// closing quote for previously opened quote
if($lastc !== '\\') {
// same quote that opened, and not escaped or double quoted
// means the end of the value
if($quoteDepth > 0) {
// closing of an embedded quote
$quoteDepth--;
} else {
$n++; // skip over quote
$quote = $openingQuote;
break;
}
} else {
// this is an intentionally escaped quote, remove the escape
$value = rtrim($value, '\\');
}
} else if($c === $openingQuote && $openingQuote !== $closingQuote) {
// another opening quote of the same type encountered while already in a quote
if(!$inDoubleQuote) $quoteDepth++;
} else if($c === '"') {
// double quote char
// not reachable if openingQuote was a double quote
if($inDoubleQuote) {
// closing a previously opened double quote
$inDoubleQuote = false;
} else {
// potentially applicable double quote
list($on, $op) = array($n, '', '');
// check if an operator came before the quote
while($on > 0 && isset(self::$operatorChars[$str[--$on]])) {
$op = self::$operatorChars[$str[$on]] . $op;
}
// if something valid does prefix the operator, cancel the operator
if(!$on || !$sanitizer->fieldName($str[$on])) $op = '';
// if an operator came before the quote, and it closes somewhere,
// we will allow the embedded double quote
if(strlen($op) && self::isOperator($op) && strrpos($str, '"') > $n) {
// opening a double quote after an operator
$inDoubleQuote = true;
} else {
// abandon the double quote
$c = null;
}
}
}
} else {
// we are in an un-quoted value string
if($c == ',' || $c == '|') {
if($lastc != '\\') {
// a non-quoted, non-escaped comma terminates the value
break;
} else {
// an intentionally escaped comma
// so remove the escape
$value = rtrim($value, '\\');
}
}
}
if($c !== null) {
$value .= $c;
$lastc = $c;
}
} while(++$n);
if($inDoubleQuote) $value .= '"'; // close double quote
$len = strlen("$value");
if($len) {
$str = substr($str, $n);
// if($len > self::maxValueLength) $value = substr($value, 0, self::maxValueLength);
}
$str = ltrim($str, ' ,"\']})'); // should be executed even if blank value
// check if a pipe character is present next, indicating an OR value may be provided
if(strlen($str) > 1 && substr($str, 0, 1) == '|') {
$str = substr($str, 1);
// perform a recursive extract to account for all OR values
$v = $this->extractValue($str, $quote);
$quote = ''; // we don't support separately quoted OR values
$value = array($value);
if(is_array($v)) $value = array_merge($value, $v);
else $value[] = $v;
}
return $value;
}
/**
* Given a value string with an "api_var" or "api_var.property" return the string value of the property
*
* #pw-internal
*
* @param string $value var or var.property
* @return null|string Returns null if it doesn't resolve to anything or a string of the value it resolves to
*
*/
public function parseValue($value) {
if(!preg_match('/^\$?[_a-zA-Z0-9]+(?:\.[_a-zA-Z0-9]+)?$/', $value)) return null;
$property = '';
if(strpos($value, '.')) list($value, $property) = explode('.', $value);
if(!in_array($value, $this->allowedParseVars)) return null;
$value = $this->wire($value);
if(is_null($value)) return null; // does not resolve to API var
if(empty($property)) return (string) $value; // no property requested, just return string value
if(!is_object($value)) return null; // property requested, but value is not an object
return (string) $value->$property;
}
/**
* Set whether or not vars should be parsed
*
* By default this is true, so only need to call this method to disable variable parsing.
*
* #pw-internal
*
* @param bool $parseVars
*
*/
public function setParseVars($parseVars) {
$this->parseVars = $parseVars ? true : false;
}
/**
* Does the given Selector value contain a parseable value?
*
* #pw-internal
*
* @param Selector $selector
* @return bool
*
*/
public function selectorHasVar(Selector $selector) {
if($selector->quote != '[') return false;
$has = false;
foreach($selector->values as $value) {
if($this->valueHasVar($value)) {
$has = true;
break;
}
}
return $has;
}
/**
* Does the given value contain an API var reference?
*
* It is assumed the value was quoted in "[value]", and the quotes are not there now.
*
* #pw-internal
*
* @param string $value The value to evaluate
* @return bool
*
*/
public function valueHasVar($value) {
if(self::stringHasOperator($value)) return false;
if(strpos($value, '.') !== false) {
list($name, $subname) = explode('.', $value);
} else {
$name = $value;
$subname = '';
}
if(!in_array($name, $this->allowedParseVars)) return false;
if(strlen($subname) && $this->wire()->sanitizer->fieldName($subname) !== $subname) return false;
return true;
}
/**
* Return array of all field names referenced in all of the Selector objects here
*
* @param bool $subfields Default is to allow "field.subfield" fields, or specify false to convert them to just "field".
* @return array Returned array has both keys and values as field names (same)
*
*/
public function getAllFields($subfields = true) {
$fields = array();
foreach($this as $selector) {
$field = $selector->field;
if(!is_array($field)) $field = array($field);
foreach($field as $f) {
if(!$subfields && strpos($f, '.')) {
list($f, $subfield) = explode('.', $f, 2);
if($subfield) {} // ignore
}
$fields[$f] = $f;
}
}
return $fields;
}
/**
* Return array of all values referenced in all Selector objects here
*
* @return array Returned array has both keys and values as field values (same)
*
*/
public function getAllValues() {
$values = array();
foreach($this as $selector) {
$value = $selector->value;
if(!is_array($value)) $value = array($value);
foreach($value as $v) {
$values[$v] = $v;
}
}
return $values;
}
/**
* Does the given Wire match these Selectors?
*
* @param Wire $item
* @return bool
*
*/
public function matches(Wire $item) {
// if item provides it's own matches function, then let it have control
if($item instanceof WireMatchable) return $item->matches($this);
$matches = true;
foreach($this as $selector) {
$value = array();
foreach($selector->fields as $property) {
if(strpos($property, '.') && $item instanceof WireData) {
$value[] = $item->getDot($property);
} else {
$value[] = (string) $item->$property;
}
}
if(!$selector->matches($value)) {
$matches = false;
// attempt any alternate operators, if present
foreach($selector->altOperators as $altOperator) {
$altSelector = self::getSelectorByOperator($altOperator);
if(!$altSelector) continue;
$this->wire($altSelector);
$selector->copyTo($altSelector);
$matches = $altSelector->matches($value);
if($matches) break;
}
// if neither selector nor altSelectors match then stop
if(!$matches) break;
}
}
return $matches;
}
/**
* Return string indicating given data type for use in selector arrays
*
* @param int|string|array $data
* @return string
*
*/
protected function getSelectorArrayType($data) {
$dataType = '';
if(is_int($data)) {
$dataType = 'int';
} else if(is_string($data)) {
$dataType = 'string';
} else if(is_array($data)) {
$dataType = ctype_digit(implode('', array_keys($data))) ? 'array' : 'assoc';
if($dataType == 'assoc' && isset($data['field'])) $dataType = 'verbose';
}
return $dataType;
}
/**
* Extract and return operator from end of field name, as used by selector arrays
*
* @param string $field
* @return array
*
*/
protected function getOperatorsFromField(&$field) {
$operators = array_keys(self::$selectorTypes);
$operatorsStr = implode('', $operators);
$c = substr($field, -1);
if(ctype_alnum($c)) return array('=');
$op = '';
while(strpos($operatorsStr, $c) !== false && strlen($field)) {
$op = $c . $op;
$field = substr($field, 0, -1);
$c = substr($field, -1);
}
if(empty($op)) return array('=');
$operators = $this->extractOperators($op);
return $operators;
}
/**
* Create this Selectors object from an array
*
* #pw-internal
*
* @param array $a
* @throws WireException
*
*/
public function setSelectorArray(array $a) {
$groupCnt = 0;
// fields that may only appear once in a selector
$singles = array(
'start' => '',
'limit' => '',
'end' => '',
);
foreach($a as $key => $data) {
$keyType = $this->getSelectorArrayType($key);
$dataType = $this->getSelectorArrayType($data);
if($keyType == 'int' && $dataType == 'assoc') {
// OR-group
$groupCnt++;
foreach($data as $k => $v) {
$s = $this->makeSelectorArrayItem($k, $v);
$selector1 = $this->create($s['field'], $s['operator'], $s['value']);
if(!empty($s['altOperators'])) $selector1->altOperators = $s['altOperators'];
$selector2 = $this->create("or$groupCnt", "=", $selector1);
$selector2->quote = '(';
$this->add($selector2);
}
} else {
$s = $this->makeSelectorArrayItem($key, $data, $dataType);
$field = $s['field'];
if(!is_array($field) && isset($singles[$field])) {
if(empty($singles[$field])) {
// mark it as present
$singles[$field] = true;
} else {
// skip, because this 'single' field has already appeared
continue;
}
}
$selector = $this->create($field, $s['operator'], $s['value']);
if($s['not']) $selector->not = true;
if($s['group']) $selector->group = $s['group'];
if($s['quote']) $selector->quote = $s['quote'];
if(!empty($s['altOperators'])) $selector->altOperators = $s['altOperators'];
$this->add($selector);
}
}
}
/**
* Return an array of an individual Selector info, for use by setSelectorArray() method
*
* @param string|int $key
* @param array $data
* @param string $dataType One of 'string', 'array', 'assoc', or 'verbose'
* @return array
* @throws WireException
*
*/
protected function makeSelectorArrayItem($key, $data, $dataType = '') {
$sanitizer = $this->wire()->sanitizer;
$sanitize = 'selectorValue';
$fields = array();
$values = array();
$operators = array('=');
$whitelist = null;
$not = false;
$group = '';
$find = ''; // sub-selector
$quote = '';
if(empty($dataType)) $dataType = $this->getSelectorArrayType($data);
if(is_int($key) && $dataType == 'verbose') {
// Verbose selector with associative array of properties, in this expected format:
//
// $data = array(
// 'field' => array|string, // field name, or field names
// 'value' => array|string|number|object, // value or values, or omit if using 'find'
// ---the following are optional---
// 'operator' => '=>', // operator, '=' is the default
// 'not' => false, // specify true to make this a NOT condition (default=false)
// 'sanitize' => 'selectorValue', // sanitizer method to use on value(s), 'selectorValue' is default
// 'find' => array(...), // sub-selector to use instead of 'value'
// 'whitelist' => null|array, // whitelist of allowed values, NULL is default, which means ignore.
// );
if(isset($data['fields']) && !isset($data['field'])) $data['field'] = $data['fields']; // allow plural alternate
if(!isset($data['field'])) {
throw new WireException("Invalid selectors array, lacks 'field' property for index $key");
}
if(isset($data['values']) && !isset($data['value'])) $data['value'] = $data['values']; // allow plural alternate
if(!isset($data['value']) && !isset($data['find'])) {
throw new WireException("Invalid selectors array, lacks 'value' property for index $key");
}
if(isset($data['sanitizer']) && !isset($data['sanitize'])) $data['sanitize'] = $data['sanitizer']; // allow alternate
if(isset($data['sanitize'])) $sanitize = $sanitizer->fieldName($data['sanitize']);
if(!empty($data['operator'])) $operators = $this->extractOperators($data['operator']);
if(!empty($data['not'])) $not = (bool) $data['not'];
// may use either 'group' or 'or' to specify or-group
if(!empty($data['group'])) {
$group = $sanitizer->fieldName($data['group']);
} else if(!empty($data['or'])) {
$group = $sanitizer->fieldName($data['or']);
}
if(!empty($data['find'])) {
if(isset($data['value'])) throw new WireException("You may not specify both 'value' and 'find' at the same time");
// if(!is_array($data['find'])) throw new WireException("Selector 'find' property must be specified as array");
$find = $data['find'];
$data['value'] = array();
}
if(isset($data['whitelist'])) {
$whitelist = $data['whitelist'];
if($whitelist instanceof WireArray) $whitelist = explode('|', (string) $whitelist);
if(!is_array($whitelist)) $whitelist = array($whitelist);
}
if($sanitize && $sanitize != 'selectorValue' && !method_exists($sanitizer, $sanitize)) {
throw new WireException("Unrecognized sanitize method: " . $sanitizer->name($sanitize));
}
$_fields = is_array($data['field']) ? $data['field'] : array($data['field']);
$_values = is_array($data['value']) ? $data['value'] : array($data['value']);
} else if(is_string($key)) {
// Non-verbose selector, where $key is the field name and $data is the value
// The $key field name may have an optional operator appended to it
$operators = $this->getOperatorsFromField($key);
$_fields = strpos($key, '|') ? explode('|', $key) : array($key);
$_values = is_array($data) ? $data : array($data);
} else if($dataType == 'array') {
// selector in format: array('field', 'operator', 'value', 'sanitizer_method')
// or array('field', 'operator', 'value', array('whitelist value1', 'whitelist value2', 'etc'))
// or array('field', 'operator', 'value')
// or array('field', 'value') where '=' is assumed operator
$field = '';
$value = array();
if(count($data) == 4) {
list($field, $operator, $value, $_sanitize) = $data;
$operators = $this->extractOperators($operator);
if(is_array($_sanitize)) {
$whitelist = $_sanitize;
} else {
$sanitize = $sanitizer->name($_sanitize);
}
} else if(count($data) == 3) {
list($field, $operator, $value) = $data;
$operators = $this->extractOperators($operator);
} else if(count($data) == 2) {
list($field, $value) = $data;
$operators = $this->getOperatorsFromField($field);
}
if(is_array($field)) {
$_fields = $field;
} else {
$_fields = strpos($field, '|') ? explode('|', $field) : array($field);
}
$_values = is_array($value) ? $value : array($value);
} else {
throw new WireException("Unable to resolve selector array");
}
// make sure operator is valid
foreach($operators as $operator) {
if(!isset(self::$selectorTypes[$operator])) {
throw new WireException("Unrecognized selector operator '$operator'");
}
}
// determine field(s)
foreach($_fields as $name) {
if(strpos($name, '.') !== false) {
// field name with multiple.named.parts, sanitize them separately
$parts = explode('.', $name);
foreach($parts as $n => $part) {
$parts[$n] = $sanitizer->fieldName($part);
}
$_name = implode('.', $parts);
} else {
$_name = $sanitizer->fieldName($name);
}
if($_name !== $name) {
throw new WireException("Invalid Selectors field name (sanitized value '$_name' did not match specified value)");
}
$fields[] = $_name;
}
// convert WireArray types to an array of $_values
if(count($_values) === 1) {
$value = reset($_values);
if($value instanceof WireArray) {
$_values = explode('|', (string) $value);
}
}
// determine value(s)
foreach($_values as $value) {
$_sanitize = $sanitize;
if(is_array($value)) $value = 'array'; // we don't allow arrays here
if(is_object($value)) $value = (string) $value;
if(is_int($value) || (ctype_digit("$value") && strpos($value, '0') !== 0)) {
$value = (int) $value;
if($_sanitize == 'selectorValue') $_sanitize = ''; // no need to sanitize integer to string
}
if(is_array($whitelist) && !in_array($value, $whitelist)) {
$fieldsStr = implode('|', $fields);
throw new WireException("Value given for '$fieldsStr' is not in provided whitelist");
}
if($_sanitize === 'selectorValue') {
$value = $sanitizer->selectorValue($value, array('useQuotes' => false));
} else if($_sanitize) {
$value = $sanitizer->$_sanitize($value);
}
$values[] = $value;
}
if($find) {
// sub-selector find
$quote = '[';
$values = new Selectors($find);
} else if($group) {
// groups use quotes '()'
$quote = '(';
}
return array(
'field' => count($fields) > 1 ? $fields : reset($fields),
'value' => count($values) > 1 ? $values : reset($values),
'operator' => array_shift($operators),
'altOperators' => $operators,
'not' => $not,
'group' => $group,
'quote' => $quote,
);
}
/**
* Get the first selector that uses given field name
*
* This is useful for quickly retrieving values of reserved properties like "include", "limit", "start", etc.
*
* Using **$or:** By default this excludes selectors that have fields in an OR expression, like "a|b|c".
* So if you specified field "a" it would not be matched. If you wanted it to still match, specify true
* for the $or argument.
*
* Using **$all:** By default only the first matching selector is returned. If you want it to return all
* matching selectors in an array, then specify true for the $all argument. This changes the return value
* to always be an array of Selector objects, or a blank array if no match.
*
* @param string $fieldName Name of field to return value for (i.e. "include", "limit", etc.)
* @param bool $or Allow fields that appear in OR expressions? (default=false)
* @param bool $all Return an array of all matching Selector objects? (default=false)
* @return Selector|array|null Returns null if field not present in selectors (or blank array if $all mode)
*
*/
public function getSelectorByField($fieldName, $or = false, $all = false) {
$selector = null;
$matches = array();
foreach($this as $sel) {
if($or) {
if(!in_array($fieldName, $sel->fields)) continue;
} else {
if($sel->field() !== $fieldName) continue;
}
if($all) {
$matches[] = $sel;
} else {
$selector = $sel;
break;
}
}
return $all ? $matches : $selector;
}
/**
* Get the first selector that uses given field name AND has the given value
*
* Using **$or:** By default this excludes selectors that have fields or values in an OR expression, like "a|b|c".
* So if you specified field "a" it would not be matched. If you wanted it to still match, specify true
* for the $or argument.
*
* Using **$all:** By default only the first matching selector is returned. If you want it to return all
* matching selectors in an array, then specify true for the $all argument. This changes the return value
* to always be an array of Selector objects, or a blank array if no match.
*
* @param string $fieldName Name of field to match
* @param string|int $value Value that must match
* @param bool $or Allow fields and values that appear in OR expressions? (default=false)
* @param bool $all Return an array of all matching Selector objects? (default=false)
* @return Selector|array|null Returns null if field not present in selectors (or blank array if $all mode)
* @since 3.0.142
*
*/
public function getSelectorByFieldValue($fieldName, $value, $or = false, $all = false) {
$selectors = $this->getSelectorByField($fieldName, $or, true);
$matches = array();
foreach($selectors as $sel) {
/** @var Selector $sel */
if($or) {
if(in_array($value, $sel->values())) $matches[] = $sel;
} else {
if($sel->value() == $value) $matches[] = $sel;
}
if(!$all && count($matches)) break;
}
if($all) return $matches;
return count($matches) ? $matches[0] : null;
}
/**
* Value when typecast as string
*
* @return string
*
*/
public function __toString() {
$str = '';
foreach($this as $selector) {
$str .= $selector->str . ", ";
}
return rtrim($str, ", ");
}
/**
* Debug info
*
* @return array
*
*/
public function __debugInfo() {
$info = parent::__debugInfo();
$info['string'] = $this->__toString();
return $info;
}
/**
* Debug info for Selector item
*
* @param Selector|mixed $item
* @return array|mixed|null|string
*
*/
public function debugInfoItem($item) {
if($item instanceof Selector) return $item->__debugInfo();
return parent::debugInfoItem($item);
}
/*** STATIC HELPERS *******************************************************************************/
/**
* Add a Selector type that processes a specific operator
*
* Static since there may be multiple instances of this Selectors class at runtime.
* See Selector.php
*
* #pw-internal
*
* @param string $operator
* @param string $class
*
*/
static public function addType($operator, $class) {
self::$selectorTypes[$operator] = $class;
for($n = 0; $n < strlen($operator); $n++) {
$c = $operator[$n];
self::$operatorChars[$c] = $c;
}
}
/**
* Get all operators allowed by selectors
*
* #pw-group-static-helpers
*
* @param array $options
* - `operator` (string): Return info for only this operator. When specified, only value is returned (default='').
* - `compareType` (int): Return only operators matching given `Selector::compareType*` constant (default=0).
* - `getIndexType` (string): Index type to use in returned array: 'operator', 'className', 'class', or 'none' (default='class')
* - `getValueType` (string): Value type to use in returned array: 'operator', 'class', 'className', 'label', 'description', 'compareType', 'verbose' (default='operator').
* If 'verbose' option used then assoc array returned for each operator containing 'class', 'className', 'operator', 'compareType', 'label', 'description'.
* @return array|string|int Returned array where values are operators and keys are class names (or requested 'getIndexType or 'getValueType' options)
* If 'operator' option specified, return value is string, int or array (of requested 'getValueType'), and there is no index.
* @since 3.0.154
*
*/
static public function getOperators(array $options = array()) {
$defaults = array(
'operator' => '',
'getIndexType' => 'class',
'getValueType' => 'operator',
'compareType' => 0,
);
$options = array_merge($defaults, $options);
$operators = array();
$compareType = (int) $options['compareType'];
$indexType = $options['getIndexType'];
$valueType = $options['getValueType'];
$selectorTypes = self::$selectorTypes;
if(!empty($options['operator'])) {
$operator = $options['operator'];
if($operator[0] === '!' && $operator !== '!=') {
// negated operator
$operator = ltrim($operator, '!');
}
if(!isset($selectorTypes[$operator])) {
// operator does not exist
if($valueType === 'compareType') return 0;
return $valueType === 'verbose' ? array() : '';
}
$selectorTypes = array($operator => $selectorTypes[$operator]);
}
foreach($selectorTypes as $operator => $typeName) {
$className = __NAMESPACE__ . "\\$typeName";
if($compareType) {
/** @var Selector $className */
if(!($className::getCompareType() & $options['compareType'])) continue;
}
if($valueType === 'class') {
$value = $typeName;
} else if($valueType === 'className') {
$value = $className;
} else if($valueType === 'label') {
$value = $className::getLabel();
} else if($valueType === 'description') {
$value = $className::getDescription();
} else if($valueType === 'compareType') {
$value = $className::getCompareType();
} else if($valueType === 'verbose') {
$value = array(
'operator' => $operator,
'class' => $typeName,
'className' => $className,
'compareType' => $className::getCompareType(),
'label' => $className::getLabel(),
'description' => $className::getDescription(),
);
} else {
$value = $operator;
}
if($indexType === 'none') {
$key = '';
} else if($indexType === 'class') {
$key = $typeName;
} else if($indexType === 'className') {
$key = $className;
} else {
$key = $operator;
}
if($key === '') {
$operators[] = $value;
} else {
$operators[$key] = $value;
}
}
if(!empty($options['operator'])) return reset($operators);
return $operators;
}
/**
* Return array of all valid operator characters
*
* #pw-group-static-helpers
*
* @return array
*
*/
static public function getOperatorChars() {
return self::$operatorChars;
}
/**
* Return array of other characters that have meaning in a selector outside of operators
*
* #pw-group-static-helpers
*
* @return array
* @since 3.0.156
*
*/
static public function getReservedChars() {
return array(
'or' => '|', // title|body=foo, summary=bar|baz
'not' => '!', // !body*=suchi tobiko
'separator' => ',', // foo=bar, bar=baz
'match-same-1' => '@', // @foo.bar=123, @foo.baz=456
'quote-value' => '"', // foo="bar"
'or-group-open' => '(', // id>0, (title=foo), (body=bar)
'or-group-close' => ')',
'sub-selector-open' => '[', // foo=[bar>0, baz%=text]
'sub-selector-close' => ']',
'api-var-open' => '[', // [page], [page.id], [user.id], etc.
'api-var-close' => ']',
);
}
/**
* Return a string indicating the type of operator that it is, or false if not an operator
*
* #pw-group-static-helpers
*
* @param string $operator Operator to check
* @param bool $is Change return value to just boolean true or false.
* @return bool|string
* @since 3.0.108
*
*/
static public function getOperatorType($operator, $is = false) {
if(!isset(self::$selectorTypes[$operator])) return false;
$type = self::$selectorTypes[$operator];
// now double check that we can map it back, in case PHP filters anything in the isset()
$op = array_search($type, self::$selectorTypes);
if($op === $operator) {
if($is) return true;
// Convert types like "SelectorEquals" to "Equals"
if(strpos($type, 'Selector') === 0) list(,$type) = explode('Selector', $type, 2);
return $type;
}
return false;
}
/**
* Given an operator, return Selector instance (or other requested Selector property)
*
* When getting a Selector instance, be sure to populate its `field` and `value` properties after retrieving it.
*
* #pw-group-static-helpers
*
* @param string $operator Operator to get Selector instance for
* @param string $property One of 'instance,', 'label', 'compareType', 'class', 'className' (default='instance')
* @return Selector|int|string|false Returns false if operator or property not recognized
* @since 3.0.160
*
*/
static public function getSelectorByOperator($operator, $property = 'instance') {
if(!isset(self::$selectorTypes[$operator])) return false;
$typeName = self::$selectorTypes[$operator];
/** @var Selector $className */
$className = __NAMESPACE__ . "\\$typeName";
if($property === 'instance' || $property === '') return new $className('', null);
if($property === 'compareType') return $className::getCompareType();
if($property === 'className') return $className;
if($property === 'label') return $className::getLabel();
if($property === 'class') return $typeName;
return false;
}
/**
* Returns true if given string is a recognized operator, or false if not
*
* #pw-group-static-helpers
*
* @param string $operator
* @param bool $returnOperator Return the operator rather than bool? When true, corrects minor typos, like mixed up
* order, returning correct found operator string if possible, false otherwise. Added 3.0.162. (default=false)
* @return bool|string
* @since 3.0.108
*
*/
static public function isOperator($operator, $returnOperator = false) {
$is = self::getOperatorType($operator, true);
if(!$returnOperator || strlen($operator) < 3) return $is;
if($is) return $operator;
$op = strrev(trim($operator, '=')) . '=';
return self::getOperatorType($op, true) ? $op : false;
}
/**
* Does the given string have an operator in it?
*
* #pw-group-static-helpers
*
* @param string $str String that might contain an operator
* @param bool $getOperator Specify true to return the operator that was found, or false if not (since 3.0.108)
* @return bool
*
*/
static public function stringHasOperator($str, $getOperator = false) {
static $letters = 'abcdefghijklmnopqrstuvwxyz';
static $digits = '_0123456789';
$has = false;
$str = (string) $str;
foreach(self::$selectorTypes as $operator => $unused) {
if($operator == '&') continue; // this operator is too common in other contexts
$pos = strpos($str, $operator);
if(!$pos) continue; // if pos is 0 or false, move onto the next
// possible match: confirm that field name precedes an operator
// if(preg_match('/\b[_a-zA-Z0-9]+' . preg_quote($operator) . '/', $str)) {
$c = $str[$pos-1]; // letter before the operator
if(stripos($letters, $c) !== false) {
// if a letter appears as the character before operator, then we're good
$has = true;
} else if(strpos($digits, $c) !== false) {
// if a digit appears as the character before operator, we need to confirm there is at least one letter
// as there can't be a field named 123, for example, which would mean the operator is likely something
// to do with math equations, which we would refuse as a valid selector operator
$n = $pos-1;
while($n > 0) {
$c = $str[--$n];
if(stripos($letters, $c) !== false) {
// if found a letter, then we've got something valid
$has = true;
break;
} else if(strpos($digits, $c) === false) {
// if we've got a non-digit (and non-letter) then definitely not valid
break;
}
}
}
if($has) {
if($getOperator) $getOperator = $operator;
break;
}
}
if($has && $getOperator) return $getOperator;
return $has;
}
/**
* Is the given string a Selector string?
*
* #pw-group-static-helpers
*
* @param string $str String to check for selector(s)
* @return bool
*
*/
static public function stringHasSelector($str) {
if(!self::stringHasOperator($str)) return false;
$has = false;
$alphabet = 'abcdefghijklmnopqrstuvwxyz';
// replace characters that are allowed but aren't useful here
if(strpos($str, '=(') !== false) $str = str_replace('=(', '=1,', $str);
$str = str_replace(array('!', '(', ')', '@', '.', '|', '_'), '', trim(strtolower($str)));
// flatten sub-selectors
$pos = strpos($str, '[');
if($pos && strrpos($str, ']') > $pos) {
$str = str_replace(array(']', '=[', '<[', '>['), array('', '=1,', '<2,', '>3,'), $str);
}
$str = rtrim($str, ", ");
// first character must match alphabet
if(strpos($alphabet, substr($str, 0, 1)) === false) return false;
$operatorChars = implode('', self::getOperatorChars());
if(strpos($str, ',')) {
// split the string into all key=value components and check each individually
$inQuote = '';
$cLast = '';
// replace comments in quoted values so that they aren't considered selector boundaries
for($n = 0; $n < strlen($str); $n++) {
$c = $str[$n];
if($c === ',') {
// commas in quoted values are replaced with semicolons
if($inQuote) $str[$n] = ';';
} else if(($c === '"' || $c === "'") && $cLast != "\\") {
if($inQuote && $inQuote === $c) {
$inQuote = ''; // end quote
} else if(!$inQuote) {
$inQuote = $c; // start quote
}
}
$cLast = $c;
}
$parts = explode(',', $str);
} else {
// outside of verbose mode, only the first apparent selector is checked
$parts = array($str);
}
// check each key=value component
foreach($parts as $part) {
$has = preg_match('/^[a-z][a-z0-9]*([' . $operatorChars . ']+)(.*)$/', trim($part), $matches);
if($has) {
$operator = $matches[1];
$value = $matches[2];
if(!isset(self::$selectorTypes[$operator])) {
$has = false;
} else if(self::stringHasOperator($value) && $value[0] != '"' && $value[0] != "'") {
// operators not allowed in values unless quoted
$has = false;
}
}
if(!$has) break;
}
return $has;
}
/**
* Does given selector have given field (and optionally operator and/or value)?
*
* #pw-group-static-helpers
*
* @param string|array|Selectors $selectors Selector string, array or Selectors object to look in
* @param string|array $fieldName Field name string to match or array of them to match any one of them
* @param array $options
* - `verbose` (bool): Return associative array with verbose result? See return value. (default=false)
* - `operator` (string): Require this operator (default='' for any)
* - `value` (string|int): Require this value (default=null for any)
* - `remove` (bool): Remove matched Selector from Selectors returned in verbose result? (default=false)
* @return array|bool True if has field, false if not, or array with the following, if 'verbose' option requested:
* - `result` (bool): Did it match (true or false)
* - `selector` (Selector|null): Selector object that matched (only if result is true)
* - `selectors` (Selectors|null): Selectors object that was analyzed or null if not needed
* - `field` (string): Field name that matched
* - `operator` (string): Operator that matched
* - `value` (string|null): Value that matched or null if not applicable
* @since 3.0.174
*
*/
static public function selectorHasField($selectors, $fieldName, array $options = array()) {
$defaults = array(
'operator' => '', // require this operator
'value' => null, // require this value
'verbose' => false, // return verbose information?
'remove' => false, // remove matched Selector from Selectors (when/if applicable)
);
$result = array(
'result' => false, // true if field found, false if not
'selectors' => null, // Selectors object when used
'selector' => null, // first Selector that matched
'field' => '', // field name that matched
'operator' => '', // operator that matched
'value' => null, // value that matched or null if not applicable
);
$options = count($options) ? array_merge($defaults, $options) : $defaults;
$fail = false;
if(is_array($selectors)) {
$selectors = new Selectors($selectors);
} else if(is_string($selectors)) {
if(is_array($fieldName)) {
foreach($fieldName as $key => $name) {
if(strpos($selectors, $name) === false) unset($fieldName[$key]);
}
$count = count($fieldName);
$fail = $count === 0;
if($count === 1) $fieldName = reset($fieldName); // simplify 1-item array to string
} else if(strpos($selectors, $fieldName) === false) {
$fail = true;
}
} else if(!$selectors instanceof Selectors) {
$fail = true;
}
if($fail) {
return ($options['verbose'] ? $result : $result['result']);
}
if(!$selectors instanceof Selectors) {
$selectors = new Selectors($selectors);
}
/** @var Selectors $selectors */
foreach($selectors as $selector) {
if($options['operator'] && $selector->operator() !== $options['operator']) continue;
$field = $selector->field;
// require specific field or one of array of fields to match
if(is_string($field)) {
// field is string
if(is_array($fieldName)) {
// find field in fieldName array
if(!in_array($field, $fieldName)) continue;
} else {
// both field and fieldName are strings
if($field !== $fieldName) continue;
}
} else if(is_array($field)) {
// field is array
if(is_array($fieldName)) {
// both field and fieldName are arrays
$has = false;
foreach($fieldName as $name) {
$has = in_array($name, $field) ? $name : false;
if($has) break;
}
if(!$has) continue;
$field = $has;
} else {
// find fieldName in field array
$key = array_search($fieldName, $field);
if($key === false) continue;
$field = $field[$key];
}
} else {
// field in unrecognized format (should not be reachable)
continue;
}
if($options['value'] !== null) {
// require specific value to match
$value = $selector->value;
if(is_array($value)) {
if(!in_array($options['value'], $value)) continue;
// match success
$result['value'] = $options['value'];
} else {
if("$value" !== "$options[value]") continue;
// match success
$result['value'] = $value;
}
} else {
// match success
$result['value'] = $selector->value;
}
if($options['remove']) $selectors->remove($selector);
$result = array_merge($result, array(
'result' => true,
'selectors' => $selectors,
'selector' => $selector,
'field' => $field,
'operator' => $selector->operator(),
));
break;
}
return ($options['verbose'] ? $result : $result['result']);
}
/**
* Simple "a=b, c=d" selector-style string conversion to associative array, for fast/simple needs
*
* - The only supported operator is "=".
* - Each key=value statement should be separated by a comma.
* - Do not use quoted values.
* - If you need a literal comma, use a double comma ",,".
* - If you need a literal equals, use a double equals "==".
*
* #pw-group-static-helpers
*
* @param string $s
* @return array
*
*/
static public function keyValueStringToArray($s) {
if(strpos($s, '~~COMMA') !== false) $s = str_replace('~~COMMA', '', $s);
if(strpos($s, '~~EQUAL') !== false) $s = str_replace('~~EQUAL', '', $s);
$hasEscaped = false;
if(strpos($s, ',,') !== false) {
$s = str_replace(',,', '~~COMMA', $s);
$hasEscaped = true;
}
if(strpos($s, '==') !== false) {
$s = str_replace('==', '~~EQUAL', $s);
$hasEscaped = true;
}
$a = array();
$parts = explode(',', $s);
foreach($parts as $part) {
if(!strpos($part, '=')) continue;
list($key, $value) = explode('=', $part);
if($hasEscaped) $value = str_replace(array('~~COMMA', '~~EQUAL'), array(',', '='), $value);
$a[trim($key)] = trim($value);
}
return $a;
}
/**
* Given an assoc array, convert to a key=value selector-style string
*
* #pw-group-static-helpers
*
* @param array $a
* @return string
*
*/
static public function arrayToKeyValueString($a) {
$s = '';
foreach($a as $key => $value) {
if(strpos($value, ',') !== false) $value = str_replace(array(',,', ','), ',,', $value);
if(strpos($value, '=') !== false) $value = str_replace('=', '==', $value);
$s .= "$key=$value, ";
}
return rtrim($s, ", ");
}
}
Selector::loadSelectorTypes();