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 "

"; * echo "Field(s): " . implode('|', $selector->fields) . "
"; * echo "Operator: " . $selector->operator . "
"; * echo "Value(s): " . implode('|', $selector->values) . "
"; * echo "

"; * } * ~~~~~ * #pw-body * * @link https://processwire.com/api/selectors/ Official Selectors Documentation * @method Selector[] getIterator() * * ProcessWire 3.x, Copyright 2021 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 Selector string or array. If not provided here, please follow-up with a setSelectorString($str) call. * */ 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(is_object($selector) && $selector instanceof Selector) { $this->add($selector); } else { $this->setSelectorString($selector); } } /** * Set the selector string * * #pw-internal * * @param string $selectorStr * */ public function setSelectorString($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 is_object($item) && $item instanceof Selector; } /** * Per WireArray interface, return a blank Selector * * #pw-internal * */ 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); $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 $group; 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 that’s 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 mixed 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", $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) { $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 || !$this->wire('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']) && $data['whitelist'] !== null) { $whitelist = $data['whitelist']; if(is_object($whitelist) && $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(is_object($value) && $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 both keys and values are operators (or values are requested 'valueType' option) * If 'operator' option specified, return value is string, int or array (requested 'valueType'), and there is no indexType. * @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 of 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();