From c9c06f833ab2ef1b69639962d8530b082307299b Mon Sep 17 00:00:00 2001 From: Ryan Cramer Date: Fri, 19 Jun 2020 12:48:18 -0400 Subject: [PATCH] Add several new text-matching Selector classes and also some refactoring in main Selectors class --- wire/core/Selector.php | 528 +++++++++++++++++++++++-- wire/core/Selectors.php | 843 +++++++++++++++++++++------------------- 2 files changed, 938 insertions(+), 433 deletions(-) diff --git a/wire/core/Selector.php b/wire/core/Selector.php index e5772b2d..68ada21f 100644 --- a/wire/core/Selector.php +++ b/wire/core/Selector.php @@ -8,7 +8,7 @@ * This file provides the base implementation for a Selector, as well as implementation * for several actual Selector types under the main Selector class. * - * ProcessWire 3.x, Copyright 2016 by Ryan Cramer + * ProcessWire 3.x, Copyright 2020 by Ryan Cramer * https://processwire.com * * #pw-summary Selector maintains a single selector consisting of field name, operator, and value. @@ -49,12 +49,24 @@ * - `SelectorContains` * - `SelectorContainsLike` * - `SelectorContainsWords` + * - `SelectorContainsWordsPartial` (3.0.160+) + * - `SelectorContainsWordsLive` (3.0.160) + * - `SelectorContainsWordsLike` (3.0.160) + * - `SelectorContainsWordsExpand` (3.0.160) + * - `SelectorContainsAnyWords` (3.0.160) + * - `SelectorContainsAnyWordsPartial` (3.0.160) + * - `SelectorContainsAnyWordsLike` (3.0.160) + * - `SelectorContainsExpand` (3.0.160) + * - `SelectorContainsMatch` (3.0.160) + * - `SelectorContainsMatchExpand` (3.0.160) + * - `SelectorContainsAdvance3d` (3.0.160) * - `SelectorStarts` * - `SelectorStartsLike` * - `SelectorEnds` * - `SelectorEndsLike` * - `SelectorBitwiseAnd` * + * * #pw-body * * @property array $fields Fields that were present in selector (same as $field, but always an array). @@ -100,6 +112,24 @@ abstract class Selector extends WireData { * */ const compareTypeBitwise = 16; + + /** + * Comparison type: Expand (value can be expanded to include other results when supported) + * + */ + const compareTypeExpand = 32; + + /** + * Comparison type: Command (value can contain additional commands interpreted by the Selector) + * + */ + const compareTypeCommand = 64; + + /** + * Comparison type: Database (Selector is only applicable for database-driven comparisons) + * + */ + const compareTypeDatabase = 128; /** * Given a field name and value, construct the Selector. @@ -116,15 +146,8 @@ abstract class Selector extends WireData { */ public function __construct($field, $value) { - $not = false; - if(!is_array($field) && isset($field[0]) && $field[0] == '!') { - $not = true; - $field = ltrim($field, '!'); - } - - $this->set('field', $field); - $this->set('value', $value); - $this->set('not', $not); + $this->setField($field); + $this->setValue($value); $this->set('group', null); // group name identified with 'group_name@' before a field name $this->set('quote', ''); // if $value in quotes, this contains either: ', ", [, {, or (, indicating quote type (set by Selectors class) $this->set('forceMatch', null); // boolean true to force match, false to force non-match @@ -242,10 +265,11 @@ abstract class Selector extends WireData { * */ public function get($key) { - if($key == 'operator') return $this->operator(); - if($key == 'str') return $this->__toString(); - if($key == 'values') return $this->values(); - if($key == 'fields') return $this->fields(); + if($key === 'operator') return $this->operator(); + if($key === 'str') return $this->__toString(); + if($key === 'values') return $this->values(); + if($key === 'fields') return $this->fields(); + if($key === 'label') return $this->getLabel(); return parent::get($key); } @@ -271,6 +295,25 @@ abstract class Selector extends WireData { return $field; } + /** + * Set field or fields + * + * @param string|array $field + * @return self + * @since 3.0.160 + * + */ + public function setField($field) { + if(is_array($field)) $field = implode('|', $field); + $field = (string) $field; + $not = strpos($field, '!') === 0; + if($not) $field = ltrim($field, '!'); + if(strpos($field, '|') !== false) $field = explode('|', $field); + parent::set('field', $field); + parent::set('not', $not); + return $this; + } + /** * Returns the selector value(s) with additional processing and forced type options * @@ -302,6 +345,19 @@ abstract class Selector extends WireData { return $value; } + /** + * Set selector value(s) + * + * @param string|int|array|mixed $value + * @return self + * @since 3.0.160 + * + */ + public function setValue($value) { + parent::set('value', $value); + return $this; + } + /** * Set a property of the Selector * @@ -311,9 +367,9 @@ abstract class Selector extends WireData { * */ public function set($key, $value) { - if($key == 'fields') return parent::set('field', $value); - if($key == 'values') return parent::set('value', $value); - if($key == 'operator') { + if($key === 'fields' || $key === 'field') return $this->setField($value); + if($key === 'values' || $key === 'value') return $this->setValue($value); + if($key === 'operator') { $this->error("You cannot set the operator on a Selector: $this"); return $this; } @@ -345,6 +401,17 @@ abstract class Selector extends WireData { public static function getCompareType() { return 0; } + + /** + * Get label that describes this Selector + * + * @return string + * @since 3.0.160 + * + */ + public static function getLabel() { + return ''; + } /** * Does $value1 match $value2? @@ -447,6 +514,33 @@ abstract class Selector extends WireData { if($this->not) return !$matches; return $matches; } + + /** + * Sanitize field name + * + * @param string|array $fieldName + * @return string|array + * @todo This needs testing and then to be used by this class + * + */ + protected function sanitizeFieldName($fieldName) { + if(strpos($fieldName, '|') !== false) { + $fieldName = explode('|', $fieldName); + } + if(is_array($fieldName)) { + $fieldNames = array(); + foreach($fieldName as $name) { + $name = $this->sanitizeFieldName($name); + if($name !== '') $fieldNames[] = $name; + } + return $fieldNames; + } + $fieldName = trim($fieldName, '. '); + if($fieldName === '') return $fieldName; + if(ctype_alnum($fieldName)) return $fieldName; + if(ctype_alnum(str_replace(array('.', '_'), '', $fieldName))) return $fieldName; + return ''; + } /** * The string value of Selector is always the selector string that it originated from @@ -498,6 +592,7 @@ abstract class Selector extends WireData { return $info; } + /** * Add all individual selector types to the runtime Selectors * @@ -505,20 +600,40 @@ abstract class Selector extends WireData { * */ static public function loadSelectorTypes() { - Selectors::addType(SelectorEqual::getOperator(), 'SelectorEqual'); - Selectors::addType(SelectorNotEqual::getOperator(), 'SelectorNotEqual'); - Selectors::addType(SelectorGreaterThan::getOperator(), 'SelectorGreaterThan'); - Selectors::addType(SelectorLessThan::getOperator(), 'SelectorLessThan'); - Selectors::addType(SelectorGreaterThanEqual::getOperator(), 'SelectorGreaterThanEqual'); - Selectors::addType(SelectorLessThanEqual::getOperator(), 'SelectorLessThanEqual'); - Selectors::addType(SelectorContains::getOperator(), 'SelectorContains'); - Selectors::addType(SelectorContainsLike::getOperator(), 'SelectorContainsLike'); - Selectors::addType(SelectorContainsWords::getOperator(), 'SelectorContainsWords'); - Selectors::addType(SelectorStarts::getOperator(), 'SelectorStarts'); - Selectors::addType(SelectorStartsLike::getOperator(), 'SelectorStartsLike'); - Selectors::addType(SelectorEnds::getOperator(), 'SelectorEnds'); - Selectors::addType(SelectorEndsLike::getOperator(), 'SelectorEndsLike'); - Selectors::addType(SelectorBitwiseAnd::getOperator(), 'SelectorBitwiseAnd'); + $types = array( + 'Equal', + 'NotEqual', + 'GreaterThan', + 'LessThan', + 'GreaterThanEqual', + 'LessThanEqual', + 'Contains', + 'ContainsLike', + 'ContainsWords', + 'ContainsWordsPartial', + 'ContainsWordsLive', + 'ContainsWordsLike', + 'ContainsWordsExpand', + 'ContainsAnyWords', + 'ContainsAnyWordsPartial', + 'ContainsAnyWordsLike', + 'ContainsExpand', + 'ContainsMatch', + 'ContainsMatchExpand', + 'ContainsAdvanced', + 'Starts', + 'StartsLike', + 'Ends', + 'EndsLike', + 'BitwiseAnd', + ); + foreach($types as $type) { + $class = "Selector$type"; + /** @var Selector $className */ + $className = __NAMESPACE__ . "\\$class"; + $operator = $className::getOperator(); + Selectors::addType($operator, $class); + } } } @@ -529,6 +644,10 @@ abstract class Selector extends WireData { class SelectorEqual extends Selector { public static function getOperator() { return '='; } public static function getCompareType() { return Selector::compareTypeExact; } + public static function getLabel() { return __('Equals', __FILE__); } + public static function getDescription() { + return __('Given value is exactly the same as value compared to.', __FILE__); + } protected function match($value1, $value2) { return $this->evaluate($value1 == $value2); } } @@ -539,6 +658,10 @@ class SelectorEqual extends Selector { class SelectorNotEqual extends Selector { public static function getOperator() { return '!='; } public static function getCompareType() { return Selector::compareTypeExact; } + public static function getLabel() { return __('Not equals', __FILE__); } + public static function getDescription() { + return __('Given value is not exactly the same as value compared to.', __FILE__); + } protected function match($value1, $value2) { return $this->evaluate($value1 != $value2); } } @@ -549,6 +672,10 @@ class SelectorNotEqual extends Selector { class SelectorGreaterThan extends Selector { public static function getOperator() { return '>'; } public static function getCompareType() { return Selector::compareTypeSort; } + public static function getLabel() { return __('Greater than', __FILE__); } + public static function getDescription() { + return __('Given value is greater than compared value.', __FILE__); + } protected function match($value1, $value2) { return $this->evaluate($value1 > $value2); } } @@ -559,6 +686,10 @@ class SelectorGreaterThan extends Selector { class SelectorLessThan extends Selector { public static function getOperator() { return '<'; } public static function getCompareType() { return Selector::compareTypeSort; } + public static function getLabel() { return __('Less than', __FILE__); } + public static function getDescription() { + return __('Given value is less than compared value.', __FILE__); + } protected function match($value1, $value2) { return $this->evaluate($value1 < $value2); } } @@ -569,6 +700,10 @@ class SelectorLessThan extends Selector { class SelectorGreaterThanEqual extends Selector { public static function getOperator() { return '>='; } public static function getCompareType() { return Selector::compareTypeSort; } + public static function getLabel() { return __('Greater than or equal', __FILE__); } + public static function getDescription() { + return __('Given value is greater than or equal to compared value.', __FILE__); + } protected function match($value1, $value2) { return $this->evaluate($value1 >= $value2); } } @@ -579,6 +714,10 @@ class SelectorGreaterThanEqual extends Selector { class SelectorLessThanEqual extends Selector { public static function getOperator() { return '<='; } public static function getCompareType() { return Selector::compareTypeSort; } + public static function getLabel() { return __('Less than or equal', __FILE__); } + public static function getDescription() { + return __('Given value is less than or equal to compared value.', __FILE__); + } protected function match($value1, $value2) { return $this->evaluate($value1 <= $value2); } } @@ -589,7 +728,27 @@ class SelectorLessThanEqual extends Selector { class SelectorContains extends Selector { public static function getOperator() { return '*='; } public static function getCompareType() { return Selector::compareTypeFind; } - protected function match($value1, $value2) { return $this->evaluate(stripos($value1, $value2) !== false); } + public static function getLabel() { return __('Contains phrase', __FILE__); } + public static function getDescription() { + return __('Given phrase or word appears in value compared to.', __FILE__); + } + protected function match($value1, $value2) { + $matches = stripos($value1, $value2) !== false && preg_match('/\b' . preg_quote($value2) . '/i', $value1); + return $this->evaluate($matches); + } +} + +/** + * Same as SelectorContains but query expansion when used for database searching + * + */ +class SelectorContainsExpand extends SelectorContains { + public static function getOperator() { return '*+='; } + public static function getCompareType() { return Selector::compareTypeFind | Selector::compareTypeExpand | Selector::compareTypeDatabase; } + public static function getLabel() { return __('Contains phrase + expand', __FILE__); } + public static function getDescription() { + return __('Given phrase, word or related terms appear in value compared to.', __FILE__); + } } /** @@ -599,18 +758,27 @@ class SelectorContains extends Selector { class SelectorContainsLike extends SelectorContains { public static function getCompareType() { return Selector::compareTypeFind | Selector::compareTypeLike; } public static function getOperator() { return '%='; } + public static function getLabel() { return __('Contains text like', __FILE__); } + public static function getDescription() { + return __('Given text appears in compared value, without regard to word boundaries.', __FILE__); + } + protected function match($value1, $value2) { return $this->evaluate(stripos($value1, $value2) !== false); } } /** - * Selector that matches one string value that happens to have all of it's words present in another string value (regardless of individual word location) + * Selector that matches one string value that happens to have all of its words present in another string value (regardless of individual word location) * */ class SelectorContainsWords extends Selector { public static function getOperator() { return '~='; } public static function getCompareType() { return Selector::compareTypeFind; } + public static function getLabel() { return __('Contains entire words', __FILE__); } + public static function getDescription() { + return __('All given whole words appear in compared value, in any order.', __FILE__); + } protected function match($value1, $value2) { $hasAll = true; - $words = preg_split('/[-\s]/', $value2, -1, PREG_SPLIT_NO_EMPTY); + $words = $this->wire()->sanitizer->wordsArray($value2); foreach($words as $key => $word) if(!preg_match('/\b' . preg_quote($word) . '\b/i', $value1)) { $hasAll = false; break; @@ -619,6 +787,274 @@ class SelectorContainsWords extends Selector { } } +/** + * Selector that matches all given words in whole or in part starting with + * + */ +class SelectorContainsWordsPartial extends Selector { + public static function getOperator() { return '~*='; } + public static function getCompareType() { return Selector::compareTypeFind; } + public static function getLabel() { return __('Contains words partial', __FILE__); } + public static function getDescription() { + return __('All given partial and whole words appear in compared value, in any order. Partial matches from beginning of words.', __FILE__); + } + protected function match($value1, $value2) { + $hasAll = true; + $words = $this->wire()->sanitizer->wordsArray($value2); + foreach($words as $key => $word) { + if(!preg_match('/\b' . preg_quote($word) . '/i', $value1)) { + $hasAll = false; + break; + } + } + return $this->evaluate($hasAll); + } +} + +/** + * Selector that matches entire words except for last word, which must start with + * + * Useful in matching "live" search results where someone is typing and last word may be partial. + * + */ +class SelectorContainsWordsLive extends Selector { + public static function getOperator() { return '~~='; } + public static function getCompareType() { return Selector::compareTypeFind; } + public static function getLabel() { return __('Contains words live', __FILE__); } + public static function getDescription() { + return __('All given whole words—and at least partial last word—appear in compared value, in any order.', __FILE__); + } + protected function match($value1, $value2) { + $hasAll = true; + $words = $this->wire()->sanitizer->wordsArray($value2); + $lastWord = array_pop($words); + foreach($words as $key => $word) { + if(!preg_match('/\b' . preg_quote($word) . '\b/i', $value1)) { + // full-word match + $hasAll = false; + break; + } + } + // last word only needs to match beginning of word + $hasAll = $hasAll && preg_match('\b' . preg_quote($lastWord) . '/i', $value1); + return $this->evaluate($hasAll); + } +} + +/** + * Selector that matches partial words at either beginning or ending + * + */ +class SelectorContainsWordsLike extends Selector { + public static function getOperator() { return '~%='; } + public static function getCompareType() { return Selector::compareTypeFind | Selector::compareTypeLike; } + public static function getLabel() { return __('Contains words like', __FILE__); } + public static function getDescription() { + return __('All given partial or whole words appear in compared value (in any order) without regard to word boundaries.', __FILE__); + } + protected function match($value1, $value2) { + $hasAll = true; + $words = $this->wire()->sanitizer->wordsArray($value2); + foreach($words as $key => $word) { + if(stripos($value1, $word) === false) { + $hasAll = false; + break; + } + } + return $this->evaluate($hasAll); + } +} + +/** + * Selector that matches all words with query expansion + * + */ +class SelectorContainsWordsExpand extends SelectorContainsWords { + public static function getOperator() { return '~+='; } + public static function getCompareType() { return Selector::compareTypeFind | Selector::compareTypeExpand; } + public static function getLabel() { return __('Contains words + expand', __FILE__); } + public static function getDescription() { + return __('All given whole words appear in compared value (in any order) and expand to match related values.', __FILE__); + } +} + +/** + * Selector that has any of the given whole words (only 1 needs to match) + * + */ +class SelectorContainsAnyWords extends Selector { + public static function getOperator() { return '~|='; } + public static function getCompareType() { return Selector::compareTypeFind; } + public static function getLabel() { return __('Contains any words', __FILE__); } + protected function match($value1, $value2) { + $hasAny = false; + $words = $this->wire()->sanitizer->wordsArray($value2); + foreach($words as $key => $word) { + if(stripos($value1, $word) !== false) { + if(preg_match('!\b' . preg_quote($word) . '\b!i', $value1)) { + $hasAny = true; + break; + } + } + } + return $this->evaluate($hasAny); + } + public static function getDescription() { + return __('Any of the given whole words appear in compared value.', __FILE__); + } +} + +/** + * Selector that has any of the given partial words (starting with, only 1 needs to match) + * + */ +class SelectorContainsAnyWordsPartial extends Selector { + public static function getOperator() { return '~|*='; } + public static function getCompareType() { return Selector::compareTypeFind; } + public static function getLabel() { return __('Contains any partial words', __FILE__); } + protected function match($value1, $value2) { + $hasAny = false; + $words = $this->wire()->sanitizer->wordsArray($value2); + foreach($words as $key => $word) { + if(stripos($value1, $word) !== false) { + if(preg_match('!\b' . preg_quote($word) . '!i', $value1)) { + $hasAny = true; + break; + } + } + } + return $this->evaluate($hasAny); + } + public static function getDescription() { + return __('Any of the given partial or whole words appear in compared value. Partial matches from beginning of words.', __FILE__); + } +} + +/** + * Selector that has any words like any of those given (only 1 needs to match) + * + */ +class SelectorContainsAnyWordsLike extends Selector { + // public static function getOperator() { return '%|='; } + public static function getOperator() { return '~|%='; } + public static function getCompareType() { return Selector::compareTypeFind | Selector::compareTypeLike; } + public static function getLabel() { return __('Contains any words like', __FILE__); } + public static function getDescription() { + return __('Any of the given partial or whole words appear in compared value, without regard to word boundaries.', __FILE__); + } + protected function match($value1, $value2) { + $hasAny = false; + $words = $this->wire()->sanitizer->wordsArray($value2); + foreach($words as $key => $word) { + if(stripos($value1, $word) !== false) { + $hasAny = true; + break; + } + } + return $this->evaluate($hasAny); + } +} + +/** + * Selector that uses standard MySQL MATCH/AGAINST behavior with implied DB-score sorting + * + * This selector is only useful for database $pages->find() queries. + * + */ +class SelectorContainsMatch extends SelectorContainsAnyWords { + public static function getOperator() { return '**='; } + public static function getCompareType() { return Selector::compareTypeFind | Selector::compareTypeDatabase; } + public static function getLabel() { return __('Contains match', __FILE__); } + public static function getDescription() { + return __('Any or all of the given words match compared value using default database logic and score.', __FILE__); + } +} + +/** + * Selector that uses standard MySQL MATCH/AGAINST behavior with implied DB-score sorting + * + * This selector is only useful for database $pages->find() queries. + * + */ +class SelectorContainsMatchExpand extends SelectorContainsMatch { + public static function getOperator() { return '**+='; } + public static function getCompareType() { return Selector::compareTypeFind | Selector::compareTypeExpand | Selector::compareTypeDatabase; } + public static function getLabel() { return __('Contains match + expand', __FILE__); } + public static function getDescription() { + return SelectorContainsMatch::getDescription() . ' ' . __('Plus, expands to include potentially related results.', __FILE__); + } +} + +/** + * Selector for advanced text searches that interprets specific search commands + * + * - `foo` Optional word has no prefix. + * - `+foo` Required word has a "+" prefix. + * - `+foo*` Required words starting with "foo" (i.e. "fool", "foobar", etc.) has "+" prefix and "*" wildcard suffix. + * - `-bar` Disallowed word has a "-" prefix. + * - `-bar*` Disallowed words starting with "bar" (i.e. "barn", "barbell", etc.) has "-" prefix and "*" wildcard suffix. + * - `"foo bar baz"` Optional phrase surrounded in quotes. + * - `+"foo bar baz"` Required phrase with "+" prefix followed by double-quoted value. + * - `-"foo bar baz"` Disallowed phrase with "-" prefix followed by double-quoted value. + * + * Note that to designate a phrase, it must be in "double quotes" (not 'single quotes'). + * + */ +class SelectorContainsAdvanced extends SelectorContains { + public static function getOperator() { return '#='; } + public static function getCompareType() { return Selector::compareTypeFind | Selector::compareTypeCommand; } + public static function getLabel() { return __('Advanced text search', __FILE__); } + public static function getDescription() { + return + __('Match values with commands: +Word MUST appear, -Word MUST NOT appear, and unprefixed Word MAY appear (at least one matches).', __FILE__) . ' ' . + __('Add asterisk for partial match: Bar* or +Bar* matches bar, barn, barge; while -Bar* prevents matching them.') . ' ' . + __('Use quotes to match phrases: +"Must Match", -"Must Not Match", or "May Match".'); + } + protected function match($value1, $value2) { + $fail = false; + $numOptionalMatch = 0; + $commandMatches = array( + '+' => array(), + '-' => array(), + '?' => array(), + ); + if(strpos($value2, '"') !== false && preg_match_all('![-+]?"([^"]+)"!', $value2, $matches)) { + // find all quoted phrases + foreach($matches[0] as $key => $fullMatch) { + $command = strpos($fullMatch, '"') === 0 ? '?' : substr($value2, 0, 1); + $phrase = trim($matches[1][$key]); + if(strlen($phrase)) $commandMatches[$command][] = $phrase; + $value2 = str_replace($fullMatch, ' ', $value2); + } + } + $words = $this->wire()->sanitizer->wordsArray($value2); + foreach($words as $key => $word) { + $command = substr($word, 0, 1); + $word = ltrim($word, '-+'); + if($command !== '+' && $command !== '-') $command = '?'; + $commandMatches[$command][] = $word; + } + foreach($commandMatches as $command => $items) { + foreach($items as $item) { + $partial = substr($item, -1) === '*'; + if($partial) $item = rtrim($item, '*'); + $re = '!\b' . preg_quote($item) . ($partial ? '!i' : '\b!i'); + if($command === '+') { + if(!preg_match($re, $value1)) $fail = true; + } else if($command === '-') { + if(preg_match($re, $value1)) $fail = true; + } else { + if(stripos($value1, $item) !== false) $numOptionalMatch++; + } + if($fail) break; + } + if($fail) break; + } + if(!$fail && count($commandMatches['?']) && !$numOptionalMatch) $fail = true; + return $this->evaluate(!$fail); + } +} + /** * Selector that matches if the value exists at the beginning of another value * @@ -626,7 +1062,13 @@ class SelectorContainsWords extends Selector { class SelectorStarts extends Selector { public static function getOperator() { return '^='; } public static function getCompareType() { return Selector::compareTypeFind; } - protected function match($value1, $value2) { return $this->evaluate(stripos(trim($value1), $value2) === 0); } + public static function getLabel() { return __('Starts with', __FILE__); } + public static function getDescription() { + return __('Given word or phrase appears at beginning of compared value.', __FILE__); + } + protected function match($value1, $value2) { + return $this->evaluate(stripos(trim($value1), $value2) === 0); + } } /** @@ -636,6 +1078,10 @@ class SelectorStarts extends Selector { class SelectorStartsLike extends SelectorStarts { public static function getOperator() { return '%^='; } public static function getCompareType() { return Selector::compareTypeFind | Selector::compareTypeLike; } + public static function getLabel() { return __('Starts like', __FILE__); } + public static function getDescription() { + return __('Given text appears at beginning of compared value, without regard for word boundaries.', __FILE__); + } } /** @@ -645,6 +1091,10 @@ class SelectorStartsLike extends SelectorStarts { class SelectorEnds extends Selector { public static function getOperator() { return '$='; } public static function getCompareType() { return Selector::compareTypeFind; } + public static function getLabel() { return __('Ends with', __FILE__); } + public static function getDescription() { + return __('Given word or phrase appears at end of compared value.', __FILE__); + } protected function match($value1, $value2) { $value2 = trim($value2); $value1 = substr($value1, -1 * strlen($value2)); @@ -659,6 +1109,10 @@ class SelectorEnds extends Selector { class SelectorEndsLike extends SelectorEnds { public static function getOperator() { return '%$='; } public static function getCompareType() { return Selector::compareTypeFind | Selector::compareTypeLike; } + public static function getLabel() { return __('Ends like', __FILE__); } + public static function getDescription() { + return __('Given text appears at end of compared value, without regard for word boundaries.', __FILE__); + } } /** @@ -668,6 +1122,10 @@ class SelectorEndsLike extends SelectorEnds { class SelectorBitwiseAnd extends Selector { public static function getOperator() { return '&'; } public static function getCompareType() { return Selector::compareTypeBitwise; } + public static function getLabel() { return __('Bitwise AND', __FILE__); } + public static function getDescription() { + return __('Given integer value matches bitwise AND with compared integer value.', __FILE__); + } protected function match($value1, $value2) { return $this->evaluate(((int) $value1) & ((int) $value2)); } } diff --git a/wire/core/Selectors.php b/wire/core/Selectors.php index 0dd3fab1..4aca2151 100644 --- a/wire/core/Selectors.php +++ b/wire/core/Selectors.php @@ -31,8 +31,11 @@ require_once(PROCESSWIRE_CORE_PATH . "Selector.php"); * @link https://processwire.com/api/selectors/ Official Selectors Documentation * @method Selector[] getIterator() * - * ProcessWire 3.x, Copyright 2016 by Ryan Cramer + * ProcessWire 3.x, Copyright 2020 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 * */ @@ -184,310 +187,6 @@ class Selectors extends WireArray { return $this->wire(new SelectorEqual('','')); } - /** - * 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' or 'class' (default='class') - * - `getValueType` (string): Value type to use in returned array: 'operator', 'class', 'compareType', 'verbose' (default='operator'). - * If 'verbose' option used then assoc array returned for each operator containing 'class', 'operator', 'compareType'. - * @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'])) { - $selectorTypes = array($options['operator'] => $selectorTypes[$options['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 === 'compareType') { - $value = $className::getCompareType(); - } else if($valueType === 'verbose') { - $value = array( - 'operator' => $operator, - 'class' => $typeName, - 'className' => $className, - 'compareType' => $className::getCompareType(), - ); - } else { - $value = $operator; - } - if($indexType === 'class') { - $key = $typeName; - } else if($indexType === 'className') { - $key = $className; - } else { - $key = $operator; - } - $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 - * - * @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; - } - - /** - * Returns true if given string is a recognized operator, or false if not - * - * @param string $operator - * @return bool - * @since 3.0.108 - * - */ - static public function isOperator($operator) { - return self::getOperatorType($operator, true); - } - - /** - * 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; - - 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; - } - /** * Create a new Selector object from a field name, operator, and value * @@ -524,7 +223,6 @@ class Selectors extends WireArray { return $selector; } - /** * Given a selector string, populate to Selector objects in this Selectors instance * @@ -1019,14 +717,13 @@ class Selectors extends WireArray { return $matches; } - public function __toString() { - $str = ''; - foreach($this as $selector) { - $str .= $selector->str . ", "; - } - return rtrim($str, ", "); - } - + /** + * 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)) { @@ -1039,7 +736,14 @@ class Selectors extends WireArray { } return $dataType; } - + + /** + * Extract and return operator from end of field name, as used by selector arrays + * + * @param string $field + * @return bool|string + * + */ protected function getOperatorFromField(&$field) { $operator = '='; $operators = array_keys(self::$selectorTypes); @@ -1059,7 +763,6 @@ class Selectors extends WireArray { return $operator; } - /** * Create this Selectors object from an array * @@ -1324,68 +1027,6 @@ class Selectors extends WireArray { ); } - /** - * 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 - * - */ - public static 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 $a - * @return string - * - */ - public static 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, ", "); - } - /** * Get the first selector that uses given field name * @@ -1465,40 +1106,446 @@ class Selectors extends WireArray { 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 *******************************************************************************/ + /** - * See if the given $selector specifies the given $field somewhere - * - * @param array|string|Selectors $selector - * @param string $field - * @return bool - * - public static function selectorHasField($selector, $field) { - - if(is_object($selector)) $selector = (string) $selector; - - if(is_array($selector)) { - if(array_key_exists($field, $selector)) return true; - $test = print_r($selector, true); - if(strpos($test, $field) === false) return false; - - - } else if(is_string($selector)) { - if(strpos($selector, $field) === false) return false; // quick exit + * 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' or 'class' (default='class') + * - `getValueType` (string): Value type to use in returned array: 'operator', 'class', 'className', 'label', 'compareType', 'verbose' (default='operator'). + * If 'verbose' option used then assoc array returned for each operator containing 'class', 'className', 'operator', 'compareType', 'label'. + * @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'])) { + $selectorTypes = array($options['operator'] => $selectorTypes[$options['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 === 'compareType') { + $value = $className::getCompareType(); + } else if($valueType === 'verbose') { + $value = array( + 'operator' => $operator, + 'class' => $typeName, + 'className' => $className, + 'compareType' => $className::getCompareType(), + 'label' => $className::getLabel(), + ); + } else { + $value = $operator; + } + if($indexType === 'class') { + $key = $typeName; + } else if($indexType === 'className') { + $key = $className; + } else { + $key = $operator; + } + $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 + * @return bool + * @since 3.0.108 + * + */ + static public function isOperator($operator) { + return self::getOperatorType($operator, true); + } + + /** + * 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; + + 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; + } + + /** + * 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 + * + */ + public static 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 + * + */ + public static 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, ", "); + } + }