├── .github ├── FUNDING.yml └── workflows │ └── tests.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── composer.json ├── phpunit.xml ├── src ├── AST │ ├── AndSymbol.php │ ├── CanBeNegated.php │ ├── CanHaveRule.php │ ├── EmptySymbol.php │ ├── ListSymbol.php │ ├── NotSymbol.php │ ├── OrSymbol.php │ ├── QuerySymbol.php │ ├── RelationshipSymbol.php │ ├── SoloSymbol.php │ └── Symbol.php ├── Compiler │ ├── CompiledParser.php │ ├── CompilerInterface.php │ ├── Grammar.pp │ ├── HoaCompiler.php │ └── HoaConverterVisitor.php ├── Concerns │ └── SearchString.php ├── Console │ ├── BaseCommand.php │ ├── DumpAstCommand.php │ ├── DumpResultCommand.php │ └── DumpSqlCommand.php ├── Exceptions │ └── InvalidSearchStringException.php ├── Options │ ├── ColumnRule.php │ ├── KeywordRule.php │ ├── Rule.php │ └── SearchStringOptions.php ├── SearchStringManager.php ├── ServiceProvider.php ├── Support │ └── DateWithPrecision.php ├── Visitors │ ├── AttachRulesVisitor.php │ ├── BuildColumnsVisitor.php │ ├── BuildKeywordsVisitor.php │ ├── DumpVisitor.php │ ├── IdentifyRelationshipsFromRulesVisitor.php │ ├── InlineDumpVisitor.php │ ├── OptimizeAstVisitor.php │ ├── RemoveKeywordsVisitor.php │ ├── RemoveNotSymbolVisitor.php │ ├── ValidateRulesVisitor.php │ └── Visitor.php └── config.php └── tests ├── Concerns ├── DumpsSql.php └── DumpsWhereClauses.php ├── CreateBuilderTest.php ├── DumpCommandsTest.php ├── ErrorHandlingStrategiesTest.php ├── LexerTest.php ├── ParserTest.php ├── RuleTest.php ├── SearchStringOptionsTest.php ├── Stubs ├── Comment.php ├── CommentUser.php ├── Product.php └── User.php ├── TestCase.php ├── UpdateBuilderTest.php ├── VisitorBuildColumnsTest.php ├── VisitorBuildKeywordsTest.php ├── VisitorIdentifyRelationshipsFromRulesTest.php ├── VisitorOptimizeAstTest.php ├── VisitorRemoveKeywordsTest.php ├── VisitorRemoveNotSymbolTest.php ├── VisitorTest.php └── VisitorValidateRulesTest.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [lorisleiva] 2 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | os: [ubuntu-latest] 12 | php: ["8.1", "8.2"] 13 | laravel: ["^9.0", "^10.0"] 14 | include: 15 | - laravel: "^9.0" 16 | testbench: "^7.0" 17 | - laravel: "^10.0" 18 | testbench: "^8.0" 19 | name: Laravel ${{ matrix.laravel }} PHP${{ matrix.php }} on ${{ matrix.os }} 20 | container: 21 | image: lorisleiva/laravel-docker:${{ matrix.php }} 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v2 25 | - name: Validate composer files 26 | run: composer validate 27 | - name: Cache dependencies 28 | uses: actions/cache@v1 29 | with: 30 | path: /composer/cache/files 31 | key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} 32 | - name: Install dependencies 33 | run: | 34 | composer require --prefer-dist --no-progress --no-suggest --no-interaction "illuminate/support:${{ matrix.laravel }}" 35 | composer install --prefer-dist --no-progress --no-suggest --no-interaction 36 | - name: Run tests 37 | run: phpunit 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /composer.lock 3 | .phpunit.result.cache -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Loris Leiva 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🔍 Laravel Search String 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/lorisleiva/laravel-search-string.svg)](https://packagist.org/packages/lorisleiva/laravel-search-string) 4 | [![GitHub Tests Action Status](https://img.shields.io/github/workflow/status/lorisleiva/laravel-search-string/Tests?label=tests)](https://github.com/lorisleiva/laravel-search-string/actions?query=workflow%3ATests+branch%3Anext) 5 | [![Total Downloads](https://img.shields.io/packagist/dt/lorisleiva/laravel-search-string.svg)](https://packagist.org/packages/lorisleiva/laravel-search-string) 6 | 7 | Generates database queries based on one unique string using a simple and customizable syntax. 8 | 9 | ![Example of a search string syntax and its result](https://user-images.githubusercontent.com/3642397/40266921-6f7b4c70-5b54-11e8-8e40-000ae3b4e201.png) 10 | 11 | 12 | ## Introduction 13 | 14 | Laravel Search String provides a simple solution for scoping your database queries using a human readable and customizable syntax. It will transform a simple string into a powerful query builder. 15 | 16 | For example, the following search string will fetch the latest blog articles that are either not published or titled "My blog article". 17 | 18 | ```php 19 | Article::usingSearchString('title:"My blog article" or not published sort:-created_at'); 20 | 21 | // Equivalent to: 22 | Article::where('title', 'My blog article') 23 | ->orWhere('published', false) 24 | ->orderBy('created_at', 'desc'); 25 | ``` 26 | 27 | This next example will search for the term "John" on the `customer` and `description` columns whilst making sure the invoices are either paid or archived. 28 | 29 | ```php 30 | Invoice::usingSearchString('John and status in (Paid,Archived) limit:10 from:10'); 31 | 32 | // Equivalent to: 33 | Invoice::where(function ($query) { 34 | $query->where('customer', 'like', '%John%') 35 | ->orWhere('description', 'like', '%John%'); 36 | }) 37 | ->whereIn('status', ['Paid', 'Archived']) 38 | ->limit(10) 39 | ->offset(10); 40 | ``` 41 | 42 | You can also query for the existence of related records, for example, articles published in 2020, which have more than 100 comments that are either not spam or written by John. 43 | 44 | ```php 45 | Article::usingSearchString('published = 2020 and comments: (not spam or author.name = John) > 100'); 46 | 47 | // Equivalent to: 48 | Article::where('published_at', '>=', '2020-01-01 00:00:00') 49 | ->where('published_at', '<=', '2020-12-31 23:59:59') 50 | ->whereHas('comments', function ($query) { 51 | $query->where('spam', false) 52 | ->orWhereHas('author' function ($query) { 53 | $query->where('name', 'John'); 54 | }); 55 | }, '>', 100); 56 | ``` 57 | 58 | As you can see, not only it provides a convenient way to communicate with your Laravel API (instead of allowing dozens of query fields), it also can be presented to your users as a tool to explore their data. 59 | 60 | ## Installation 61 | 62 | ```bash 63 | # Install via composer 64 | composer require lorisleiva/laravel-search-string 65 | 66 | # (Optional) Publish the search-string.php configuration file 67 | php artisan vendor:publish --tag=search-string 68 | ``` 69 | 70 | ## Basic usage 71 | 72 | Add the `SearchString` trait to your models and configure the columns that should be used within your search string. 73 | 74 | ```php 75 | use Lorisleiva\LaravelSearchString\Concerns\SearchString; 76 | 77 | class Article extends Model 78 | { 79 | use SearchString; 80 | 81 | protected $searchStringColumns = [ 82 | 'title', 'body', 'status', 'rating', 'published', 'created_at', 83 | ]; 84 | } 85 | ``` 86 | 87 | Note that you can define these in [other parts of your code](#other-places-to-configure) and [customise the behaviour of each column](#configuring-columns). 88 | 89 | That's it! Now you can create a database query using the search string syntax. 90 | 91 | ```php 92 | Article::usingSearchString('title:"Hello world" sort:-created_at,published')->get(); 93 | ``` 94 | 95 | ## The search string syntax 96 | 97 | Note that the spaces between operators don't matter. 98 | 99 | ### Exact matches 100 | 101 | ```php 102 | 'rating: 0' 103 | 'rating = 0' 104 | 'title: Hello' // Strings without spaces do not need quotes 105 | 'title: "Hello World"' // Strings with spaces require quotes 106 | "title: 'Hello World'" // Single quotes can be used too 107 | 'rating = 99.99' 108 | 'created_at: "2018-07-06 00:00:00"' 109 | ``` 110 | 111 | ### Comparisons 112 | 113 | ```php 114 | 'title < B' 115 | 'rating > 3' 116 | 'created_at >= "2018-07-06 00:00:00"' 117 | ``` 118 | 119 | ### Lists 120 | 121 | ```php 122 | 'title in (Hello, Hi, "My super article")' 123 | 'status in(Finished,Archived)' 124 | 'status:Finished,Archived' 125 | ``` 126 | 127 | ### Dates 128 | 129 | The column must either be cast as a date or explicitly marked as a date in the [column options](#date). 130 | 131 | ```php 132 | // Year precision 133 | 'created_at >= 2020' // 2020-01-01 00:00:00 <= created_at 134 | 'created_at > 2020' // 2020-12-31 23:59:59 < created_at 135 | 'created_at = 2020' // 2020-01-01 00:00:00 <= created_at <= 2020-12-31 23:59:59 136 | 'not created_at = 2020' // created_at < 2020-01-01 00:00:00 and created_at > 2020-12-31 23:59:59 137 | 138 | // Month precision 139 | 'created_at = 01/2020' // 2020-01-01 00:00:00 <= created_at <= 2020-01-31 23:59:59 140 | 'created_at <= "Jan 2020"' // created_at <= 2020-01-31 23:59:59 141 | 'created_at < 2020-1' // created_at < 2020-01-01 00:00:00 142 | 143 | // Day precision 144 | 'created_at = 2020-12-31' // 2020-12-31 00:00:00 <= created_at <= 2020-12-31 23:59:59 145 | 'created_at >= 12/31/2020"' // 2020-12-31 23:59:59 <= created_at 146 | 'created_at > "Dec 31 2020"' // 2020-12-31 23:59:59 < created_at 147 | 148 | // Hour and minute precisions 149 | 'created_at = "2020-12-31 16"' // 2020-12-31 16:00:00 <= created_at <= 2020-12-31 16:59:59 150 | 'created_at = "2020-12-31 16:30"' // 2020-12-31 16:30:00 <= created_at <= 2020-12-31 16:30:59 151 | 'created_at = "Dec 31 2020 5pm"' // 2020-12-31 17:00:00 <= created_at <= 2020-12-31 17:59:59 152 | 'created_at = "Dec 31 2020 5:15pm"' // 2020-12-31 17:15:00 <= created_at <= 2020-12-31 17:15:59 153 | 154 | // Exact precision 155 | 'created_at = "2020-12-31 16:30:00"' // created_at = 2020-12-31 16:30:00 156 | 'created_at = "Dec 31 2020 5:15:10pm"' // created_at = 2020-12-31 17:15:10 157 | 158 | // Relative dates 159 | 'created_at = today' // today between 00:00 and 23:59 160 | 'not created_at = today' // any time before today 00:00 and after today 23:59 161 | 'created_at >= tomorrow' // from tomorrow at 00:00 162 | 'created_at <= tomorrow' // until tomorrow at 23:59 163 | 'created_at > tomorrow' // from the day after tomorrow at 00:00 164 | 'created_at < tomorrow' // until today at 23:59 165 | ``` 166 | 167 | ### Booleans 168 | 169 | The column must either be cast as a boolean or explicitly marked as a boolean in the [column options](#boolean). 170 | 171 | Alternatively, if the column is marked as a date, it will automatically be marked as a boolean using `is null` and `is not null`. 172 | 173 | ```php 174 | 'published' // published = true 175 | 'created_at' // created_at is not null 176 | ``` 177 | 178 | ### Negations 179 | 180 | ```php 181 | 'not title:Hello' 182 | 'not title="My super article"' 183 | 'not rating:0' 184 | 'not rating>4' 185 | 'not status in (Finished,Archived)' 186 | 'not published' // published = false 187 | 'not created_at' // created_at is null 188 | ``` 189 | 190 | ### Null values 191 | 192 | The term `NULL` is case sensitive. 193 | 194 | ```php 195 | 'body:NULL' // body is null 196 | 'not body:NULL' // body is not null 197 | ``` 198 | 199 | ### Searchable 200 | 201 | At least one column must be [defined as searchable](#searchable-1). 202 | 203 | The queried term must not match a boolean column, otherwise it will be handled as a boolean query. 204 | 205 | ```php 206 | 'Apple' // %Apple% like at least one of the searchable columns 207 | '"John Doe"' // %John Doe% like at least one of the searchable columns 208 | 'not "John Doe"' // %John Doe% not like any of the searchable columns 209 | ``` 210 | 211 | ### And/Or 212 | 213 | ```php 214 | 'title:Hello body:World' // Implicit and 215 | 'title:Hello and body:World' // Explicit and 216 | 'title:Hello or body:World' // Explicit or 217 | 'A B or C D' // Equivalent to '(A and B) or (C and D)' 218 | 'A or B and C or D' // Equivalent to 'A or (B and C) or D' 219 | '(A or B) and (C or D)' // Explicit nested priority 220 | 'not (A and B)' // Equivalent to 'not A or not B' 221 | 'not (A or B)' // Equivalent to 'not A and not B' 222 | ``` 223 | 224 | ### Relationships 225 | 226 | The column must be explicitly [defined as a relationship](#relationship) and the model associated with this relationship must also use the `SearchString` trait. 227 | 228 | When making a nested query within a relationship, Laravel Search String will use the column definition of the related model. 229 | 230 | In the following examples, `comments` is a `HasMany` relationship and `author` is a nested `BelongsTo` relationship within the `Comment` model. 231 | 232 | ```php 233 | // Simple "has" check 234 | 'comments' // Has comments 235 | 'not comments' // Doesn't have comments 236 | 'comments = 3' // Has 3 comments 237 | 'not comments = 3' // Doesn't have 3 comments 238 | 'comments > 10' // Has more than 10 comments 239 | 'not comments <= 10' // Same as before 240 | 'comments <= 5' // Has 5 or less comments 241 | 'not comments > 5' // Same as before 242 | 243 | // "WhereHas" check 244 | 'comments: (title: Superbe)' // Has comments with the title "Superbe" 245 | 'comments: (not title: Superbe)' // Has comments whose titles are different than "Superbe" 246 | 'not comments: (title: Superbe)' // Doesn't have comments with the title "Superbe" 247 | 'comments: (quality)' // Has comments whose searchable columns match "%quality%" 248 | 'not comments: (spam)' // Doesn't have comments marked as spam 249 | 'comments: (spam) >= 3' // Has at least 3 spam comments 250 | 'not comments: (spam) >= 3' // Has at most 2 spam comments 251 | 'comments: (not spam) >= 3' // Has at least 3 comments that are not spam 252 | 'comments: (likes < 5)' // Has comments with less than 5 likes 253 | 'comments: (likes < 5) <= 10' // Has at most 10 comments with less than 5 likes 254 | 'not comments: (likes < 5)' // Doesn't have comments with less than 5 likes 255 | 'comments: (likes > 10 and not spam)' // Has non-spam comments with more than 10 likes 256 | 257 | // "WhereHas" shortcuts 258 | 'comments.title: Superbe' // Same as 'comments: (title: Superbe)' 259 | 'not comments.title: Superbe' // Same as 'not comments: (title: Superbe)' 260 | 'comments.spam' // Same as 'comments: (spam)' 261 | 'not comments.spam' // Same as 'not comments: (spam)' 262 | 'comments.likes < 5' // Same as 'comments: (likes < 5)' 263 | 'not comments.likes < 5' // Same as 'not comments: (likes < 5)' 264 | 265 | // Nested relationships 266 | 'comments: (author: (name: John))' // Has comments from the author named John 267 | 'comments.author: (name: John)' // Same as before 268 | 'comments.author.name: John' // Same as before 269 | 270 | // Nested relationships are optimised 271 | 'comments.author.name: John and comments.author.age > 21' // Same as: 'comments: (author: (name: John and age > 21)) 272 | 'comments.likes > 10 or comments.author.age > 21' // Same as: 'comments: (likes > 10 or author: (age > 21)) 273 | ``` 274 | 275 | Note that all these expressions delegate to the `has` query method. Therefore, it works out-of-the-box with the following relationship types: `HasOne`, `HasMany`, `HasOneThrough`, `HasManyThrough`, `BelongsTo`, `BelongsToMany`, `MorphOne`, `MorphMany` and `MorphToMany`. 276 | 277 | The only relationship type currently not supported is `MorphTo` since Laravel Search String needs an explicit related model to use withing nested queries. 278 | 279 | ### Special keywords 280 | 281 | Note that these keywords [can be customised](#configuring-special-keywords). 282 | 283 | ```php 284 | 'fields:title,body,created_at' // Select only title, body, created_at 285 | 'not fields:rating' // Select all columns but rating 286 | 'sort:rating,-created_at' // Order by rating asc, created_at desc 287 | 'limit:1' // Limit 1 288 | 'from:10' // Offset 10 289 | ``` 290 | 291 | ## Configuring columns 292 | 293 | ### Column aliases 294 | 295 | If you want a column to be queried using a different name, you can define it as a key/value pair where the key is the database column name and the value is the alias you wish to use. 296 | 297 | ```php 298 | protected $searchStringColumns = [ 299 | 'title', 300 | 'body' => 'content', 301 | 'published_at' => 'published', 302 | 'created_at' => 'created', 303 | ]; 304 | ``` 305 | 306 | You can also provide a regex pattern for a more flexible alias definition. 307 | 308 | ```php 309 | protected $searchStringColumns = [ 310 | 'published_at' => '/^(published|live)$/', 311 | // ... 312 | ]; 313 | ``` 314 | 315 | ### Column options 316 | 317 | You can configure a column even further by assigning it an array of options. 318 | 319 | ```php 320 | protected $searchStringColumns = [ 321 | 'created_at' => [ 322 | 'key' => 'created', // Default to column name: /^created_at$/ 323 | 'date' => true, // Default to true only if the column is cast as date. 324 | 'boolean' => true, // Default to true only if the column is cast as boolean or date. 325 | 'searchable' => false // Default to false. 326 | 'relationship' => false // Default to false. 327 | 'map' => ['x' => 'y'] // Maps data from the user input to the database values. Default to []. 328 | ], 329 | // ... 330 | ]; 331 | ``` 332 | 333 | #### Key 334 | The `key` option is what we've been configuring so far, i.e. the alias of the column. It can be either a regex pattern (therefore allowing multiple matches) or a regular string for an exact match. 335 | 336 | #### Date 337 | If a column is marked as a `date`, the value of the query will be parsed using `Carbon` whilst keeping the level of precision given by the user. For example, if the `created_at` column is marked as a `date`: 338 | 339 | ```php 340 | 'created_at >= tomorrow' // Equivalent to: 341 | $query->where('created_at', '>=', 'YYYY-MM-DD 00:00:00'); 342 | // where `YYYY-MM-DD` matches the date of tomorrow. 343 | 344 | 'created_at = "July 6, 2018"' // Equivalent to: 345 | $query->where('created_at', '>=', '2018-07-06 00:00:00'); 346 | ->where('created_at', '<=', '2018-07-06 23:59:59'); 347 | ``` 348 | 349 | By default any column that is cast as a date (using Laravel properties), will be marked as a date for LaravelSearchString. You can force a column to not be marked as a date by assigning `date` to `false`. 350 | 351 | #### Boolean 352 | If a column is marked as a `boolean`, it can be used with no operator or value. For example, if the `paid` column is marked as a `boolean`: 353 | 354 | ```php 355 | 'paid' // Equivalent to: 356 | $query->where('paid', true); 357 | 358 | 'not paid' // Equivalent to: 359 | $query->where('paid', false); 360 | ``` 361 | 362 | If a column is marked as both `boolean` and `date`, it will be compared to `null` when used as a boolean. For example, if the `published_at` column is marked as `boolean` and `date` and uses the `published` alias: 363 | 364 | ```php 365 | 'published' // Equivalent to: 366 | $query->whereNotNull('published'); 367 | 368 | 'not published_at' // Equivalent to: 369 | $query->whereNull('published'); 370 | ``` 371 | 372 | By default any column that is cast as a boolean or as a date (using Laravel properties), will be marked as a boolean. You can force a column to not be marked as a boolean by assigning `boolean` to `false`. 373 | 374 | #### Searchable 375 | If a column is marked as `searchable`, it will be used to match search queries, i.e. terms that are alone but are not booleans like `Apple Banana` or `"John Doe"`. 376 | 377 | For example if both columns `title` and `description` are marked as `searchable`: 378 | 379 | ```php 380 | 'Apple Banana' // Equivalent to: 381 | $query->where(function($query) { 382 | $query->where('title', 'like', '%Apple%') 383 | ->orWhere('description', 'like', '%Apple%'); 384 | }) 385 | ->where(function($query) { 386 | $query->where('title', 'like', '%Banana%') 387 | ->orWhere('description', 'like', '%Banana%'); 388 | }); 389 | 390 | '"John Doe"' // Equivalent to: 391 | $query->where(function($query) { 392 | $query->where('title', 'like', '%John Doe%') 393 | ->orWhere('description', 'like', '%John Doe%'); 394 | }); 395 | ``` 396 | 397 | If no searchable columns are provided, such terms or strings will be ignored. 398 | 399 | #### Relationship 400 | 401 | If a column is marked as a `relationship`, it will be used to query relationships. 402 | 403 | The column name must match a valid relationship method on the model but, as usual, aliases can be created using the [`key` option](#key). 404 | 405 | The model associated with that relationship method must also use the `SearchString` trait in order to nest relationship queries. 406 | 407 | For example, say you have an Article Model and you want to query its related comments. Then, there must be a valid `comments` relationship method and the `Comment` model must itself use the `SearchString` trait. 408 | 409 | ```php 410 | use Lorisleiva\LaravelSearchString\Concerns\SearchString; 411 | 412 | class Article extends Model 413 | { 414 | use SearchString; 415 | 416 | protected $searchStringColumns = [ 417 | 'comments' => [ 418 | 'key' => '/^comments?$/', // aliases the column to `comments` or `comment`. 419 | 'relationship' => true, // There must be a `comments` method that defines a relationship. 420 | ], 421 | ]; 422 | 423 | public function comments() 424 | { 425 | return $this->hasMany(Comment::class); 426 | } 427 | } 428 | 429 | class Comment extends Model 430 | { 431 | use SearchString; 432 | 433 | protected $searchStringColumns = [ 434 | // ... 435 | ]; 436 | } 437 | ``` 438 | 439 | Note that, since Laravel Search String is simply delegating to the `$builder->has(...)` method, you can provide any fancy relationship method you want and the constraints will be kept. For example: 440 | 441 | ```php 442 | protected $searchStringColumns = [ 443 | 'myComments' => [ 444 | 'key' => 'my_comments', 445 | 'relationship' => true, 446 | ], 447 | ]; 448 | 449 | public function myComments() 450 | { 451 | return $this->hasMany(Comment::class)->where('author_id', Auth::user()->id); 452 | } 453 | ``` 454 | 455 | ## Configuring special keywords 456 | 457 | You can customise the name of a keyword by defining a key/value pair within the `$searchStringKeywords` property. 458 | 459 | ```php 460 | protected $searchStringKeywords = [ 461 | 'select' => 'fields', // Updates the selected query columns 462 | 'order_by' => 'sort', // Updates the order of the query results 463 | 'limit' => 'limit', // Limits the number of results 464 | 'offset' => 'from', // Starts the results at a further index 465 | ]; 466 | ``` 467 | 468 | Similarly to column values you can provide an array to define a custom `key` of the keyword. Note that the `date`, `boolean`, `searchable` and `relationship` options are not applicable for keywords. 469 | 470 | ```php 471 | protected $searchStringKeywords = [ 472 | 'select' => [ 473 | 'key' => 'fields', 474 | ], 475 | // ... 476 | ]; 477 | ``` 478 | 479 | ## Other places to configure 480 | 481 | As we've seen so far, you can configure your columns and special keywords using the `searchStringColumns` and `searchStringKeywords` properties on your model. 482 | 483 | You can also override the `getSearchStringOptions` method on your model which defaults to: 484 | 485 | ```php 486 | public function getSearchStringOptions() 487 | { 488 | return [ 489 | 'columns' => $this->searchStringColumns ?? [], 490 | 'keywords' => $this->searchStringKeywords ?? [], 491 | ]; 492 | } 493 | ``` 494 | 495 | If you'd rather not define any of these configurations on the model itself, you can define them directly on the `config/search-string.php` file like this: 496 | 497 | ```php 498 | // config/search-string.php 499 | return [ 500 | 'default' => [ 501 | 'keywords' => [ /* ... */ ], 502 | ], 503 | 504 | Article::class => [ 505 | 'columns' => [ /* ... */ ], 506 | 'keywords' => [ /* ... */ ], 507 | ], 508 | ]; 509 | ``` 510 | 511 | When resolving the options for a particular model, LaravelSearchString will merge those configurations in the following order: 512 | 1. First using the configurations defined on the model 513 | 2. Then using the config file at the key matching the model class 514 | 3. Then using the config file at the `default` key 515 | 4. Finally using some fallback configurations 516 | 517 | ## Configuring case insensitive searches 518 | 519 | When using databases like PostgreSql, you can override the default behavior of case sensitive searches by setting case_insensitive to true in your options amongst columns and keywords. For example, in the config/search-string.php 520 | 521 | ```php 522 | return [ 523 | 'default' => [ 524 | 'case_insensitive' => true, // <- Globally. 525 | // ... 526 | ], 527 | 528 | Article::class => [ 529 | 'case_insensitive' => true, // <- Only for the Article class. 530 | // ... 531 | ], 532 | ]; 533 | ``` 534 | 535 | When set to true, it will lowercase both the column and the value before comparing them using the like operator. 536 | 537 | ``` 538 | $value = mb_strtolower($value, 'UTF8'); 539 | $query->whereRaw("LOWER($column) LIKE ?", ["%$value%"]); 540 | ``` 541 | 542 | 543 | ## Error handling 544 | 545 | The provided search string can be invalid for numerous reasons. 546 | - It does not comply to the search string syntax 547 | - It tries to query an inexisting column or column alias 548 | - It provides invalid values to special keywords like `limit` 549 | - Etc. 550 | 551 | Any of those errors will throw an `InvalidSearchStringException`. 552 | 553 | However you can choose whether you want these exceptions to bubble up to the Laravel exception handler or whether you want them to fail silently. For that, you need to choose a fail strategy on your `config/search-string.php` configuration file: 554 | 555 | ```php 556 | // config/search-string.php 557 | return [ 558 | 'fail' => 'all-results', // (Default) Silently fail with a query containing everything. 559 | 'fail' => 'no-results', // Silently fail with a query containing nothing. 560 | 'fail' => 'exceptions', // Throw exceptions. 561 | 562 | // ... 563 | ]; 564 | ``` 565 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lorisleiva/laravel-search-string", 3 | "type": "library", 4 | "description": "Generates database queries based on one unique string using a simple and customizable syntax.", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Loris Leiva", 9 | "email": "loris.leiva@gmail.com" 10 | } 11 | ], 12 | "require": { 13 | "php": "^8.1", 14 | "illuminate/support": "^9.0|^10.0", 15 | "hoa/compiler": "^3.17", 16 | "sanmai/hoa-protocol": "^1.17" 17 | }, 18 | "require-dev": { 19 | "phpunit/phpunit": "^9.0", 20 | "orchestra/testbench": "^7.0|^8.0" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "Lorisleiva\\LaravelSearchString\\": "src" 25 | } 26 | }, 27 | "autoload-dev": { 28 | "psr-4": { 29 | "Lorisleiva\\LaravelSearchString\\Tests\\": "tests" 30 | } 31 | }, 32 | "extra": { 33 | "laravel": { 34 | "providers": [ 35 | "Lorisleiva\\LaravelSearchString\\ServiceProvider" 36 | ] 37 | } 38 | }, 39 | "minimum-stability": "dev", 40 | "prefer-stable": true 41 | } 42 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./tests 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/AST/AndSymbol.php: -------------------------------------------------------------------------------- 1 | expressions = Collection::wrap($expressions); 16 | } 17 | 18 | public function accept(Visitor $visitor) 19 | { 20 | return $visitor->visitAnd($this); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/AST/CanBeNegated.php: -------------------------------------------------------------------------------- 1 | negated = ! $this->negated; 13 | 14 | return $this; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/AST/CanHaveRule.php: -------------------------------------------------------------------------------- 1 | rule = $rule; 15 | 16 | return $this; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/AST/EmptySymbol.php: -------------------------------------------------------------------------------- 1 | visitEmpty($this); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/AST/ListSymbol.php: -------------------------------------------------------------------------------- 1 | key = $key; 21 | $this->values = $values; 22 | } 23 | 24 | public function accept(Visitor $visitor) 25 | { 26 | return $visitor->visitList($this); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/AST/NotSymbol.php: -------------------------------------------------------------------------------- 1 | expression = $expression; 15 | } 16 | 17 | public function accept(Visitor $visitor) 18 | { 19 | return $visitor->visitNot($this); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/AST/OrSymbol.php: -------------------------------------------------------------------------------- 1 | expressions = Collection::wrap($expressions); 16 | } 17 | 18 | public function accept(Visitor $visitor) 19 | { 20 | return $visitor->visitOr($this); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/AST/QuerySymbol.php: -------------------------------------------------------------------------------- 1 | key = $key; 23 | $this->operator = $operator; 24 | $this->value = $value; 25 | } 26 | 27 | public function accept(Visitor $visitor) 28 | { 29 | return $visitor->visitQuery($this); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/AST/RelationshipSymbol.php: -------------------------------------------------------------------------------- 1 | ', int $expectedCount = 0) 24 | { 25 | $this->key = $key; 26 | $this->expression = $expression; 27 | $this->expectedOperator = $expectedOperator; 28 | $this->expectedCount = $expectedCount; 29 | } 30 | 31 | public function accept(Visitor $visitor) 32 | { 33 | return $visitor->visitRelationship($this); 34 | } 35 | 36 | public function getNormalizedExpectedOperation($normalizeComparisons = false) 37 | { 38 | switch (true) { 39 | case $this->isCheckingExistance(): 40 | return ['>=', 1]; 41 | case $this->isCheckingInexistance(): 42 | return ['<', 1]; 43 | case $normalizeComparisons && $this->expectedOperator === '>': 44 | return ['>=', $this->expectedCount + 1]; 45 | case $normalizeComparisons && $this->expectedOperator === '<=': 46 | return ['<', $this->expectedCount + 1]; 47 | default: 48 | return [$this->expectedOperator, $this->expectedCount]; 49 | } 50 | } 51 | 52 | public function getExpectedOperation(): string 53 | { 54 | return sprintf('%s %s', $this->expectedOperator, $this->expectedCount); 55 | } 56 | 57 | public function isCheckingExistance(): bool 58 | { 59 | return in_array($this->getExpectedOperation(), ['> 0', '!= 0', '>= 1']); 60 | } 61 | 62 | public function isCheckingInexistance(): bool 63 | { 64 | return in_array($this->getExpectedOperation(), ['<= 0', '= 0', '< 1']); 65 | } 66 | 67 | public function match(Symbol $that): bool 68 | { 69 | if (! $that instanceof RelationshipSymbol) { 70 | return false; 71 | } 72 | 73 | $thisOperation = $this->getNormalizedExpectedOperation(true); 74 | $thatOperation = $that->getNormalizedExpectedOperation(true); 75 | 76 | return $this->rule && $that->rule 77 | && $this->rule->column === $that->rule->column 78 | && $thisOperation === $thatOperation; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/AST/SoloSymbol.php: -------------------------------------------------------------------------------- 1 | content = $content; 18 | } 19 | 20 | public function accept(Visitor $visitor) 21 | { 22 | return $visitor->visitSolo($this); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/AST/Symbol.php: -------------------------------------------------------------------------------- 1 | [ 12 | 'skip' => '\s', 13 | 'T_ASSIGNMENT' => ':|=', 14 | 'T_COMPARATOR' => '>=?|<=?', 15 | 'T_AND' => '(and|AND)(?![^\(\)\s])', 16 | 'T_OR' => '(or|OR)(?![^\(\)\s])', 17 | 'T_NOT' => '(not|NOT)(?![^\(\)\s])', 18 | 'T_IN' => '(in|IN)(?![^\(\)\s])', 19 | 'T_NULL' => '(NULL)(?![^\(\)\s])', 20 | 'T_SINGLE_LQUOTE:single_quote_string' => '\'', 21 | 'T_DOUBLE_LQUOTE:double_quote_string' => '"', 22 | 'T_LPARENTHESIS' => '\(', 23 | 'T_RPARENTHESIS' => '\)', 24 | 'T_DOT' => '\.', 25 | 'T_COMMA' => ',', 26 | 'T_INTEGER' => '(\d+)(?![^\(\)\s])', 27 | 'T_DECIMAL' => '(\d+\.\d+)(?![^\(\)\s])', 28 | 'T_TERM' => '[^\s:><="\\\'\(\)\.,]+', 29 | ], 30 | 'single_quote_string' => [ 31 | 'T_STRING' => '[^\']+', 32 | 'T_SINGLE_RQUOTE:default' => '\'', 33 | ], 34 | 'double_quote_string' => [ 35 | 'T_STRING' => '[^"]+', 36 | 'T_DOUBLE_RQUOTE:default' => '"', 37 | ], 38 | ], 39 | [ 40 | 'Expr' => new \Hoa\Compiler\Llk\Rule\Concatenation('Expr', ['OrNode'], null), 41 | 1 => new \Hoa\Compiler\Llk\Rule\Token(1, 'T_OR', null, -1, false), 42 | 2 => new \Hoa\Compiler\Llk\Rule\Concatenation(2, [1, 'AndNode'], '#OrNode'), 43 | 3 => new \Hoa\Compiler\Llk\Rule\Repetition(3, 0, -1, 2, null), 44 | 'OrNode' => new \Hoa\Compiler\Llk\Rule\Concatenation('OrNode', ['AndNode', 3], null), 45 | 5 => new \Hoa\Compiler\Llk\Rule\Token(5, 'T_AND', null, -1, false), 46 | 6 => new \Hoa\Compiler\Llk\Rule\Repetition(6, 0, 1, 5, null), 47 | 7 => new \Hoa\Compiler\Llk\Rule\Concatenation(7, [6, 'TerminalNode'], '#AndNode'), 48 | 8 => new \Hoa\Compiler\Llk\Rule\Repetition(8, 0, -1, 7, null), 49 | 'AndNode' => new \Hoa\Compiler\Llk\Rule\Concatenation('AndNode', ['TerminalNode', 8], null), 50 | 'TerminalNode' => new \Hoa\Compiler\Llk\Rule\Choice('TerminalNode', ['NestedExpr', 'NotNode', 'RelationshipNode', 'NestedRelationshipNode', 'QueryNode', 'ListNode', 'SoloNode'], null), 51 | 11 => new \Hoa\Compiler\Llk\Rule\Token(11, 'T_LPARENTHESIS', null, -1, false), 52 | 12 => new \Hoa\Compiler\Llk\Rule\Token(12, 'T_RPARENTHESIS', null, -1, false), 53 | 'NestedExpr' => new \Hoa\Compiler\Llk\Rule\Concatenation('NestedExpr', [11, 'Expr', 12], null), 54 | 14 => new \Hoa\Compiler\Llk\Rule\Token(14, 'T_NOT', null, -1, false), 55 | 'NotNode' => new \Hoa\Compiler\Llk\Rule\Concatenation('NotNode', [14, 'TerminalNode'], '#NotNode'), 56 | 16 => new \Hoa\Compiler\Llk\Rule\Token(16, 'T_TERM', null, -1, true), 57 | 17 => new \Hoa\Compiler\Llk\Rule\Concatenation(17, [16], '#NestedRelationshipNode'), 58 | 18 => new \Hoa\Compiler\Llk\Rule\Concatenation(18, ['NestedTerms'], '#NestedRelationshipNode'), 59 | 19 => new \Hoa\Compiler\Llk\Rule\Choice(19, [17, 18], null), 60 | 20 => new \Hoa\Compiler\Llk\Rule\Token(20, 'T_ASSIGNMENT', null, -1, false), 61 | 21 => new \Hoa\Compiler\Llk\Rule\Token(21, 'T_LPARENTHESIS', null, -1, false), 62 | 22 => new \Hoa\Compiler\Llk\Rule\Token(22, 'T_RPARENTHESIS', null, -1, false), 63 | 23 => new \Hoa\Compiler\Llk\Rule\Token(23, 'T_INTEGER', null, -1, true), 64 | 24 => new \Hoa\Compiler\Llk\Rule\Concatenation(24, ['Operator', 23], null), 65 | 25 => new \Hoa\Compiler\Llk\Rule\Repetition(25, 0, 1, 24, null), 66 | 'NestedRelationshipNode' => new \Hoa\Compiler\Llk\Rule\Concatenation('NestedRelationshipNode', [19, 20, 21, 'Expr', 22, 25], null), 67 | 27 => new \Hoa\Compiler\Llk\Rule\Concatenation(27, ['Operator', 'NullableScalar'], '#RelationshipNode'), 68 | 28 => new \Hoa\Compiler\Llk\Rule\Repetition(28, 0, 1, 27, null), 69 | 'RelationshipNode' => new \Hoa\Compiler\Llk\Rule\Concatenation('RelationshipNode', ['NestedTerms', 28], null), 70 | 30 => new \Hoa\Compiler\Llk\Rule\Token(30, 'T_TERM', null, -1, true), 71 | 31 => new \Hoa\Compiler\Llk\Rule\Token(31, 'T_DOT', null, -1, false), 72 | 32 => new \Hoa\Compiler\Llk\Rule\Token(32, 'T_TERM', null, -1, true), 73 | 33 => new \Hoa\Compiler\Llk\Rule\Concatenation(33, [31, 32], '#NestedTerms'), 74 | 34 => new \Hoa\Compiler\Llk\Rule\Repetition(34, 1, -1, 33, null), 75 | 'NestedTerms' => new \Hoa\Compiler\Llk\Rule\Concatenation('NestedTerms', [30, 34], null), 76 | 36 => new \Hoa\Compiler\Llk\Rule\Token(36, 'T_TERM', null, -1, true), 77 | 'QueryNode' => new \Hoa\Compiler\Llk\Rule\Concatenation('QueryNode', [36, 'Operator', 'NullableScalar'], '#QueryNode'), 78 | 38 => new \Hoa\Compiler\Llk\Rule\Token(38, 'T_TERM', null, -1, true), 79 | 39 => new \Hoa\Compiler\Llk\Rule\Token(39, 'T_IN', null, -1, false), 80 | 40 => new \Hoa\Compiler\Llk\Rule\Token(40, 'T_LPARENTHESIS', null, -1, false), 81 | 41 => new \Hoa\Compiler\Llk\Rule\Token(41, 'T_RPARENTHESIS', null, -1, false), 82 | 42 => new \Hoa\Compiler\Llk\Rule\Concatenation(42, [38, 39, 40, 'ScalarList', 41], '#ListNode'), 83 | 43 => new \Hoa\Compiler\Llk\Rule\Token(43, 'T_TERM', null, -1, true), 84 | 44 => new \Hoa\Compiler\Llk\Rule\Token(44, 'T_ASSIGNMENT', null, -1, false), 85 | 45 => new \Hoa\Compiler\Llk\Rule\Concatenation(45, [43, 44, 'ScalarList'], '#ListNode'), 86 | 'ListNode' => new \Hoa\Compiler\Llk\Rule\Choice('ListNode', [42, 45], null), 87 | 47 => new \Hoa\Compiler\Llk\Rule\Concatenation(47, ['Scalar'], null), 88 | 'SoloNode' => new \Hoa\Compiler\Llk\Rule\Concatenation('SoloNode', [47], '#SoloNode'), 89 | 49 => new \Hoa\Compiler\Llk\Rule\Token(49, 'T_COMMA', null, -1, false), 90 | 50 => new \Hoa\Compiler\Llk\Rule\Concatenation(50, [49, 'Scalar'], '#ScalarList'), 91 | 51 => new \Hoa\Compiler\Llk\Rule\Repetition(51, 0, -1, 50, null), 92 | 'ScalarList' => new \Hoa\Compiler\Llk\Rule\Concatenation('ScalarList', ['Scalar', 51], null), 93 | 53 => new \Hoa\Compiler\Llk\Rule\Token(53, 'T_TERM', null, -1, true), 94 | 'Scalar' => new \Hoa\Compiler\Llk\Rule\Choice('Scalar', ['String', 'Number', 53], null), 95 | 55 => new \Hoa\Compiler\Llk\Rule\Token(55, 'T_NULL', null, -1, true), 96 | 'NullableScalar' => new \Hoa\Compiler\Llk\Rule\Choice('NullableScalar', ['Scalar', 55], null), 97 | 57 => new \Hoa\Compiler\Llk\Rule\Token(57, 'T_SINGLE_LQUOTE', null, -1, false), 98 | 58 => new \Hoa\Compiler\Llk\Rule\Token(58, 'T_STRING', null, -1, true), 99 | 59 => new \Hoa\Compiler\Llk\Rule\Repetition(59, 0, 1, 58, null), 100 | 60 => new \Hoa\Compiler\Llk\Rule\Token(60, 'T_SINGLE_RQUOTE', null, -1, false), 101 | 61 => new \Hoa\Compiler\Llk\Rule\Concatenation(61, [57, 59, 60], null), 102 | 62 => new \Hoa\Compiler\Llk\Rule\Token(62, 'T_DOUBLE_LQUOTE', null, -1, false), 103 | 63 => new \Hoa\Compiler\Llk\Rule\Token(63, 'T_STRING', null, -1, true), 104 | 64 => new \Hoa\Compiler\Llk\Rule\Repetition(64, 0, 1, 63, null), 105 | 65 => new \Hoa\Compiler\Llk\Rule\Token(65, 'T_DOUBLE_RQUOTE', null, -1, false), 106 | 66 => new \Hoa\Compiler\Llk\Rule\Concatenation(66, [62, 64, 65], null), 107 | 'String' => new \Hoa\Compiler\Llk\Rule\Choice('String', [61, 66], null), 108 | 68 => new \Hoa\Compiler\Llk\Rule\Token(68, 'T_INTEGER', null, -1, true), 109 | 69 => new \Hoa\Compiler\Llk\Rule\Token(69, 'T_DECIMAL', null, -1, true), 110 | 'Number' => new \Hoa\Compiler\Llk\Rule\Choice('Number', [68, 69], null), 111 | 71 => new \Hoa\Compiler\Llk\Rule\Token(71, 'T_ASSIGNMENT', null, -1, true), 112 | 72 => new \Hoa\Compiler\Llk\Rule\Token(72, 'T_COMPARATOR', null, -1, true), 113 | 'Operator' => new \Hoa\Compiler\Llk\Rule\Choice('Operator', [71, 72], null), 114 | ], 115 | [ 116 | ] 117 | ); 118 | 119 | $this->getRule('Expr')->setPPRepresentation(' OrNode()'); 120 | $this->getRule('OrNode')->setPPRepresentation(' AndNode() ( ::T_OR:: AndNode() #OrNode )*'); 121 | $this->getRule('AndNode')->setPPRepresentation(' TerminalNode() ( ::T_AND::? TerminalNode() #AndNode )*'); 122 | $this->getRule('TerminalNode')->setPPRepresentation(' NestedExpr() | NotNode() | RelationshipNode() | NestedRelationshipNode() | QueryNode() | ListNode() | SoloNode()'); 123 | $this->getRule('NestedExpr')->setPPRepresentation(' ::T_LPARENTHESIS:: Expr() ::T_RPARENTHESIS::'); 124 | $this->getRule('NotNode')->setDefaultId('#NotNode'); 125 | $this->getRule('NotNode')->setPPRepresentation(' ::T_NOT:: TerminalNode()'); 126 | $this->getRule('NestedRelationshipNode')->setDefaultId('#NestedRelationshipNode'); 127 | $this->getRule('NestedRelationshipNode')->setPPRepresentation(' (|NestedTerms()) ::T_ASSIGNMENT:: ::T_LPARENTHESIS:: Expr() ::T_RPARENTHESIS:: (Operator() )?'); 128 | $this->getRule('RelationshipNode')->setDefaultId('#RelationshipNode'); 129 | $this->getRule('RelationshipNode')->setPPRepresentation(' NestedTerms() (Operator() NullableScalar())?'); 130 | $this->getRule('NestedTerms')->setDefaultId('#NestedTerms'); 131 | $this->getRule('NestedTerms')->setPPRepresentation(' (::T_DOT:: )+'); 132 | $this->getRule('QueryNode')->setDefaultId('#QueryNode'); 133 | $this->getRule('QueryNode')->setPPRepresentation(' Operator() NullableScalar()'); 134 | $this->getRule('ListNode')->setDefaultId('#ListNode'); 135 | $this->getRule('ListNode')->setPPRepresentation(' ::T_IN:: ::T_LPARENTHESIS:: ScalarList() ::T_RPARENTHESIS:: | ::T_ASSIGNMENT:: ScalarList()'); 136 | $this->getRule('SoloNode')->setDefaultId('#SoloNode'); 137 | $this->getRule('SoloNode')->setPPRepresentation(' Scalar()'); 138 | $this->getRule('ScalarList')->setDefaultId('#ScalarList'); 139 | $this->getRule('ScalarList')->setPPRepresentation(' Scalar() ( ::T_COMMA:: Scalar() )*'); 140 | $this->getRule('Scalar')->setPPRepresentation(' String() | Number() | '); 141 | $this->getRule('NullableScalar')->setPPRepresentation(' Scalar() | '); 142 | $this->getRule('String')->setPPRepresentation(' ::T_SINGLE_LQUOTE:: ? ::T_SINGLE_RQUOTE:: | ::T_DOUBLE_LQUOTE:: ? ::T_DOUBLE_RQUOTE::'); 143 | $this->getRule('Number')->setPPRepresentation(' | '); 144 | $this->getRule('Operator')->setPPRepresentation(' | '); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/Compiler/CompilerInterface.php: -------------------------------------------------------------------------------- 1 | =?|<=? 5 | %token T_AND (and|AND)(?![^\(\)\s]) 6 | %token T_OR (or|OR)(?![^\(\)\s]) 7 | %token T_NOT (not|NOT)(?![^\(\)\s]) 8 | %token T_IN (in|IN)(?![^\(\)\s]) 9 | %token T_NULL (NULL)(?![^\(\)\s]) 10 | 11 | %token T_SINGLE_LQUOTE ' -> single_quote_string 12 | %token T_DOUBLE_LQUOTE " -> double_quote_string 13 | %token single_quote_string:T_STRING [^']+ 14 | %token double_quote_string:T_STRING [^"]+ 15 | %token single_quote_string:T_SINGLE_RQUOTE ' -> default 16 | %token double_quote_string:T_DOUBLE_RQUOTE " -> default 17 | 18 | %token T_LPARENTHESIS \( 19 | %token T_RPARENTHESIS \) 20 | %token T_DOT \. 21 | %token T_COMMA , 22 | 23 | %token T_INTEGER (\d+)(?![^\(\)\s]) 24 | %token T_DECIMAL (\d+\.\d+)(?![^\(\)\s]) 25 | %token T_TERM [^\s:><="\'\(\)\.,]+ 26 | 27 | Expr: 28 | OrNode() 29 | 30 | OrNode: 31 | AndNode() ( ::T_OR:: AndNode() #OrNode )* 32 | 33 | AndNode: 34 | TerminalNode() ( ::T_AND::? TerminalNode() #AndNode )* 35 | 36 | TerminalNode: 37 | NestedExpr() | NotNode() | RelationshipNode() | NestedRelationshipNode() | QueryNode() | ListNode() | SoloNode() 38 | 39 | NestedExpr: 40 | ::T_LPARENTHESIS:: Expr() ::T_RPARENTHESIS:: 41 | 42 | #NotNode: 43 | ::T_NOT:: TerminalNode() 44 | 45 | #NestedRelationshipNode: 46 | (|NestedTerms()) ::T_ASSIGNMENT:: ::T_LPARENTHESIS:: Expr() ::T_RPARENTHESIS:: (Operator() )? 47 | 48 | #RelationshipNode: 49 | NestedTerms() (Operator() NullableScalar())? 50 | 51 | #NestedTerms: 52 | (::T_DOT:: )+ 53 | 54 | #QueryNode: 55 | Operator() NullableScalar() 56 | 57 | #ListNode: 58 | ::T_IN:: ::T_LPARENTHESIS:: ScalarList() ::T_RPARENTHESIS:: | 59 | ::T_ASSIGNMENT:: ScalarList() 60 | 61 | #SoloNode: 62 | Scalar() 63 | 64 | #ScalarList: 65 | Scalar() ( ::T_COMMA:: Scalar() )* 66 | 67 | Scalar: 68 | String() | Number() | 69 | 70 | NullableScalar: 71 | Scalar() | 72 | 73 | String: 74 | ::T_SINGLE_LQUOTE:: ? ::T_SINGLE_RQUOTE:: | 75 | ::T_DOUBLE_LQUOTE:: ? ::T_DOUBLE_RQUOTE:: 76 | 77 | Number: 78 | | 79 | 80 | Operator: 81 | | 82 | -------------------------------------------------------------------------------- /src/Compiler/HoaCompiler.php: -------------------------------------------------------------------------------- 1 | manager = $manager; 24 | } 25 | 26 | public function lex(?string $input): Enumerable 27 | { 28 | Llk::parsePP($this->manager->getGrammar(), $tokens, $rules, $pragmas, 'streamName'); 29 | $lexer = new Lexer($pragmas); 30 | 31 | try { 32 | $generator = $lexer->lexMe($input ?? '', $tokens); 33 | } catch (UnrecognizedToken $exception) { 34 | throw InvalidSearchStringException::fromLexer($exception->getMessage(), $exception->getArguments()[1]); 35 | } 36 | 37 | return LazyCollection::make(function() use ($generator) { 38 | yield from $generator; 39 | }); 40 | } 41 | 42 | public function parse(?string $input): Symbol 43 | { 44 | if (! $input) { 45 | return new EmptySymbol(); 46 | } 47 | 48 | try { 49 | $ast = $this->getParser()->parse($input); 50 | } catch (UnrecognizedToken $exception) { 51 | throw InvalidSearchStringException::fromLexer($exception->getMessage(), $exception->getArguments()[1]); 52 | } 53 | 54 | return $ast->accept(new HoaConverterVisitor()); 55 | } 56 | 57 | protected function getParser() 58 | { 59 | if (class_exists(CompiledParser::class)) { 60 | return new CompiledParser(); 61 | } 62 | 63 | return $this->loadParser(); 64 | } 65 | 66 | protected function loadParser(): Parser 67 | { 68 | return Llk::load(new Read($this->manager->getGrammarFile())); 69 | } 70 | 71 | protected function saveParser(): void 72 | { 73 | $file = "loadParser(), 'CompiledParser'); 76 | 77 | file_put_contents(__DIR__ . '/CompiledParser.php', $file); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Compiler/HoaConverterVisitor.php: -------------------------------------------------------------------------------- 1 | getId()) { 25 | case '#OrNode': 26 | return new OrSymbol($this->parseChildren($element)); 27 | case '#AndNode': 28 | return new AndSymbol($this->parseChildren($element)); 29 | case '#NotNode': 30 | return new NotSymbol($this->parseChildren($element)->get(0)); 31 | case '#NestedRelationshipNode': 32 | return $this->parseNestedRelationshipNode($element); 33 | case '#RelationshipNode': 34 | return $this->parseRelationshipNode($element); 35 | case '#SoloNode': 36 | return $this->parseSoloNode($element); 37 | case '#QueryNode': 38 | return $this->parseQueryNode($element); 39 | case '#ListNode': 40 | return $this->parseListNode($element); 41 | case '#ScalarList': 42 | return $this->parseScalarList($element); 43 | case '#NestedTerms': 44 | return $this->parseChildren($element); 45 | case 'token': 46 | return $this->parseToken($element); 47 | } 48 | } 49 | 50 | protected function parseNestedRelationshipNode(TreeNode $element): RelationshipSymbol 51 | { 52 | $children = $this->parseChildren($element); 53 | $terms = Collection::wrap($children->get(0)); 54 | $head = $terms->shift(); 55 | $expression = $this->parseRelationshipExpression($terms, $children->get(1)); 56 | $expectedArgs = $children->has(2) ? [$children->get(2), $children->get(3)]: []; 57 | 58 | return new RelationshipSymbol($head, $expression, ...$expectedArgs); 59 | } 60 | 61 | protected function parseRelationshipNode(TreeNode $element): RelationshipSymbol 62 | { 63 | $children = $this->parseChildren($element); 64 | $terms = Collection::wrap($children->get(0)); 65 | $head = $terms->shift(); 66 | $expression = $this->parseRelationshipExpression($terms, [$children->get(1), $children->get(2)]); 67 | 68 | return new RelationshipSymbol($head, $expression); 69 | } 70 | 71 | protected function parseRelationshipExpression(Collection $terms, $expression): Symbol 72 | { 73 | if ($expression instanceof Symbol && $terms->isEmpty()) { 74 | return $expression; 75 | } 76 | 77 | if (is_array($expression) && $terms->count() === 1) { 78 | return $expression[0] 79 | ? new QuerySymbol($terms->first(), $expression[0], $expression[1]) 80 | : new SoloSymbol($terms->first()); 81 | } 82 | 83 | $head = $terms->shift(); 84 | $expression = $this->parseRelationshipExpression($terms, $expression); 85 | return new RelationshipSymbol($head, $expression); 86 | } 87 | 88 | protected function parseSoloNode(TreeNode $element): SoloSymbol 89 | { 90 | return new SoloSymbol( 91 | $this->parseChildren($element)->get(0, '') 92 | ); 93 | } 94 | 95 | protected function parseQueryNode(TreeNode $element): QuerySymbol 96 | { 97 | if (($children = $this->parseChildren($element))->count() < 2) { 98 | throw InvalidSearchStringException::fromVisitor('QueryNode expects at least two children.'); 99 | } 100 | 101 | return new QuerySymbol( 102 | $children->get(0), 103 | $children->get(1), 104 | $children->get(2, '') 105 | ); 106 | } 107 | 108 | protected function parseListNode(TreeNode $element): ListSymbol 109 | { 110 | if (($children = $this->parseChildren($element))->count() !== 2) { 111 | throw InvalidSearchStringException::fromVisitor('ListNode expects two children.'); 112 | } 113 | 114 | return new ListSymbol( 115 | $children->get(0), 116 | $children->get(1) 117 | ); 118 | } 119 | 120 | protected function parseScalarList(TreeNode $element): array 121 | { 122 | return $this->parseChildren($element)->toArray(); 123 | } 124 | 125 | protected function parseToken(TreeNode $element) 126 | { 127 | switch ($element->getValueToken()) { 128 | case 'T_ASSIGNMENT': 129 | return '='; 130 | case 'T_NULL': 131 | return null; 132 | default: 133 | return $element->getValueValue(); 134 | } 135 | } 136 | 137 | protected function parseChildren(TreeNode $element): Collection 138 | { 139 | if (! $children = $element->getChildren()) { 140 | return collect(); 141 | } 142 | 143 | return collect($children)->map(function (TreeNode $child) { 144 | return $child->accept($this); 145 | }); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/Concerns/SearchString.php: -------------------------------------------------------------------------------- 1 | $this->searchStringColumns ?? [], 27 | 'keywords' => $this->searchStringKeywords ?? [], 28 | ]; 29 | } 30 | 31 | public function getSearchStringVisitors($manager, $builder) 32 | { 33 | return [ 34 | new AttachRulesVisitor($manager), 35 | new IdentifyRelationshipsFromRulesVisitor(), 36 | new ValidateRulesVisitor(), 37 | new RemoveNotSymbolVisitor(), 38 | new BuildKeywordsVisitor($manager, $builder), 39 | new RemoveKeywordsVisitor(), 40 | new OptimizeAstVisitor(), 41 | new BuildColumnsVisitor($manager, $builder), 42 | ]; 43 | } 44 | 45 | public function scopeUsingSearchString($query, $string) 46 | { 47 | $this->getSearchStringManager()->updateBuilder($query, $string); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Console/BaseCommand.php: -------------------------------------------------------------------------------- 1 | argument('model'); 17 | $modelClass = str_replace('/', '\\', $modelClass); 18 | $modelClass = Str::startsWith($modelClass, '\\') ? $modelClass : sprintf('App\\%s', $modelClass); 19 | 20 | if (! class_exists($modelClass) || ! is_subclass_of($modelClass, Model::class)) { 21 | throw new InvalidArgumentException(sprintf('Class [%s] must be a Eloquent Model.', $modelClass)); 22 | } 23 | 24 | $model = new $modelClass(); 25 | 26 | if (! method_exists($model, 'getSearchStringManager')) { 27 | throw new InvalidArgumentException(sprintf('Class [%s] must use the SearchString trait.', $modelClass)); 28 | } 29 | 30 | return $model; 31 | } 32 | 33 | public function getManager(?Model $model = null): SearchStringManager 34 | { 35 | /** @var SearchString $model */ 36 | $model = $model ?: $this->getModel(); 37 | $manager = $model->getSearchStringManager(); 38 | 39 | if (! $manager instanceof SearchStringManager) { 40 | throw new InvalidArgumentException('Method getSearchStringManager must return an instance of SearchStringManager.', $model); 41 | } 42 | 43 | return $manager; 44 | } 45 | 46 | public function getQuery(): string 47 | { 48 | return implode(' ', $this->argument('query')); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Console/DumpAstCommand.php: -------------------------------------------------------------------------------- 1 | getManager()->visit($this->getQuery()); 15 | $dump = $ast->accept(new DumpVisitor()); 16 | 17 | $this->getOutput()->write($dump); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Console/DumpResultCommand.php: -------------------------------------------------------------------------------- 1 | getManager()->createBuilder($this->getQuery()); 13 | 14 | dump($builder->get()); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Console/DumpSqlCommand.php: -------------------------------------------------------------------------------- 1 | getManager()->createBuilder($this->getQuery()); 15 | $sql = Str::replaceArray('?', $builder->getBindings(), $builder->toSql()); 16 | 17 | $this->line($sql); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidSearchStringException.php: -------------------------------------------------------------------------------- 1 | message = $message; 14 | $this->step = $step; 15 | $this->token = $token; 16 | parent::__construct($this->__toString()); 17 | } 18 | 19 | public static function fromLexer(?string $message = null, ?string $token = null) 20 | { 21 | return new static($message, 'Lexer', $token); 22 | } 23 | 24 | public static function fromParser(?string $message = null) 25 | { 26 | return new static($message, 'Parser'); 27 | } 28 | 29 | public static function fromVisitor(?string $message = null) 30 | { 31 | return new static($message, 'Visitor'); 32 | } 33 | 34 | public function getStep() 35 | { 36 | return $this->step; 37 | } 38 | 39 | public function getToken() 40 | { 41 | return $this->token; 42 | } 43 | 44 | public function __toString() 45 | { 46 | if ($this->message) { 47 | return $this->message; 48 | } 49 | 50 | return 'Invalid search string'; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Options/ColumnRule.php: -------------------------------------------------------------------------------- 1 | isCastAsBoolean($model, $column); 37 | $isDate = $this->isCastAsDate($model, $column); 38 | 39 | $this->date = Arr::get($rule, 'date', $isDate); 40 | $this->boolean = Arr::get($rule, 'boolean', $isBoolean || $isDate); 41 | $this->searchable = Arr::get($rule, 'searchable', false); 42 | $this->relationship = Arr::get($rule, 'relationship', false); 43 | $this->map = Collection::wrap(Arr::get($rule, 'map', [])); 44 | 45 | if ($this->relationship) { 46 | $this->setRelationshipModel($model); 47 | } 48 | } 49 | 50 | protected function isCastAsDate(Model $model, string $column): bool 51 | { 52 | return $model->hasCast($column, ['date', 'datetime']) 53 | || in_array($column, $model->getDates()); 54 | } 55 | 56 | protected function isCastAsBoolean(Model $model, string $column): bool 57 | { 58 | return $model->hasCast($column, 'boolean'); 59 | } 60 | 61 | protected function setRelationshipModel(Model $model) 62 | { 63 | $relation = $this->getRelation($model); 64 | $related = $relation->getRelated(); 65 | 66 | if (! in_array(SearchString::class, class_uses_recursive($related))) { 67 | throw new LogicException(sprintf( 68 | '%s must use the %s trait to be used as a relationship.', get_class($related), SearchString::class 69 | )); 70 | } 71 | 72 | $this->relationshipModel = $related; 73 | } 74 | 75 | public function getRelation(Model $model): Relation 76 | { 77 | $method = $this->column; 78 | $relation = $model->$method(); 79 | 80 | if (! $relation instanceof Relation) { 81 | if (is_null($relation)) { 82 | throw new LogicException(sprintf( 83 | '%s::%s must return a relationship instance, but "null" was returned. Was the "return" keyword used?', get_class($model), $method 84 | )); 85 | } 86 | 87 | throw new LogicException(sprintf( 88 | '%s::%s must return a relationship instance.', get_class($model), $method 89 | )); 90 | } 91 | 92 | return $relation; 93 | } 94 | 95 | public function __toString() 96 | { 97 | $parent = parent::__toString(); 98 | $booleans = collect([ 99 | $this->searchable ? 'searchable' : null, 100 | $this->boolean ? 'boolean' : null, 101 | $this->date ? 'date' : null, 102 | $this->relationship ? 'relationship' : null, 103 | ])->filter()->implode(']['); 104 | 105 | $mappings = $this->map->map(function ($value, $key) { 106 | return "{$key}={$value}"; 107 | })->implode(','); 108 | 109 | if ($mappings !== '') { 110 | $mappings = "[{$mappings}]"; 111 | } 112 | 113 | return "{$parent}[{$booleans}]$mappings"; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Options/KeywordRule.php: -------------------------------------------------------------------------------- 1 | $rule ]; 24 | } 25 | 26 | $this->column = $column; 27 | $this->key = $this->getPattern($rule, 'key', $column); 28 | } 29 | 30 | public function match($key) 31 | { 32 | return preg_match($this->key, $key); 33 | } 34 | 35 | public function qualifyColumn($builder) 36 | { 37 | return SearchStringManager::qualifyColumn($builder, $this->column); 38 | } 39 | 40 | protected function getPattern($rawRule, $key, $default = null) 41 | { 42 | $default = $default ?? $this->$key; 43 | $pattern = Arr::get($rawRule, $key, $default); 44 | $pattern = is_null($pattern) ? $default : $pattern; 45 | 46 | return $this->regexify($pattern); 47 | } 48 | 49 | protected function regexify($pattern) 50 | { 51 | return $this->isRegularExpression($pattern) 52 | ? $pattern 53 | : '/^' . preg_quote($pattern, '/') . '$/'; 54 | } 55 | 56 | protected function isRegularExpression($pattern) 57 | { 58 | try { 59 | preg_match($pattern, null); 60 | 61 | return preg_last_error() === PREG_NO_ERROR; 62 | } catch (\Throwable $exception) { 63 | return false; 64 | } 65 | } 66 | 67 | public function __toString() 68 | { 69 | return "[$this->key]"; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Options/SearchStringOptions.php: -------------------------------------------------------------------------------- 1 | false, 17 | 'columns' => [], 18 | 'keywords' => [ 19 | 'order_by' => 'sort', 20 | 'select' => 'fields', 21 | 'limit' => 'limit', 22 | 'offset' => 'from', 23 | ], 24 | ]; 25 | 26 | protected function generateOptions(Model $model): void 27 | { 28 | $options = array_replace_recursive( 29 | static::$fallbackOptions, 30 | Arr::get(config('search-string'), 'default', []), 31 | Arr::get(config('search-string'), get_class($model), []), 32 | $model->getSearchStringOptions() ?? [] 33 | ); 34 | 35 | $this->options = $this->parseOptions($options, $model); 36 | } 37 | 38 | protected function parseOptions(array $options, Model $model): Collection 39 | { 40 | return collect($options)->merge([ 41 | 'columns' => $this->parseColumns($options, $model), 42 | 'keywords' => $this->parseKeywords($options), 43 | ]); 44 | } 45 | 46 | protected function parseColumns(array $options, Model $model): Collection 47 | { 48 | return collect(Arr::get($options, 'columns', [])) 49 | ->mapWithKeys(function ($rule, $column) { 50 | return $this->parseNonAssociativeColumn($rule, $column); 51 | }) 52 | ->map(function ($rule, $column) use ($model) { 53 | return new ColumnRule($model, $column, $rule); 54 | }); 55 | } 56 | 57 | protected function parseKeywords(array $options): Collection 58 | { 59 | return collect(Arr::get($options, 'keywords', [])) 60 | ->mapWithKeys(function ($rule, $keyword) { 61 | return $this->parseNonAssociativeColumn($rule, $keyword); 62 | }) 63 | ->map(function ($rule, $keyword) { 64 | return new KeywordRule($keyword, $rule); 65 | }); 66 | } 67 | 68 | protected function parseNonAssociativeColumn($rule, $column): array 69 | { 70 | return is_string($column) ? [$column => $rule] : [$rule => null]; 71 | } 72 | 73 | public function getOptions(): Collection 74 | { 75 | return $this->options; 76 | } 77 | 78 | public function getKeywordRules(): Collection 79 | { 80 | return $this->options->get('keywords'); 81 | } 82 | 83 | public function getColumnRules(): Collection 84 | { 85 | return $this->options->get('columns'); 86 | } 87 | 88 | public function getKeywordRule($key): ?KeywordRule 89 | { 90 | return $this->getKeywordRules()->first(function ($rule) use ($key) { 91 | return $rule->match($key); 92 | }); 93 | } 94 | 95 | public function getColumnRule($key): ?ColumnRule 96 | { 97 | return $this->getColumnRules()->first(function ($rule) use ($key) { 98 | return $rule->match($key); 99 | }); 100 | } 101 | 102 | public function getColumnNameFromAlias($alias): string 103 | { 104 | $columnRule = $this->getColumnRule($alias); 105 | 106 | return $columnRule ? $columnRule->column : $alias; 107 | } 108 | 109 | public function getRule($key): ?Rule 110 | { 111 | if ($rule = $this->getKeywordRule($key)) { 112 | return $rule; 113 | } 114 | 115 | return $this->getColumnRule($key); 116 | } 117 | 118 | public function getColumns(): Collection 119 | { 120 | return $this->getColumnRules()->reject->relationship->keys(); 121 | } 122 | 123 | public function getSearchables(): Collection 124 | { 125 | return $this->getColumnRules()->filter->searchable->keys(); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/SearchStringManager.php: -------------------------------------------------------------------------------- 1 | model = $model; 22 | $this->generateOptions($model); 23 | } 24 | 25 | public function getCompiler(): CompilerInterface 26 | { 27 | return new HoaCompiler($this); 28 | } 29 | 30 | public function getGrammarFile(): string 31 | { 32 | return __DIR__ . '/Compiler/Grammar.pp'; 33 | } 34 | 35 | public function getGrammar() 36 | { 37 | return file_get_contents($this->getGrammarFile()); 38 | } 39 | 40 | public function lex($input) 41 | { 42 | return $this->getCompiler()->lex($input); 43 | } 44 | 45 | public function parse($input) 46 | { 47 | return $this->getCompiler()->parse($input); 48 | } 49 | 50 | public function visit($input) 51 | { 52 | $ast = $this->parse($input); 53 | $visitors = $this->model->getSearchStringVisitors($this, $this->model->newQuery()); 54 | 55 | foreach ($visitors as $visitor) { 56 | $ast = $ast->accept($visitor); 57 | } 58 | 59 | return $ast; 60 | } 61 | 62 | public function build(EloquentBuilder $builder, $input) 63 | { 64 | $ast = $this->parse($input); 65 | $visitors = $this->model->getSearchStringVisitors($this, $builder); 66 | 67 | foreach ($visitors as $visitor) { 68 | $ast = $ast->accept($visitor); 69 | } 70 | 71 | return $ast; 72 | } 73 | 74 | public function updateBuilder(EloquentBuilder $builder, $input) 75 | { 76 | try { 77 | $this->build($builder, $input); 78 | } catch (InvalidSearchStringException $e) { 79 | switch (config('search-string.fail')) { 80 | case 'exceptions': 81 | throw $e; 82 | 83 | case 'no-results': 84 | return $builder->whereRaw('1 = 0'); 85 | 86 | default: 87 | return $builder; 88 | } 89 | } 90 | } 91 | 92 | public function createBuilder($input) 93 | { 94 | $builder = $this->model->newQuery(); 95 | $this->updateBuilder($builder, $input); 96 | return $builder; 97 | } 98 | 99 | public static function qualifyColumn($builder, $column) 100 | { 101 | if (strpos($column, '.') !== false) { 102 | return $column; 103 | } 104 | 105 | if (! $table = static::getTableFromBuilder($builder)) { 106 | return $column; 107 | } 108 | 109 | return $table . '.' . $column; 110 | } 111 | 112 | protected static function getTableFromBuilder($builder) 113 | { 114 | if ($builder instanceof EloquentBuilder) { 115 | return $builder->getQuery()->from; 116 | } 117 | 118 | if ($builder instanceof QueryBuilder) { 119 | return $builder->from; 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 13 | $this->commands([ 14 | Console\DumpAstCommand::class, 15 | Console\DumpSqlCommand::class, 16 | Console\DumpResultCommand::class, 17 | ]); 18 | } 19 | 20 | $this->publishes([ 21 | __DIR__.'/config.php' => config_path('search-string.php'), 22 | ], 'search-string'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Support/DateWithPrecision.php: -------------------------------------------------------------------------------- 1 | ['Y-m-d H:i:s.u'], 16 | 'second' => ['Y-m-d H:i:s'], 17 | 'minute' => ['Y-m-d H:i'], 18 | 'hour' => ['Y-m-d H'], 19 | 'day' => ['Y-m-d'], 20 | 'month' => [ 21 | 'Y-m', 'Y/m', 'Y m', 'm-Y', 'm/Y', 'm Y', 'm-y', 'm/y', 'm y', 22 | 'Y-n', 'Y/n', 'Y n', 'n-Y', 'n/Y', 'n Y', 'n-y', 'n/y', 'n y', 23 | 'Y M', 'M Y', 'y M', 'M y', 24 | 'Y F', 'F Y', 'y F', 'F y', 25 | ], 26 | 'year' => ['Y', 'y'], 27 | ]; 28 | 29 | public function __construct($original) 30 | { 31 | $this->original = $original; 32 | $this->parseOriginal(); 33 | } 34 | 35 | public function parseOriginal() 36 | { 37 | if ($this->parseFromFormatMapping()) { 38 | return; 39 | } 40 | 41 | try { 42 | $this->carbon = Carbon::parse($this->original); 43 | } catch (\Exception $e) { 44 | return; 45 | } 46 | 47 | foreach (static::FORMATS_PER_PRECISIONS as $precision => $formats) { 48 | if ($this->carbon->$precision !== 0) { 49 | return $this->precision = $precision; 50 | } 51 | } 52 | 53 | // Fallback precision. 54 | $this->precision = 'micro'; 55 | } 56 | 57 | public function parseFromFormatMapping() 58 | { 59 | foreach (static::FORMATS_PER_PRECISIONS as $precision => $formats) { 60 | foreach ($formats as $format) { 61 | if (Carbon::hasFormat($this->original, $format)) { 62 | $this->carbon = Carbon::createFromFormat($format, $this->original); 63 | $this->precision = $precision; 64 | 65 | if (! in_array($this->precision, ['micro', 'second'])) { 66 | $precision = Str::title(Str::camel($this->precision)); 67 | $this->carbon->{"startOf$precision"}(); 68 | } 69 | 70 | return true; 71 | } 72 | } 73 | } 74 | return false; 75 | } 76 | 77 | public function getRange() 78 | { 79 | if (in_array($this->precision, ['micro', 'second'])) { 80 | return $this->carbon; 81 | } 82 | 83 | $precision = Str::title(Str::camel($this->precision)); 84 | 85 | return [ 86 | $this->carbon->copy()->{"startOf$precision"}(), 87 | $this->carbon->copy()->{"endOf$precision"}(), 88 | ]; 89 | } 90 | } -------------------------------------------------------------------------------- /src/Visitors/AttachRulesVisitor.php: -------------------------------------------------------------------------------- 1 | manager = $manager; 21 | } 22 | 23 | public function visitRelationship(RelationshipSymbol $relationship) 24 | { 25 | if (! $rule = $this->manager->getColumnRule($relationship->key)) { 26 | return $relationship; 27 | } 28 | 29 | $relationship->attachRule($rule); 30 | 31 | $originalManager = $this->manager; 32 | $this->manager = $rule->relationshipModel->getSearchStringManager(); 33 | $relationship->expression = $relationship->expression->accept($this); 34 | $this->manager = $originalManager; 35 | 36 | return $relationship; 37 | } 38 | 39 | public function visitSolo(SoloSymbol $solo) 40 | { 41 | return $this->attachRuleFromColumns($solo, $solo->content); 42 | } 43 | 44 | public function visitQuery(QuerySymbol $query) 45 | { 46 | return $this->attachRuleFromKeywordsOrColumns($query, $query->key); 47 | } 48 | 49 | public function visitList(ListSymbol $list) 50 | { 51 | return $this->attachRuleFromKeywordsOrColumns($list, $list->key); 52 | } 53 | 54 | protected function attachRuleFromKeywordsOrColumns(Symbol $symbol, string $key) 55 | { 56 | if ($rule = $this->manager->getRule($key)) { 57 | return $symbol->attachRule($rule); 58 | } 59 | 60 | return $symbol; 61 | } 62 | 63 | protected function attachRuleFromColumns(Symbol $symbol, string $key) 64 | { 65 | if ($rule = $this->manager->getColumnRule($key)) { 66 | return $symbol->attachRule($rule); 67 | } 68 | 69 | return $symbol; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Visitors/BuildColumnsVisitor.php: -------------------------------------------------------------------------------- 1 | manager = $manager; 28 | $this->builder = $builder; 29 | $this->boolean = $boolean; 30 | } 31 | 32 | public function visitOr(OrSymbol $or) 33 | { 34 | $callback = $this->getNestedCallback($or->expressions, 'or'); 35 | $this->builder->where($callback, null, null, $this->boolean); 36 | 37 | return $or; 38 | } 39 | 40 | public function visitAnd(AndSymbol $and) 41 | { 42 | $callback = $this->getNestedCallback($and->expressions, 'and'); 43 | $this->builder->where($callback, null, null, $this->boolean); 44 | 45 | return $and; 46 | } 47 | 48 | public function visitRelationship(RelationshipSymbol $relationship) 49 | { 50 | $this->buildRelationship($relationship); 51 | 52 | return $relationship; 53 | } 54 | 55 | public function visitSolo(SoloSymbol $solo) 56 | { 57 | $this->buildSolo($solo); 58 | 59 | return $solo; 60 | } 61 | 62 | public function visitQuery(QuerySymbol $query) 63 | { 64 | $this->buildQuery($query); 65 | 66 | return $query; 67 | } 68 | 69 | public function visitList(ListSymbol $list) 70 | { 71 | $this->buildList($list); 72 | 73 | return $list; 74 | } 75 | 76 | protected function getNestedCallback(Collection $expressions, string $newBoolean = 'and', $newManager = null) 77 | { 78 | return function ($nestedBuilder) use ($expressions, $newBoolean, $newManager) { 79 | 80 | // Save and update the new builder and boolean. 81 | $originalBuilder = $this->builder; 82 | $originalBoolean = $this->boolean; 83 | $originalManager = $this->manager; 84 | $this->builder = $nestedBuilder; 85 | $this->boolean = $newBoolean; 86 | $this->manager = $newManager ?: $originalManager; 87 | 88 | // Recursively generate the nested builder. 89 | $expressions->each->accept($this); 90 | 91 | // Restore the original builder and boolean. 92 | $this->builder = $originalBuilder; 93 | $this->boolean = $originalBoolean; 94 | $this->manager = $originalManager; 95 | 96 | }; 97 | } 98 | 99 | protected function buildRelationship(RelationshipSymbol $relationship) 100 | { 101 | /** @var ColumnRule $rule */ 102 | if (! $rule = $relationship->rule) { 103 | return; 104 | } 105 | 106 | $nestedExpressions = collect([$relationship->expression]); 107 | $newManager = $rule->relationshipModel->getSearchStringManager(); 108 | $callback = $this->getNestedCallback($nestedExpressions, 'and', $newManager); 109 | $callback = $relationship->expression instanceof EmptySymbol ? null : $callback; 110 | list($operator, $count) = $relationship->getNormalizedExpectedOperation(); 111 | 112 | return $this->builder->has($rule->column, $operator, $count, $this->boolean, $callback); 113 | } 114 | 115 | protected function buildSolo(SoloSymbol $solo) 116 | { 117 | /** @var ColumnRule $rule */ 118 | $rule = $solo->rule; 119 | 120 | if ($rule && $rule->boolean && $rule->date) { 121 | return $this->builder->whereNull($rule->qualifyColumn($this->builder), $this->boolean, ! $solo->negated); 122 | } 123 | 124 | if ($rule && $rule->boolean) { 125 | return $this->builder->where($rule->qualifyColumn($this->builder), '=', ! $solo->negated, $this->boolean); 126 | } 127 | 128 | return $this->buildSearch($solo); 129 | } 130 | 131 | protected function buildSearch(SoloSymbol $solo) 132 | { 133 | $wheres = $this->manager->getSearchables()->map(function ($column) use ($solo) { 134 | return $this->buildSearchWhereClause($solo, $column); 135 | }); 136 | 137 | if ($wheres->isEmpty()) { 138 | return; 139 | } 140 | 141 | if ($wheres->count() === 1) { 142 | $where = $wheres->first(); 143 | return $this->builder->where($where[0], $where[1], $where[2], $this->boolean); 144 | } 145 | 146 | return $this->builder->where($wheres->toArray(), null, null, $this->boolean); 147 | } 148 | 149 | protected function buildSearchWhereClause(SoloSymbol $solo, $column): array 150 | { 151 | $boolean = $solo->negated ? 'and' : 'or'; 152 | $operator = $solo->negated ? 'not like' : 'like'; 153 | $content = $solo->content; 154 | $qualifiedColumn = SearchStringManager::qualifyColumn($this->builder, $column); 155 | $isCaseInsensitive = $this->manager->getOptions()->get('case_insensitive', false); 156 | 157 | if ($isCaseInsensitive) { 158 | $content = mb_strtolower($content, 'UTF8'); 159 | $qualifiedColumn = DB::raw("LOWER($qualifiedColumn)"); 160 | } 161 | 162 | return [$qualifiedColumn, $operator, "%$content%", $boolean]; 163 | } 164 | 165 | protected function buildQuery(QuerySymbol $query) 166 | { 167 | /** @var ColumnRule $rule */ 168 | if (! $rule = $query->rule) { 169 | return; 170 | } 171 | 172 | $query->value = $this->mapValue($query->value, $rule); 173 | 174 | if ($rule->date) { 175 | return $this->buildDate($query, $rule); 176 | } 177 | 178 | return $this->buildBasicQuery($query, $rule); 179 | } 180 | 181 | protected function buildList(ListSymbol $list) 182 | { 183 | /** @var ColumnRule $rule */ 184 | if (! $rule = $list->rule) { 185 | return; 186 | } 187 | 188 | $column = $rule->qualifyColumn($this->builder); 189 | $list->values = $this->mapValue($list->values, $rule); 190 | 191 | return $this->builder->whereIn($column, $list->values, $this->boolean, $list->negated); 192 | } 193 | 194 | protected function buildDate(QuerySymbol $query, ColumnRule $rule) 195 | { 196 | $dateWithPrecision = new DateWithPrecision($query->value); 197 | 198 | if (! $dateWithPrecision->carbon) { 199 | return $this->buildBasicQuery($query, $rule); 200 | } 201 | 202 | if (in_array($dateWithPrecision->precision, ['micro', 'second'])) { 203 | $query->value = $dateWithPrecision->carbon; 204 | return $this->buildBasicQuery($query, $rule); 205 | } 206 | 207 | list($start, $end) = $dateWithPrecision->getRange(); 208 | 209 | if (in_array($query->operator, ['>', '<', '>=', '<='])) { 210 | $query->value = in_array($query->operator, ['<', '>=']) ? $start : $end; 211 | return $this->buildBasicQuery($query, $rule); 212 | } 213 | 214 | return $this->buildDateRange($query, $start, $end, $rule); 215 | } 216 | 217 | protected function buildDateRange(QuerySymbol $query, $start, $end, ColumnRule $rule) 218 | { 219 | $column = $rule->qualifyColumn($this->builder); 220 | $exclude = in_array($query->operator, ['!=', 'not in']); 221 | 222 | return $this->builder->where([ 223 | [$column, ($exclude ? '<' : '>='), $start, $this->boolean], 224 | [$column, ($exclude ? '>' : '<='), $end, $this->boolean], 225 | ], null, null, $this->boolean); 226 | } 227 | 228 | protected function buildBasicQuery(QuerySymbol $query, ColumnRule $rule) 229 | { 230 | $column = $rule->qualifyColumn($this->builder); 231 | 232 | return $this->builder->where($column, $query->operator, $query->value, $this->boolean); 233 | } 234 | 235 | protected function mapValue($value, ColumnRule $rule) 236 | { 237 | if (is_array($value)) { 238 | return array_map(function ($value) use ($rule) { 239 | return $rule->map->has($value) ? $rule->map->get($value) : $value; 240 | }, $value); 241 | } 242 | 243 | if ($rule->map->has($value)) { 244 | return $rule->map->get($value); 245 | } 246 | 247 | if (is_numeric($value)) { 248 | return $value + 0; 249 | } 250 | 251 | return $value; 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/Visitors/BuildKeywordsVisitor.php: -------------------------------------------------------------------------------- 1 | manager = $manager; 24 | $this->builder = $builder; 25 | } 26 | 27 | public function visitRelationship(RelationshipSymbol $relationship) 28 | { 29 | // Keywords are not allowed within relationships. 30 | return $relationship; 31 | } 32 | 33 | public function visitQuery(QuerySymbol $query) 34 | { 35 | if (! $query->rule instanceof KeywordRule) { 36 | return $query; 37 | } 38 | 39 | switch ($query->rule->column) { 40 | case 'order_by': 41 | $this->buildOrderBy($query->value); 42 | break; 43 | case 'select': 44 | $this->buildSelect($query->value, $query->operator === '!='); 45 | break; 46 | case 'limit': 47 | $this->buildLimit($query->value); 48 | break; 49 | case 'offset': 50 | $this->buildOffset($query->value); 51 | break; 52 | } 53 | 54 | return $query; 55 | } 56 | 57 | public function visitList(ListSymbol $list) 58 | { 59 | if (! $list->rule instanceof KeywordRule) { 60 | return $list; 61 | } 62 | 63 | switch ($list->rule->column) { 64 | case 'order_by': 65 | $this->buildOrderBy($list->values); 66 | break; 67 | case 'select': 68 | $this->buildSelect($list->values, $list->negated); 69 | break; 70 | case 'limit': 71 | throw InvalidSearchStringException::fromVisitor('The limit must be an integer'); 72 | case 'offset': 73 | throw InvalidSearchStringException::fromVisitor('The offset must be an integer'); 74 | } 75 | 76 | return $list; 77 | } 78 | 79 | protected function buildOrderBy($values) 80 | { 81 | $this->builder->getQuery()->orders = null; 82 | 83 | Collection::wrap($values)->each(function ($value) { 84 | $desc = Str::startsWith($value, '-') ? 'desc' : 'asc'; 85 | $column = Str::startsWith($value, '-') ? Str::after($value, '-') : $value; 86 | $column = $this->manager->getColumnNameFromAlias($column); 87 | $qualifiedColumn = SearchStringManager::qualifyColumn($this->builder, $column); 88 | $this->builder->orderBy($qualifiedColumn, $desc); 89 | }); 90 | } 91 | 92 | protected function buildSelect($values, bool $negated) 93 | { 94 | $columns = Collection::wrap($values)->map(function ($value) { 95 | return $this->manager->getColumnNameFromAlias($value); 96 | }); 97 | 98 | $columns = $negated 99 | ? $this->manager->getColumns()->diff($columns) 100 | : $this->manager->getColumns()->intersect($columns); 101 | 102 | $columns = $columns->map(function ($column) { 103 | return SearchStringManager::qualifyColumn($this->builder, $column); 104 | }); 105 | 106 | $this->builder->select($columns->values()->toArray()); 107 | } 108 | 109 | protected function buildLimit($value) 110 | { 111 | if (! ctype_digit($value)) { 112 | throw InvalidSearchStringException::fromVisitor('The limit must be an integer'); 113 | } 114 | 115 | $this->builder->limit($value); 116 | } 117 | 118 | protected function buildOffset($value) 119 | { 120 | if (! ctype_digit($value)) { 121 | throw InvalidSearchStringException::fromVisitor('The offset must be an integer'); 122 | } 123 | 124 | $this->builder->offset($value); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Visitors/DumpVisitor.php: -------------------------------------------------------------------------------- 1 | indent === 0) return ''; 21 | return str_repeat('> ', $this->indent); 22 | } 23 | 24 | public function dump($value) 25 | { 26 | return $this->indent() . $value . "\n"; 27 | } 28 | 29 | public function visitOr(OrSymbol $or) 30 | { 31 | $root = $this->dump('OR'); 32 | $this->indent++; 33 | $leaves = collect($or->expressions)->map->accept($this)->implode(''); 34 | $this->indent--; 35 | return $root . $leaves; 36 | } 37 | 38 | public function visitAnd(AndSymbol $and) 39 | { 40 | $root = $this->dump('AND'); 41 | $this->indent++; 42 | $leaves = collect($and->expressions)->map->accept($this)->implode(''); 43 | $this->indent--; 44 | return $root . $leaves; 45 | } 46 | 47 | public function visitNot(NotSymbol $not) 48 | { 49 | $root = $this->dump('NOT'); 50 | $this->indent++; 51 | $leaves = $not->expression->accept($this); 52 | $this->indent--; 53 | return $root . $leaves; 54 | } 55 | 56 | public function visitRelationship(RelationshipSymbol $relationship) 57 | { 58 | $explicitOperation = ! $relationship->isCheckingExistance() && ! $relationship->isCheckingInexistance(); 59 | 60 | $root = $this->dump(sprintf( 61 | '%s [%s]%s', 62 | $relationship->isCheckingInexistance() ? 'NOT_EXISTS' : 'EXISTS', 63 | $relationship->key, 64 | $explicitOperation ? (' ' . $relationship->getExpectedOperation()) : '', 65 | )); 66 | 67 | $this->indent++; 68 | $leaves = $relationship->expression->accept($this); 69 | $this->indent--; 70 | return $root . $leaves; 71 | } 72 | 73 | public function visitSolo(SoloSymbol $solo) 74 | { 75 | return $this->dump(sprintf( 76 | '%s %s', 77 | $solo->negated ? 'NOT_SOLO' : 'SOLO', 78 | $solo->content 79 | )); 80 | } 81 | 82 | public function visitQuery(QuerySymbol $query) 83 | { 84 | return $this->dump("$query->key $query->operator $query->value"); 85 | } 86 | 87 | public function visitList(ListSymbol $list) 88 | { 89 | $operator = $list->negated ? 'not in' : 'in'; 90 | return $this->dump(sprintf('%s %s [%s]', $list->key, $operator, implode(', ', $list->values))); 91 | } 92 | 93 | public function visitEmpty(EmptySymbol $empty) 94 | { 95 | return $this->dump('EMPTY'); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Visitors/IdentifyRelationshipsFromRulesVisitor.php: -------------------------------------------------------------------------------- 1 | isRelationship($solo->rule)) { 18 | return $solo; 19 | } 20 | 21 | return (new RelationshipSymbol($solo->content, new EmptySymbol())) 22 | ->attachRule($solo->rule); 23 | } 24 | 25 | public function visitQuery(QuerySymbol $query) 26 | { 27 | if (! $this->isRelationship($query->rule)) { 28 | return $query; 29 | } 30 | 31 | if (! ctype_digit($query->value)) { 32 | throw InvalidSearchStringException::fromVisitor('The expected relationship count must be an integer'); 33 | } 34 | 35 | return (new RelationshipSymbol($query->key, new EmptySymbol(), $query->operator, $query->value)) 36 | ->attachRule($query->rule); 37 | } 38 | 39 | protected function isRelationship(?Rule $rule) 40 | { 41 | return $rule && $rule instanceof ColumnRule && $rule->relationship; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Visitors/InlineDumpVisitor.php: -------------------------------------------------------------------------------- 1 | shortenQuery = $shortenQuery; 21 | } 22 | 23 | public function visitOr(OrSymbol $or) 24 | { 25 | return 'OR(' . collect($or->expressions)->map->accept($this)->implode(', ') . ')'; 26 | } 27 | 28 | public function visitAnd(AndSymbol $and) 29 | { 30 | return 'AND(' . collect($and->expressions)->map->accept($this)->implode(', ') . ')'; 31 | } 32 | 33 | public function visitNot(NotSymbol $not) 34 | { 35 | return 'NOT(' . $not->expression->accept($this) . ')'; 36 | } 37 | 38 | public function visitRelationship(RelationshipSymbol $relationship) 39 | { 40 | $expression = $relationship->expression->accept($this); 41 | $explicitOperation = ! $relationship->isCheckingExistance() && ! $relationship->isCheckingInexistance(); 42 | 43 | return sprintf( 44 | '%s(%s, %s)%s', 45 | $relationship->isCheckingInexistance() ? 'NOT_EXISTS' : 'EXISTS', 46 | $relationship->key, 47 | $expression, 48 | $explicitOperation ? (' ' . $relationship->getExpectedOperation()) : '', 49 | ); 50 | } 51 | 52 | public function visitSolo(SoloSymbol $solo) 53 | { 54 | if ($this->shortenQuery) { 55 | return $solo->content; 56 | } 57 | 58 | return $solo->negated 59 | ? "SOLO_NOT($solo->content)" 60 | : "SOLO($solo->content)"; 61 | } 62 | 63 | public function visitQuery(QuerySymbol $query) 64 | { 65 | $value = $query->value; 66 | 67 | if ($this->shortenQuery) { 68 | $value = is_array($value) ? '[' . implode(', ', $value) . ']' : $value; 69 | return $query->key . (is_bool($value) ? '' : " $query->operator $value"); 70 | } 71 | 72 | $value = is_bool($value) ? ($value ? 'true' : 'false') : $value; 73 | $value = is_array($value) ? '[' . implode(', ', $value) . ']' : $value; 74 | return "QUERY($query->key $query->operator $value)"; 75 | } 76 | 77 | public function visitList(ListSymbol $list) 78 | { 79 | $operator = $list->negated ? 'not in' : 'in'; 80 | $dump = sprintf('%s %s [%s]', $list->key, $operator, implode(', ', $list->values)); 81 | 82 | return $this->shortenQuery ? $dump : "LIST($dump)"; 83 | } 84 | 85 | public function visitEmpty(EmptySymbol $empty) 86 | { 87 | return 'EMPTY'; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Visitors/OptimizeAstVisitor.php: -------------------------------------------------------------------------------- 1 | expressions->map->accept($this); 18 | $leaves = $this->flattenNestedLeaves($leaves, OrSymbol::class); 19 | $leaves = $this->mergeEquivalentRelationshipSymbols($leaves, OrSymbol::class); 20 | 21 | return $this->getAppropriateSymbolForNestedLeaves($leaves, OrSymbol::class); 22 | } 23 | 24 | public function visitAnd(AndSymbol $and) 25 | { 26 | $leaves = $and->expressions->map->accept($this); 27 | $leaves = $this->flattenNestedLeaves($leaves, AndSymbol::class); 28 | $leaves = $this->mergeEquivalentRelationshipSymbols($leaves, AndSymbol::class); 29 | 30 | return $this->getAppropriateSymbolForNestedLeaves($leaves, AndSymbol::class); 31 | } 32 | 33 | public function visitNot(NotSymbol $not) 34 | { 35 | $leaf = $not->expression->accept($this); 36 | return $leaf instanceof EmptySymbol ? new EmptySymbol : new NotSymbol($leaf); 37 | } 38 | 39 | public function flattenNestedLeaves(Collection $leaves, string $symbolClass) 40 | { 41 | return $leaves 42 | ->flatMap(function ($leaf) use ($symbolClass) { 43 | return $leaf instanceof $symbolClass ? $leaf->expressions : [$leaf]; 44 | }) 45 | ->filter(function ($leaf) { 46 | return ! $leaf instanceof EmptySymbol; 47 | }); 48 | } 49 | 50 | public function getAppropriateSymbolForNestedLeaves(Collection $leaves, string $symbolClass): Symbol 51 | { 52 | $leaves = $leaves->filter(function ($leaf) { 53 | return ! $leaf instanceof EmptySymbol; 54 | }); 55 | 56 | if ($leaves->isEmpty()) { 57 | return new EmptySymbol(); 58 | } 59 | 60 | if ($leaves->count() === 1) { 61 | return $leaves->first(); 62 | } 63 | 64 | return new $symbolClass($leaves); 65 | } 66 | 67 | public function mergeEquivalentRelationshipSymbols(Collection $leaves, string $symbolClass): Collection 68 | { 69 | return $leaves 70 | ->reduce(function (Collection $acc, Symbol $symbol) { 71 | if ($group = $this->findRelationshipGroup($acc, $symbol)) { 72 | $group->push($symbol); 73 | } else { 74 | $acc->push(collect([$symbol])); 75 | } 76 | 77 | return $acc; 78 | }, collect()) 79 | ->map(function (Collection $group) use ($symbolClass) { 80 | return $group->count() > 1 81 | ? $this->mergeRelationshipGroup($group, $symbolClass) 82 | : $group->first(); 83 | }); 84 | } 85 | 86 | public function findRelationshipGroup(Collection $leafGroups, Symbol $symbol): ?Collection 87 | { 88 | if (! $symbol instanceof RelationshipSymbol) { 89 | return null; 90 | } 91 | 92 | return $leafGroups->first(function (Collection $group) use ($symbol) { 93 | return $symbol->match($group->first()); 94 | }); 95 | } 96 | 97 | public function mergeRelationshipGroup(Collection $relationshipGroup, string $symbolClass): RelationshipSymbol 98 | { 99 | $relationshipSymbol = $relationshipGroup->first(); 100 | $expressions = $relationshipGroup->map->expression; 101 | $relationshipSymbol->expression = (new $symbolClass($expressions))->accept($this); 102 | 103 | return $relationshipSymbol; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Visitors/RemoveKeywordsVisitor.php: -------------------------------------------------------------------------------- 1 | rule instanceof KeywordRule ? new EmptySymbol : $query; 22 | } 23 | 24 | public function visitList(ListSymbol $list) 25 | { 26 | return $list->rule instanceof KeywordRule ? new EmptySymbol : $list; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Visitors/RemoveNotSymbolVisitor.php: -------------------------------------------------------------------------------- 1 | negate; 21 | 22 | $leaves = $or->expressions->map(function ($expression) use ($originalNegate) { 23 | $this->negate = $originalNegate; 24 | return $expression->accept($this); 25 | }); 26 | 27 | $this->negate = $originalNegate; 28 | 29 | return $this->negate ? new AndSymbol($leaves) : new OrSymbol($leaves); 30 | } 31 | 32 | public function visitAnd(AndSymbol $and) 33 | { 34 | $originalNegate = $this->negate; 35 | 36 | $leaves = $and->expressions->map(function ($expression) use ($originalNegate) { 37 | $this->negate = $originalNegate; 38 | return $expression->accept($this); 39 | }); 40 | 41 | $this->negate = $originalNegate; 42 | 43 | return $this->negate ? new OrSymbol($leaves) : new AndSymbol($leaves); 44 | } 45 | 46 | public function visitNot(NotSymbol $not) 47 | { 48 | $this->negate = ! $this->negate; 49 | $expression = $not->expression->accept($this); 50 | $this->negate = false; 51 | 52 | return $expression; 53 | } 54 | 55 | public function visitRelationship(RelationshipSymbol $relationship) 56 | { 57 | $originalNegate = $this->negate; 58 | $this->negate = false; 59 | $relationship->expression = $relationship->expression->accept($this); 60 | $this->negate = $originalNegate; 61 | 62 | if ($this->negate) { 63 | $relationship->expectedOperator = $this->reverseOperator($relationship->expectedOperator); 64 | } 65 | 66 | return $relationship; 67 | } 68 | 69 | public function visitSolo(SoloSymbol $solo) 70 | { 71 | if ($this->negate) { 72 | $solo->negate(); 73 | } 74 | 75 | return $solo; 76 | } 77 | 78 | public function visitQuery(QuerySymbol $query) 79 | { 80 | if (! $this->negate) { 81 | return $query; 82 | } 83 | 84 | if (is_bool($query->value)) { 85 | $query->value = ! $query->value; 86 | return $query; 87 | } 88 | 89 | $query->operator = $this->reverseOperator($query->operator); 90 | return $query; 91 | } 92 | 93 | public function visitList(ListSymbol $list) 94 | { 95 | if ($this->negate) { 96 | $list->negate(); 97 | } 98 | 99 | return $list; 100 | } 101 | 102 | protected function reverseOperator($operator) 103 | { 104 | return Arr::get([ 105 | '=' => '!=', 106 | '!=' => '=', 107 | '>' => '<=', 108 | '>=' => '<', 109 | '<' => '>=', 110 | '<=' => '>', 111 | ], $operator, $operator); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Visitors/ValidateRulesVisitor.php: -------------------------------------------------------------------------------- 1 | rule) { 15 | throw InvalidSearchStringException::fromVisitor(sprintf('Unrecognized key pattern [%s]', $relationship->key)); 16 | } 17 | 18 | $relationship->expression->accept($this); 19 | 20 | return $relationship; 21 | } 22 | 23 | public function visitQuery(QuerySymbol $query) 24 | { 25 | if (! $query->rule) { 26 | throw InvalidSearchStringException::fromVisitor(sprintf('Unrecognized key pattern [%s]', $query->key)); 27 | } 28 | 29 | return $query; 30 | } 31 | 32 | public function visitList(ListSymbol $list) 33 | { 34 | if (! $list->rule) { 35 | throw InvalidSearchStringException::fromVisitor(sprintf('Unrecognized key pattern [%s]', $list->key)); 36 | } 37 | 38 | return $list; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Visitors/Visitor.php: -------------------------------------------------------------------------------- 1 | expressions->map->accept($this)); 20 | } 21 | 22 | public function visitAnd(AndSymbol $and) 23 | { 24 | return new AndSymbol($and->expressions->map->accept($this)); 25 | } 26 | 27 | public function visitNot(NotSymbol $not) 28 | { 29 | return new NotSymbol($not->expression->accept($this)); 30 | } 31 | 32 | public function visitRelationship(RelationshipSymbol $relationship) 33 | { 34 | $relationship->expression = $relationship->expression->accept($this); 35 | 36 | return $relationship; 37 | } 38 | 39 | public function visitSolo(SoloSymbol $solo) 40 | { 41 | return $solo; 42 | } 43 | 44 | public function visitQuery(QuerySymbol $query) 45 | { 46 | return $query; 47 | } 48 | 49 | public function visitList(ListSymbol $list) 50 | { 51 | return $list; 52 | } 53 | 54 | public function visitEmpty(EmptySymbol $empty) 55 | { 56 | return $empty; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/config.php: -------------------------------------------------------------------------------- 1 | 'all-results', 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Default options 21 | |-------------------------------------------------------------------------- 22 | | 23 | | When options are missing from your models, this array will be used 24 | | to fill the gaps. You can also define a set of options specific 25 | | to a model, using its class name as a key, e.g. 'App\User'. 26 | | 27 | */ 28 | 29 | 'default' => [ 30 | 'keywords' => [ 31 | 'order_by' => 'sort', 32 | 'select' => 'fields', 33 | 'limit' => 'limit', 34 | 'offset' => 'from', 35 | ], 36 | ], 37 | ]; 38 | -------------------------------------------------------------------------------- /tests/Concerns/DumpsSql.php: -------------------------------------------------------------------------------- 1 | toSql()); 17 | 18 | $bindings = collect($builder->getBindings())->map(function ($binding) { 19 | if (is_string($binding)) return "'$binding'"; 20 | if (is_bool($binding)) return $binding ? 'true' : 'false'; 21 | return $binding; 22 | })->toArray(); 23 | 24 | return str_replace('`', '', vsprintf($query, $bindings)); 25 | } 26 | 27 | /** 28 | * @param EloquentBuilder|QueryBuilder $builder 29 | * @return string 30 | */ 31 | public function dumpSqlWhereClauses($builder): string 32 | { 33 | return preg_replace('/select \* from [\w.]+ where (.*)/', '$1', $this->dumpSql($builder)); 34 | } 35 | 36 | /** 37 | * @param string $input 38 | * @param string $expected 39 | * @param null $model 40 | */ 41 | public function assertSqlEquals(string $input, string $expected, $model = null) 42 | { 43 | $this->assertEquals($expected, $this->dumpSql($this->build($input, $model))); 44 | } 45 | 46 | /** 47 | * @param string $input 48 | * @param string $expected 49 | * @param null $model 50 | */ 51 | public function assertWhereSqlEquals(string $input, string $expected, $model = null) 52 | { 53 | $this->assertEquals($expected, $this->dumpSqlWhereClauses($this->build($input, $model))); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/Concerns/DumpsWhereClauses.php: -------------------------------------------------------------------------------- 1 | getQuery(); 20 | } 21 | 22 | return collect($query->wheres)->mapWithKeys(function ($where, $i){ 23 | $where = (object) $where; 24 | $key = "$where->type[{$where->boolean}][$i]"; 25 | 26 | if (isset($where->query)) { 27 | $children = $this->dumpWhereClauses($where->query); 28 | return [$key => $children]; 29 | } 30 | 31 | $column = $where->column instanceof Expression 32 | ? $where->column->getValue(new MySqlGrammar()) 33 | : $where->column; 34 | 35 | $value = $where->value ?? $where->values ?? null; 36 | $value = is_array($value) ? ('[' . implode(', ', $value) . ']') : $value; 37 | $value = is_bool($value) ? ($value ? 'true' : 'false') : $value; 38 | $value = isset($where->operator) ? "$where->operator $value" : $value; 39 | 40 | return [$key => is_null($value) ? $column : "$column $value"]; 41 | })->toArray(); 42 | } 43 | 44 | /** 45 | * @param $input 46 | * @param array $expected 47 | * @param null $model 48 | */ 49 | public function assertWhereClauses($input, array $expected, $model = null) 50 | { 51 | $wheres = $this->dumpWhereClauses($this->getBuilder($input, $model)); 52 | $this->assertEquals($expected, $wheres); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/CreateBuilderTest.php: -------------------------------------------------------------------------------- 1 | 100', 61 | "select * from products " 62 | . "where ((products.created_at >= 2020-01-01 00:00:00 and products.created_at <= 2020-12-31 23:59:59) " 63 | . "and (select count(*) from comments where products.id = comments.product_id and (" 64 | . "comments.spam = false " 65 | . "or exists (select * from users where comments.user_id = users.id and users.name = 'John'))) " 66 | . "> 100)" 67 | ], 68 | ]; 69 | } 70 | 71 | public function successWhereOnly() 72 | { 73 | $tomorrowStart = now()->addDay()->startOfDay(); 74 | $tomorrowEnd = now()->addDay()->endOfDay(); 75 | 76 | return [ 77 | // Assignments. 78 | ['name:John', "products.name = 'John'"], 79 | ['name=John', "products.name = 'John'"], 80 | ['name="John doe"', "products.name = 'John doe'"], 81 | ['not name:John', "products.name != 'John'"], 82 | 83 | // Booleans. 84 | ['boolean_variable', "products.boolean_variable = true"], 85 | ['paid', "products.paid = true"], 86 | ['not paid', "products.paid = false"], 87 | 88 | // Comparisons. 89 | ['price>0', "products.price > 0"], 90 | ['price>=0', "products.price >= 0"], 91 | ['price<0', "products.price < 0"], 92 | ['price<=0', "products.price <= 0"], 93 | ['price>0.55', "products.price > 0.55"], 94 | 95 | // Null in capital treated as null value. 96 | ['name:NULL', "products.name is null"], 97 | ['not name:NULL', "products.name is not null"], 98 | 99 | // Dates year precision. 100 | ['created_at >= 2020', "products.created_at >= 2020-01-01 00:00:00"], 101 | ['created_at > 2020', "products.created_at > 2020-12-31 23:59:59"], 102 | ['created_at = 2020', "(products.created_at >= 2020-01-01 00:00:00 and products.created_at <= 2020-12-31 23:59:59)"], 103 | ['not created_at = 2020', "(products.created_at < 2020-01-01 00:00:00 and products.created_at > 2020-12-31 23:59:59)"], 104 | 105 | // Dates month precision. 106 | ['created_at = 01/2020', "(products.created_at >= 2020-01-01 00:00:00 and products.created_at <= 2020-01-31 23:59:59)"], 107 | ['created_at = 2020-01', "(products.created_at >= 2020-01-01 00:00:00 and products.created_at <= 2020-01-31 23:59:59)"], 108 | ['created_at <= "Jan 2020"', "products.created_at <= 2020-01-31 23:59:59"], 109 | ['created_at < 2020-1', "products.created_at < 2020-01-01 00:00:00"], 110 | 111 | // Dates day precision. 112 | ['created_at = 2020-12-31', "(products.created_at >= 2020-12-31 00:00:00 and products.created_at <= 2020-12-31 23:59:59)"], 113 | ['created_at >= 12/31/2020', "products.created_at >= 2020-12-31 00:00:00"], 114 | ['created_at = 2018-05-17', "(products.created_at >= 2018-05-17 00:00:00 and products.created_at <= 2018-05-17 23:59:59)"], 115 | ['not created_at = 2018-05-17', "(products.created_at < 2018-05-17 00:00:00 and products.created_at > 2018-05-17 23:59:59)"], 116 | ['created_at = "May 17 2018"', "(products.created_at >= 2018-05-17 00:00:00 and products.created_at <= 2018-05-17 23:59:59)"], 117 | 118 | // Dates hour precision. 119 | ['created_at = "2020-12-31 16"', "(products.created_at >= 2020-12-31 16:00:00 and products.created_at <= 2020-12-31 16:59:59)"], 120 | ['created_at = "Dec 31 2020 2am"', "(products.created_at >= 2020-12-31 02:00:00 and products.created_at <= 2020-12-31 02:59:59)"], 121 | 122 | // Dates minute precision. 123 | ['created_at = "2020-12-31 16:30"', "(products.created_at >= 2020-12-31 16:30:00 and products.created_at <= 2020-12-31 16:30:59)"], 124 | ['created_at = "Dec 31 2020 5:15pm"', "(products.created_at >= 2020-12-31 17:15:00 and products.created_at <= 2020-12-31 17:15:59)"], 125 | 126 | // Dates exact precision. 127 | ['created_at = "2020-12-31 16:30:00"', "products.created_at = 2020-12-31 16:30:00"], 128 | ['created_at = "Dec 31 2020 5:15:10pm"', "products.created_at = 2020-12-31 17:15:10"], 129 | 130 | // Relative dates. 131 | ['created_at = tomorrow', "(products.created_at >= $tomorrowStart and products.created_at <= $tomorrowEnd)"], 132 | ['created_at < tomorrow', "products.created_at < $tomorrowStart"], 133 | ['created_at <= tomorrow', "products.created_at <= $tomorrowEnd"], 134 | ['created_at > tomorrow', "products.created_at > $tomorrowEnd"], 135 | ['created_at >= tomorrow', "products.created_at >= $tomorrowStart"], 136 | 137 | // Dates as booleans. 138 | ['created_at', "products.created_at is not null"], 139 | ['not created_at', "products.created_at is null"], 140 | 141 | // Lists. 142 | ['name in (John, Jane)', "products.name in ('John', 'Jane')"], 143 | ['not name in (John, Jane)', "products.name not in ('John', 'Jane')"], 144 | ['name in (John)', "products.name in ('John')"], 145 | ['not name in (John)', "products.name not in ('John')"], 146 | ['name:John,Jane', "products.name in ('John', 'Jane')"], 147 | ['not name:John,Jane', "products.name not in ('John', 'Jane')"], 148 | 149 | // Search. 150 | ['John', "(products.name like '%John%' or products.description like '%John%')"], 151 | ['"John Doe"', "(products.name like '%John Doe%' or products.description like '%John Doe%')"], 152 | ['not John', "(products.name not like '%John%' and products.description not like '%John%')"], 153 | 154 | // Nested And/Or where clauses. 155 | ['name:John and price>0', "(products.name = 'John' and products.price > 0)"], 156 | ['name:John or name:Jane', "(products.name = 'John' or products.name = 'Jane')"], 157 | ['name:1 and name:2 or name:3', "((products.name = 1 and products.name = 2) or products.name = 3)"], 158 | ['name:1 and (name:2 or name:3)', "(products.name = 1 and (products.name = 2 or products.name = 3))"], 159 | 160 | // Relationships existance. 161 | ['comments', "exists (select * from comments where products.id = comments.product_id)"], 162 | ['comments > 0', "exists (select * from comments where products.id = comments.product_id)"], 163 | ['comments >= 1', "exists (select * from comments where products.id = comments.product_id)"], 164 | ['not comments = 0', "exists (select * from comments where products.id = comments.product_id)"], 165 | 166 | // Relationships inexistance. 167 | ['not comments', "not exists (select * from comments where products.id = comments.product_id)"], 168 | ['not comments > 0', "not exists (select * from comments where products.id = comments.product_id)"], 169 | ['not comments >= 1', "not exists (select * from comments where products.id = comments.product_id)"], 170 | ['comments = 0', "not exists (select * from comments where products.id = comments.product_id)"], 171 | 172 | // Relationships count. 173 | ['comments = 10', "(select count(*) from comments where products.id = comments.product_id) = 10"], 174 | ['comments > 5', "(select count(*) from comments where products.id = comments.product_id) > 5"], 175 | ['not comments = 1', "(select count(*) from comments where products.id = comments.product_id) != 1"], 176 | ['not comments < 2', "(select count(*) from comments where products.id = comments.product_id) >= 2"], 177 | 178 | // Relationships nested terms. 179 | ['comments.author', "exists (select * from comments where products.id = comments.product_id and exists (select * from users where comments.user_id = users.id))"], 180 | ['comments.title = "My comment"', "exists (select * from comments where products.id = comments.product_id and comments.title = 'My comment')"], 181 | ['comments.author.name = John', "exists (select * from comments where products.id = comments.product_id and exists (select * from users where comments.user_id = users.id and users.name = 'John'))"], 182 | ['comments.author.writtenComments', "exists (select * from comments where products.id = comments.product_id and exists (select * from users where comments.user_id = users.id and exists (select * from comments where users.id = comments.user_id)))"], 183 | ['comments.favouritors > 10', "exists (select * from comments where products.id = comments.product_id and (select count(*) from users inner join comment_user on users.id = comment_user.user_id where comments.id = comment_user.comment_id) > 10)"], 184 | ['comments.favourites > 10', "exists (select * from comments where products.id = comments.product_id and (select count(*) from comment_user where comments.id = comment_user.comment_id) > 10)"], 185 | 186 | // Relationships nested terms negated. 187 | ['not comments.author', "not exists (select * from comments where products.id = comments.product_id and exists (select * from users where comments.user_id = users.id))"], 188 | ['not comments.title = "My comment"', "not exists (select * from comments where products.id = comments.product_id and comments.title = 'My comment')"], 189 | ['not comments.author.name = John', "not exists (select * from comments where products.id = comments.product_id and exists (select * from users where comments.user_id = users.id and users.name = 'John'))"], 190 | ['not comments.author.writtenComments', "not exists (select * from comments where products.id = comments.product_id and exists (select * from users where comments.user_id = users.id and exists (select * from comments where users.id = comments.user_id)))"], 191 | ['not comments.favouritors > 10', "not exists (select * from comments where products.id = comments.product_id and (select count(*) from users inner join comment_user on users.id = comment_user.user_id where comments.id = comment_user.comment_id) > 10)"], 192 | ['not comments.favourites > 10', "not exists (select * from comments where products.id = comments.product_id and (select count(*) from comment_user where comments.id = comment_user.comment_id) > 10)"], 193 | 194 | // Nested relationships. 195 | ['comments: (title: Hi)', "exists (select * from comments where products.id = comments.product_id and comments.title = 'Hi')"], 196 | ['comments: (not author)', "exists (select * from comments where products.id = comments.product_id and not exists (select * from users where comments.user_id = users.id))"], 197 | ['comments: (author.name: John or favourites > 5)', "exists (select * from comments where products.id = comments.product_id and (exists (select * from users where comments.user_id = users.id and users.name = 'John') or (select count(*) from comment_user where comments.id = comment_user.comment_id) > 5))"], 198 | ['comments: (favourites > 10) > 3', "(select count(*) from comments where products.id = comments.product_id and (select count(*) from comment_user where comments.id = comment_user.comment_id) > 10) > 3"], 199 | ['comments: ("This is great")', "exists (select * from comments where products.id = comments.product_id and (comments.title like '%This is great%' or comments.body like '%This is great%'))"], 200 | ['comments: (author: (name: "John Doe" age > 18)) > 3', "(select count(*) from comments where products.id = comments.product_id and exists (select * from users where comments.user_id = users.id and (users.name = 'John Doe' and users.age > 18))) > 3"], 201 | 202 | // Relationships & And/Or. 203 | ['name:A or comments: (title:B and title:C)', "(products.name = 'A' or exists (select * from comments where products.id = comments.product_id and (comments.title = 'B' and comments.title = 'C')))"], 204 | ['name:A or comments: (title:B or title:C)', "(products.name = 'A' or exists (select * from comments where products.id = comments.product_id and (comments.title = 'B' or comments.title = 'C')))"], 205 | ['name:A and not comments: (title:B or title:C)', "(products.name = 'A' and not exists (select * from comments where products.id = comments.product_id and (comments.title = 'B' or comments.title = 'C')))"], 206 | ['name:A (name:B or comments) or name:C and name:D', "((products.name = 'A' and (products.name = 'B' or exists (select * from comments where products.id = comments.product_id))) or (products.name = 'C' and products.name = 'D'))"], 207 | ['name:A not (name:B or comments) or name:C and name:D', "((products.name = 'A' and products.name != 'B' and not exists (select * from comments where products.id = comments.product_id)) or (products.name = 'C' and products.name = 'D'))"], 208 | ['name:A (name:B or not comments: (title:X and title:Y or not (author and not title:Z)))', "(products.name = 'A' and (products.name = 'B' or not exists (select * from comments where products.id = comments.product_id and ((comments.title = 'X' and comments.title = 'Y') or not exists (select * from users where comments.user_id = users.id) or comments.title = 'Z'))))"], 209 | ]; 210 | } 211 | 212 | public function expectInvalidSearchStringException() 213 | { 214 | return [ 215 | 'Limit should be a positive integer' => ['limit:-1'], 216 | 'Offset should be a positive integer' => ['from:"foo bar"'], 217 | 'Relationship expected count should be a positive integer' => ['comments = foo'], 218 | 'Relationship expected count (from nested terms) should be a positive integer' => ['comments.author = bar'], 219 | 'Relationship expected count (from nested relationship) should be a positive integer' => ['comments: (author: baz)'], 220 | ]; 221 | } 222 | 223 | /** @test */ 224 | public function it_uses_the_real_column_name_when_using_an_alias() 225 | { 226 | $model = $this->getModelWithColumns([ 227 | 'zipcode' => 'postcode', 228 | 'created_at' => ['key' => 'created', 'date' => true, 'boolean' => true], 229 | 'activated' => ['key' => 'active', 'boolean' => true], 230 | ]); 231 | 232 | $this->assertWhereSqlEquals('postcode:1028', "models.zipcode = 1028", $model); 233 | $this->assertWhereSqlEquals('postcode>10', "models.zipcode > 10", $model); 234 | $this->assertWhereSqlEquals('not postcode in (1000, 1002)', "models.zipcode not in ('1000', '1002')", $model); 235 | $this->assertWhereSqlEquals('created>2019-01-01', "models.created_at > 2019-01-01 23:59:59", $model); 236 | $this->assertWhereSqlEquals('created', "models.created_at is not null", $model); 237 | $this->assertWhereSqlEquals('not created', "models.created_at is null", $model); 238 | $this->assertWhereSqlEquals('active', "models.activated = true", $model); 239 | $this->assertWhereSqlEquals('not active', "models.activated = false", $model); 240 | } 241 | 242 | /** @test */ 243 | public function is_does_not_prefix_the_column_table_when_it_already_is_prefixed() 244 | { 245 | $model = $this->getModelWithColumns([ 246 | 'my_model_table.zipcode' => 'postcode', 247 | ]); 248 | 249 | $this->assertWhereSqlEquals('postcode:1028', "my_model_table.zipcode = 1028", $model); 250 | } 251 | 252 | /** 253 | * @test 254 | * @dataProvider success 255 | * @param string $input 256 | * @param string $expected 257 | */ 258 | public function create_builder_success(string $input, string $expected) 259 | { 260 | $this->assertSqlEquals($input, $expected); 261 | } 262 | 263 | /** 264 | * @test 265 | * @dataProvider successWhereOnly 266 | * @param string $input 267 | * @param string $expected 268 | */ 269 | public function create_builder_success_where_only(string $input, string $expected) 270 | { 271 | $this->assertWhereSqlEquals($input, $expected); 272 | } 273 | 274 | /** 275 | * @test 276 | * @dataProvider expectInvalidSearchStringException 277 | * @param string $input 278 | */ 279 | public function create_builder_expect_exception(string $input) 280 | { 281 | config()->set('search-string.fail', 'exceptions'); 282 | $this->expectException(InvalidSearchStringException::class); 283 | $this->build($input); 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /tests/DumpCommandsTest.php: -------------------------------------------------------------------------------- 1 | assertEquals( 13 | << name = A 16 | > price > 10 17 | EOL, 18 | $this->ast('name: A price > 10') 19 | ); 20 | 21 | $this->assertEquals( 22 | << EXISTS [author] 25 | > > name = John 26 | EOL, 27 | $this->ast('comments.author.name = John') 28 | ); 29 | } 30 | 31 | /** @test */ 32 | public function it_dumps_the_sql_query() 33 | { 34 | $this->assertEquals( 35 | 'select * from `products` where (`products`.`name` = A and `products`.`price` > 10)', 36 | $this->sql('name: A price > 10') 37 | ); 38 | 39 | $this->assertEquals( 40 | 'select * from `products` where exists (select * from `comments` where `products`.`id` = `comments`.`product_id` and exists (select * from `users` where `comments`.`user_id` = `users`.`id` and `users`.`name` = John))', 41 | $this->sql('comments.author.name = John') 42 | ); 43 | 44 | $this->assertEquals( 45 | 'select * from `products` where ((`products`.`name` like %A% or `products`.`description` like %A%) or (`products`.`name` like %B% or `products`.`description` like %B%))', 46 | $this->sql('A or B') 47 | ); 48 | } 49 | 50 | public function ast(string $query) 51 | { 52 | return $this->query('ast', $query); 53 | } 54 | 55 | public function sql(string $query) 56 | { 57 | return $this->query('sql', $query); 58 | } 59 | 60 | public function result(string $query) 61 | { 62 | return $this->query('get', $query); 63 | } 64 | 65 | public function query(string $type, string $query) 66 | { 67 | Artisan::call(sprintf('search-string:%s /Lorisleiva/LaravelSearchString/Tests/Stubs/Product "%s"', $type, $query)); 68 | 69 | return trim(Artisan::output(), "\n"); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/ErrorHandlingStrategiesTest.php: -------------------------------------------------------------------------------- 1 | setStrategy('exceptions'); 16 | $this->expectException(InvalidSearchStringException::class); 17 | $this->build('Hello "'); 18 | } 19 | 20 | /** @test */ 21 | public function exceptions_strategy_throws_on_parser_error() 22 | { 23 | $this->setStrategy('exceptions'); 24 | $this->expectException(InvalidSearchStringException::class); 25 | $this->build('parser error in in'); 26 | } 27 | 28 | /** @test */ 29 | public function exceptions_strategy_throws_on_unmatched_key() 30 | { 31 | $this->setStrategy('exceptions'); 32 | $this->expectException(InvalidSearchStringException::class); 33 | 34 | $model = $this->getModelWithColumns(['foo']); 35 | 36 | $this->build('bar:1', $model); 37 | } 38 | 39 | /** @test */ 40 | public function all_results_strategy_returns_an_unmodified_query_builder() 41 | { 42 | $this->setStrategy('all-results'); 43 | $this->assertSqlEquals('parser error in in', 'select * from products'); 44 | } 45 | 46 | /** @test */ 47 | public function no_results_strategy_returns_a_query_builder_with_a_limit_of_zero() 48 | { 49 | $this->setStrategy('no-results'); 50 | $this->assertSqlEquals('parser error in in', 'select * from products where 1 = 0'); 51 | } 52 | 53 | public function setStrategy($strategy) 54 | { 55 | config()->set('search-string.fail', $strategy); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/LexerTest.php: -------------------------------------------------------------------------------- 1 | bar', 'T_TERM T_COMPARATOR T_TERM'], 29 | ['foo>=bar', 'T_TERM T_COMPARATOR T_TERM'], 30 | ['foo<"bar"', 'T_TERM T_COMPARATOR T_DOUBLE_LQUOTE T_STRING T_DOUBLE_RQUOTE'], 31 | 32 | // Boolean operators. 33 | ['foo and bar', 'T_TERM T_AND T_TERM'], 34 | ['foo or bar', 'T_TERM T_OR T_TERM'], 35 | ['foo and not bar', 'T_TERM T_AND T_NOT T_TERM'], 36 | 37 | // Lists. 38 | ['foo in (a,b,c)', 'T_TERM T_IN T_LPARENTHESIS T_TERM T_COMMA T_TERM T_COMMA T_TERM T_RPARENTHESIS'], 39 | 40 | // Complex examples. 41 | [ 42 | 'foo12bar.x.y?z and (foo:1 or bar> 3.5)', 43 | 'T_TERM T_DOT T_TERM T_DOT T_TERM T_AND T_LPARENTHESIS T_TERM T_ASSIGNMENT T_INTEGER T_OR T_TERM T_COMPARATOR T_DECIMAL T_RPARENTHESIS' 44 | ], 45 | 46 | // Greedy on terms. 47 | ['and', 'T_AND'], 48 | ['andora', 'T_TERM'], 49 | ['or', 'T_OR'], 50 | ['oracle', 'T_TERM'], 51 | ['not', 'T_NOT'], 52 | ['notice', 'T_TERM'], 53 | 54 | // Terminating keywords. 55 | ['and', 'T_AND'], 56 | ['or', 'T_OR'], 57 | ['not', 'T_NOT'], 58 | ['in', 'T_IN'], 59 | ['and)', 'T_AND T_RPARENTHESIS'], 60 | ['or)', 'T_OR T_RPARENTHESIS'], 61 | ['not)', 'T_NOT T_RPARENTHESIS'], 62 | ['in)', 'T_IN T_RPARENTHESIS'], 63 | ]; 64 | } 65 | 66 | /** 67 | * @test 68 | * @dataProvider success 69 | * @param $input 70 | * @param $expectedTokens 71 | */ 72 | public function lexer_success($input, $expectedTokens) 73 | { 74 | $tokens = $this->lex($input)->map->token->all(); 75 | array_pop($tokens); // Ignore EOF token. 76 | $this->assertEquals($expectedTokens, implode(' ', $tokens)); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/ParserTest.php: -------------------------------------------------------------------------------- 1 | 0', 'QUERY(amount > 0)'], 30 | ['amount> 0', 'QUERY(amount > 0)'], 31 | ['amount >0', 'QUERY(amount > 0)'], 32 | ['amount > 0', 'QUERY(amount > 0)'], 33 | ['amount >= 0', 'QUERY(amount >= 0)'], 34 | ['amount < 0', 'QUERY(amount < 0)'], 35 | ['amount <= 0', 'QUERY(amount <= 0)'], 36 | ['users_todos <= 10', 'QUERY(users_todos <= 10)'], 37 | ['date > "2018-05-14 00:41:10"', 'QUERY(date > 2018-05-14 00:41:10)'], 38 | 39 | // Solo. 40 | ['lonely', 'SOLO(lonely)'], 41 | [' lonely ', 'SOLO(lonely)'], 42 | ['"lonely"', 'SOLO(lonely)'], 43 | [' "lonely" ', 'SOLO(lonely)'], 44 | ['"so lonely"', 'SOLO(so lonely)'], 45 | 46 | // Not. 47 | ['not A', 'NOT(SOLO(A))'], 48 | ['not (not A)', 'NOT(NOT(SOLO(A)))'], 49 | ['not not A', 'NOT(NOT(SOLO(A)))'], 50 | 51 | //And. 52 | ['A and B and C', 'AND(SOLO(A), SOLO(B), SOLO(C))'], 53 | ['(A AND B) and C', 'AND(AND(SOLO(A), SOLO(B)), SOLO(C))'], 54 | ['A AND (B AND C)', 'AND(SOLO(A), AND(SOLO(B), SOLO(C)))'], 55 | 56 | // Or. 57 | ['A or B or C', 'OR(SOLO(A), SOLO(B), SOLO(C))'], 58 | ['(A OR B) or C', 'OR(OR(SOLO(A), SOLO(B)), SOLO(C))'], 59 | ['A OR (B OR C)', 'OR(SOLO(A), OR(SOLO(B), SOLO(C)))'], 60 | 61 | // Or precedes And. 62 | ['A or B and C or D', 'OR(SOLO(A), AND(SOLO(B), SOLO(C)), SOLO(D))'], 63 | ['(A or B) and C', 'AND(OR(SOLO(A), SOLO(B)), SOLO(C))'], 64 | 65 | // Lists. 66 | ['foo:1,2,3', 'LIST(foo in [1, 2, 3])'], 67 | ['foo: 1,2,3', 'LIST(foo in [1, 2, 3])'], 68 | ['foo :1,2,3', 'LIST(foo in [1, 2, 3])'], 69 | ['foo : 1,2,3', 'LIST(foo in [1, 2, 3])'], 70 | ['foo : 1 , 2 , 3', 'LIST(foo in [1, 2, 3])'], 71 | ['foo = "A B C",baz,"bar"', 'LIST(foo in [A B C, baz, bar])'], 72 | ['foo in(1,2,3)', 'LIST(foo in [1, 2, 3])'], 73 | ['foo in (1,2,3)', 'LIST(foo in [1, 2, 3])'], 74 | [' foo in ( 1 , 2 , 3 ) ', 'LIST(foo in [1, 2, 3])'], 75 | 76 | // Complex examples. 77 | [ 78 | 'A: 1 or B > 2 and not C or D <= "foo bar"', 79 | 'OR(QUERY(A = 1), AND(QUERY(B > 2), NOT(SOLO(C))), QUERY(D <= foo bar))' 80 | ], 81 | [ 82 | 'sort:-name,date events > 10 and not started_at <= tomorrow', 83 | 'AND(LIST(sort in [-name, date]), QUERY(events > 10), NOT(QUERY(started_at <= tomorrow)))' 84 | ], 85 | [ 86 | 'A (B) not C', 87 | 'AND(SOLO(A), SOLO(B), NOT(SOLO(C)))' 88 | ], 89 | 90 | // Empty. 91 | ['', 'EMPTY'], 92 | 93 | // Relationships. 94 | ['comments.author = "John Doe"', 'EXISTS(comments, QUERY(author = John Doe))'], 95 | ['comments.author.tags > 3', 'EXISTS(comments, EXISTS(author, QUERY(tags > 3)))'], 96 | ['comments.author', 'EXISTS(comments, SOLO(author))'], 97 | ['comments.author.tags', 'EXISTS(comments, EXISTS(author, SOLO(tags)))'], 98 | ['not comments.author', 'NOT(EXISTS(comments, SOLO(author)))'], 99 | ['not comments.author = "John Doe"', 'NOT(EXISTS(comments, QUERY(author = John Doe)))'], 100 | 101 | // Nested relationships. 102 | ['comments: (author: John or votes > 10)', 'EXISTS(comments, OR(QUERY(author = John), QUERY(votes > 10)))'], 103 | ['comments: (author: John) = 20', 'EXISTS(comments, QUERY(author = John)) = 20'], 104 | ['comments: (author: John) <= 10', 'EXISTS(comments, QUERY(author = John)) <= 10'], 105 | ['comments: ("This is great")', 'EXISTS(comments, SOLO(This is great))'], 106 | ['comments.author: (name: "John Doe" age > 18) > 3', 'EXISTS(comments, EXISTS(author, AND(QUERY(name = John Doe), QUERY(age > 18)))) > 3'], 107 | ['comments: (achievements: (Laravel) >= 2) > 10', 'EXISTS(comments, EXISTS(achievements, SOLO(Laravel)) >= 2) > 10'], 108 | ['comments: (not achievements: (Laravel))', 'EXISTS(comments, NOT(EXISTS(achievements, SOLO(Laravel))))'], 109 | ['not comments: (achievements: (Laravel))', 'NOT(EXISTS(comments, EXISTS(achievements, SOLO(Laravel))))'], 110 | ]; 111 | } 112 | 113 | public function failure() 114 | { 115 | return [ 116 | // Unfinished. 117 | ['not ', 'EOF'], 118 | ['foo = ', 'T_ASSIGNMENT'], 119 | ['foo <= ', 'T_COMPARATOR'], 120 | ['foo in ', 'T_IN'], 121 | ['(', 'EOF'], 122 | 123 | // Strings as key. 124 | ['"string as key":foo', 'T_ASSIGNMENT'], 125 | ['foo and bar and "string as key" > 3', 'T_COMPARATOR'], 126 | ['not "string as key" in (1,2,3)', 'T_IN'], 127 | 128 | // Lonely operators. 129 | ['and', 'T_AND'], 130 | ['or', 'T_OR'], 131 | ['in', 'T_IN'], 132 | ['=', 'T_ASSIGNMENT'], 133 | [':', 'T_ASSIGNMENT'], 134 | ['<', 'T_COMPARATOR'], 135 | ['<=', 'T_COMPARATOR'], 136 | ['>', 'T_COMPARATOR'], 137 | ['>=', 'T_COMPARATOR'], 138 | 139 | // Invalid operators. 140 | ['foo<>3', 'T_COMPARATOR'], 141 | ['foo=>3', 'T_ASSIGNMENT'], 142 | ['foo=<3', 'T_ASSIGNMENT'], 143 | ['foo < in 3', 'T_COMPARATOR'], 144 | ['foo in = 1,2,3', 'T_IN'], 145 | ['foo == 1,2,3', 'T_ASSIGNMENT'], 146 | ['foo := 1,2,3', 'T_ASSIGNMENT'], 147 | ['foo:1:2:3:4', 'T_ASSIGNMENT'], 148 | ]; 149 | } 150 | 151 | /** 152 | * @test 153 | * @dataProvider success 154 | * @param $input 155 | * @param $expected 156 | */ 157 | public function parser_success($input, $expected) 158 | { 159 | $this->assertAstEquals($input, $expected); 160 | } 161 | 162 | /** 163 | * @test 164 | * @dataProvider failure 165 | * @param $input 166 | * @param $unexpectedToken 167 | */ 168 | public function parser_failure($input, $unexpectedToken) 169 | { 170 | try { 171 | $ast = $this->visit($input); 172 | $this->fail("Expected \"$input\" to fail. Instead got: \"$ast\""); 173 | } catch (InvalidSearchStringException $e) { 174 | if ($unexpectedToken) { 175 | $this->assertEquals($unexpectedToken, $e->getToken()); 176 | } else { 177 | $this->assertTrue(true); 178 | } 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /tests/RuleTest.php: -------------------------------------------------------------------------------- 1 | assertEquals("[/^foobar$/]", $this->parseRule('/^foobar$/')); 13 | $this->assertEquals("[~^foobar$~]", $this->parseRule('~^foobar$~')); 14 | $this->assertEquals("[/^(published|live)$/]", $this->parseRule('/^(published|live)$/')); 15 | } 16 | 17 | /** @test */ 18 | public function it_wraps_non_regex_patterns_into_regex_delimiters() 19 | { 20 | $this->assertEquals("[/^foobar$/]", $this->parseRule('foobar')); 21 | } 22 | 23 | /** @test */ 24 | public function it_preg_quote_non_regex_patterns() 25 | { 26 | $this->assertEquals('[/^\/ke\(y$/]', $this->parseRule('/ke(y')); 27 | $this->assertEquals('[/^\^\\\\d\$$/]', $this->parseRule('^\d$')); 28 | $this->assertEquals('[/^\.\*\\\w\(value$/]', $this->parseRule('.*\w(value')); 29 | } 30 | 31 | /** @test */ 32 | public function it_provides_fallback_values_when_patterns_are_missing() 33 | { 34 | $this->assertEquals('[/^fallback_column$/]', $this->parseRule(null, 'fallback_column')); 35 | $this->assertEquals('[/^fallback_column$/]', $this->parseRule([], 'fallback_column')); 36 | } 37 | 38 | /** @test */ 39 | public function it_parses_string_rules_as_the_key_of_the_rule() 40 | { 41 | $this->assertEquals("[/^foobar$/]", $this->parseRule('foobar')); 42 | $this->assertEquals("[/^\w{1,10}\?/]", $this->parseRule('/^\w{1,10}\?/')); 43 | } 44 | 45 | public function parseRule($rule, $column = 'column') 46 | { 47 | return (string) new class($column, $rule) extends Rule {}; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/SearchStringOptionsTest.php: -------------------------------------------------------------------------------- 1 | assertColumnsRulesFor(new Product, [ 15 | 'name' => '[/^name$/][searchable]', 16 | 'price' => '[/^price$/][]', 17 | 'description' => '[/^description$/][searchable]', 18 | 'paid' => '[/^paid$/][boolean]', 19 | 'boolean_variable' => '[/^boolean_variable$/][boolean]', 20 | 'created_at' => '[/^created_at$/][boolean][date]', 21 | 'comments' => '[/^comments$/][relationship]', 22 | ]); 23 | 24 | $this->assertKeywordRulesFor(new Product, [ 25 | 'order_by' => '[/^sort$/]', 26 | 'select' => '[/^fields$/]', 27 | 'limit' => '[/^limit$/]', 28 | 'offset' => '[/^from$/]', 29 | ]); 30 | } 31 | 32 | /** @test */ 33 | public function it_can_create_rules_without_explicit_configurations() 34 | { 35 | $model = $this->getModelWithColumns(['name']); 36 | 37 | $this->assertColumnsRulesFor($model, [ 38 | 'name' => '[/^name$/][]', 39 | ]); 40 | } 41 | 42 | /** @test */ 43 | public function it_can_create_rules_with_key_alias_only() 44 | { 45 | $model = $this->getModelWithColumns(['name' => 'alias']); 46 | 47 | $this->assertColumnsRulesFor($model, [ 48 | 'name' => '[/^alias$/][]', 49 | ]); 50 | } 51 | 52 | /** @test */ 53 | public function it_can_define_columns_as_searchable() 54 | { 55 | $model = $this->getModelWithColumns(['title' => ['searchable' => true]]); 56 | 57 | $this->assertColumnsRulesFor($model, [ 58 | 'title' => '[/^title$/][searchable]', 59 | ]); 60 | } 61 | 62 | /** @test */ 63 | public function it_can_define_columns_as_booleans() 64 | { 65 | $model = $this->getModelWithColumns(['paid' => ['boolean' => true]]); 66 | 67 | $this->assertColumnsRulesFor($model, [ 68 | 'paid' => '[/^paid$/][boolean]', 69 | ]); 70 | } 71 | 72 | /** @test */ 73 | public function it_can_define_columns_as_dates() 74 | { 75 | $model = $this->getModelWithColumns(['published_at' => ['date' => true]]); 76 | 77 | $this->assertColumnsRulesFor($model, [ 78 | 'published_at' => '[/^published_at$/][date]', 79 | ]); 80 | } 81 | 82 | /** @test */ 83 | public function it_default_boolean_to_true_if_column_is_cast_as_boolean() 84 | { 85 | $model = new class extends Model { 86 | use SearchString; 87 | protected $casts = ['paid' => 'boolean']; 88 | protected $searchStringColumns = ['paid']; 89 | }; 90 | 91 | $this->assertColumnsRulesFor($model, [ 92 | 'paid' => '[/^paid$/][boolean]', 93 | ]); 94 | } 95 | 96 | /** @test */ 97 | public function it_default_date_and_boolean_to_true_if_column_is_cast_as_date() 98 | { 99 | // Cast as datetime 100 | $model = new class extends Model { 101 | use SearchString; 102 | protected $casts = ['published_at' => 'datetime']; 103 | protected $searchStringColumns = ['published_at']; 104 | }; 105 | $this->assertColumnsRulesFor($model, [ 106 | 'published_at' => '[/^published_at$/][boolean][date]', 107 | ]); 108 | 109 | // Cast as date 110 | $model = new class extends Model { 111 | use SearchString; 112 | protected $casts = ['published_at' => 'date']; 113 | protected $searchStringColumns = ['published_at']; 114 | }; 115 | $this->assertColumnsRulesFor($model, [ 116 | 'published_at' => '[/^published_at$/][boolean][date]', 117 | ]); 118 | } 119 | 120 | /** @test */ 121 | public function it_can_force_boolean_and_date_to_false_when_casted_as_boolean_or_date() 122 | { 123 | // Disable boolean option. 124 | $model = new class extends Model { 125 | use SearchString; 126 | protected $casts = ['paid' => 'boolean']; 127 | protected $searchStringColumns = ['paid' => ['boolean' => false]]; 128 | }; 129 | $this->assertColumnsRulesFor($model, [ 130 | 'paid' => '[/^paid$/][]', 131 | ]); 132 | 133 | // Disable boolean and date option. 134 | $model = new class extends Model { 135 | use SearchString; 136 | protected $casts = ['published_at' => 'datetime']; 137 | protected $searchStringColumns = ['published_at' => [ 138 | 'date' => false, 139 | 'boolean' => false, 140 | ]]; 141 | }; 142 | $this->assertColumnsRulesFor($model, [ 143 | 'published_at' => '[/^published_at$/][]', 144 | ]); 145 | } 146 | 147 | /** @test */ 148 | public function it_can_define_a_value_mapping() 149 | { 150 | $model = $this->getModelWithColumns([ 151 | 'support_level_id' => [ 152 | 'key' => 'support_level', 153 | 'map' => [ 154 | 'testing' => 1, 155 | 'community' => 2, 156 | 'official' => 3, 157 | ], 158 | ] 159 | ]); 160 | 161 | $this->assertColumnsRulesFor($model, [ 162 | 'support_level_id' => '[/^support_level$/][][testing=1,community=2,official=3]' 163 | ]); 164 | } 165 | 166 | public function assertColumnsRulesFor($model, $expected) 167 | { 168 | $manager = $this->getSearchStringManager($model); 169 | $options = $manager->getColumnRules()->map->__toString()->toArray(); 170 | $this->assertEquals($expected, $options); 171 | } 172 | 173 | public function assertKeywordRulesFor($model, $expected) 174 | { 175 | $manager = $this->getSearchStringManager($model); 176 | $options = $manager->getKeywordRules()->map->__toString()->toArray(); 177 | $this->assertEquals($expected, $options); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /tests/Stubs/Comment.php: -------------------------------------------------------------------------------- 1 | ['searchable' => true], 14 | 'body' => ['searchable' => true], 15 | 'spam' => ['boolean' => true], 16 | 'user' => [ 17 | 'key' => 'author', 18 | 'relationship' => true, 19 | ], 20 | 'favourites' => ['relationship' => true], 21 | 'favouritors' => ['relationship' => true], 22 | 'created_at' => 'date', 23 | ]; 24 | 25 | public function user() 26 | { 27 | return $this->belongsTo(User::class); 28 | } 29 | 30 | public function favourites() 31 | { 32 | return $this->hasMany(CommentUser::class); 33 | } 34 | 35 | public function favouritors() 36 | { 37 | return $this->belongsToMany(User::class); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Stubs/CommentUser.php: -------------------------------------------------------------------------------- 1 | ['relationship' => true], 14 | 'user' => ['relationship' => true], 15 | 'created_at' => 'date', 16 | ]; 17 | 18 | public function comment() 19 | { 20 | return $this->belongsTo(Comment::class); 21 | } 22 | 23 | public function user() 24 | { 25 | return $this->belongsTo(User::class); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/Stubs/Product.php: -------------------------------------------------------------------------------- 1 | 'boolean', 14 | ]; 15 | 16 | protected $searchStringColumns = [ 17 | 'name' => ['searchable' => true], 18 | 'price', 19 | 'description' => ['searchable' => true], 20 | 'paid', // Automatically marked as boolean. 21 | 'boolean_variable' => ['boolean' => true], 22 | 'created_at', // Automatically marked as date and boolean. 23 | 'comments' => ['relationship' => true], 24 | ]; 25 | 26 | protected $searchStringKeywords = [ 27 | 'order_by' => 'sort', 28 | 'select' => 'fields', 29 | 'limit' => 'limit', 30 | 'offset' => 'from', 31 | ]; 32 | 33 | public function comments() 34 | { 35 | return $this->hasMany(Comment::class); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Stubs/User.php: -------------------------------------------------------------------------------- 1 | ['searchable' => true], 14 | 'email' => ['searchable' => true], 15 | 'age', 16 | 'comments' => [ 17 | 'key' => '/^comments|writtenComments$/', 18 | 'relationship' => true, 19 | ], 20 | 'favouriteComments' => ['relationship' => true], 21 | 'favourites' => ['relationship' => true], 22 | 'created_at' => 'date', 23 | ]; 24 | 25 | public function comments() 26 | { 27 | return $this->hasMany(Comment::class); 28 | } 29 | 30 | public function favouriteComments() 31 | { 32 | return $this->belongsToMany(Comment::class); 33 | } 34 | 35 | public function favourites() 36 | { 37 | return $this->hasMany(CommentUser::class); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | set('search-string', include __DIR__ . '/../src/config.php'); 20 | } 21 | 22 | public function getModelWithOptions($options) 23 | { 24 | return new class($options) extends Model { 25 | use SearchString; 26 | 27 | protected $table = 'models'; 28 | protected $options = []; 29 | 30 | public function __construct($options) 31 | { 32 | parent::__construct(); 33 | $this->options = $options; 34 | } 35 | 36 | public function getSearchStringOptions() 37 | { 38 | return $this->options; 39 | } 40 | }; 41 | } 42 | 43 | public function getModelWithColumns($columns) 44 | { 45 | return $this->getModelWithOptions(['columns' => $columns]); 46 | } 47 | 48 | public function getModelWithKeywords($keywords) 49 | { 50 | return $this->getModelWithOptions(['keywords' => $keywords]); 51 | } 52 | 53 | public function getSearchStringManager($model = null) 54 | { 55 | return new SearchStringManager($model ?? new Product); 56 | } 57 | 58 | public function lex($input, $model = null) 59 | { 60 | return $this->getSearchStringManager($model)->getCompiler()->lex($input); 61 | } 62 | 63 | public function parse($input, $model = null) 64 | { 65 | return $this->getSearchStringManager($model)->parse($input); 66 | } 67 | 68 | public function visit($input, $visitors, $model = null) 69 | { 70 | $ast = is_string($input) ? $this->parse($input, $model) : $input; 71 | 72 | foreach ($visitors as $visitor) { 73 | $ast = $ast->accept($visitor); 74 | } 75 | 76 | return $ast; 77 | } 78 | 79 | public function build($input, $model = null) 80 | { 81 | return $this->getSearchStringManager($model)->createBuilder($input); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/UpdateBuilderTest.php: -------------------------------------------------------------------------------- 1 | build('limit:1')->limit(2); 16 | 17 | $this->assertEquals('select * from products limit 2', $this->dumpSql($builder)); 18 | } 19 | 20 | /** @test */ 21 | public function it_can_use_first_instead_of_get() 22 | { 23 | $queryLog = DB::pretend(function () { 24 | $this->build('')->first(); 25 | }); 26 | 27 | $this->assertEquals('select * from `products` limit 1', $queryLog[0]['query']); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/VisitorBuildColumnsTest.php: -------------------------------------------------------------------------------- 1 | assertWhereClauses('name:1', ['Basic[and][0]' => 'products.name = 1']); 28 | $this->assertWhereClauses('name=1', ['Basic[and][0]' => 'products.name = 1']); 29 | $this->assertWhereClauses('name=Hello', ['Basic[and][0]' => 'products.name = Hello']); 30 | $this->assertWhereClauses('name="Hello World"', ['Basic[and][0]' => 'products.name = Hello World']); 31 | $this->assertWhereClauses('not name:1', ['Basic[and][0]' => 'products.name != 1']); 32 | $this->assertWhereClauses('not name=Hello', ['Basic[and][0]' => 'products.name != Hello']); 33 | 34 | $this->assertWhereClauses('name<0', ['Basic[and][0]' => 'products.name < 0']); 35 | $this->assertWhereClauses('name<=0', ['Basic[and][0]' => 'products.name <= 0']); 36 | $this->assertWhereClauses('name>0', ['Basic[and][0]' => 'products.name > 0']); 37 | $this->assertWhereClauses('name>=0', ['Basic[and][0]' => 'products.name >= 0']); 38 | $this->assertWhereClauses('not name<0', ['Basic[and][0]' => 'products.name >= 0']); 39 | $this->assertWhereClauses('not name<=0', ['Basic[and][0]' => 'products.name > 0']); 40 | $this->assertWhereClauses('not name>0', ['Basic[and][0]' => 'products.name <= 0']); 41 | $this->assertWhereClauses('not name>=0', ['Basic[and][0]' => 'products.name < 0']); 42 | 43 | // boolean_variable is defined in the `columns.boolean` option. 44 | $this->assertWhereClauses('boolean_variable', ['Basic[and][0]' => 'products.boolean_variable = true']); 45 | $this->assertWhereClauses('not boolean_variable', ['Basic[and][0]' => 'products.boolean_variable = false']); 46 | } 47 | 48 | /** @test */ 49 | public function it_can_generate_in_and_not_in_where_clauses() 50 | { 51 | $this->assertWhereClauses('name in (1,2,3)', ['In[and][0]' => 'products.name [1, 2, 3]']); 52 | $this->assertWhereClauses('not name in (1,2,3)', ['NotIn[and][0]' => 'products.name [1, 2, 3]']); 53 | $this->assertWhereClauses('name:1,2,3', ['In[and][0]' => 'products.name [1, 2, 3]']); 54 | $this->assertWhereClauses('not name:1,2,3', ['NotIn[and][0]' => 'products.name [1, 2, 3]']); 55 | } 56 | 57 | /** @test */ 58 | public function it_generates_where_clauses_from_aliased_columned_using_the_real_column_name() 59 | { 60 | $model = $this->getModelWithColumns([ 61 | 'zipcode' => 'postcode', 62 | 'created_at' => ['key' => '/^(created|date)$/', 'date' => true, 'boolean' => true], 63 | 'activated' => ['key' => 'active', 'boolean' => true], 64 | ]); 65 | 66 | $this->assertWhereClauses('postcode:1028', ['Basic[and][0]' => 'models.zipcode = 1028'], $model); 67 | $this->assertWhereClauses('postcode>10', ['Basic[and][0]' => 'models.zipcode > 10'], $model); 68 | $this->assertWhereClauses('not postcode in (1000, 1002)', ['NotIn[and][0]' => 'models.zipcode [1000, 1002]'], $model); 69 | $this->assertWhereClauses('created>2019-01-01', ['Basic[and][0]' => 'models.created_at > 2019-01-01 23:59:59'], $model); 70 | $this->assertWhereClauses('created', ['NotNull[and][0]' => 'models.created_at'], $model); 71 | $this->assertWhereClauses('date', ['NotNull[and][0]' => 'models.created_at'], $model); 72 | $this->assertWhereClauses('not created', ['Null[and][0]' => 'models.created_at'], $model); 73 | $this->assertWhereClauses('not date', ['Null[and][0]' => 'models.created_at'], $model); 74 | $this->assertWhereClauses('active', ['Basic[and][0]' => 'models.activated = true'], $model); 75 | $this->assertWhereClauses('not active', ['Basic[and][0]' => 'models.activated = false'], $model); 76 | } 77 | 78 | /** @test */ 79 | public function it_searches_using_like_where_clauses() 80 | { 81 | $this->assertWhereClauses('foobar', [ 82 | 'Nested[and][0]' => [ 83 | 'Basic[or][0]' => 'products.name like %foobar%', 84 | 'Basic[or][1]' => 'products.description like %foobar%', 85 | ] 86 | ]); 87 | 88 | $this->assertWhereClauses('not foobar', [ 89 | 'Nested[and][0]' => [ 90 | 'Basic[and][0]' => 'products.name not like %foobar%', 91 | 'Basic[and][1]' => 'products.description not like %foobar%', 92 | ] 93 | ]); 94 | } 95 | 96 | /** @test */ 97 | public function it_can_searches_using_case_insensitive_like_where_clauses() 98 | { 99 | $model = $this->getModelWithOptions([ 100 | 'case_insensitive' => true, 101 | 'columns' => [ 102 | 'name' => ['searchable' => true], 103 | 'description' => ['searchable' => true], 104 | ] 105 | ]); 106 | 107 | $this->assertWhereClauses('FooBar', [ 108 | 'Nested[and][0]' => [ 109 | 'Basic[or][0]' => 'LOWER(models.name) like %foobar%', 110 | 'Basic[or][1]' => 'LOWER(models.description) like %foobar%', 111 | ] 112 | ], $model); 113 | 114 | $this->assertWhereClauses('not FooBar', [ 115 | 'Nested[and][0]' => [ 116 | 'Basic[and][0]' => 'LOWER(models.name) not like %foobar%', 117 | 'Basic[and][1]' => 'LOWER(models.description) not like %foobar%', 118 | ] 119 | ], $model); 120 | } 121 | 122 | /** @test */ 123 | public function it_does_not_add_where_clause_if_no_searchable_columns_were_given() 124 | { 125 | $model = $this->getModelWithOptions([]); 126 | 127 | $this->assertWhereClauses('foobar', [], $model); 128 | $this->assertWhereClauses('not foobar', [], $model); 129 | } 130 | 131 | /** @test */ 132 | public function it_does_not_nest_where_clauses_if_only_one_searchable_columns_is_given() 133 | { 134 | $model = $this->getModelWithColumns([ 135 | 'name' => [ 'searchable' => true ] 136 | ]); 137 | 138 | $this->assertWhereClauses('foobar', [ 139 | 'Basic[and][0]' => 'models.name like %foobar%' 140 | ], $model); 141 | 142 | $this->assertWhereClauses('not foobar', [ 143 | 'Basic[and][0]' => 'models.name not like %foobar%' 144 | ], $model); 145 | } 146 | 147 | /** @test */ 148 | public function it_wraps_basic_queries_in_nested_and_or_where_clauses() 149 | { 150 | $this->assertWhereClauses('name:1 and price>1', [ 151 | 'Nested[and][0]' => [ 152 | 'Basic[and][0]' => 'products.name = 1', 153 | 'Basic[and][1]' => 'products.price > 1', 154 | ] 155 | ]); 156 | 157 | $this->assertWhereClauses('name:1 or price>1', [ 158 | 'Nested[and][0]' => [ 159 | 'Basic[or][0]' => 'products.name = 1', 160 | 'Basic[or][1]' => 'products.price > 1', 161 | ] 162 | ]); 163 | } 164 | 165 | /** @test */ 166 | public function it_wraps_search_queries_in_nested_and_or_where_clauses() 167 | { 168 | $this->assertWhereClauses('foo and bar', [ 169 | 'Nested[and][0]' => [ 170 | 'Nested[and][0]' => [ 171 | 'Basic[or][0]' => 'products.name like %foo%', 172 | 'Basic[or][1]' => 'products.description like %foo%', 173 | ], 174 | 'Nested[and][1]' => [ 175 | 'Basic[or][0]' => 'products.name like %bar%', 176 | 'Basic[or][1]' => 'products.description like %bar%', 177 | ], 178 | ] 179 | ]); 180 | 181 | $this->assertWhereClauses('foo or bar', [ 182 | 'Nested[and][0]' => [ 183 | 'Nested[or][0]' => [ 184 | 'Basic[or][0]' => 'products.name like %foo%', 185 | 'Basic[or][1]' => 'products.description like %foo%', 186 | ], 187 | 'Nested[or][1]' => [ 188 | 'Basic[or][0]' => 'products.name like %bar%', 189 | 'Basic[or][1]' => 'products.description like %bar%', 190 | ], 191 | ] 192 | ]); 193 | 194 | $this->assertWhereClauses('not foo or not bar', [ 195 | 'Nested[and][0]' => [ 196 | 'Nested[or][0]' => [ 197 | 'Basic[and][0]' => 'products.name not like %foo%', 198 | 'Basic[and][1]' => 'products.description not like %foo%', 199 | ], 200 | 'Nested[or][1]' => [ 201 | 'Basic[and][0]' => 'products.name not like %bar%', 202 | 'Basic[and][1]' => 'products.description not like %bar%', 203 | ], 204 | ] 205 | ]); 206 | } 207 | 208 | /** @test */ 209 | public function it_wraps_complex_and_or_operators_in_nested_where_clauses() 210 | { 211 | $this->assertWhereClauses('name:4 or (name:1 or name:2) and price>1 or name:3', [ 212 | 'Nested[and][0]' => [ 213 | 'Basic[or][0]' => 'products.name = 4', 214 | 'Nested[or][1]' => [ 215 | 'Nested[and][0]' => [ 216 | 'Basic[or][0]' => 'products.name = 1', 217 | 'Basic[or][1]' => 'products.name = 2', 218 | ], 219 | 'Basic[and][1]' => 'products.price > 1', 220 | ], 221 | 'Basic[or][2]' => 'products.name = 3', 222 | ] 223 | ]); 224 | } 225 | 226 | /** @test */ 227 | public function it_updates_query_values_according_to_the_rules_map() 228 | { 229 | $model = $this->getModelWithColumns([ 230 | 'support_level_id' => [ 231 | 'key' => 'support_level', 232 | 'map' => [ 233 | 'testing' => 1, 234 | 'community' => 2, 235 | 'official' => 3, 236 | ], 237 | ] 238 | ]); 239 | 240 | $this->assertWhereClauses('support_level:testing', ['Basic[and][0]' => 'models.support_level_id = 1'], $model); 241 | $this->assertWhereClauses('support_level:community', ['Basic[and][0]' => 'models.support_level_id = 2'], $model); 242 | $this->assertWhereClauses('support_level:official', ['Basic[and][0]' => 'models.support_level_id = 3'], $model); 243 | } 244 | 245 | /** @test */ 246 | public function it_does_not_update_query_values_if_the_rule_mapping_is_missing() 247 | { 248 | $model = $this->getModelWithColumns([ 249 | 'support_level_id' => [ 250 | 'key' => 'support_level', 251 | 'map' => ['testing' => 1], 252 | ] 253 | ]); 254 | 255 | $this->assertWhereClauses('support_level:missing_value', ['Basic[and][0]' => 'models.support_level_id = missing_value'], $model); 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /tests/VisitorBuildKeywordsTest.php: -------------------------------------------------------------------------------- 1 | getBuilder('fields:name'); 32 | $this->assertEquals(['products.name'], $builder->getQuery()->columns); 33 | } 34 | 35 | /** @test */ 36 | public function it_excludes_columns_when_operator_is_negative() 37 | { 38 | $builder = $this->getBuilder('not fields:name'); 39 | 40 | $this->assertEquals( 41 | ['products.price', 'products.description', 'products.paid', 'products.boolean_variable', 'products.created_at'], 42 | $builder->getQuery()->columns 43 | ); 44 | } 45 | 46 | /** @test */ 47 | public function it_can_set_and_exclude_multiple_columns() 48 | { 49 | $builder = $this->getBuilder('fields:name,price,description'); 50 | $this->assertEquals(['products.name', 'products.price', 'products.description'], $builder->getQuery()->columns); 51 | 52 | $builder = $this->getBuilder('not fields:name,price,description'); 53 | $this->assertEquals(['products.paid', 'products.boolean_variable', 'products.created_at'], $builder->getQuery()->columns); 54 | } 55 | 56 | /** @test */ 57 | public function it_uses_only_the_last_select_that_matches() 58 | { 59 | $builder = $this->getBuilder('fields:name fields:price fields:description'); 60 | $this->assertEquals(['products.description'], $builder->getQuery()->columns); 61 | } 62 | 63 | /** @test */ 64 | public function it_uses_the_alias_of_select_columns() 65 | { 66 | $model = $this->getModelWithColumns(['created_at' => 'date']); 67 | $builder = $this->getBuilder('fields:date', $model); 68 | 69 | $this->assertEquals(['models.created_at'], $builder->getQuery()->columns); 70 | } 71 | 72 | /* 73 | * OrderBy 74 | */ 75 | 76 | /** @test */ 77 | public function it_sets_the_order_by_of_the_builder() 78 | { 79 | $builder = $this->getBuilder('sort:name'); 80 | 81 | $this->assertEquals([ 82 | [ 'column' => 'products.name', 'direction' => 'asc' ], 83 | ], $builder->getQuery()->orders); 84 | } 85 | 86 | /** @test */ 87 | public function it_sets_the_descending_order_when_preceded_by_a_minus() 88 | { 89 | $builder = $this->getBuilder('sort:-name'); 90 | 91 | $this->assertEquals([ 92 | [ 'column' => 'products.name', 'direction' => 'desc' ], 93 | ], $builder->getQuery()->orders); 94 | } 95 | 96 | /** @test */ 97 | public function it_can_set_multiple_order_by() 98 | { 99 | $builder = $this->getBuilder('sort:name,-price,created_at'); 100 | 101 | $this->assertEquals([ 102 | [ 'column' => 'products.name', 'direction' => 'asc' ], 103 | [ 'column' => 'products.price', 'direction' => 'desc' ], 104 | [ 'column' => 'products.created_at', 'direction' => 'asc' ], 105 | ], $builder->getQuery()->orders); 106 | } 107 | 108 | /** @test */ 109 | public function it_uses_only_the_last_order_by_that_matches() 110 | { 111 | $builder = $this->getBuilder('sort:name sort:-price sort:created_at'); 112 | 113 | $this->assertEquals([ 114 | [ 'column' => 'products.created_at', 'direction' => 'asc' ], 115 | ], $builder->getQuery()->orders); 116 | } 117 | 118 | /** @test */ 119 | public function it_uses_the_alias_of_order_by_columns() 120 | { 121 | $model = $this->getModelWithColumns(['created_at' => 'date']); 122 | $builder = $this->getBuilder('sort:date', $model); 123 | 124 | $this->assertEquals([ 125 | [ 'column' => 'models.created_at', 'direction' => 'asc' ], 126 | ], $builder->getQuery()->orders); 127 | } 128 | 129 | /* 130 | * Limit 131 | */ 132 | 133 | /** @test */ 134 | public function it_sets_the_limit_of_the_builder() 135 | { 136 | $builder = $this->getBuilder('limit:10'); 137 | $this->assertEquals(10, $builder->getQuery()->limit); 138 | } 139 | 140 | /** @test */ 141 | public function it_throws_an_exception_if_the_limit_is_not_an_integer() 142 | { 143 | $this->expectException(InvalidSearchStringException::class); 144 | $this->getBuilder('limit:foobar'); 145 | } 146 | 147 | /** @test */ 148 | public function it_throws_an_exception_if_the_limit_is_an_array() 149 | { 150 | $this->expectException(InvalidSearchStringException::class); 151 | $this->getBuilder('limit:10,foo,23'); 152 | } 153 | 154 | /** @test */ 155 | public function it_uses_only_the_last_limit_that_matches() 156 | { 157 | $builder = $this->getBuilder('limit:10 limit:20 limit:30'); 158 | $this->assertEquals(30, $builder->getQuery()->limit); 159 | } 160 | 161 | /* 162 | * Offset 163 | */ 164 | 165 | /** @test */ 166 | public function it_sets_the_offset_of_the_builder() 167 | { 168 | $builder = $this->getBuilder('from:10'); 169 | $this->assertEquals(10, $builder->getQuery()->offset); 170 | } 171 | 172 | /** @test */ 173 | public function it_throws_an_exception_if_the_offset_is_not_an_integer() 174 | { 175 | $this->expectException(InvalidSearchStringException::class); 176 | $this->getBuilder('from:foobar'); 177 | } 178 | 179 | /** @test */ 180 | public function it_throws_an_exception_if_the_offset_is_an_array() 181 | { 182 | $this->expectException(InvalidSearchStringException::class); 183 | $this->getBuilder('from:10,foo,23'); 184 | } 185 | 186 | /** @test */ 187 | public function it_uses_only_the_last_offset_that_matches() 188 | { 189 | $builder = $this->getBuilder('from:10 from:20 from:30'); 190 | $this->assertEquals(30, $builder->getQuery()->offset); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /tests/VisitorIdentifyRelationshipsFromRulesTest.php: -------------------------------------------------------------------------------- 1 | 1', 'EXISTS(comments, EMPTY) <= 1'], 34 | ['not comments > 0', 'NOT_EXISTS(comments, EMPTY)'], 35 | 36 | // It recognises solo symbols inside relationships. 37 | ['comments: (favouritors)', 'EXISTS(comments, EXISTS(favouritors, EMPTY))'], 38 | ['comments: (not favouritors)', 'EXISTS(comments, NOT_EXISTS(favouritors, EMPTY))'], 39 | ['not comments: (not favouritors)', 'NOT_EXISTS(comments, NOT_EXISTS(favouritors, EMPTY))'], 40 | 41 | // It recognises query symbols inside relationships. 42 | ['comments: (favouritors = 3)', 'EXISTS(comments, EXISTS(favouritors, EMPTY) = 3)'], 43 | ['comments: (not favouritors > 0)', 'EXISTS(comments, NOT_EXISTS(favouritors, EMPTY))'], 44 | ['not comments: (favouritors = 0)', 'NOT_EXISTS(comments, NOT_EXISTS(favouritors, EMPTY))'], 45 | 46 | // It recognise symbols inside nested terms. 47 | ['comments.author', 'EXISTS(comments, EXISTS(author, EMPTY))'], 48 | ['comments.author > 5', 'EXISTS(comments, EXISTS(author, EMPTY) > 5)'], 49 | ['comments.favouritors', 'EXISTS(comments, EXISTS(favouritors, EMPTY))'], 50 | ['not comments.favouritors', 'NOT_EXISTS(comments, EXISTS(favouritors, EMPTY))'], 51 | ['comments.favouritors.name = John', 'EXISTS(comments, EXISTS(favouritors, QUERY(name = John)))'], 52 | ['not comments.favouritors.name = John', 'NOT_EXISTS(comments, EXISTS(favouritors, QUERY(name = John)))'], 53 | ['comments.favouritors: (not name = John)', 'EXISTS(comments, EXISTS(favouritors, QUERY(name != John)))'], 54 | 55 | // It does not affect non-relationship symbols. 56 | ['title', 'SOLO(title)'], 57 | ['title = 3', 'QUERY(title = 3)'], 58 | ['comments: (published)', 'EXISTS(comments, SOLO(published))'], 59 | 60 | // It works with circular dependencies. 61 | ['comments.favourites.comment', 'EXISTS(comments, EXISTS(favourites, EXISTS(comment, EMPTY)))'], 62 | ['comments.favourites.comment = 0', 'EXISTS(comments, EXISTS(favourites, NOT_EXISTS(comment, EMPTY)))'], 63 | ['comments.favouritors.comments', 'EXISTS(comments, EXISTS(favouritors, EXISTS(comments, EMPTY)))'], 64 | ['comments.favouritors.writtenComments', 'EXISTS(comments, EXISTS(favouritors, EXISTS(writtenComments, EMPTY)))'], 65 | ['comments.author.comments > 10', 'EXISTS(comments, EXISTS(author, EXISTS(comments, EMPTY) > 10))'], 66 | ]; 67 | } 68 | 69 | /** 70 | * @test 71 | * @dataProvider success 72 | * @param $input 73 | * @param $expected 74 | */ 75 | public function visitor_identify_relationships_from_rules_success($input, $expected) 76 | { 77 | $this->assertAstEquals($input, $expected); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tests/VisitorOptimizeAstTest.php: -------------------------------------------------------------------------------- 1 | 0 and comments >= 1 and comments and not comments = 0', 'EXISTS(comments, EMPTY)'], 68 | ['comments and not (comments = 0 or not comments)', 'EXISTS(comments, EMPTY)'], 69 | 70 | // Flatten relationship inexistance. 71 | ['not comments and not comments', 'NOT_EXISTS(comments, EMPTY)'], 72 | ['not comments and not (comments or comments)', 'NOT_EXISTS(comments, EMPTY)'], 73 | ['comments = 0 and comments < 1 and comments <= 0 and not comments and not comments > 0', 'NOT_EXISTS(comments, EMPTY)'], 74 | 75 | // Flatten relationship count. 76 | ['comments > 1 and comments > 1', 'EXISTS(comments, EMPTY) > 1'], 77 | ['comments > 1 and comments >= 2', 'EXISTS(comments, EMPTY) > 1'], 78 | ['comments >= 2 and comments > 1', 'EXISTS(comments, EMPTY) >= 2'], 79 | ['comments = 2 and comments = 2', 'EXISTS(comments, EMPTY) = 2'], 80 | 81 | // Flatten relationships complex examples. 82 | ['comments.title = A comments.title = B', 'EXISTS(comments, AND(title = A, title = B))'], 83 | ['comments.title = A or comments.title = B', 'EXISTS(comments, OR(title = A, title = B))'], 84 | ['comments.title = A comments.title = B and foobar', 'AND(EXISTS(comments, AND(title = A, title = B)), foobar)'], 85 | ['comments.author.name = John and comments.title = "My Comment"', 'EXISTS(comments, AND(EXISTS(author, name = John), title = My Comment))'], 86 | ['comments.author.name = John and comments.author.name = Jane', 'EXISTS(comments, EXISTS(author, AND(name = John, name = Jane)))'], 87 | ['comments.author.name = John or comments.author.name = Jane', 'EXISTS(comments, EXISTS(author, OR(name = John, name = Jane)))'], 88 | ['(comments.title = A and comments.title = B) and (comments.title = C and comments.title = D)', 'EXISTS(comments, AND(title = A, title = B, title = C, title = D))'], 89 | ['(comments.title = A or comments.title = B) or (comments.title = C or comments.title = D)', 'EXISTS(comments, OR(title = A, title = B, title = C, title = D))'], 90 | 91 | // Keep relationships separate if merging them changes the behavious of the query. 92 | ['comments and not comments', 'AND(EXISTS(comments, EMPTY), NOT_EXISTS(comments, EMPTY))'], 93 | ['comments and comments > 10', 'AND(EXISTS(comments, EMPTY), EXISTS(comments, EMPTY) > 10)'], 94 | ['comments = 3 or not comments = 3', 'OR(EXISTS(comments, EMPTY) = 3, EXISTS(comments, EMPTY) != 3)'], 95 | ['comments.title = A or comments > 10', 'OR(EXISTS(comments, title = A), EXISTS(comments, EMPTY) > 10)'], 96 | ['comments.title = A foobar comments > 10', 'AND(EXISTS(comments, title = A), foobar, EXISTS(comments, EMPTY) > 10)'], 97 | ['comments.title = A or comments.title = B price > 10', 'OR(EXISTS(comments, title = A), AND(EXISTS(comments, title = B), price > 10))'], 98 | ]; 99 | } 100 | 101 | /** 102 | * @test 103 | * @dataProvider success 104 | * @param $input 105 | * @param $expected 106 | */ 107 | public function visitor_optimize_ast_success($input, $expected) 108 | { 109 | $this->assertAstEquals($input, $expected); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /tests/VisitorRemoveKeywordsTest.php: -------------------------------------------------------------------------------- 1 | getModelWithKeywords(['banana_keyword' => $rule]); 50 | $this->assertAstEquals($input, $expected, $model); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/VisitorRemoveNotSymbolTest.php: -------------------------------------------------------------------------------- 1 | = 1)'], 29 | ['not foo>1', 'QUERY(foo <= 1)'], 30 | ['not foo<=1', 'QUERY(foo > 1)'], 31 | ['not foo>=1', 'QUERY(foo < 1)'], 32 | ['not foo in(1, 2, 3)', 'LIST(foo not in [1, 2, 3])'], 33 | 34 | // Negate solo symbols. 35 | ['foobar', 'SOLO(foobar)'], 36 | ['not foobar', 'SOLO_NOT(foobar)'], 37 | ['"John Doe"', 'SOLO(John Doe)'], 38 | ['not "John Doe"', 'SOLO_NOT(John Doe)'], 39 | 40 | // Negate and/or symbols. 41 | ['not (A and B)', 'OR(SOLO_NOT(A), SOLO_NOT(B))'], 42 | ['not (A and not B)', 'OR(SOLO_NOT(A), SOLO(B))'], 43 | ['not (A or B)', 'AND(SOLO_NOT(A), SOLO_NOT(B))'], 44 | ['not (A or not B)', 'AND(SOLO_NOT(A), SOLO(B))'], 45 | ['not (A or (B and C))', 'AND(SOLO_NOT(A), OR(SOLO_NOT(B), SOLO_NOT(C)))'], 46 | ['not (A and (B or C))', 'OR(SOLO_NOT(A), AND(SOLO_NOT(B), SOLO_NOT(C)))'], 47 | ['not (A and not B and not C and D)', 'OR(SOLO_NOT(A), SOLO(B), SOLO(C), SOLO_NOT(D))'], 48 | ['not (A or not B or not C or D)', 'AND(SOLO_NOT(A), SOLO(B), SOLO(C), SOLO_NOT(D))'], 49 | ['not ((A or not B) and not (not C and D))', 'OR(AND(SOLO_NOT(A), SOLO(B)), AND(SOLO_NOT(C), SOLO(D)))'], 50 | 51 | // Cancel the negation of another not. 52 | ['not not foo:bar', 'QUERY(foo = bar)'], 53 | ['not not foo:-1,2,3', 'LIST(foo in [-1, 2, 3])'], 54 | ['not not foo="bar"', 'QUERY(foo = bar)'], 55 | ['not not foo<1', 'QUERY(foo < 1)'], 56 | ['not not foo>1', 'QUERY(foo > 1)'], 57 | ['not not foo<=1', 'QUERY(foo <= 1)'], 58 | ['not not foo>=1', 'QUERY(foo >= 1)'], 59 | ['not not foo in(1, 2, 3)', 'LIST(foo in [1, 2, 3])'], 60 | ['not not (A and B)', 'AND(SOLO(A), SOLO(B))'], 61 | ['not not (A or B)', 'OR(SOLO(A), SOLO(B))'], 62 | ['not not foobar', 'SOLO(foobar)'], 63 | ['not not "John Doe"', 'SOLO(John Doe)'], 64 | ['not not not "John Doe"', 'SOLO_NOT(John Doe)'], 65 | ['not not not not "John Doe"', 'SOLO(John Doe)'], 66 | 67 | // Relationships. 68 | ['not comments.author', 'NOT_EXISTS(comments, SOLO(author))'], 69 | ['not comments.author = "John Doe"', 'NOT_EXISTS(comments, QUERY(author = John Doe))'], 70 | ['not comments.author.tags', 'NOT_EXISTS(comments, EXISTS(author, SOLO(tags)))'], 71 | ['comments: (not achievements: (Laravel))', 'EXISTS(comments, NOT_EXISTS(achievements, SOLO(Laravel)))'], 72 | ['not comments: (achievements: (Laravel))', 'NOT_EXISTS(comments, EXISTS(achievements, SOLO(Laravel)))'], 73 | ['not comments: (not (A or not B) or not D)', 'NOT_EXISTS(comments, OR(AND(SOLO_NOT(A), SOLO(B)), SOLO_NOT(D)))'], 74 | ]; 75 | } 76 | 77 | /** 78 | * @test 79 | * @dataProvider success 80 | * @param $input 81 | * @param $expected 82 | */ 83 | public function visitor_remove_not_symbol_success($input, $expected) 84 | { 85 | $this->assertAstEquals($input, $expected); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/VisitorTest.php: -------------------------------------------------------------------------------- 1 | getVisitors($model)); 17 | } 18 | 19 | public function getVisitors($model = null) 20 | { 21 | $arguments = $this->getManagerBuilderAndModel($model); 22 | 23 | return $this->visitors(...$arguments); 24 | } 25 | 26 | public function getBuilder($input, $model = null) 27 | { 28 | list($manager, $builder, $model) = $this->getManagerBuilderAndModel($model); 29 | $this->visit($this->parse($input), $this->visitors($manager, $builder, $model)); 30 | 31 | return $builder; 32 | } 33 | 34 | public function getManagerBuilderAndModel($model = null) 35 | { 36 | $manager = $this->getSearchStringManager($model = $model ?? new Product); 37 | 38 | return [$manager, $model->newQuery(), $model]; 39 | } 40 | 41 | public function assertAstEquals($input, $expectedAst, $model = null) 42 | { 43 | $this->assertEquals($expectedAst, $this->visit($input, null, $model)); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/VisitorValidateRulesTest.php: -------------------------------------------------------------------------------- 1 | 0'], 27 | ['not unknown_column = 3'], 28 | ['unknown_column = Something or name = Hi'], 29 | 30 | // ListSymbol. 31 | ['unknown_column: 1,2,3'], 32 | ['unknown_column in (a, b, c)'], 33 | ['not unknown_column = foo, "bar"'], 34 | ['not unknown_column in (1, "two", three)'], 35 | 36 | // RelationshipSymbol. 37 | ['unknown_column.title = 3'], 38 | ['comments.unknown_column = 3'], 39 | ['comments: (unknown_column = foo)'], 40 | ['unknown_column: (title = foo)'], 41 | ['not unknown_column: (author.name = John)'], 42 | ]; 43 | } 44 | 45 | /** 46 | * @test 47 | * @dataProvider failure 48 | * @param $input 49 | */ 50 | public function visitor_validate_rules_failure($input) 51 | { 52 | $this->expectException(InvalidSearchStringException::class); 53 | $this->expectExceptionMessage('Unrecognized key pattern [unknown_column]'); 54 | $this->visit($input); 55 | } 56 | } 57 | --------------------------------------------------------------------------------