├── .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 | [](https://packagist.org/packages/lorisleiva/laravel-search-string)
4 | [](https://github.com/lorisleiva/laravel-search-string/actions?query=workflow%3ATests+branch%3Anext)
5 | [](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 | 
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 |
--------------------------------------------------------------------------------