├── .travis.yml ├── README.md ├── Testdox.txt ├── composer.json ├── phpunit.xml.dist ├── src ├── SearchstringParser.php └── SearchstringParser │ ├── ContradictoryModifiersException.php │ ├── InvalidSyntaxException.php │ ├── ModifierAsFirstOrLastTermException.php │ ├── NotAsLastTermException.php │ └── UnclosedQuoteException.php └── test └── SearchstringParserTest.php /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | php: 3 | - 5.3 4 | - 5.4 5 | - 5.5 6 | - 5.6 7 | - 7 8 | - hhvm 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![SensioLabsInsight](https://insight.sensiolabs.com/projects/2dc0e9b6-2357-40bd-a56b-9a8dade3408f/mini.png)](https://insight.sensiolabs.com/projects/2dc0e9b6-2357-40bd-a56b-9a8dade3408f) 2 | [![Build Status](https://travis-ci.org/BlueM/searchstringparser.svg?branch=master)](https://travis-ci.org/BlueM/searchstringparser) 3 | 4 | SearchstringParser Overview 5 | =========================== 6 | 7 | What is it? 8 | -------------- 9 | SearchstringParser is a library for PHP 5.3 or higher which will take a “typical” search-engine-like search string and split it into parts. It supports phrases, required, optional and excluded terms/phrases by using `+`, `-`, `AND`, `NOT` and `OR`. 10 | 11 | If you use a search engine like Apache Solr which does the parsing itself, you may not need a library such as this. But in cases where you need switchable search backends but still have a consistent search syntax or where you simply use your SQL database’s fulltext search features, it provides simple and easy parsing. Equally, even when using a software such as Solr, it can be handy to control what is passed to Solr, for instance to optimize the search syntax (example: by setting the `mm` parameter depending on the number of optional terms). 12 | 13 | 14 | Installation 15 | ------------ 16 | The preferred way to install this library is through [Composer](https://getcomposer.org). For this, add `"bluem/searchstringparser": "~2.0"` to the requirements in your `composer.json` file. As this library uses [semantic versioning](http://semver.org), you will get fixes and feature additions when running `composer update`, but not changes which break the API. 17 | 18 | Alternatively, you can clone the repository using git or download a tagged release. 19 | 20 | 21 | Supported Syntax 22 | ---------------- 23 | 24 | * A term which is prefixed by `+` is regarded as required. Example: `+word`. 25 | * When there is an `AND` between two terms, both are regarded as required. Example: `Hello AND World`. 26 | * A term which is prefixed by `-` is regarded as excluded. Example: `-word` 27 | * A term which is preceded by `NOT` is regarded as excluded. Example: `NOT word` 28 | * When there is an `OR` between two terms, both are regarded as optional. Example: `Hello OR World`. 29 | * Phrases can be specified using double quotes. Double quotes inside a phrase can be escaped using a backslash. Example: `"my \" phrase"`. 30 | * Everything said above regarding `+`, `-`, `AND`, `OR`, `NOT` applies to phrases as well. 31 | 32 | Any term to which none of above rules applies, is by default regarded as an optional term . This can be changed by passing `array('defaultOperator' => SearchstringParser:SYMBOL_AND)` as argument 2 to `SearchstringParser`’s constructor to make such terms required. 33 | 34 | Examples: 35 | 36 | * `Hello World` ➔ Optional terms “Hello” and “World”, no required or excluded terms 37 | * `Hello World -foobar` ➜ Optional terms “Hello” and “World”, excluded term “foobar”, no required terms (Equivalent to: `Hello World NOT foobar`) 38 | * `+"search string parser" "PHP 5.6" OR "PHP 5.3" NOT "PHP 4" NOT C# -C++ C` ➔ Required phrase “search string parser”, optional phrases “PHP 5.6” and “PHP 5.3”, excluded phrases/terms “PHP 4”, “C#” and “C++” and skipped term “C” (which is shorter than the default minimum length of 2 characters) 39 | 40 | Example with `array('defaultOperator' => SearchstringParser:SYMBOL_AND)`: 41 | * `Hello World -foobar` ➜ Required terms “Hello” and “World”, excluded term “foobar”, no optional terms 42 | 43 | 44 | Usage 45 | ======== 46 | ```php 47 | $search = new BlueM\SearchstringParser('Your AND string long OR short NOT "exclude this phrase" X'); 48 | 49 | $search->getAndTerms(); // array('your', 'string') 50 | $search->getOrTerms(); // array('long', 'short') 51 | $search->getNotTerms(); // array('exclude this phrase') 52 | $search->getSkippedTerms(); // array('X') 53 | ``` 54 | 55 | Changing the minimum length 56 | --------------------------- 57 | Simply pass the length to the constructor: 58 | 59 | ```php 60 | $search = new BlueM\SearchstringParser('...', array('minlength' => 3)); 61 | ``` 62 | 63 | 64 | Dealing with errors 65 | --------------------------- 66 | The following errors might occur: 67 | 68 | * A phrase is opened using `"`, but not closed 69 | * “NOT” is used as last term 70 | * “OR” is used as first or last term 71 | * “AND” is used as first or last term 72 | * “OR” is preceded or followed by an excluded term/phrase 73 | * “AND” is preceded or followed by an excluded term/phrase 74 | * “NOT” is followed by a term prefixed with “+” 75 | 76 | The default behaviour is to not throw exceptions, but to make the best out of the situation. (See unit tests or Testdox output for details.) SearchstringParser will still collect exceptions, so if you want to provide hints to the user, you can do that by getting them via method `getExceptions()`. As `SearchstringParser` throws different exceptions depending on the type of problem, you can nicely handle (or ignore) the errors separately, for example by performing `instanceof` checks. 77 | 78 | 79 | Author & License 80 | ==================== 81 | This code was written by Carsten Blüm ([www.bluem.net](http://www.bluem.net)) and licensed under the BSD 2-Clause license. 82 | 83 | 84 | Changes from earlier versions 85 | ============================= 86 | 87 | From 2.0.3 to 2.0.4 88 | ----------------- 89 | * Fix namespace issues in exception classes 90 | * Add PHPUnit as dev-dependency (tests were present, but dependency was missing) 91 | 92 | From 2.0.2 to 2.0.3 93 | ----------------- 94 | * Update in Readme 95 | 96 | From 2.0.1 to 2.0.2 97 | ----------------- 98 | * Use PSR-4 instead of PSR-0 99 | 100 | From 2.0 to 2.0.1 101 | ----------------- 102 | * HHVM compatibility 103 | 104 | From 1.0.1 to 2.0 105 | ----------------- 106 | * API is unchanged, but the semantics have changed. Versions below 2 behaved as if `defaultOperator` (introduced with 2.0) was set to `SearchstringParser:SYMBOL_AND` 107 | -------------------------------------------------------------------------------- /Testdox.txt: -------------------------------------------------------------------------------- 1 | BlueM\SearchstringParser 2 | [x] The constructor throws an exception if the search string is empty 3 | [x] The constructor throws an exception if the minimum length is not an int 4 | [x] The constructor throws an exception if the minimum length is smaller than 1 5 | [x] The constructor accepts a valid minimum length 6 | [x] The AND terms are returned by the getter 7 | [x] The OR terms are returned by the getter 8 | [x] The NOT terms are returned by the getter 9 | [x] The skipped terms are returned by the getter 10 | [x] The exceptions are returned by the getter 11 | [x] All terms in a search string without modifiers are regarded as optional by default 12 | [x] All terms in a search string without modifiers are regarded as mandatory if the default operator is set to AND 13 | [x] A minus prefix excludes the following term 14 | [x] A NOT excludes the following term 15 | [x] A plus prefix makes the following term requires 16 | [x] An AND makes the previous and next term required 17 | [x] An OR makes the previous and next term optional 18 | [x] Phrases can be defined using quotes 19 | [x] Escaped quotes inside phrases can be used 20 | [x] Phrases can be excluded by prefixing them with a minus 21 | [x] A term which is shorter than the defined minimum is skipped 22 | [x] A combination of supported syntaxes is parsed correctly 23 | [x] A combination of supported syntaxes is parsed correctly with and as default operator 24 | [x] A combination of supported syntaxes using and is parsed correctly 25 | [x] An unclosed phrase throws an exception if the throw option is set 26 | [x] An unclosed phrase ends silently at the end of string if the throw option is not set 27 | [x] If the last term is NOT an exception is thrown if the throw option is set 28 | [x] If the last term is NOT it is dropped silently if the throw option is not set 29 | [x] If the first term is AND an exception is thrown if the throw option is set 30 | [x] If the first term is OR an exception is thrown if the throw option is set 31 | [x] If the first term is OR it is dropped silently if the throw option is not set 32 | [x] If the last term is AND an exception is thrown if the throw option is set 33 | [x] If the last term is OR an exception is thrown if the throw option is set 34 | [x] If the last term is OR it is dropped silently if the throw option is not set 35 | [x] If a negated term precedes an OR an exception is thrown if the throw option is set 36 | [x] If a negated term precedes an OR the OR is dropped silently if the throw option is not set 37 | [x] If a required term is preceded by NOT an exception is thrown if the throw option is set 38 | [x] If a negated term follows an OR an exception is thrown if the throw option is set 39 | [x] If a negated term follows an OR the OR is dropped silently if the throw option is not set 40 | 41 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bluem/searchstringparser", 3 | "type": "library", 4 | "description": "Library for parsing search strings", 5 | "keywords": ["searching", "parsing"], 6 | "homepage": "https://github.com/BlueM/searchstringparser", 7 | "license": "BSD-2-Clause", 8 | "authors": [ 9 | { 10 | "name": "Carsten Blüm", 11 | "email": "carsten@bluem.net" 12 | } 13 | ], 14 | "require": { 15 | "php": ">=5.3.0" 16 | }, 17 | "require-dev": { 18 | "phpunit/phpunit": "^5.0" 19 | }, 20 | "autoload": { 21 | "psr-4": {"BlueM\\": "src/"} 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./test 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/SearchstringParser.php: -------------------------------------------------------------------------------- 1 | 15 | * @license http://www.opensource.org/licenses/bsd-license.php BSD 2-Clause License 16 | */ 17 | class SearchstringParser 18 | { 19 | const SYMBOL_AND = '+'; 20 | const SYMBOL_OR = '|'; 21 | const SYMBOL_NOT = '-'; 22 | 23 | /** 24 | * @var array 25 | */ 26 | protected $andTerms = array(); 27 | 28 | /** 29 | * @var array 30 | */ 31 | protected $orTerms = array(); 32 | 33 | /** 34 | * @var array 35 | */ 36 | protected $notTerms = array(); 37 | 38 | /** 39 | * @var array 40 | */ 41 | protected $skipped = array(); 42 | 43 | /** 44 | * @var array 45 | */ 46 | protected $exceptions = array(); 47 | 48 | /** 49 | * @var array 50 | */ 51 | protected $options = array( 52 | 'minlength' => 2, 53 | 'throw' => false, 54 | ); 55 | 56 | /** 57 | * Constructor. Takes a string and immediately parses it. 58 | * 59 | * @param string $string String to be parsed 60 | * @param array $options Associative array with 0 or more of keys of: "minlength" 61 | * (minimum length in characters a search term or phrase must 62 | * have; default value: 2), "throw" (bool: throw an exception 63 | * in case of parsing error?; default: false) or 64 | * "defaultOperator" (set to SearchstringParser:SYMBOL_AND 65 | * if you want unclassified terms to be regarded as required. 66 | * Default is SearchstringParser:SYMBOL_OR). 67 | * 68 | * @throws \InvalidArgumentException 69 | * 70 | */ 71 | public function __construct($string, array $options = array()) 72 | { 73 | if (!trim($string)) { 74 | throw new \InvalidArgumentException('Empty search string'); 75 | } 76 | 77 | if (array_key_exists('minlength', $options)) { 78 | if (!preg_match('/^[1-9]\d*$/', trim($options['minlength']))) { 79 | throw new \InvalidArgumentException('Invalid minimum length'); 80 | } 81 | $this->options['minlength'] = (int) $options['minlength']; 82 | } 83 | 84 | if (array_key_exists('defaultOperator', $options)) { 85 | if (self::SYMBOL_AND === $options['defaultOperator'] || 86 | self::SYMBOL_OR === $options['defaultOperator']) 87 | { 88 | $defaultOperator = $options['defaultOperator']; 89 | } else { 90 | throw new \InvalidArgumentException('Invalid default operator'); 91 | } 92 | } else { 93 | $defaultOperator = self::SYMBOL_OR; 94 | } 95 | 96 | $this->options['throw'] = !empty($options['throw']); 97 | 98 | $terms = $this->parseToTerms($string); 99 | $terms = $this->processModifierTerms($terms); 100 | 101 | $this->categorizeTerms($terms, $defaultOperator); 102 | 103 | if ($this->options['throw'] && count($this->exceptions)) { 104 | throw $this->exceptions[0]; 105 | } 106 | } 107 | 108 | /** 109 | * @return array 110 | */ 111 | public function getAndTerms() 112 | { 113 | return $this->andTerms; 114 | } 115 | 116 | /** 117 | * @return array 118 | */ 119 | public function getOrTerms() 120 | { 121 | return $this->orTerms; 122 | } 123 | 124 | /** 125 | * @return array 126 | */ 127 | public function getNotTerms() 128 | { 129 | return $this->notTerms; 130 | } 131 | 132 | /** 133 | * @return array 134 | */ 135 | public function getSkippedTerms() 136 | { 137 | return $this->skipped; 138 | } 139 | 140 | /** 141 | * @return array 142 | */ 143 | public function getExceptions() 144 | { 145 | return $this->exceptions; 146 | } 147 | 148 | /** 149 | * Splits the search string into "terms", which can be words or quoted phrases 150 | * 151 | * @param string $string The search string 152 | * 153 | * @return array 154 | */ 155 | protected function parseToTerms($string) 156 | { 157 | $string = trim($string); 158 | $terms = array(); 159 | 160 | // Find quoted strings and remove them from $string 161 | while (false !== $qstart = strpos($string, '"')) { 162 | // Try to find closing quotation mark, starting at $qstart + 1 163 | $offset = $qstart + 1; 164 | $qend = 0; 165 | 166 | while (!$qend) { 167 | if (false === $i = strpos($string, '"', $offset)) { 168 | // No closing quotation mark before end of string >> use rest 169 | $this->exceptions[] = new UnclosedQuoteException( 170 | 'Opening quote not closed before end of string' 171 | ); 172 | $qend = strlen($string); 173 | } else { 174 | // Make sure this quotation mark is not escaped 175 | if ('\\' !== substr($string, $i - 1, 1)) { 176 | $qend = $i; // Not escaped >> set $qend in order to break loop 177 | } else { 178 | $offset ++; // Escaped >> proceed with next character 179 | } 180 | } 181 | } 182 | 183 | $phrase = str_replace('\\"', '"', substr($string, $qstart + 1, $qend - $qstart - 1)); 184 | 185 | // Get the character preceding the opening quote character 186 | $preceding = $qstart > 0 ? substr($string, $qstart - 1, 1) : false; 187 | 188 | // Prepend $phrase with $preceding if $preceding looks 189 | // like a modifier symbol and decrement $qstart 190 | if (static::SYMBOL_AND === $preceding || 191 | static::SYMBOL_NOT === $preceding 192 | ) { 193 | $symbol = $preceding; 194 | $qstart --; 195 | } else { 196 | $symbol = null; 197 | } 198 | 199 | // Get unquoted strings before the quotes start 200 | if ($qstart > 0) { 201 | foreach ($this->processNonPhraseTerms(substr($string, 0, $qstart)) as $term) { 202 | $terms[] = $term; 203 | } 204 | } 205 | 206 | $terms[] = array($symbol, $phrase); 207 | $string = substr($string, $qend + 1); 208 | } 209 | 210 | // Process the remaining string 211 | if (trim($string)) { 212 | foreach ($this->processNonPhraseTerms($string) as $term) { 213 | $terms[] = $term; 214 | } 215 | } 216 | 217 | return $terms; 218 | } 219 | 220 | /** 221 | * @param array $terms 222 | * 223 | * @return array 224 | */ 225 | private function processModifierTerms(array $terms) 226 | { 227 | for ($i = 0, $ii = count($terms); $i < $ii; $i++) { 228 | 229 | switch (strtolower($terms[$i][1])) { 230 | case 'not': 231 | if (empty($terms[$i + 1][1])) { 232 | $this->exceptions[] = new NotAsLastTermException(); 233 | } else { 234 | if (empty($terms[$i + 1][0]) || self::SYMBOL_NOT === $terms[$i + 1][0]) { 235 | $terms[$i + 1][0] = self::SYMBOL_NOT; 236 | } else { 237 | $this->exceptions[] = new ContradictoryModifiersException(); 238 | } 239 | } 240 | unset($terms[$i]); 241 | continue 2; 242 | case 'or': 243 | $symbol = static::SYMBOL_OR; 244 | break; 245 | case 'and': 246 | $symbol = static::SYMBOL_AND; 247 | break; 248 | default: 249 | // Not interested in this 250 | continue 2; 251 | } 252 | 253 | unset($terms[$i]); 254 | 255 | if ($i === 0) { 256 | $this->exceptions[] = new ModifierAsFirstOrLastTermException(); 257 | continue; 258 | } 259 | 260 | if ($i === $ii - 1) { 261 | $this->exceptions[] = new ModifierAsFirstOrLastTermException(); 262 | break; 263 | } 264 | 265 | if ($symbol !== $terms[$i - 1][0]) { 266 | // Previous term does not have same modifier 267 | if (self::SYMBOL_NOT === $terms[$i - 1][0]) { 268 | $this->exceptions[] = new ContradictoryModifiersException(); 269 | } else { 270 | $terms[$i - 1][0] = $symbol; 271 | } 272 | } 273 | 274 | $i ++; 275 | 276 | if (static::SYMBOL_NOT === $terms[$i][0]) { 277 | $this->exceptions[] = new ContradictoryModifiersException(); 278 | } else { 279 | $terms[$i][0] = $symbol; 280 | } 281 | } 282 | 283 | return $terms; 284 | } 285 | 286 | /** 287 | * @param array $terms 288 | */ 289 | protected function categorizeTerms(array $terms, $defaultOperator) 290 | { 291 | foreach ($terms as $term) { 292 | if (mb_strlen($term[1]) < $this->options['minlength']) { 293 | $this->skipped[] = $term[1]; 294 | } elseif (static::SYMBOL_AND === $term[0]) { 295 | $this->andTerms[] = $term[1]; 296 | } elseif (static::SYMBOL_NOT === $term[0]) { 297 | $this->notTerms[] = $term[1]; 298 | } elseif (static::SYMBOL_OR === $term[0]) { 299 | $this->orTerms[] = $term[1]; 300 | } else { 301 | if (self::SYMBOL_AND === $defaultOperator) { 302 | $this->andTerms[] = $term[1]; 303 | } else { 304 | $this->orTerms[] = $term[1]; 305 | } 306 | } 307 | } 308 | } 309 | 310 | /** 311 | * @param string $string 312 | * 313 | * @return array 314 | */ 315 | private function processNonPhraseTerms($string) 316 | { 317 | $terms = array(); 318 | 319 | $trimmedString = trim($string); 320 | 321 | if (!$trimmedString) { 322 | return array(); 323 | } 324 | 325 | foreach (preg_split('#\s+#', $trimmedString) as $term) { 326 | 327 | if (self::SYMBOL_AND === substr($term, 0, 1)) { 328 | $terms[] = array(self::SYMBOL_AND, substr($term, 1)); 329 | continue; 330 | } elseif (self::SYMBOL_NOT === substr($term, 0, 1)) { 331 | $terms[] = array(self::SYMBOL_NOT, substr($term, 1)); 332 | } else { 333 | $terms[] = array(null, $term); 334 | } 335 | 336 | } 337 | 338 | return $terms; 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /src/SearchstringParser/ContradictoryModifiersException.php: -------------------------------------------------------------------------------- 1 | 'abc')); 41 | } 42 | 43 | /** 44 | * @test 45 | * @expectedException \InvalidArgumentException 46 | * @expectedExceptionMessage Invalid minimum length 47 | */ 48 | public function theConstructorThrowsAnExceptionIfTheMinimumLengthIsSmallerThan1() 49 | { 50 | new SearchstringParser('Hello World', array('minlength' => 0)); 51 | } 52 | 53 | /** 54 | * @test 55 | */ 56 | public function theConstructorAcceptsAValidMinimumLength() 57 | { 58 | $search = new SearchstringParser('Hello World', array('minlength' => 2)); 59 | 60 | $optionsProperty = new \ReflectionProperty($search, 'options'); 61 | $optionsProperty->setAccessible(true); 62 | $options = $optionsProperty->getValue($search); 63 | 64 | $this->assertSame(2, $options['minlength']); 65 | } 66 | 67 | /** 68 | * @test 69 | */ 70 | public function The_AND_terms_are_returned_by_the_getter() 71 | { 72 | $search = new SearchstringParser('Hello World'); 73 | 74 | $termsProperty = new \ReflectionProperty($search, 'andTerms'); 75 | $termsProperty->setAccessible(true); 76 | $termsProperty->setValue($search, array('term1', 'term2')); 77 | 78 | $this->assertSame(array('term1', 'term2'), $search->getAndTerms()); 79 | } 80 | 81 | /** 82 | * @test 83 | */ 84 | public function The_OR_terms_are_returned_by_the_getter() 85 | { 86 | $search = new SearchstringParser('Hello World'); 87 | 88 | $termsProperty = new \ReflectionProperty($search, 'orTerms'); 89 | $termsProperty->setAccessible(true); 90 | $termsProperty->setValue($search, array('term1', 'term2')); 91 | 92 | $this->assertSame(array('term1', 'term2'), $search->getOrTerms()); 93 | } 94 | 95 | /** 96 | * @test 97 | */ 98 | public function The_NOT_terms_are_returned_by_the_getter() 99 | { 100 | $search = new SearchstringParser('Hello World'); 101 | 102 | $exclusionTermsProperty = new \ReflectionProperty($search, 'notTerms'); 103 | $exclusionTermsProperty->setAccessible(true); 104 | $exclusionTermsProperty->setValue($search, array('term1', 'term2')); 105 | 106 | $this->assertSame(array('term1', 'term2'), $search->getNotTerms()); 107 | } 108 | 109 | /** 110 | * @test 111 | */ 112 | public function theSkippedTermsAreReturnedByTheGetter() 113 | { 114 | $search = new SearchstringParser('Hello World'); 115 | 116 | $skippedProperty = new \ReflectionProperty($search, 'skipped'); 117 | $skippedProperty->setAccessible(true); 118 | $skippedProperty->setValue($search, array('term1', 'term2')); 119 | 120 | $this->assertSame(array('term1', 'term2'), $search->getSkippedTerms()); 121 | } 122 | 123 | /** 124 | * @test 125 | */ 126 | public function theExceptionsAreReturnedByTheGetter() 127 | { 128 | $search = new SearchstringParser('Hello World'); 129 | $exception = new NotAsLastTermException(); 130 | 131 | $exceptionsProperty = new \ReflectionProperty($search, 'exceptions'); 132 | $exceptionsProperty->setAccessible(true); 133 | $exceptionsProperty->setValue($search, array($exception)); 134 | 135 | $this->assertSame(array($exception), $search->getExceptions()); 136 | } 137 | 138 | /** 139 | * @test 140 | */ 141 | public function allTermsInASearchStringWithoutModifiersAreRegardedAsOptionalByDefault() 142 | { 143 | $search = new SearchstringParser('Hello World'); 144 | 145 | $this->assertSame(array(), $search->getAndTerms()); 146 | $this->assertSame(array('Hello', 'World'), $search->getOrTerms()); 147 | $this->assertSame(array(), $search->getNotTerms()); 148 | $this->assertSame(array(), $search->getSkippedTerms()); 149 | } 150 | 151 | /** 152 | * @test 153 | */ 154 | public function All_terms_in_a_search_string_without_modifiers_are_regarded_as_mandatory_if_the_default_operator_is_set_to_AND() 155 | { 156 | $search = new SearchstringParser('Hello World', 157 | array('defaultOperator' => SearchstringParser::SYMBOL_AND) 158 | ); 159 | 160 | $this->assertSame(array('Hello', 'World'), $search->getAndTerms()); 161 | $this->assertSame(array(), $search->getOrTerms()); 162 | $this->assertSame(array(), $search->getNotTerms()); 163 | $this->assertSame(array(), $search->getSkippedTerms()); 164 | } 165 | 166 | /** 167 | * @test 168 | */ 169 | public function aMinusPrefixExcludesTheFollowingTerm() 170 | { 171 | $search = new SearchstringParser('Hello -World'); 172 | 173 | $this->assertSame(array(), $search->getAndTerms()); 174 | $this->assertSame(array('Hello'), $search->getOrTerms()); 175 | $this->assertSame(array('World'), $search->getNotTerms()); 176 | $this->assertSame(array(), $search->getSkippedTerms()); 177 | } 178 | 179 | /** 180 | * @test 181 | */ 182 | public function A_NOT_excludes_the_following_term() 183 | { 184 | $search = new SearchstringParser('Hello NOT World'); 185 | 186 | $this->assertSame(array(), $search->getAndTerms()); 187 | $this->assertSame(array('Hello'), $search->getOrTerms()); 188 | $this->assertSame(array('World'), $search->getNotTerms()); 189 | $this->assertSame(array(), $search->getSkippedTerms()); 190 | } 191 | 192 | /** 193 | * @test 194 | */ 195 | public function aPlusPrefixMakesTheFollowingTermRequires() 196 | { 197 | $search = new SearchstringParser('Hello +World'); 198 | 199 | $this->assertSame(array('World'), $search->getAndTerms()); 200 | $this->assertSame(array('Hello'), $search->getOrTerms()); 201 | $this->assertSame(array(), $search->getNotTerms()); 202 | $this->assertSame(array(), $search->getSkippedTerms()); 203 | } 204 | 205 | /** 206 | * @test 207 | */ 208 | public function An_AND_makes_the_previous_and_next_term_required() 209 | { 210 | $search = new SearchstringParser('Hello AND World AnotherString'); 211 | 212 | $this->assertSame(array('Hello', 'World'), $search->getAndTerms()); 213 | $this->assertSame(array('AnotherString'), $search->getOrTerms()); 214 | $this->assertSame(array(), $search->getNotTerms()); 215 | $this->assertSame(array(), $search->getSkippedTerms()); 216 | } 217 | 218 | /** 219 | * @test 220 | */ 221 | public function An_OR_makes_the_previous_and_next_term_optional() 222 | { 223 | $search = new SearchstringParser('term1 OR term2'); 224 | 225 | $this->assertSame(array(), $search->getAndTerms()); 226 | $this->assertSame(array('term1', 'term2'), $search->getOrTerms()); 227 | $this->assertSame(array(), $search->getNotTerms()); 228 | $this->assertSame(array(), $search->getSkippedTerms()); 229 | } 230 | 231 | /** 232 | * @test 233 | */ 234 | public function phrasesCanBeDefinedUsingQuotes() 235 | { 236 | $search = new SearchstringParser('test "Hello World"'); 237 | 238 | $this->assertSame(array(), $search->getAndTerms()); 239 | $this->assertSame(array('test', 'Hello World'), $search->getOrTerms()); 240 | $this->assertSame(array(), $search->getNotTerms()); 241 | $this->assertSame(array(), $search->getSkippedTerms()); 242 | } 243 | 244 | /** 245 | * @test 246 | */ 247 | public function escapedQuotesInsidePhrasesCanBeUsed() 248 | { 249 | $search = new SearchstringParser('"Hello \" World"'); 250 | 251 | $this->assertSame(array(), $search->getAndTerms()); 252 | $this->assertSame(array('Hello " World'), $search->getOrTerms()); 253 | $this->assertSame(array(), $search->getNotTerms()); 254 | $this->assertSame(array(), $search->getSkippedTerms()); 255 | } 256 | 257 | /** 258 | * @test 259 | */ 260 | public function phrasesCanBeExcludedByPrefixingThemWithAMinus() 261 | { 262 | $search = new SearchstringParser('test -"Hello World"'); 263 | 264 | $this->assertSame(array(), $search->getAndTerms()); 265 | $this->assertSame(array('test'), $search->getOrTerms()); 266 | $this->assertSame(array('Hello World'), $search->getNotTerms()); 267 | $this->assertSame(array(), $search->getSkippedTerms()); 268 | } 269 | 270 | /** 271 | * @test 272 | */ 273 | public function aTermWhichIsShorterThanTheDefinedMinimumIsSkipped() 274 | { 275 | $search = new SearchstringParser('AB C'); 276 | 277 | $this->assertSame(array(), $search->getAndTerms()); 278 | $this->assertSame(array('AB'), $search->getOrTerms()); 279 | $this->assertSame(array(), $search->getNotTerms()); 280 | $this->assertSame(array('C'), $search->getSkippedTerms()); 281 | } 282 | 283 | /** 284 | * @test 285 | */ 286 | public function aCombinationOfSupportedSyntaxesIsParsedCorrectly() 287 | { 288 | $search = new SearchstringParser( 289 | '"search string parser" "PHP 5.4" OR "PHP 5.3" NOT "PHP 4" +OOP NOT C# -C++ C' 290 | ); 291 | 292 | $this->assertSame(array('OOP'), $search->getAndTerms()); 293 | $this->assertSame(array('search string parser', 'PHP 5.4', 'PHP 5.3'), $search->getOrTerms()); 294 | $this->assertSame(array('PHP 4', 'C#', 'C++'), $search->getNotTerms()); 295 | $this->assertSame(array('C'), $search->getSkippedTerms()); 296 | } 297 | 298 | /** 299 | * @test 300 | */ 301 | public function aCombinationOfSupportedSyntaxesIsParsedCorrectlyWithAndAsDefaultOperator() 302 | { 303 | $search = new SearchstringParser( 304 | '"search string parser" "PHP 5.4" OR "PHP 5.3" NOT "PHP 4" +OOP NOT C# -C++ C', 305 | array('defaultOperator' => SearchstringParser::SYMBOL_AND) 306 | ); 307 | 308 | $this->assertSame(array('search string parser', 'OOP'), $search->getAndTerms()); 309 | $this->assertSame(array('PHP 5.4', 'PHP 5.3'), $search->getOrTerms()); 310 | $this->assertSame(array('PHP 4', 'C#', 'C++'), $search->getNotTerms()); 311 | $this->assertSame(array('C'), $search->getSkippedTerms()); 312 | } 313 | 314 | /** 315 | * @test 316 | */ 317 | public function aCombinationOfSupportedSyntaxesUsingAndIsParsedCorrectly() 318 | { 319 | $search = new SearchstringParser( 320 | 'Word1 AND Word2 +"Phrase 1" -"Phrase 2" Word3 -Word4 Word5 OR Word6 OR Word7 NOT Word8 X' 321 | ); 322 | 323 | $this->assertSame(array('Word1', 'Word2', 'Phrase 1'), $search->getAndTerms()); 324 | $this->assertSame(array('Word3', 'Word5', 'Word6', 'Word7'), $search->getOrTerms()); 325 | $this->assertSame(array('Phrase 2', 'Word4', 'Word8'), $search->getNotTerms()); 326 | $this->assertSame(array('X'), $search->getSkippedTerms()); 327 | } 328 | 329 | /** 330 | * @test 331 | * @expectedException \BlueM\SearchstringParser\UnclosedQuoteException 332 | * @expectedExceptionMessage unclosed quote 333 | */ 334 | public function anUnclosedPhraseThrowsAnExceptionIfTheThrowOptionIsSet() 335 | { 336 | new SearchstringParser('Hello "World', array('throw' => true)); 337 | } 338 | 339 | /** 340 | * @test 341 | */ 342 | public function anUnclosedPhraseEndsSilentlyAtTheEndOfStringIfTheThrowOptionIsNotSet() 343 | { 344 | $search = new SearchstringParser('Test "Hello World'); 345 | $this->assertSame(array(), $search->getAndTerms()); 346 | $this->assertSame(array('Test', 'Hello World'), $search->getOrTerms()); 347 | $this->assertSame(array(), $search->getNotTerms()); 348 | $this->assertSame(array(), $search->getSkippedTerms()); 349 | 350 | $exceptions = $search->getExceptions(); 351 | $this->assertTrue($exceptions[0] instanceof UnclosedQuoteException); 352 | } 353 | 354 | /** 355 | * @test 356 | * @expectedException \BlueM\SearchstringParser\NotAsLastTermException 357 | * @expectedExceptionMessage must not end with “NOT” 358 | */ 359 | public function If_the_last_term_is_NOT_an_exception_is_thrown_if_the_throw_option_is_set() 360 | { 361 | new SearchstringParser('Hello NOT', array('throw' => true)); 362 | } 363 | 364 | /** 365 | * @test 366 | */ 367 | public function If_the_last_term_is_NOT_it_is_dropped_silently_if_the_throw_option_is_not_set() 368 | { 369 | $search = new SearchstringParser('Test not'); 370 | $this->assertSame(array(), $search->getAndTerms()); 371 | $this->assertSame(array('Test'), $search->getOrTerms()); 372 | $this->assertSame(array(), $search->getNotTerms()); 373 | $this->assertSame(array(), $search->getSkippedTerms()); 374 | 375 | $exceptions = $search->getExceptions(); 376 | $this->assertTrue($exceptions[0] instanceof NotAsLastTermException); 377 | } 378 | 379 | /** 380 | * @test 381 | * @expectedException \BlueM\SearchstringParser\ModifierAsFirstOrLastTermException 382 | * @expectedExceptionMessage must neither start nor end with “AND” or “OR” 383 | */ 384 | public function If_the_first_term_is_AND_an_exception_is_thrown_if_the_throw_option_is_set() 385 | { 386 | new SearchstringParser('AND Hello', array('throw' => true)); 387 | } 388 | 389 | /** 390 | * @test 391 | * @expectedException \BlueM\SearchstringParser\ModifierAsFirstOrLastTermException 392 | * @expectedExceptionMessage must neither start nor end with “AND” or “OR” 393 | */ 394 | public function If_the_first_term_is_OR_an_exception_is_thrown_if_the_throw_option_is_set() 395 | { 396 | new SearchstringParser('OR Hello', array('throw' => true)); 397 | } 398 | 399 | /** 400 | * @test 401 | */ 402 | public function If_the_first_term_is_OR_it_is_dropped_silently_if_the_throw_option_is_not_set() 403 | { 404 | $search = new SearchstringParser('or Test'); 405 | $this->assertSame(array(), $search->getAndTerms()); 406 | $this->assertSame(array('Test'), $search->getOrTerms()); 407 | $this->assertSame(array(), $search->getNotTerms()); 408 | $this->assertSame(array(), $search->getSkippedTerms()); 409 | 410 | $exceptions = $search->getExceptions(); 411 | $this->assertTrue($exceptions[0] instanceof ModifierAsFirstOrLastTermException); 412 | } 413 | 414 | /** 415 | * @test 416 | * @expectedException \BlueM\SearchstringParser\ModifierAsFirstOrLastTermException 417 | */ 418 | public function If_the_last_term_is_AND_an_exception_is_thrown_if_the_throw_option_is_set() 419 | { 420 | new SearchstringParser('Hello AND', array('throw' => true)); 421 | } 422 | 423 | /** 424 | * @test 425 | * @expectedException \BlueM\SearchstringParser\ModifierAsFirstOrLastTermException 426 | */ 427 | public function If_the_last_term_is_OR_an_exception_is_thrown_if_the_throw_option_is_set() 428 | { 429 | new SearchstringParser('Hello OR', array('throw' => true)); 430 | } 431 | 432 | /** 433 | * @test 434 | */ 435 | public function If_the_last_term_is_OR_it_is_dropped_silently_if_the_throw_option_is_not_set() 436 | { 437 | $search = new SearchstringParser('Test or'); 438 | $this->assertSame(array(), $search->getAndTerms()); 439 | $this->assertSame(array('Test'), $search->getOrTerms()); 440 | $this->assertSame(array(), $search->getNotTerms()); 441 | $this->assertSame(array(), $search->getSkippedTerms()); 442 | 443 | $exceptions = $search->getExceptions(); 444 | $this->assertTrue($exceptions[0] instanceof ModifierAsFirstOrLastTermException); 445 | } 446 | 447 | /** 448 | * @test 449 | * @expectedException \BlueM\SearchstringParser\ContradictoryModifiersException 450 | * @expectedExceptionMessage contradictory instructions 451 | */ 452 | public function If_a_negated_term_precedes_an_OR_an_exception_is_thrown_if_the_throw_option_is_set() 453 | { 454 | new SearchstringParser('-Hello OR World', array('throw' => true)); 455 | } 456 | 457 | /** 458 | * @test 459 | */ 460 | public function If_a_negated_term_precedes_an_OR_the_OR_is_dropped_silently_if_the_throw_option_is_not_set() 461 | { 462 | $search = new SearchstringParser('-Hello OR World'); 463 | $this->assertSame(array(), $search->getAndTerms()); 464 | $this->assertSame(array('World'), $search->getOrTerms()); 465 | $this->assertSame(array('Hello'), $search->getNotTerms()); 466 | $this->assertSame(array(), $search->getSkippedTerms()); 467 | 468 | $exceptions = $search->getExceptions(); 469 | $this->assertTrue($exceptions[0] instanceof ContradictoryModifiersException); 470 | } 471 | 472 | /** 473 | * @test 474 | * @expectedException \BlueM\SearchstringParser\ContradictoryModifiersException 475 | * @expectedExceptionMessage contradictory instructions 476 | */ 477 | public function If_a_required_term_is_preceded_by_NOT_an_exception_is_thrown_if_the_throw_option_is_set( 478 | ) 479 | { 480 | new SearchstringParser('Word1 NOT +Word', array('throw' => true)); 481 | } 482 | 483 | /** 484 | * @test 485 | * @expectedException \BlueM\SearchstringParser\ContradictoryModifiersException 486 | */ 487 | public function If_a_negated_term_follows_an_OR_an_exception_is_thrown_if_the_throw_option_is_set() 488 | { 489 | new SearchstringParser('Hello OR -negated', array('throw' => true)); 490 | } 491 | 492 | /** 493 | * @test 494 | */ 495 | public function If_a_negated_term_follows_an_OR_the_OR_is_dropped_silently_if_the_throw_option_is_not_set() 496 | { 497 | $search = new SearchstringParser('Test or -negated'); 498 | $this->assertSame(array(), $search->getAndTerms()); 499 | $this->assertSame(array('Test'), $search->getOrTerms()); 500 | $this->assertSame(array('negated'), $search->getNotTerms()); 501 | $this->assertSame(array(), $search->getSkippedTerms()); 502 | 503 | $exceptions = $search->getExceptions(); 504 | $this->assertTrue($exceptions[0] instanceof ContradictoryModifiersException); 505 | } 506 | } 507 | --------------------------------------------------------------------------------