├── .github
└── workflows
│ ├── pull_request.yml
│ └── versioning.yml
├── .gitignore
├── LICENSE
├── README.md
├── composer.json
├── composer.lock
├── config
└── asseco-json-query-builder.php
├── phpunit.xml
├── src
├── CategorizedValues.php
├── Config
│ ├── ModelConfig.php
│ ├── OperatorsConfig.php
│ ├── RequestParametersConfig.php
│ ├── SearchConfig.php
│ └── TypesConfig.php
├── CustomFieldSearchParser.php
├── Exceptions
│ └── JsonQueryBuilderException.php
├── JsonQuery.php
├── JsonQueryServiceProvider.php
├── RequestParameters
│ ├── AbstractParameter.php
│ ├── CountParameter.php
│ ├── DoesntHaveRelationsParameter.php
│ ├── GroupByParameter.php
│ ├── LimitParameter.php
│ ├── OffsetParameter.php
│ ├── OrderByParameter.php
│ ├── RelationsParameter.php
│ ├── ReturnsParameter.php
│ ├── SearchParameter.php
│ └── SoftDeletedParameter.php
├── SearchCallbacks
│ ├── AbstractCallback.php
│ ├── Between.php
│ ├── Contains.php
│ ├── EndsWith.php
│ ├── Equals.php
│ ├── GreaterThan.php
│ ├── GreaterThanOrEqual.php
│ ├── LessThan.php
│ ├── LessThanOrEqual.php
│ ├── NotBetween.php
│ ├── NotEquals.php
│ └── StartsWith.php
├── SearchParser.php
├── SearchParserInterface.php
├── Traits
│ └── CleansValues.php
└── Types
│ ├── AbstractType.php
│ ├── BooleanType.php
│ └── GenericType.php
└── tests
├── Feature
└── .gitignore
├── TestCase.php
├── TestModel.php
├── TestRelationOneModel.php
└── Unit
├── CategorizedValuesTest.php
├── Config
├── ModelConfigTest.php
├── OperatorsConfigTest.php
├── RequestParametersConfigTest.php
└── TypesConfigTest.php
├── JsonQueryTest.php
├── RequestParameters
├── CountParameterTest.php
├── GroupByParameterTest.php
├── LimitParameterTest.php
├── OffsetParameterTest.php
├── OrderByParameterTest.php
├── RelationsParameterTest.php
├── ReturnsParameterTest.php
├── SearchParameterTest.php
└── SoftDeletedParameterTest.php
├── SearchCallbacks
├── BetweenTest.php
├── EqualsTest.php
├── GreaterThanOrEqualTest.php
├── GreaterThanTest.php
├── LessThanOrEqualTest.php
├── LessThanTest.php
├── NotBetweenTest.php
└── NotEqualsTest.php
├── SearchParserTest.php
└── Types
└── BooleanTypeTest.php
/.github/workflows/pull_request.yml:
--------------------------------------------------------------------------------
1 | name: PR pipeline
2 |
3 | on:
4 | pull_request:
5 | branches: [ master ]
6 |
7 | jobs:
8 | build:
9 |
10 | runs-on: ubuntu-20.04
11 |
12 | steps:
13 | - uses: actions/checkout@v2
14 |
15 | - name: Setup PHP
16 | uses: shivammathur/setup-php@v2
17 | with:
18 | php-version: '8.1'
19 | extensions: mbstring, intl
20 | ini-values: post_max_size=256M, max_execution_time=180
21 | coverage: xdebug
22 | tools: php-cs-fixer, phpunit
23 |
24 | - name: Validate composer.json and composer.lock
25 | run: composer validate
26 |
27 | - name: Cache Composer packages
28 | id: composer-cache
29 | uses: actions/cache@v2
30 | with:
31 | path: vendor
32 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
33 | restore-keys: |
34 | ${{ runner.os }}-php-
35 |
36 | - name: Install dependencies
37 | if: steps.composer-cache.outputs.cache-hit != 'true'
38 | run: composer install --prefer-dist --no-progress --no-suggest
39 |
40 | - name: Execute PHPUnit tests
41 | run:
42 | vendor/phpunit/phpunit/phpunit
43 |
44 | - name: PHP STatic ANalyser (phpstan)
45 | uses: php-actions/phpstan@v3
46 | with:
47 | path: 'src'
48 | php_version: '8.1'
49 | level: 0
50 |
--------------------------------------------------------------------------------
/.github/workflows/versioning.yml:
--------------------------------------------------------------------------------
1 | # Manual Bumping: Any PR title or commit message that includes #major, #minor, or #patch
2 | # will trigger the respective version bump. If two or more are present,
3 | # the highest-ranking one will take precedence.
4 |
5 | name: Bump version
6 | on:
7 | push:
8 | branches:
9 | - master
10 | jobs:
11 | build:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v2
15 | with:
16 | fetch-depth: '0'
17 | - name: Bump version and push tag
18 | uses: anothrNick/github-tag-action@1.39.0
19 | env:
20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
21 | WITH_V: true
22 | DEFAULT_BUMP: none
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /public/hot
3 | /public/storage
4 | /storage/*.key
5 | /vendor
6 | .env
7 | .env.backup
8 | .phpunit.result.cache
9 | Homestead.json
10 | Homestead.yaml
11 | npm-debug.log
12 | yarn-error.log
13 | /.vscode
14 | /nbproject
15 | /.vagrant
16 | *.log
17 | \.php_cs\.cache
18 |
19 | .DS_Store
20 | .idea
21 |
22 | # User-specific stuff
23 | .idea/**/workspace.xml
24 | .idea/**/tasks.xml
25 | .idea/**/usage.statistics.xml
26 | .idea/**/dictionaries
27 | .idea/**/shelf
28 |
29 | # Generated files
30 | .idea/**/contentModel.xml
31 |
32 | # Sensitive or high-churn files
33 | .idea/**/dataSources/
34 | .idea/**/dataSources.ids
35 | .idea/**/dataSources.local.xml
36 | .idea/**/sqlDataSources.xml
37 | .idea/**/dynamic.xml
38 | .idea/**/uiDesigner.xml
39 | .idea/**/dbnavigator.xml
40 |
41 | # Gradle
42 | .idea/**/gradle.xml
43 | .idea/**/libraries
44 |
45 | # Gradle and Maven with auto-import
46 | # When using Gradle or Maven with auto-import, you should exclude module files,
47 | # since they will be recreated, and may cause churn. Uncomment if using
48 | # auto-import.
49 | # .idea/modules.xml
50 | # .idea/*.iml
51 | # .idea/modules
52 | # *.iml
53 | # *.ipr
54 |
55 | # CMake
56 | cmake-build-*/
57 |
58 | # Mongo Explorer plugin
59 | .idea/**/mongoSettings.xml
60 |
61 | # File-based project format
62 | *.iws
63 |
64 | # IntelliJ
65 | out/
66 |
67 | # mpeltonen/sbt-idea plugin
68 | .idea_modules/
69 |
70 | # JIRA plugin
71 | atlassian-ide-plugin.xml
72 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Asseco SEE
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 |

2 |
3 | # Laravel JSON query builder
4 |
5 | This package enables building queries from JSON objects following
6 | the special logic explained below.
7 |
8 | ## Installation
9 |
10 | Install the package through composer. It is automatically registered
11 | as a Laravel service provider.
12 |
13 | ``composer require asseco-voice/laravel-json-query-builder``
14 |
15 | ## Usage
16 |
17 | In order to use the package, you need to instantiate ``JsonQuery()`` providing
18 | two dependencies to it. One is ``Illuminate\Database\Eloquent\Builder`` instance,
19 | and the other is a JSON/array input.
20 |
21 | Once instantiated, you need to run the ``search()`` method, and query will be
22 | constructed on the provided builder object.
23 |
24 | ```
25 | $jsonQuery = new JsonQuery($builder, $input);
26 | $jsonQuery->search();
27 | ```
28 |
29 | ## Dev naming conventions for this package
30 |
31 | - **parameter** is a top-level JSON key name (see the options [below](#parameter-breakdown))
32 | - **arguments** are parameter values. Everything within a top-level JSON.
33 | - **argument** is a single key-value pair.
34 | - single argument is further broken down to **column / operator / value**
35 |
36 | ```
37 | {
38 | "search": { <-- parameter
39 | "first_name": "=foo", <-- argument
40 | "last_name": " = bar " <-- argument
41 | ˆˆˆˆˆˆˆˆˆ ˆ ˆˆˆ
42 | column operator value
43 | }
44 | }
45 | ```
46 |
47 | ## Parameter breakdown
48 | Parameters follow a special logic to query the DB. It is possible to use the following
49 | JSON parameters (keys):
50 |
51 | - ``search`` - will perform the querying logic (explained in detail [below](#search))
52 | - ``returns`` - will return only the columns provided as values.
53 | - ``order_by`` - will order the results based on values provided.
54 | - ``group_by`` - will group the results based on values provided.
55 | - ``relations`` - will load the relations for the given model.
56 | - `limit` - will limit the results returned.
57 | - `offset` - will return a subset of results starting from a point given. This parameter **MUST**
58 | be used together with ``limit`` parameter.
59 | - `count` - will return record count.
60 | - `soft_deleted` - will include soft deleted models in search results.
61 | - `doesnt_have_relations` - will only return entries that don't have any of the specified relations.
62 |
63 | ### Search
64 |
65 | The logic is done in a ``"column": "operator values"`` fashion in which we assume the
66 | following:
67 |
68 | - `column` represents a column in the database. Multiple keys can be separated as a new
69 | JSON key-value pair.
70 | - It is possible to search by related models using ``.`` as a divider i.e.
71 | `"relation.column": "operator value")`. **Note** this will execute a `WHERE EXISTS`, it will
72 | not filter resulting relations if included within relations. To do a ``WHERE NOT EXISTS`` you can ue `!relation.column`.
73 | - ``operator`` is one of the available main operators for querying (listed [below](#main-operators))
74 | - ``values`` is a semicolon (`;`) separated list of values
75 | (i.e. `"column": "=value;value2;value3"`) which
76 | can have micro-operators on them as well (i.e. `"column": "=value;!value2;%value3%"`).
77 |
78 | #### Main operators
79 |
80 | - `=` - equals
81 | - `!=` - does not equal
82 | - `<` - less than (requires exactly one value)
83 | - `>` - greater than (requires exactly one value)
84 | - `<=` - less than or equal (requires exactly one value)
85 | - `>=` - greater than or equal (requires exactly one value)
86 | - `<>` - between (requires exactly two values)
87 | - `!<>` - not between (requires exactly two values)
88 |
89 | Example:
90 |
91 | ```
92 | {
93 | "search": {
94 | "first_name": "=foo1;foo2",
95 | "last_name": "!=bar1;bar2"
96 | }
97 | }
98 | ```
99 |
100 | Will perform a ``SELECT * FROM some_table WHERE first_name IN
101 | ('foo1', 'foo2') AND last_name NOT IN ('bar1', 'bar2')``.
102 |
103 | In case you pass a single value for a column on string type, LIKE operator will be used
104 | instead of IN. For Postgres databases, ILIKE is used instead of LIKE to support
105 | case-insensitive search.
106 |
107 | ```
108 | {
109 | "search": {
110 | "first_name": "=foo",
111 | "last_name": "!=bar"
112 | }
113 | }
114 | ```
115 |
116 | Will perform a ``SELECT * FROM some_table WHERE first_name LIKE 'foo' AND
117 | last_name NOT LIKE 'bar'``.
118 |
119 | #### Micro operators
120 |
121 | - `!` - negates the value. Works only on the beginning of the value (i.e. `!value`).
122 | - `%` - performs a `LIKE` query. Works only on a beginning, end or both ends of the
123 | value (i.e. `%value`, `value%` or `%value%`).
124 | - logical operators are used to use **multiple operators** (you can't do `=1||2`, but `=1||=2`) for a
125 | single column (order matters!):
126 | - `&&` enables you to connect values using AND
127 | - `||` enables you to connect values using OR
128 |
129 |
130 | ```
131 | {
132 | "search": {
133 | "first_name": "=!foo",
134 | "last_name": "=bar%"
135 | }
136 | }
137 | ```
138 |
139 | Will perform a ``SELECT * FROM some_table WHERE first_name NOT LIKE
140 | 'foo' AND last_name LIKE 'bar%'``.
141 |
142 | Notice that here ``!value`` behaved the same as ``!=`` main operator. The difference
143 | is that ``!=`` main operator negates the complete list of values, whereas the
144 | ``!value`` only negates that specific value. I.e. `!=value1;value2` is semantically
145 | the same as ``=!value1;!value2``.
146 |
147 | Logical operator example:
148 |
149 | ```
150 | {
151 | "search": {
152 | "first_name": "=foo||=bar",
153 | }
154 | }
155 | ```
156 |
157 | Will perform ``SELECT * FROM some_table WHERE first_name IN
158 | ('foo') OR first_name IN ('bar')``.
159 |
160 | Note that logical operators are using standard bool logic precedence,
161 | therefore ``x AND y OR z AND q`` is the same as `(x AND y) OR (z AND q)`.
162 |
163 | #### Nested relation searches
164 |
165 | You can nest another search object if the key used is a relation name which
166 | will execute a ``whereHas()`` query builder method.
167 |
168 | I.e.
169 | ```
170 | {
171 | "search": {
172 | "some_relation": {
173 | "search": { ... }
174 | },
175 | }
176 | }
177 | ```
178 |
179 | ### Returns
180 |
181 | Using a ``returns`` key will effectively only return the fields given within it.
182 | This operator accepts an array of values or a single value.
183 |
184 | Example:
185 |
186 | Return single value:
187 | ```
188 | {
189 | "returns": "first_name",
190 | }
191 | ```
192 | Will perform a ``SELECT first_name FROM ...``
193 |
194 | Return multiple values:
195 | ```
196 | {
197 | "returns": ["first_name", "last_name"]
198 | }
199 | ```
200 | Will perform a ``SELECT first_name, last_name FROM ...``
201 |
202 | ### Order by
203 |
204 | Using ``order_by`` key does an 'order by' based on the given key(s). Order of the keys
205 | matters!
206 |
207 | Arguments are presumed to be in a ``"column": "direction"`` fashion, where `direction`
208 | MUST be ``asc`` (ascending) or `desc` (descending). In case that only column is provided,
209 | direction will be assumed to be an ascending order.
210 |
211 | Example:
212 | ```
213 | {
214 | "order_by": {
215 | "first_name": "asc",
216 | "last_name": "desc"
217 | }
218 | }
219 | ```
220 |
221 | Will perform a ``SELECT ... ORDER BY first_name asc, last_name desc``
222 |
223 | ### Group by
224 |
225 | Using ``group_by`` key does an 'group by' based on the given key(s). Order of the keys
226 | matters!
227 |
228 | Arguments are presumed to be a single attribute or array of attributes.
229 |
230 | Since group by behaves like it would in a plain SQL query, be sure to select
231 | the right fields and aggregate functions.
232 |
233 | Example:
234 | ```
235 | {
236 | "group_by": ["last_name", "first_name"]
237 | }
238 | ```
239 |
240 | Will perform a ``SELECT ... GROUP BY last_name, first_name``
241 |
242 | ### Relations
243 |
244 | It is possible to load object relations as well by using ``relations`` parameter.
245 | This operator accepts an array of values or a single value.
246 |
247 |
248 | #### Simple
249 |
250 | Example:
251 |
252 | Resolve single relation:
253 | ```
254 | {
255 | "relations": "containers",
256 | }
257 | ```
258 |
259 | Resolve multiple relations:
260 | ```
261 | {
262 | "relations": ["containers", "addresses"]
263 | }
264 | ```
265 |
266 | Relations, if defined properly and following Laravel convention, should be predictable
267 | to assume:
268 |
269 | - 1:M & M:M - relation name is in plural (i.e. Contact has many **Addresses**, relation
270 | name is thus 'addresses')
271 | - M:1 - relation name is in singular (i.e. Comment belongs to a **Post**, relation
272 | name is thus 'post')
273 | - **important**: since Laravel returns API responses as **snake_case**, it is enabled to
274 | provide a **snake_case'd** relation (even though **camelCase** works as well) for multi-word
275 | relations. I.e. doing ``"relations": "workspace_items"`` is the equivalent of calling
276 | ``"relations": "workspaceItems"``, but it is recommended to use **snake_case** approach.
277 |
278 | It is possible to recursively load relations using dot notation:
279 |
280 | ```
281 | {
282 | "relations": "media.type"
283 | }
284 | ```
285 |
286 | This will load media relations as well as resolve media types right away. If you have the
287 | need to resolve multiple second level relations you can provide an array of those:
288 |
289 | ```
290 | {
291 | "relations": ["media.type", "media.category"]
292 | }
293 | ```
294 |
295 | This will load media relations together with resolved type and category for each media object.
296 |
297 | It is also possible to stack relations using dot notation without a limit. It must be taken
298 | into account though that this can **seriously hurt performance**!
299 |
300 | ```
301 | {
302 | "relations": "media.type.contact.title"
303 | }
304 | ```
305 |
306 | #### Complex
307 |
308 | Relation can also be an object with nested search attributes to additionally filter out
309 | the given result set.
310 |
311 | Example:
312 |
313 | ```
314 | "relations": [
315 | {
316 | "media": {
317 | "search": {
318 | "media_type_id": "=1"
319 | }
320 | }
321 | }
322 | ]
323 | ```
324 |
325 | will load a ``media`` relationship on the object, but only returning objects whose
326 | ``media_type_id`` equals to `1`.
327 |
328 | ### Limit
329 |
330 | You can limit the number of results fetched by doing:
331 |
332 | ```
333 | {
334 | "limit": 10
335 | }
336 | ```
337 |
338 | This will do a ``SELECT * FROM table LIMIT 10``.
339 |
340 | ### Offset
341 |
342 | You can use offset to further limit the returned results, however it
343 | requires using limit alongside it.
344 |
345 | ```
346 | {
347 | "limit": 10,
348 | "offset": 5
349 | }
350 | ```
351 |
352 | This will do a ``SELECT * FROM table LIMIT 10 OFFSET 5``.
353 |
354 | ### Count
355 |
356 | You can fetch count of records instead of concrete records by adding the count key:
357 |
358 | ```
359 | {
360 | "count": true
361 | }
362 | ```
363 |
364 | This will do a ``SELECT count(*) FROM table``.
365 |
366 | ### Soft deleted
367 |
368 | By default, soft deleted records are excluded from the search. This can be overridden
369 | with ``soft_deleted``:
370 |
371 | ```
372 | {
373 | "soft_deleted": true
374 | }
375 | ```
376 |
377 | ### Doesn't have relations
378 |
379 | In case you want to only find entries without a specified relation, you can do so with ``doesnt_have_relations`` key:
380 |
381 | ```
382 | {
383 | "doesnt_have_relations": "containers"
384 | }
385 | ```
386 |
387 | If you want to specify multiple relations, you can do so in the following way:
388 |
389 | ```
390 | {
391 | "doesnt_have_relations": ["containers", "addresses"]
392 | }
393 | ```
394 |
395 | ## Top level logical operators
396 |
397 | Additionally, it is possible to group search clauses by top-level logical operator.
398 |
399 | Available operators:
400 | - ``&&`` AND
401 | - ``||`` OR
402 |
403 | **Using no top-level operator will assume AND operator.**
404 |
405 | ### Examples
406 |
407 | These operators take in a single object, or an array of objects, with few differences worth mentioning.
408 | Single object will apply the operator on given attributes:
409 |
410 | ```
411 | {
412 | "search": {
413 | "&&": {
414 | "id": "=1",
415 | "name": "=foo"
416 | }
417 | }
418 | }
419 | ```
420 |
421 | Resulting in ```id=1 AND name=foo```.
422 | Whereas an array of objects will apply the operator between array objects, **not** within the objects themselves:
423 |
424 | ```
425 | {
426 | "search": {
427 | "||": [
428 | {
429 | "id": "=1",
430 | "name": "=foo"
431 | },
432 | {
433 | "id": "=2",
434 | "name": "=bar"
435 | }
436 | ]
437 | }
438 | }
439 | ```
440 |
441 | Resulting in ``(id=1 AND name=foo) OR (id=2 AND name=bar)``. This is done intentionally, default operator is
442 | AND, thus it will be applied within objects.
443 |
444 | If you'd like inner attributes changed to OR instead, you can go recursive:
445 |
446 | ```
447 | {
448 | "search": {
449 | "||": [
450 | {
451 | "||": {
452 | "id": "=1",
453 | "name": "=foo"
454 | }
455 | },
456 | {
457 | "id": "=2",
458 | "name": "=bar"
459 | }
460 | ]
461 | }
462 | }
463 | ```
464 |
465 | Resulting in ``(id=1 OR name=foo) OR (id=2 AND name=bar)``.
466 |
467 | ### Absurd examples
468 |
469 | Since logic is made recursive, you can go as absurd and deep as you'd like, but at this point
470 | it may be smarter to revise what do you actually want from your life and universe:
471 |
472 | ```
473 | {
474 | "search": {
475 | "||": {
476 | "&&": [
477 | {
478 | "||": [
479 | {
480 | "id": "=2||=3",
481 | "name": "=foo"
482 | },
483 | {
484 | "id": "=1",
485 | "name": "=foo%&&=%bar"
486 | }
487 | ]
488 | },
489 | {
490 | "we": "=cool"
491 | }
492 | ],
493 | "love": "<3",
494 | "recursion": "=rrr"
495 | }
496 | }
497 | }
498 | ```
499 |
500 | Breakdown:
501 |
502 | - Step 1
503 | ```
504 | {
505 | "id": "=2||=3",
506 | "name": "=foo"
507 | },
508 | ```
509 | Result: ``(id=2 OR id=3) AND name=foo``
510 |
511 | - Step 2
512 | ```
513 | {
514 | "id": "=1",
515 | "name": "=foo%&&=%bar"
516 | }
517 | ```
518 | Result: ``id=1 AND (name LIKE foo% AND name LIKE %bar)``
519 |
520 | - Step 3 (merge)
521 |
522 | ```
523 | "||": [
524 | {...},
525 | {...}
526 | ]
527 | ```
528 | Result: ``(step1) OR (step2)``
529 |
530 | - Step 4 ``we=cool``
531 | ```
532 | {
533 | "we": "=cool"
534 | }
535 | ```
536 |
537 | - Step 5 (merge)
538 | ```
539 | "&&": [
540 | {
541 | "||": [...]
542 | },
543 | {
544 | "we": "=cool"
545 | }
546 | ],
547 | ```
548 | Result: ``(step3) AND (step4)``
549 |
550 | - Step 6 (ultimate merge)
551 |
552 | ```
553 | "||": {
554 | "&&": [...],
555 | "love": "<3",
556 | "recursion": "=rrr"
557 | }
558 | ```
559 | Result: ``(step5) OR love<3 OR recursion=rrr``
560 |
561 | The final query (kill it with fire):
562 |
563 | ``((((id=2 OR id=3) AND name=foo) OR (id=1 AND (name LIKE foo% AND name LIKE %bar))) AND we=cool) OR love<3 OR recursion=rrr``
564 |
565 | ## Config
566 |
567 | Aside from standard query string search, it is possible to provide additional
568 | package configuration.
569 |
570 | Publish the configuration by running
571 | `php artisan vendor:publish --provider="Asseco\JsonQueryBuilder\JsonQueryServiceProvider"`.
572 |
573 | All the keys within the configuration file have a detailed explanation above each key.
574 |
575 | ### Package extensions
576 |
577 | Once configuration is published you will see several keys which you can extend with your
578 | custom code.
579 |
580 | - request parameters are registered under ``request_parameters`` config key.
581 | You can extend this functionality by adding your own custom parameter. It
582 | needs to extend ``Asseco\JsonQueryBuilder\RequestParameters\AbstractParameter``
583 | in order to work.
584 | - operators are registered under ``operators`` config key. Those can be
585 | extended by adding a class which extends ``Asseco\JsonQueryBuilder\SearchCallbacks\AbstractCallback``.
586 | - types are registered under ``types`` config key. Those can be extended
587 | by adding a class which extends ``Asseco\JsonQueryBuilder\Types\AbstractType``.
588 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "asseco-voice/laravel-json-query-builder",
3 | "description": "Laravel JSON query builder",
4 | "keywords": [
5 | "php",
6 | "laravel"
7 | ],
8 | "license": "MIT",
9 | "require": {
10 | "php": "^8.1",
11 | "laravel/framework": "^10.0"
12 | },
13 | "require-dev": {
14 | "phpunit/phpunit": "^10.0",
15 | "mockery/mockery": "^1.4.4",
16 | "fakerphp/faker": "^1.9.1",
17 | "orchestra/testbench": "^8.5"
18 | },
19 | "autoload": {
20 | "psr-4": {
21 | "Asseco\\JsonQueryBuilder\\": "src/"
22 | }
23 | },
24 | "autoload-dev": {
25 | "psr-4": {
26 | "Asseco\\JsonQueryBuilder\\Tests\\": "tests/"
27 | }
28 | },
29 | "extra": {
30 | "laravel": {
31 | "providers": [
32 | "Asseco\\JsonQueryBuilder\\JsonQueryServiceProvider"
33 | ]
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/config/asseco-json-query-builder.php:
--------------------------------------------------------------------------------
1 | [
32 | SearchParameter::class,
33 | ReturnsParameter::class,
34 | OrderByParameter::class,
35 | RelationsParameter::class,
36 | LimitParameter::class,
37 | OffsetParameter::class,
38 | CountParameter::class,
39 | GroupByParameter::class,
40 | SoftDeletedParameter::class,
41 | DoesntHaveRelationsParameter::class,
42 | ],
43 |
44 | /**
45 | * Registered operators/callbacks. Operator order matters!
46 | * Callbacks having more const OPERATOR characters must come before those with less.
47 | */
48 | 'operators' => [
49 | NotBetween::class,
50 | LessThanOrEqual::class,
51 | GreaterThanOrEqual::class,
52 | Between::class,
53 | NotEquals::class,
54 | Equals::class,
55 | LessThan::class,
56 | GreaterThan::class,
57 | Contains::class,
58 | StartsWith::class,
59 | EndsWith::class,
60 | ],
61 |
62 | /**
63 | * Registered types. Generic type is the default one and should be used if
64 | * no special care for type value is needed.
65 | */
66 | 'types' => [
67 | GenericType::class,
68 | BooleanType::class,
69 | ],
70 |
71 | /**
72 | * List of globally forbidden columns to search on.
73 | * Searching by forbidden columns will throw an exception
74 | * This takes precedence before other exclusions.
75 | */
76 | 'global_forbidden_columns' => [
77 | // 'id', 'created_at' ...
78 | ],
79 |
80 | /**
81 | * TODO: these options are currently disabled and will not work
82 | * Refined options for a single model.
83 | * Use if you want to enforce rules on a specific model without affecting globally all models.
84 | */
85 | 'model_options' => [
86 |
87 | /**
88 | * For real usage, use real models without quotes. This is only meant to show the available options.
89 | */
90 | 'SomeModel::class' => [
91 | /**
92 | * If enabled, this will read from model guarded/fillable properties
93 | * and decide whether it is allowed to search by these parameters.
94 | * If guarded property is present, fillable won't be taken. Laravel standard
95 | * is to use one or the other, not both.
96 | * This takes precedence before forbidden columns, but if both are used, it
97 | * will behave like union of columns to be excluded.
98 | * Searching on forbidden columns will throw an exception.
99 | */
100 | 'eloquent_exclusion' => false,
101 | /**
102 | * Disable search on specific columns. Searching on forbidden columns will throw an exception.
103 | */
104 | 'forbidden_columns' => ['column', 'column2'],
105 | /**
106 | * Array of columns to order by in 'column => direction' format.
107 | * 'order-by' from query string takes precedence before these values.
108 | */
109 | 'order_by' => [
110 | 'id' => 'asc',
111 | 'created_at' => 'desc',
112 | ],
113 | /**
114 | * List of columns to return. Return values forwarded within the request will
115 | * override these values. This acts as a 'SELECT /return only columns/' from.
116 | * By default, 'SELECT *' will be ran.
117 | */
118 | 'returns' => ['column', 'column2'],
119 | /**
120 | * List of relations to load by default. These will be overridden if provided within query string.
121 | */
122 | 'relations' => ['rel1', 'rel2'],
123 |
124 | /**
125 | * TBD
126 | * Some column names may be different on frontend than on backend.
127 | * It is possible to map such columns so that the true ORM
128 | * property stays hidden.
129 | */
130 | 'column_mapping' => [
131 | 'frontend_column' => 'backend_column',
132 | ],
133 | ],
134 | ],
135 | ];
136 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | ./tests/Feature
6 |
7 |
8 | ./tests/Unit
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | ./app
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/src/CategorizedValues.php:
--------------------------------------------------------------------------------
1 | searchParser = $searchParser;
41 |
42 | $this->type = (new TypesConfig())->getTypeClassFromTypeName($this->searchParser->type);
43 |
44 | $this->categorize();
45 | $this->format();
46 | }
47 |
48 | public function categorize()
49 | {
50 | foreach ($this->searchParser->values as $value) {
51 | if ($value === self::IS_NULL) {
52 | $this->null = true;
53 | continue;
54 | }
55 |
56 | if ($value === self::IS_NOT_NULL) {
57 | $this->notNull = true;
58 | continue;
59 | }
60 |
61 | if ($this->isNegated($value)) {
62 | $value = $this->replaceNegation($value);
63 |
64 | if ($this->hasWildCard($value) || $this->isSingleStringValue()) {
65 | $value = $this->replaceWildCard($value);
66 | $this->notLike[] = $value;
67 | continue;
68 | }
69 |
70 | $this->not[] = $value;
71 | continue;
72 | }
73 |
74 | if ($this->hasWildCard($value) || $this->isSingleStringValue()) {
75 | $value = $this->replaceWildCard($value);
76 | $this->andLike[] = $value;
77 | continue;
78 | }
79 |
80 | $this->and[] = $value;
81 | }
82 | }
83 |
84 | /**
85 | * Format categorized values. It must be done after categorizing
86 | * because of micro operators.
87 | */
88 | public function format()
89 | {
90 | $this->and = $this->type->prepare($this->and);
91 | $this->andLike = $this->type->prepare($this->andLike);
92 | $this->not = $this->type->prepare($this->not);
93 | $this->notLike = $this->type->prepare($this->notLike);
94 | }
95 |
96 | protected function isNegated($splitValue): bool
97 | {
98 | return substr($splitValue, 0, 1) === self::NOT;
99 | }
100 |
101 | protected function hasWildCard(string $value): bool
102 | {
103 | if (!$value) {
104 | return false;
105 | }
106 |
107 | return $value[0] === self::LIKE || $value[strlen($value) - 1] === self::LIKE;
108 | }
109 |
110 | protected function replaceWildCard($value)
111 | {
112 | return str_replace(self::LIKE, '%', $value);
113 | }
114 |
115 | protected function replaceNegation($value)
116 | {
117 | return preg_replace('~' . self::NOT . '~', '', $value, 1);
118 | }
119 |
120 | // Hack so that LIKE operator is used when a single value of string type is passed.
121 | // Not happy with this solution, might need to refactor this later
122 | protected function isSingleStringValue(): bool
123 | {
124 | return count($this->searchParser->values) == 1 && $this->searchParser->type == 'string';
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/src/Config/ModelConfig.php:
--------------------------------------------------------------------------------
1 | model = $model;
23 | $this->config = $this->hasConfig() ? $this->getConfig() : [];
24 | }
25 |
26 | public function hasConfig(): bool
27 | {
28 | return array_key_exists(get_class($this->model), config('asseco-json-query-builder.model_options'));
29 | }
30 |
31 | protected function getConfig(): array
32 | {
33 | return config('asseco-json-query-builder.model_options.' . get_class($this->model));
34 | }
35 |
36 | public function getReturns(): array
37 | {
38 | if (array_key_exists('returns', $this->config) && $this->config['returns']) {
39 | return Arr::wrap($this->config['returns']);
40 | }
41 |
42 | return ['*'];
43 | }
44 |
45 | public function getRelations(): array
46 | {
47 | if (array_key_exists('relations', $this->config) && $this->config['relations']) {
48 | return Arr::wrap($this->config['relations']);
49 | }
50 |
51 | return [];
52 | }
53 |
54 | public function getOrderBy(): array
55 | {
56 | $parameters = [];
57 |
58 | if (array_key_exists('order_by', $this->config) && $this->config['order_by']) {
59 | foreach ($this->config['order_by'] as $key => $value) {
60 | $parameters[] = "$key=$value";
61 | }
62 | }
63 |
64 | return $parameters;
65 | }
66 |
67 | /**
68 | * Union of Eloquent exclusion (guarded/fillable) and forbidden columns.
69 | *
70 | * @param array $forbiddenKeys
71 | * @return array
72 | */
73 | public function getForbidden(array $forbiddenKeys)
74 | {
75 | $forbiddenKeys = $this->getEloquentExclusion($forbiddenKeys);
76 | $forbiddenKeys = $this->getForbiddenColumns($forbiddenKeys);
77 |
78 | return $forbiddenKeys;
79 | }
80 |
81 | protected function getEloquentExclusion($forbiddenKeys): array
82 | {
83 | if (!array_key_exists('eloquent_exclusion', $this->config) || !$this->config['eloquent_exclusion']) {
84 | return $forbiddenKeys;
85 | }
86 |
87 | $guarded = $this->model->getGuarded();
88 | $fillable = $this->model->getFillable();
89 |
90 | if ($guarded[0] != '*') { // Guarded property is never empty. It is '*' by default.
91 | $forbiddenKeys = array_merge($forbiddenKeys, $guarded);
92 | } elseif (count($fillable) > 0) {
93 | $forbiddenKeys = array_diff(array_keys($this->getModelColumns()), $fillable);
94 | }
95 |
96 | return $forbiddenKeys;
97 | }
98 |
99 | protected function getForbiddenColumns(array $forbiddenKeys): array
100 | {
101 | if (!array_key_exists('forbidden_columns', $this->config) || !$this->config['forbidden_columns']) {
102 | return $forbiddenKeys;
103 | }
104 |
105 | return array_merge($forbiddenKeys, $this->config['forbidden_columns']);
106 | }
107 |
108 | /**
109 | * Will return column and column type array for a calling model.
110 | * Column types will equal Eloquent column types.
111 | *
112 | * @return array
113 | */
114 | public function getModelColumns(): array
115 | {
116 | $table = $this->model->getTable();
117 | $connection = $this->model->getConnection();
118 |
119 | if (Cache::has(self::CACHE_PREFIX . $table)) {
120 | return Cache::get(self::CACHE_PREFIX . $table);
121 | }
122 |
123 | $columns = $connection->getSchemaBuilder()->getColumnListing($table);
124 | $modelColumns = [];
125 |
126 | $this->registerEnumTypeForDoctrine($connection);
127 |
128 | try {
129 | foreach ($columns as $column) {
130 | $modelColumns[$column] = $connection->getSchemaBuilder()->getColumnType($table, $column);
131 | }
132 | } catch (Exception) {
133 | // leave model columns as an empty array and cache it.
134 | }
135 |
136 | Cache::put(self::CACHE_PREFIX . $table, $modelColumns, self::CACHE_TTL);
137 |
138 | return $modelColumns;
139 | }
140 |
141 | /**
142 | * Having 'enum' in table definition will throw Doctrine error because it is not defined in their types.
143 | * Registering it manually.
144 | */
145 | protected function registerEnumTypeForDoctrine($connection): void
146 | {
147 | if (!class_exists('Doctrine\DBAL\Driver\AbstractSQLiteDriver')) {
148 | return;
149 | }
150 |
151 | $connection
152 | ->getDoctrineSchemaManager()
153 | ->getDatabasePlatform()
154 | ->registerDoctrineTypeMapping('enum', 'string');
155 | }
156 | }
157 |
--------------------------------------------------------------------------------
/src/Config/OperatorsConfig.php:
--------------------------------------------------------------------------------
1 | getOperators();
20 | $callbacks = $this->registered;
21 |
22 | return array_combine($operators, $callbacks);
23 | }
24 |
25 | /**
26 | * Extract operators from registered 'operator' classes.
27 | *
28 | * @return array
29 | */
30 | public function getOperators(): array
31 | {
32 | /**
33 | * @var AbstractCallback $callback
34 | */
35 | return array_map(fn ($callback) => $callback::operator(), $this->registered);
36 | }
37 |
38 | /**
39 | * @param string $operator
40 | * @return string
41 | *
42 | * @throws JsonQueryBuilderException
43 | */
44 | public function getCallbackClassFromOperator(string $operator): string
45 | {
46 | if (!array_key_exists($operator, $this->operatorCallbackMapping())) {
47 | throw new JsonQueryBuilderException("No valid callback registered for '$operator' operator.");
48 | }
49 |
50 | return $this->operatorCallbackMapping()[$operator];
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/Config/RequestParametersConfig.php:
--------------------------------------------------------------------------------
1 | config = config('asseco-json-query-builder');
22 | $this->register();
23 | }
24 |
25 | /**
26 | * Get registered classes from configuration file.
27 | *
28 | * @throws JsonQueryBuilderException
29 | */
30 | protected function register(): void
31 | {
32 | $key = $this->configKey();
33 |
34 | if (!array_key_exists($key, $this->config)) {
35 | throw new JsonQueryBuilderException("Config file is missing '$key'");
36 | }
37 |
38 | $this->registered = $this->config[$key];
39 | }
40 |
41 | abstract protected function configKey(): string;
42 | }
43 |
--------------------------------------------------------------------------------
/src/Config/TypesConfig.php:
--------------------------------------------------------------------------------
1 | nameClassMapping();
26 |
27 | if (!array_key_exists($typeName, $mapping)) {
28 | if (!array_key_exists('generic', $mapping)) {
29 | throw new JsonQueryBuilderException("No valid callback for '$typeName' type.");
30 | }
31 |
32 | return new $mapping['generic'];
33 | }
34 |
35 | return new $mapping[$typeName];
36 | }
37 |
38 | protected function nameClassMapping(): array
39 | {
40 | $names = $this->getTypeNames();
41 | $callbacks = $this->registered;
42 |
43 | return array_combine($names, $callbacks);
44 | }
45 |
46 | protected function getTypeNames(): array
47 | {
48 | /**
49 | * @var AbstractType $type
50 | */
51 | return array_map(fn ($type) => $type::name(), $this->registered);
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/CustomFieldSearchParser.php:
--------------------------------------------------------------------------------
1 | modelConfig = $modelConfig;
43 |
44 | foreach ($arguments as $col => $val) {
45 | if (str_contains($col, $this->cf_field_identificator)) {
46 | $this->cf_field_value = $val;
47 | } else {
48 | $this->column = $col;
49 | $this->argument = $val;
50 | }
51 | }
52 |
53 | $this->checkForForbiddenColumns();
54 |
55 | $this->operator = $this->parseOperator($operatorsConfig->getOperators(), $this->argument);
56 | $arguments = str_replace($this->operator, '', $this->argument);
57 | $this->values = $this->splitValues($arguments);
58 | $this->type = $this->getColumnType();
59 | }
60 |
61 | /**
62 | * @param $operators
63 | * @param string $argument
64 | * @return string
65 | *
66 | * @throws JsonQueryBuilderException
67 | */
68 | protected function parseOperator($operators, string $argument): string
69 | {
70 | foreach ($operators as $operator) {
71 | $argumentHasOperator = strpos($argument, $operator) !== false;
72 |
73 | if (!$argumentHasOperator) {
74 | continue;
75 | }
76 |
77 | return $operator;
78 | }
79 |
80 | throw new JsonQueryBuilderException("No valid callback registered for $argument. Are you missing an operator?");
81 | }
82 |
83 | /**
84 | * Split values by a given separator.
85 | *
86 | * Input: val1;val2
87 | *
88 | * Output: val1
89 | * val2
90 | *
91 | * @param string $values
92 | * @return array
93 | *
94 | * @throws JsonQueryBuilderException
95 | */
96 | protected function splitValues(string $values): array
97 | {
98 | $valueArray = explode(self::VALUE_SEPARATOR, $values);
99 | $cleanedUpValues = $this->cleanValues($valueArray);
100 |
101 | if (count($cleanedUpValues) < 1) {
102 | throw new JsonQueryBuilderException("Column '$this->column' is missing a value.");
103 | }
104 |
105 | return $cleanedUpValues;
106 | }
107 |
108 | /**
109 | * @return string
110 | *
111 | * @throws JsonQueryBuilderException
112 | */
113 | protected function getColumnType(): string
114 | {
115 | $columns = $this->modelConfig->getModelColumns();
116 |
117 | if (!array_key_exists($this->column, $columns)) {
118 | // TODO: integrate recursive column check for related models?
119 | return 'generic';
120 | }
121 |
122 | return $columns[$this->column];
123 | }
124 |
125 | /**
126 | * Check if global forbidden key is used.
127 | *
128 | * @throws JsonQueryBuilderException
129 | */
130 | protected function checkForForbiddenColumns()
131 | {
132 | $forbiddenKeys = Config::get('asseco-json-query-builder.global_forbidden_columns');
133 | $forbiddenKeys = $this->modelConfig->getForbidden($forbiddenKeys);
134 |
135 | if (in_array($this->column, $forbiddenKeys)) {
136 | throw new JsonQueryBuilderException("Searching by '$this->column' field is forbidden. Check the configuration if this is not a desirable behavior.");
137 | }
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/src/Exceptions/JsonQueryBuilderException.php:
--------------------------------------------------------------------------------
1 | builder = $builder;
32 | $this->input = $input;
33 |
34 | $this->forbidForExistingModels();
35 |
36 | $this->modelConfig = new ModelConfig($this->builder->getModel());
37 | $this->registeredParameters = (new RequestParametersConfig())->registered;
38 | }
39 |
40 | /**
41 | * @throws JsonQueryBuilderException
42 | */
43 | protected function forbidForExistingModels(): void
44 | {
45 | if ($this->builder->getModel()->exists) {
46 | throw new JsonQueryBuilderException('Searching is not allowed on already loaded models.');
47 | }
48 | }
49 |
50 | /**
51 | * Perform the search.
52 | *
53 | * @throws Exceptions\JsonQueryBuilderException
54 | */
55 | public function search(): void
56 | {
57 | $this->appendParameterQueries();
58 | $this->appendConfigQueries();
59 | }
60 |
61 | /**
62 | * Append all queries from registered parameters.
63 | *
64 | * @throws Exceptions\JsonQueryBuilderException
65 | */
66 | protected function appendParameterQueries(): void
67 | {
68 | foreach ($this->registeredParameters as $requestParameter) {
69 | if (!$this->parameterExists($requestParameter)) {
70 | // TODO: append config query?
71 | continue;
72 | }
73 |
74 | $this->instantiateRequestParameter($requestParameter)
75 | ->run();
76 | }
77 | }
78 |
79 | /**
80 | * Append all queries from config.
81 | */
82 | protected function appendConfigQueries(): void
83 | {
84 | // TODO: implement...or not
85 | }
86 |
87 | /**
88 | * @param string $requestParameter
89 | * @return bool
90 | */
91 | protected function parameterExists(string $requestParameter): bool
92 | {
93 | /**
94 | * @var AbstractParameter $requestParameter
95 | */
96 | return Arr::has($this->input, $requestParameter::getParameterName());
97 | }
98 |
99 | /**
100 | * @param $requestParameter
101 | * @return AbstractParameter
102 | *
103 | * @throws JsonQueryBuilderException
104 | */
105 | protected function instantiateRequestParameter(string $requestParameter): AbstractParameter
106 | {
107 | if (!is_subclass_of($requestParameter, AbstractParameter::class)) {
108 | throw new JsonQueryBuilderException("$requestParameter must extend " . AbstractParameter::class);
109 | }
110 |
111 | $input = $this->wrapInput($requestParameter::getParameterName());
112 |
113 | return new $requestParameter($input, $this->builder, $this->modelConfig);
114 | }
115 |
116 | /**
117 | * Get input for given parameter name and wrap it as an array if it's not already an array.
118 | *
119 | * @param string $parameterName
120 | * @return array
121 | */
122 | protected function wrapInput(string $parameterName): array
123 | {
124 | return Arr::wrap(
125 | Arr::get($this->input, $parameterName)
126 | );
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/src/JsonQueryServiceProvider.php:
--------------------------------------------------------------------------------
1 | mergeConfigFrom(__DIR__ . '/../config/asseco-json-query-builder.php', 'asseco-json-query-builder');
17 | }
18 |
19 | /**
20 | * Bootstrap the application services.
21 | */
22 | public function boot()
23 | {
24 | $this->publishes([__DIR__ . '/../config/asseco-json-query-builder.php' => config_path('asseco-json-query-builder.php')]);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/RequestParameters/AbstractParameter.php:
--------------------------------------------------------------------------------
1 | arguments = $arguments;
27 | $this->builder = $builder;
28 | $this->modelConfig = $modelConfig;
29 | }
30 |
31 | /**
32 | * JSON key by which the parameter will be recognized.
33 | *
34 | * @return string
35 | */
36 | abstract public static function getParameterName(): string;
37 |
38 | /**
39 | * Append the query to Eloquent builder.
40 | *
41 | * @throws JsonQueryBuilderException
42 | */
43 | abstract protected function appendQuery(): void;
44 |
45 | /**
46 | * @throws JsonQueryBuilderException
47 | */
48 | public function run(): void
49 | {
50 | $this->areArgumentsValid();
51 | $this->appendQuery();
52 | }
53 |
54 | /**
55 | * Check validity of fetched arguments and throw exception if it fails.
56 | *
57 | * @throws JsonQueryBuilderException
58 | */
59 | protected function areArgumentsValid(): void
60 | {
61 | if (count($this->arguments) < 1) {
62 | throw new JsonQueryBuilderException("Couldn't get values for '{$this->getParameterName()}'.");
63 | }
64 |
65 | // Override or extend on child objects if needed
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/RequestParameters/CountParameter.php:
--------------------------------------------------------------------------------
1 | arguments) != 1) {
20 | throw new JsonQueryBuilderException("Parameter '{$this->getParameterName()}' expects only one argument.");
21 | }
22 |
23 | if (!in_array($this->arguments[0], [1, '1', true, 'true'], true)) {
24 | throw new JsonQueryBuilderException("Parameter '{$this->getParameterName()}' expects to be 'true' if it is to be used.");
25 | }
26 | }
27 |
28 | protected function appendQuery(): void
29 | {
30 | $this->builder->addSelect(DB::raw('count(*) as count'));
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/RequestParameters/DoesntHaveRelationsParameter.php:
--------------------------------------------------------------------------------
1 | arguments as $argument) {
20 | if (is_string($argument)) {
21 | $this->builder->doesntHave(Str::camel($argument));
22 | continue;
23 | }
24 |
25 | throw new JsonQueryBuilderException('Wrong relation parameters provided.');
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/RequestParameters/GroupByParameter.php:
--------------------------------------------------------------------------------
1 | builder->groupBy($this->arguments);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/RequestParameters/LimitParameter.php:
--------------------------------------------------------------------------------
1 | arguments) != 1) {
19 | throw new JsonQueryBuilderException("Parameter '{$this->getParameterName()}' expects only one argument.");
20 | }
21 |
22 | if (!is_numeric($this->arguments[0])) {
23 | throw new JsonQueryBuilderException("Parameter '{$this->getParameterName()}' must be numeric.");
24 | }
25 | }
26 |
27 | protected function appendQuery(): void
28 | {
29 | $this->builder->limit($this->arguments[0]);
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/RequestParameters/OffsetParameter.php:
--------------------------------------------------------------------------------
1 | arguments) != 1) {
19 | throw new JsonQueryBuilderException("Parameter '{$this->getParameterName()}' expects only one argument.");
20 | }
21 |
22 | if (!is_numeric($this->arguments[0])) {
23 | throw new JsonQueryBuilderException("Parameter '{$this->getParameterName()}' must be numeric.");
24 | }
25 | }
26 |
27 | protected function appendQuery(): void
28 | {
29 | $this->builder->offset($this->arguments[0]);
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/RequestParameters/OrderByParameter.php:
--------------------------------------------------------------------------------
1 | arguments as $column => $direction) {
17 | [$column, $direction] = $this->fallBackToDefaultDirection($column, $direction);
18 |
19 | $this->appendSingle($column, $direction);
20 | }
21 | }
22 |
23 | /**
24 | * If argument is provided as a simple string without direction, we will
25 | * assume that direction is 'asc'.
26 | *
27 | * @param string|int $column
28 | * @param string $direction
29 | * @return array
30 | */
31 | protected function fallBackToDefaultDirection($column, string $direction): array
32 | {
33 | if (is_numeric($column)) {
34 | $column = $direction;
35 | $direction = 'asc';
36 | }
37 |
38 | return [$column, $direction];
39 | }
40 |
41 | protected function appendSingle(string $column, string $direction): void
42 | {
43 | $this->builder->orderBy($column, $direction);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/RequestParameters/RelationsParameter.php:
--------------------------------------------------------------------------------
1 | arguments as $argument) {
21 | if (is_string($argument)) {
22 | $this->appendSimpleRelation($argument);
23 | continue;
24 | }
25 |
26 | if (is_array($argument) && count($argument) > 0) {
27 | $this->appendComplexRelation($argument);
28 | continue;
29 | }
30 |
31 | throw new JsonQueryBuilderException('Wrong relation parameters provided.');
32 | }
33 | }
34 |
35 | protected function appendSimpleRelation(string $argument): void
36 | {
37 | $this->builder->with(Str::camel($argument));
38 | }
39 |
40 | protected function appendComplexRelation(array $argument): void
41 | {
42 | $relation = key($argument);
43 | $input = $argument[$relation];
44 |
45 | $this->builder->with([Str::camel($relation) => function ($query) use ($input) {
46 | $jsonQuery = new JsonQuery($query->getQuery(), $input);
47 | $jsonQuery->search();
48 | }]);
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/RequestParameters/ReturnsParameter.php:
--------------------------------------------------------------------------------
1 | builder->addSelect($this->arguments);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/RequestParameters/SearchParameter.php:
--------------------------------------------------------------------------------
1 | arguments;
42 |
43 | $this->operatorsConfig = new OperatorsConfig();
44 |
45 | // Wrapped within a where clause to protect from orWhere "exploits".
46 | $this->builder->where(function (Builder $builder) use ($arguments) {
47 | $this->makeQuery($builder, $arguments);
48 | });
49 | }
50 |
51 | /**
52 | * Making query from input parameters with recursive calls if needed for top level logical operators (check readme).
53 | *
54 | * @param Builder $builder
55 | * @param array $arguments
56 | * @param string $boolOperator
57 | *
58 | * @throws JsonQueryBuilderException
59 | */
60 | protected function makeQuery(Builder $builder, array $arguments, string $boolOperator = self::AND): void
61 | {
62 | foreach ($arguments as $key => $value) {
63 | if ($this->isTopLevelBoolOperator($key)) {
64 | $this->makeQuery($builder, $value, $key);
65 | continue;
66 | }
67 |
68 | $functionName = $this->getQueryFunctionName($boolOperator);
69 |
70 | if ($this->isTopLevelInclusiveCFOperator($key)) {
71 | // Custom fields custom search logic ..... both columns has to be in the same where clause (custom_field_id & search column)
72 | $builder->{$functionName}(function ($queryBuilder) use ($value) {
73 | $searchModel = new CustomFieldSearchParser($this->modelConfig, $this->operatorsConfig, $value);
74 | $this->appendSingle($queryBuilder, $this->operatorsConfig, $searchModel);
75 | });
76 | continue;
77 | } elseif ($this->queryInitiatedByTopLevelBool($key, $value)) {
78 | $builder->{$functionName}(function ($queryBuilder) use ($value) {
79 | // Recursion for inner keys which are &&/||
80 | $this->makeQuery($queryBuilder, $value);
81 | });
82 | continue;
83 | }
84 |
85 | if ($this->hasSubSearch($key, $value)) {
86 | // If query has sub-search, it is a relation for sure.
87 | $builder->whereHas(Str::camel($key), function ($query) use ($value) {
88 | $jsonQuery = new JsonQuery($query, $value);
89 | $jsonQuery->search();
90 | });
91 | continue;
92 | }
93 |
94 | $this->makeSingleQuery($functionName, $builder, $key, $value);
95 | }
96 | }
97 |
98 | protected function isTopLevelBoolOperator($key): bool
99 | {
100 | return in_array($key, [self::OR, self::AND], true);
101 | }
102 |
103 | protected function isTopLevelInclusiveCFOperator($key): bool
104 | {
105 | return in_array($key, [self::AND_INCLUSIVE_CF], true);
106 | }
107 |
108 | /**
109 | * @param string $boolOperator
110 | * @return string
111 | *
112 | * @throws JsonQueryBuilderException
113 | */
114 | protected function getQueryFunctionName(string $boolOperator): string
115 | {
116 | if ($boolOperator === self::AND || $boolOperator === self::AND_INCLUSIVE_CF) {
117 | return self::LARAVEL_WHERE;
118 | } elseif ($boolOperator === self::OR) {
119 | return self::LARAVEL_OR_WHERE;
120 | }
121 |
122 | throw new JsonQueryBuilderException('Invalid bool operator provided');
123 | }
124 |
125 | protected function queryInitiatedByTopLevelBool($key, $value): bool
126 | {
127 | // Since this will be triggered by recursion, key will be numeric
128 | // and not the actual key.
129 | return !is_string($key) && is_array($value);
130 | }
131 |
132 | protected function hasSubSearch($key, $value): bool
133 | {
134 | return is_string($key) && is_array($value);
135 | }
136 |
137 | protected function isRelationSearch($key): bool
138 | {
139 | return str_contains($key, self::RELATION_SEPARATOR);
140 | }
141 |
142 | /**
143 | * @param string $functionName
144 | * @param Builder $builder
145 | * @param $key
146 | * @param $value
147 | *
148 | * @throws JsonQueryBuilderException
149 | */
150 | protected function makeSingleQuery(string $functionName, Builder $builder, $key, $value): void
151 | {
152 | $builder->{$functionName}(function ($queryBuilder) use ($key, $value) {
153 | $this->applyArguments($queryBuilder, $this->operatorsConfig, $key, $value);
154 | });
155 | }
156 |
157 | /**
158 | * @param Builder $builder
159 | * @param OperatorsConfig $operatorsConfig
160 | * @param string $column
161 | * @param string $argument
162 | *
163 | * @throws JsonQueryBuilderException
164 | */
165 | protected function applyArguments(Builder $builder, OperatorsConfig $operatorsConfig, string $column, string $argument): void
166 | {
167 | $splitArguments = $this->splitByBoolOperators($argument);
168 |
169 | foreach ($splitArguments as $splitArgument) {
170 | $builder->orWhere(function ($builder) use ($splitArgument, $operatorsConfig, $column) {
171 | foreach ($splitArgument as $argument) {
172 | $searchModel = new SearchParser($this->modelConfig, $operatorsConfig, $column, $argument);
173 |
174 | $this->appendSingle($builder, $operatorsConfig, $searchModel);
175 | }
176 | });
177 | }
178 | }
179 |
180 | /**
181 | * @param $argument
182 | * @return array
183 | *
184 | * @throws JsonQueryBuilderException
185 | */
186 | protected function splitByBoolOperators($argument): array
187 | {
188 | $splitByOr = explode(self::OR, $argument);
189 |
190 | if (empty($splitByOr)) {
191 | throw new JsonQueryBuilderException('Something went wrong. Did you forget to add arguments?');
192 | }
193 |
194 | $splitByAnd = [];
195 |
196 | foreach ($splitByOr as $item) {
197 | $splitByAnd[] = explode(self::AND, $item);
198 | }
199 |
200 | return $splitByAnd;
201 | }
202 |
203 | /**
204 | * Append the query based on the given argument.
205 | *
206 | * @param Builder $builder
207 | * @param OperatorsConfig $operatorsConfig
208 | * @param SearchParser $searchParser
209 | *
210 | * @throws JsonQueryBuilderException
211 | */
212 | protected function appendSingle(Builder $builder, OperatorsConfig $operatorsConfig, SearchParserInterface $searchParser): void
213 | {
214 | $callbackClassName = $operatorsConfig->getCallbackClassFromOperator($searchParser->operator);
215 |
216 | /** @var AbstractCallback $callback */
217 | new $callbackClassName($builder, $searchParser);
218 | }
219 | }
220 |
--------------------------------------------------------------------------------
/src/RequestParameters/SoftDeletedParameter.php:
--------------------------------------------------------------------------------
1 | arguments) != 1) {
20 | throw new JsonQueryBuilderException("Parameter '{$this->getParameterName()}' expects only one argument.");
21 | }
22 |
23 | if (!in_array($this->arguments[0], [1, '1', true, 'true'], true)) {
24 | throw new JsonQueryBuilderException("Parameter '{$this->getParameterName()}' expects to be 'true' if it is to be used.");
25 | }
26 | }
27 |
28 | protected function appendQuery(): void
29 | {
30 | $this->builder->withoutGlobalScope(SoftDeletingScope::class);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/SearchCallbacks/AbstractCallback.php:
--------------------------------------------------------------------------------
1 | builder = $builder;
41 | $this->searchParser = $searchParser;
42 | $this->categorizedValues = new CategorizedValues($this->searchParser);
43 |
44 | $this->builder->when(
45 | str_contains($this->searchParser->column, '.'),
46 | function (Builder $builder) {
47 | // Hack for whereDoesntHave relation, doesn't work recursively.
48 | if (str_contains($this->searchParser->column, '!') !== false) {
49 | $this->searchParser->column = str_replace('!', '', $this->searchParser->column);
50 | $this->appendRelations($builder, $this->searchParser->column, $this->categorizedValues, 'orWhereDoesntHave');
51 |
52 | return;
53 | }
54 | $this->appendRelations($builder, $this->searchParser->column, $this->categorizedValues);
55 | },
56 | function (Builder $builder) {
57 | $this->execute($builder, $this->searchParser->column, $this->categorizedValues);
58 | $this->checkExecuteForCustomfieldsParameter($builder);
59 | }
60 | );
61 | }
62 |
63 | /**
64 | * Shorthand operator sign.
65 | *
66 | * I.e. '=', '<', '>'...
67 | *
68 | * @return string
69 | */
70 | abstract public static function operator(): string;
71 |
72 | /**
73 | * Execute a callback on a given column, providing the array of values.
74 | *
75 | * @param Builder $builder
76 | * @param string $column
77 | * @param CategorizedValues $values
78 | *
79 | * @throws JsonQueryBuilderException
80 | */
81 | abstract public function execute(Builder $builder, string $column, CategorizedValues $values): void;
82 |
83 | protected function appendRelations(Builder $builder, string $column, CategorizedValues $values, string $method = 'orWhereHas'): void
84 | {
85 | [$relationName, $relatedColumns] = explode('.', $column, 2);
86 |
87 | $builder->{$method}(Str::camel($relationName), function (Builder $builder) use ($relatedColumns, $values) {
88 | // Support for inner relation calls like model.relation.relation2.relation2_attribute
89 | if (str_contains($relatedColumns, '.')) {
90 | $this->appendRelations($builder, $relatedColumns, $values, 'whereHas');
91 |
92 | return;
93 | }
94 |
95 | // $this->execute($builder, $relatedColumns, $values);
96 | // need to group those wheres statements....otherwise, there will be OR statement added, and relation would be "broken"
97 | $builder->where(function ($builder) use ($relatedColumns, $values) {
98 | $this->execute($builder, $relatedColumns, $values);
99 | });
100 | $this->checkExecuteForCustomfieldsParameter($builder);
101 | });
102 | }
103 |
104 | /**
105 | * @param Builder $builder
106 | * @param string $column
107 | * @param CategorizedValues $values
108 | * @param string $operator
109 | *
110 | * @throws JsonQueryBuilderException
111 | */
112 | protected function lessOrMoreCallback(Builder $builder, string $column, CategorizedValues $values, string $operator)
113 | {
114 | $this->checkAllowedValues($values, $operator);
115 |
116 | if (count($values->and) > 1) {
117 | throw new JsonQueryBuilderException("Using $operator operator assumes one parameter only. Remove excess parameters.");
118 | }
119 |
120 | if (!$values->and) {
121 | throw new JsonQueryBuilderException("No valid arguments for '$operator' operator.");
122 | }
123 |
124 | $method = $this->isDate($this->searchParser->type) ? 'whereDate' : 'where';
125 | $builder->{$method}($column, $operator, $values->and[0]);
126 | }
127 |
128 | /**
129 | * @param Builder $builder
130 | * @param string $column
131 | * @param CategorizedValues $values
132 | * @param string $operator
133 | *
134 | * @throws JsonQueryBuilderException
135 | */
136 | protected function betweenCallback(Builder $builder, string $column, CategorizedValues $values, string $operator)
137 | {
138 | $this->checkAllowedValues($values, $operator);
139 |
140 | if (count($values->and) != 2) {
141 | throw new JsonQueryBuilderException("Using $operator operator assumes exactly 2 parameters. Wrong number of parameters provided.");
142 | }
143 |
144 | $callback = $operator == '<>' ? 'whereBetween' : 'whereNotBetween';
145 |
146 | $builder->{$callback}($column, [$values->and[0], $values->and[1]]);
147 | }
148 |
149 | /**
150 | * @param Builder $builder
151 | * @param string $column
152 | * @param CategorizedValues $values
153 | * @param string $operator
154 | *
155 | * @throws JsonQueryBuilderException
156 | */
157 | protected function containsCallback(Builder $builder, string $column, CategorizedValues $values, string $operator)
158 | {
159 | if ($values->andLike) {
160 | $builder->where($column, $this->getLikeOperator(), '%' . $values->andLike[0] . '%');
161 | }
162 | if ($values->and) {
163 | foreach ($values->and as $andValue) {
164 | $builder->orWhere($column, $this->getLikeOperator(), '%' . $andValue . '%');
165 | }
166 | }
167 | }
168 |
169 | /**
170 | * @param Builder $builder
171 | * @param string $column
172 | * @param CategorizedValues $values
173 | * @param string $operator
174 | *
175 | * @throws JsonQueryBuilderException
176 | */
177 | protected function endsWithCallback(Builder $builder, string $column, CategorizedValues $values, string $operator)
178 | {
179 | if ($values->andLike) {
180 | $builder->where($column, $this->getLikeOperator(), '%' . $values->andLike[0]);
181 | }
182 | if ($values->and) {
183 | foreach ($values->and as $andValue) {
184 | $builder->orWhere($column, $this->getLikeOperator(), '%' . $andValue);
185 | }
186 | }
187 | }
188 |
189 | /**
190 | * @param Builder $builder
191 | * @param string $column
192 | * @param CategorizedValues $values
193 | * @param string $operator
194 | *
195 | * @throws JsonQueryBuilderException
196 | */
197 | protected function startsWithCallback(Builder $builder, string $column, CategorizedValues $values, string $operator)
198 | {
199 | if ($values->andLike) {
200 | $builder->where($column, $this->getLikeOperator(), $values->andLike[0] . '%');
201 | }
202 | if ($values->and) {
203 | foreach ($values->and as $andValue) {
204 | $builder->orWhere($column, $this->getLikeOperator(), $andValue . '%');
205 | }
206 | }
207 | }
208 |
209 | /**
210 | * Should throw exception if anything except '$values->and' is filled out.
211 | *
212 | * @param CategorizedValues $values
213 | * @param string $operator
214 | *
215 | * @throws JsonQueryBuilderException
216 | */
217 | protected function checkAllowedValues(CategorizedValues $values, string $operator): void
218 | {
219 | if ($values->null || $values->notNull || $values->not || $values->notLike || $values->andLike) {
220 | throw new JsonQueryBuilderException("Wrong parameter type(s) for '$operator' operator.");
221 | }
222 | }
223 |
224 | protected function isDate(string $type): bool
225 | {
226 | return in_array($type, self::DATE_FIELDS);
227 | }
228 |
229 | protected function isDateTime(string $type): bool
230 | {
231 | return in_array($type, self::DATETIME_FIELDS);
232 | }
233 |
234 | //Hack to enable case-insensitive search when using PostgreSQL database
235 | protected function getLikeOperator(): string
236 | {
237 | if (DB::connection()->getPDO()->getAttribute(PDO::ATTR_DRIVER_NAME) == 'pgsql') {
238 | return 'ILIKE';
239 | }
240 |
241 | return 'LIKE';
242 | }
243 |
244 | protected function checkExecuteForCustomfieldsParameter($builder)
245 | {
246 | if ($this->searchParser instanceof CustomFieldSearchParser) {
247 | $builder->where($this->searchParser->cf_field_identificator, '=', $this->searchParser->cf_field_value);
248 | }
249 | }
250 | }
251 |
--------------------------------------------------------------------------------
/src/SearchCallbacks/Between.php:
--------------------------------------------------------------------------------
1 | ';
15 | }
16 |
17 | public function execute(Builder $builder, string $column, CategorizedValues $values): void
18 | {
19 | $this->betweenCallback($builder, $column, $values, '<>');
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/SearchCallbacks/Contains.php:
--------------------------------------------------------------------------------
1 | containsCallback($builder, $column, $values, 'contains');
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/SearchCallbacks/EndsWith.php:
--------------------------------------------------------------------------------
1 | endsWithCallback($builder, $column, $values, 'ends_with');
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/SearchCallbacks/Equals.php:
--------------------------------------------------------------------------------
1 | andLike as $andLike) {
29 | $builder->where($column, $this->getLikeOperator(), $andLike);
30 | }
31 |
32 | foreach ($values->notLike as $notLike) {
33 | $builder->where($column, 'NOT ' . $this->getLikeOperator(), $notLike);
34 | }
35 |
36 | if ($values->null) {
37 | $builder->whereNull($column);
38 | }
39 |
40 | if ($values->notNull) {
41 | $builder->whereNotNull($column);
42 | }
43 |
44 | if ($values->and) {
45 | if ($this->isDate($this->searchParser->type)) {
46 | foreach ($values->and as $andValue) {
47 | $builder->orWhereDate($column, $andValue);
48 | }
49 | } elseif ($this->isDateTime($this->searchParser->type)) {
50 | foreach ($values->and as $andValue) {
51 | $dateTimeValue = new \DateTime($andValue);
52 | $formattedDateTime = $dateTimeValue->format('Y-m-d H:i:s');
53 |
54 | if ($dateTimeValue->format('s') === '00') {
55 | $builder->orWhere(function ($query) use ($column, $formattedDateTime) {
56 | $query->whereDate($column, '=', date('Y-m-d', strtotime($formattedDateTime)))
57 | ->whereTime($column, '>=', date('H:i', strtotime($formattedDateTime)))
58 | ->whereTime($column, '<', date('H:i', strtotime($formattedDateTime . ' +1 minute')));
59 | });
60 | } else {
61 | $builder->orWhere(function ($query) use ($column, $formattedDateTime) {
62 | $query->where($column, '=', $formattedDateTime);
63 | });
64 | }
65 | }
66 | } else {
67 | $builder->whereIn($column, $values->and);
68 | }
69 | }
70 |
71 | if ($values->not) {
72 | if ($this->isDate($this->searchParser->type)) {
73 | throw new Exception('Not operator is not supported for date(time) fields');
74 | }
75 |
76 | $builder->whereNotIn($column, $values->not);
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/SearchCallbacks/GreaterThan.php:
--------------------------------------------------------------------------------
1 | ';
15 | }
16 |
17 | public function execute(Builder $builder, string $column, CategorizedValues $values): void
18 | {
19 | $this->lessOrMoreCallback($builder, $column, $values, '>');
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/SearchCallbacks/GreaterThanOrEqual.php:
--------------------------------------------------------------------------------
1 | =';
15 | }
16 |
17 | public function execute(Builder $builder, string $column, CategorizedValues $values): void
18 | {
19 | $this->lessOrMoreCallback($builder, $column, $values, '>=');
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/SearchCallbacks/LessThan.php:
--------------------------------------------------------------------------------
1 | lessOrMoreCallback($builder, $column, $values, '<');
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/SearchCallbacks/LessThanOrEqual.php:
--------------------------------------------------------------------------------
1 | lessOrMoreCallback($builder, $column, $values, '<=');
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/SearchCallbacks/NotBetween.php:
--------------------------------------------------------------------------------
1 | ';
15 | }
16 |
17 | public function execute(Builder $builder, string $column, CategorizedValues $values): void
18 | {
19 | $this->betweenCallback($builder, $column, $values, '!<>');
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/SearchCallbacks/NotEquals.php:
--------------------------------------------------------------------------------
1 | andLike, $values->notLike) as $like) {
29 | if ($this->isDate($this->searchParser->type)) {
30 | throw new Exception('Not operator is not supported for date(time) fields');
31 | }
32 |
33 | $builder->where($column, 'NOT ' . $this->getLikeOperator(), $like);
34 | }
35 |
36 | if ($values->null || $values->notNull) {
37 | $builder->whereNotNull($column);
38 | }
39 |
40 | if (array_merge($values->and, $values->not)) {
41 | $builder->whereNotIn($column, array_merge($values->and, $values->not));
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/SearchCallbacks/StartsWith.php:
--------------------------------------------------------------------------------
1 | startsWithCallback($builder, $column, $values, 'starts_with');
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/SearchParser.php:
--------------------------------------------------------------------------------
1 | modelConfig = $modelConfig;
43 | $this->column = $column;
44 | $this->argument = $argument;
45 |
46 | $this->checkForForbiddenColumns();
47 |
48 | $this->operator = $this->parseOperator($operatorsConfig->getOperators(), $argument);
49 | $arguments = str_replace($this->operator, '', $this->argument);
50 | $this->values = $this->splitValues($arguments);
51 | $this->type = $this->getColumnType();
52 | }
53 |
54 | /**
55 | * @param $operators
56 | * @param string $argument
57 | * @return string
58 | *
59 | * @throws JsonQueryBuilderException
60 | */
61 | protected function parseOperator($operators, string $argument): string
62 | {
63 | foreach ($operators as $operator) {
64 | $argumentHasOperator = strpos($argument, $operator) !== false;
65 |
66 | if (!$argumentHasOperator) {
67 | continue;
68 | }
69 |
70 | return $operator;
71 | }
72 |
73 | throw new JsonQueryBuilderException("No valid callback registered for $argument. Are you missing an operator?");
74 | }
75 |
76 | /**
77 | * Split values by a given separator.
78 | *
79 | * Input: val1;val2
80 | *
81 | * Output: val1
82 | * val2
83 | *
84 | * @param string $values
85 | * @return array
86 | *
87 | * @throws JsonQueryBuilderException
88 | */
89 | protected function splitValues(string $values): array
90 | {
91 | $valueArray = explode(self::VALUE_SEPARATOR, $values);
92 | $cleanedUpValues = $this->cleanValues($valueArray);
93 |
94 | if (count($cleanedUpValues) < 1) {
95 | throw new JsonQueryBuilderException("Column '$this->column' is missing a value.");
96 | }
97 |
98 | return $cleanedUpValues;
99 | }
100 |
101 | /**
102 | * @return string
103 | *
104 | * @throws JsonQueryBuilderException
105 | */
106 | protected function getColumnType(): string
107 | {
108 | $columns = $this->modelConfig->getModelColumns();
109 |
110 | if (!array_key_exists($this->column, $columns)) {
111 | // TODO: integrate recursive column check for related models?
112 | return 'generic';
113 | }
114 |
115 | return $columns[$this->column];
116 | }
117 |
118 | /**
119 | * Check if global forbidden key is used.
120 | *
121 | * @throws JsonQueryBuilderException
122 | */
123 | protected function checkForForbiddenColumns()
124 | {
125 | $forbiddenKeys = Config::get('asseco-json-query-builder.global_forbidden_columns');
126 | $forbiddenKeys = $this->modelConfig->getForbidden($forbiddenKeys);
127 |
128 | if (in_array($this->column, $forbiddenKeys)) {
129 | throw new JsonQueryBuilderException("Searching by '$this->column' field is forbidden. Check the configuration if this is not a desirable behavior.");
130 | }
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/src/SearchParserInterface.php:
--------------------------------------------------------------------------------
1 | $item == '')
28 | );
29 |
30 | foreach ($deleteKeys as $deleteKey) {
31 | unset($trimmedInput[$deleteKey]);
32 | }
33 |
34 | return $trimmedInput;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Types/AbstractType.php:
--------------------------------------------------------------------------------
1 | hasMany(TestRelationOneModel::class);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/tests/TestRelationOneModel.php:
--------------------------------------------------------------------------------
1 | belongsTo(TestModel::class);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/tests/Unit/CategorizedValuesTest.php:
--------------------------------------------------------------------------------
1 | searchParser = Mockery::mock(SearchParser::class);
21 |
22 | $this->searchParser->type = 'string';
23 | }
24 |
25 | /** @test */
26 | public function has_null_value()
27 | {
28 | $this->searchParser->values = ['123', '456', 'null', '789'];
29 |
30 | $categorizedValues = new CategorizedValues($this->searchParser);
31 |
32 | $this->assertTrue($categorizedValues->null);
33 | }
34 |
35 | /** @test */
36 | public function has_no_null_value()
37 | {
38 | $this->searchParser->values = ['123', '456', '789'];
39 |
40 | $categorizedValues = new CategorizedValues($this->searchParser);
41 |
42 | $this->assertFalse($categorizedValues->null);
43 | }
44 |
45 | /** @test */
46 | public function has_not_null_value()
47 | {
48 | $this->searchParser->values = ['123', '456', '!null', '789'];
49 |
50 | $categorizedValues = new CategorizedValues($this->searchParser);
51 |
52 | $this->assertTrue($categorizedValues->notNull);
53 | }
54 |
55 | /** @test */
56 | public function has_no_not_null_value()
57 | {
58 | $this->searchParser->values = ['123', '456', '789'];
59 |
60 | $categorizedValues = new CategorizedValues($this->searchParser);
61 |
62 | $this->assertFalse($categorizedValues->notNull);
63 | }
64 |
65 | /** @test */
66 | public function has_not_values()
67 | {
68 | $this->searchParser->values = ['123', '!456', '!789'];
69 |
70 | $categorizedValues = new CategorizedValues($this->searchParser);
71 |
72 | $this->assertEquals(['456', '789'], $categorizedValues->not);
73 | }
74 |
75 | /** @test */
76 | public function has_not_like_values()
77 | {
78 | $this->searchParser->values = ['123', '!%456', '!789%'];
79 |
80 | $categorizedValues = new CategorizedValues($this->searchParser);
81 |
82 | $this->assertEquals(['%456', '789%'], $categorizedValues->notLike);
83 | }
84 |
85 | /** @test */
86 | public function has_and_values()
87 | {
88 | $this->searchParser->values = ['123', '456', '!789'];
89 |
90 | $categorizedValues = new CategorizedValues($this->searchParser);
91 |
92 | $this->assertEquals(['123', '456'], $categorizedValues->and);
93 | }
94 |
95 | /** @test */
96 | public function has_and_like_values()
97 | {
98 | $this->searchParser->values = ['123', '%456', '789%'];
99 |
100 | $categorizedValues = new CategorizedValues($this->searchParser);
101 |
102 | $this->assertEquals(['%456', '789%'], $categorizedValues->andLike);
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/tests/Unit/Config/ModelConfigTest.php:
--------------------------------------------------------------------------------
1 | model = Mockery::mock(Model::class);
21 | }
22 |
23 | /** @test */
24 | public function model_has_config()
25 | {
26 | config(['asseco-json-query-builder.model_options' => [
27 | get_class($this->model) => ['random_config' => '123'],
28 | ]]);
29 |
30 | $modelConfig = new ModelConfig($this->model);
31 |
32 | $this->assertTrue($modelConfig->hasConfig());
33 | }
34 |
35 | /** @test */
36 | public function has_returns_config_set()
37 | {
38 | config(['asseco-json-query-builder.model_options' => [
39 | get_class($this->model) => ['returns' => '123'],
40 | ]]);
41 |
42 | $modelConfig = new ModelConfig($this->model);
43 |
44 | $this->assertEquals(['123'], $modelConfig->getReturns());
45 | }
46 |
47 | /** @test */
48 | public function has_default_returns_config_set()
49 | {
50 | $modelConfig = new ModelConfig($this->model);
51 |
52 | $this->assertEquals(['*'], $modelConfig->getReturns());
53 | }
54 |
55 | /** @test */
56 | public function has_order_by_config_set()
57 | {
58 | config(['asseco-json-query-builder.model_options' => [
59 | get_class($this->model) => [
60 | 'order_by' => [
61 | 'attribute' => 'asc',
62 | ],
63 | ],
64 | ]]);
65 |
66 | $modelConfig = new ModelConfig($this->model);
67 |
68 | $this->assertEquals(['attribute=asc'], $modelConfig->getOrderBy());
69 | }
70 |
71 | /** @test */
72 | public function has_default_order_by_config_set()
73 | {
74 | $modelConfig = new ModelConfig($this->model);
75 |
76 | $this->assertEquals([], $modelConfig->getOrderBy());
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/tests/Unit/Config/OperatorsConfigTest.php:
--------------------------------------------------------------------------------
1 | assertNotEmpty($operators->registered);
20 | }
21 |
22 | /** @test */
23 | public function throws_on_missing_config()
24 | {
25 | $this->expectException(Exception::class);
26 |
27 | config(['asseco-json-query-builder' => []]);
28 |
29 | new OperatorsConfig();
30 | }
31 |
32 | /** @test */
33 | public function returns_registered_operators()
34 | {
35 | $operatorsConfig = new OperatorsConfig();
36 |
37 | $expected = ['!<>', '<=', '>=', '<>', '!=', '=', '<', '>', 'contains', 'starts_with', 'ends_with'];
38 |
39 | $this->assertEquals($expected, $operatorsConfig->getOperators());
40 | }
41 |
42 | /** @test */
43 | public function returns_class_from_given_operator()
44 | {
45 | $operatorsConfig = new OperatorsConfig();
46 |
47 | $this->assertEquals(Equals::class, $operatorsConfig->getCallbackClassFromOperator('='));
48 | }
49 |
50 | /** @test */
51 | public function throws_exception_on_non_existing_operator()
52 | {
53 | $this->expectException(Exception::class);
54 |
55 | $operatorsConfig = new OperatorsConfig();
56 |
57 | $operatorsConfig->getCallbackClassFromOperator('123');
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/tests/Unit/Config/RequestParametersConfigTest.php:
--------------------------------------------------------------------------------
1 | assertNotEmpty($requestParameters->registered);
19 | }
20 |
21 | /** @test */
22 | public function throws_on_missing_config()
23 | {
24 | $this->expectException(Exception::class);
25 |
26 | config(['asseco-json-query-builder' => []]);
27 |
28 | new RequestParametersConfig();
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/tests/Unit/Config/TypesConfigTest.php:
--------------------------------------------------------------------------------
1 | assertNotEmpty($typesConfig->registered);
21 | }
22 |
23 | /** @test */
24 | public function throws_on_missing_config()
25 | {
26 | $this->expectException(Exception::class);
27 |
28 | config(['asseco-json-query-builder' => []]);
29 |
30 | new TypesConfig();
31 | }
32 |
33 | /** @test */
34 | public function it_returns_type_class()
35 | {
36 | $typesConfig = new TypesConfig();
37 |
38 | $this->assertEquals(new BooleanType(),
39 | $typesConfig->getTypeClassFromTypeName('boolean'));
40 | }
41 |
42 | /** @test */
43 | public function it_returns_generic_type_if_non_existing_type_is_given()
44 | {
45 | $typesConfig = new TypesConfig();
46 |
47 | $this->assertEquals(new GenericType(),
48 | $typesConfig->getTypeClassFromTypeName('test'));
49 | }
50 |
51 | /** @test */
52 | public function it_throws_exception_if_neither_generic_type_nor_given_type_exist()
53 | {
54 | $this->expectException(Exception::class);
55 |
56 | config(['asseco-json-query-builder.types' => []]);
57 |
58 | $typesConfig = new TypesConfig();
59 |
60 | $this->assertEquals(new GenericType(),
61 | $typesConfig->getTypeClassFromTypeName('test'));
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/tests/Unit/JsonQueryTest.php:
--------------------------------------------------------------------------------
1 | builder = app(Builder::class);
24 | $this->builder->setModel(new TestModel());
25 | }
26 |
27 | /** @test */
28 | public function throws_on_existing_models()
29 | {
30 | $this->expectException(Exception::class);
31 |
32 | $this->builder->getModel()->exists = true;
33 |
34 | new JsonQuery($this->builder, []);
35 | }
36 |
37 | /** @test */
38 | public function searches_single_attribute()
39 | {
40 | $input = [
41 | 'search' => [
42 | 'att1' => '=1',
43 | ],
44 | ];
45 |
46 | $jsonQuery = new JsonQuery($this->builder, $input);
47 | $jsonQuery->search();
48 |
49 | $sql = 'select * from "test" where ((("att1" in (?))))';
50 |
51 | $this->assertEquals($sql, $this->builder->toSql());
52 | }
53 |
54 | /** @test */
55 | public function searches_multiple_attributes()
56 | {
57 | $input = [
58 | 'search' => [
59 | 'att1' => '=1;2;3',
60 | 'att2' => '=4;5;6',
61 | ],
62 | ];
63 |
64 | $jsonQuery = new JsonQuery($this->builder, $input);
65 | $jsonQuery->search();
66 |
67 | $sql = 'select * from "test" where ((("att1" in (?, ?, ?))) and (("att2" in (?, ?, ?))))';
68 |
69 | $this->assertEquals($sql, $this->builder->toSql());
70 | }
71 |
72 | /** @test */
73 | public function searches_negated_attributes()
74 | {
75 | $input = [
76 | 'search' => [
77 | 'att1' => '=1;!2;!3',
78 | ],
79 | ];
80 |
81 | $jsonQuery = new JsonQuery($this->builder, $input);
82 | $jsonQuery->search();
83 |
84 | $sql = 'select * from "test" where ((("att1" in (?) and "att1" not in (?, ?))))';
85 |
86 | $this->assertEquals($sql, $this->builder->toSql());
87 | }
88 |
89 | /** @test */
90 | public function searches_wildcard_attributes()
91 | {
92 | $input = [
93 | 'search' => [
94 | 'att1' => '=1;%2;3%',
95 | ],
96 | ];
97 |
98 | $jsonQuery = new JsonQuery($this->builder, $input);
99 | $jsonQuery->search();
100 |
101 | $sql = 'select * from "test" where ((("att1" LIKE ? and "att1" LIKE ? and "att1" in (?))))';
102 |
103 | $this->assertEquals($sql, $this->builder->toSql());
104 | }
105 |
106 | /** @test */
107 | public function searches_with_all_operators()
108 | {
109 | $input = [
110 | 'search' => [
111 | 'att1' => '=1',
112 | 'att2' => '<1',
113 | 'att3' => '<=1',
114 | 'att4' => '>1',
115 | 'att5' => '>=1',
116 | 'att6' => '<>1;2',
117 | 'att7' => '!<>1;2',
118 | 'att8' => '!=1',
119 | ],
120 | ];
121 |
122 | $jsonQuery = new JsonQuery($this->builder, $input);
123 | $jsonQuery->search();
124 |
125 | $sql = 'select * from "test" where ((("att1" in (?))) and (("att2" < ?)) and (("att3" <= ?)) and (("att4" > ?)) and (("att5" >= ?)) and (("att6" between ? and ?)) and (("att7" not between ? and ?)) and (("att8" not in (?))))';
126 |
127 | $this->assertEquals($sql, $this->builder->toSql());
128 | }
129 |
130 | /** @test */
131 | public function searches_with_or_micro_operator()
132 | {
133 | $input = [
134 | 'search' => [
135 | 'id' => '=1||=2',
136 | ],
137 | ];
138 |
139 | $jsonQuery = new JsonQuery($this->builder, $input);
140 | $jsonQuery->search();
141 |
142 | $sql = 'select * from "test" where ((("id" in (?)) or ("id" in (?))))';
143 |
144 | $this->assertEquals($sql, $this->builder->toSql());
145 | }
146 |
147 | /** @test */
148 | public function searches_with_and_micro_operator()
149 | {
150 | $input = [
151 | 'search' => [
152 | 'id' => '=1&&=2',
153 | ],
154 | ];
155 |
156 | $jsonQuery = new JsonQuery($this->builder, $input);
157 | $jsonQuery->search();
158 |
159 | $sql = 'select * from "test" where ((("id" in (?) and "id" in (?))))';
160 |
161 | $this->assertEquals($sql, $this->builder->toSql());
162 | }
163 |
164 | /** @test */
165 | public function selects_only_given_attributes()
166 | {
167 | $input = [
168 | 'returns' => ['id', 'other'],
169 | ];
170 |
171 | $jsonQuery = new JsonQuery($this->builder, $input);
172 | $jsonQuery->search();
173 |
174 | $sql = 'select "id", "other" from "test"';
175 |
176 | $this->assertEquals($sql, $this->builder->toSql());
177 | }
178 |
179 | /** @test */
180 | public function orders_by_attributes()
181 | {
182 | $input = [
183 | 'order_by' => [
184 | 'att1' => 'asc',
185 | 'att2' => 'desc',
186 | 'att3',
187 | ],
188 | ];
189 |
190 | $jsonQuery = new JsonQuery($this->builder, $input);
191 | $jsonQuery->search();
192 |
193 | $sql = 'select * from "test" order by "att1" asc, "att2" desc, "att3" asc';
194 |
195 | $this->assertEquals($sql, $this->builder->toSql());
196 | }
197 |
198 | /** @test */
199 | public function groups_by_attributes()
200 | {
201 | $input = [
202 | 'group_by' => ['att1', 'att2'],
203 | ];
204 |
205 | $jsonQuery = new JsonQuery($this->builder, $input);
206 | $jsonQuery->search();
207 |
208 | $sql = 'select * from "test" group by "att1", "att2"';
209 |
210 | $this->assertEquals($sql, $this->builder->toSql());
211 | }
212 |
213 | /** @test */
214 | public function limits_and_offsets_results()
215 | {
216 | $input = [
217 | 'limit' => 5,
218 | 'offset' => 10,
219 | ];
220 |
221 | $jsonQuery = new JsonQuery($this->builder, $input);
222 | $jsonQuery->search();
223 |
224 | $sql = 'select * from "test" limit 5 offset 10';
225 |
226 | $this->assertEquals($sql, $this->builder->toSql());
227 | }
228 |
229 | /** @test */
230 | public function counts_results()
231 | {
232 | $input = [
233 | 'count' => true,
234 | ];
235 |
236 | $jsonQuery = new JsonQuery($this->builder, $input);
237 | $jsonQuery->search();
238 |
239 | $sql = 'select count(*) as count from "test"';
240 |
241 | $this->assertEquals($sql, $this->builder->toSql());
242 | }
243 |
244 | /** @test */
245 | public function uses_top_level_logical_operator_for_complex_queries()
246 | {
247 | $input = [
248 | 'search' => [
249 | '||' => [
250 | 'att1' => '=1',
251 | 'att2' => '=1',
252 | ],
253 | ],
254 | ];
255 |
256 | $jsonQuery = new JsonQuery($this->builder, $input);
257 | $jsonQuery->search();
258 |
259 | $sql = 'select * from "test" where ((("att1" in (?))) or (("att2" in (?))))';
260 |
261 | $this->assertEquals($sql, $this->builder->toSql());
262 | }
263 |
264 | /** @test */
265 | public function uses_top_level_logical_operator_for_complex_recursive_queries()
266 | {
267 | $input = [
268 | 'search' => [
269 | '&&' => [
270 | '||' => [
271 | 'att1' => '=1',
272 | 'att2' => '=1',
273 | ],
274 | 'att3' => '=1',
275 | 'att4' => '=1',
276 | ],
277 | ],
278 | ];
279 |
280 | $jsonQuery = new JsonQuery($this->builder, $input);
281 | $jsonQuery->search();
282 |
283 | $sql = 'select * from "test" where ((("att1" in (?))) or (("att2" in (?))) and (("att3" in (?))) and (("att4" in (?))))';
284 |
285 | $this->assertEquals($sql, $this->builder->toSql());
286 | }
287 |
288 | /** @test */
289 | public function can_recurse_absurdly_deep()
290 | {
291 | $input = [
292 | 'search' => [
293 | '||' => [
294 | '&&' => [
295 | [
296 | '||' => [
297 | [
298 | 'id' => '=2||=3',
299 | 'name' => '=foo',
300 | ],
301 | [
302 | 'id' => '=1',
303 | 'name' => '=foo%&&=%bar',
304 | ],
305 | ],
306 | ],
307 | [
308 | 'we' => '=cool',
309 | ],
310 | ],
311 | 'love' => '<3',
312 | 'recursion' => '=rrr',
313 | ],
314 | ],
315 | ];
316 |
317 | $jsonQuery = new JsonQuery($this->builder, $input);
318 | $jsonQuery->search();
319 |
320 | $sql = 'select * from "test" where ((((("id" in (?)) or ("id" in (?))) and (("name" in (?)))) or ((("id" in (?))) and (("name" LIKE ? and "name" LIKE ?)))) and ((("we" in (?)))) or (("love" < ?)) or (("recursion" in (?))))';
321 |
322 | $this->assertEquals($sql, $this->builder->toSql());
323 | }
324 | }
325 |
--------------------------------------------------------------------------------
/tests/Unit/RequestParameters/CountParameterTest.php:
--------------------------------------------------------------------------------
1 | builder = app(Builder::class);
24 |
25 | $this->modelConfig = Mockery::mock(ModelConfig::class);
26 | }
27 |
28 | /** @test */
29 | public function has_a_name()
30 | {
31 | $countParameter = new CountParameter([], $this->builder, $this->modelConfig);
32 |
33 | $this->assertEquals('count', $countParameter::getParameterName());
34 | }
35 |
36 | /** @test */
37 | public function accepts_valid_arguments()
38 | {
39 | foreach ([1, '1', true, 'true'] as $validArgument) {
40 | $countParameter = new CountParameter([$validArgument], $this->builder, $this->modelConfig);
41 | $countParameter->run();
42 | }
43 |
44 | $this->assertTrue(true);
45 | }
46 |
47 | /** @test */
48 | public function rejects_non_bool_argument()
49 | {
50 | $this->expectException(Exception::class);
51 |
52 | $countParameter = new CountParameter(['invalid'], $this->builder, $this->modelConfig);
53 | $countParameter->run();
54 | }
55 |
56 | /** @test */
57 | public function rejects_multiple_arguments()
58 | {
59 | $this->expectException(Exception::class);
60 |
61 | $countParameter = new CountParameter([1, 1], $this->builder, $this->modelConfig);
62 | $countParameter->run();
63 | }
64 |
65 | /** @test */
66 | public function produces_query()
67 | {
68 | $countParameter = new CountParameter([true], $this->builder, $this->modelConfig);
69 | $countParameter->run();
70 |
71 | $query = 'select count(*) as count';
72 |
73 | $this->assertEquals($query, $this->builder->toSql());
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/tests/Unit/RequestParameters/GroupByParameterTest.php:
--------------------------------------------------------------------------------
1 | builder = app(Builder::class);
24 |
25 | $this->modelConfig = Mockery::mock(ModelConfig::class);
26 | }
27 |
28 | /** @test */
29 | public function has_a_name()
30 | {
31 | $groupByParameter = new GroupByParameter([], $this->builder, $this->modelConfig);
32 |
33 | $this->assertEquals('group_by', $groupByParameter::getParameterName());
34 | }
35 |
36 | /** @test */
37 | public function accepts_valid_arguments()
38 | {
39 | $groupByParameter = new GroupByParameter(
40 | ['attribute1', 'attribute2'], $this->builder, $this->modelConfig);
41 | $groupByParameter->run();
42 |
43 | $this->assertTrue(true);
44 | }
45 |
46 | /** @test */
47 | public function rejects_empty_argument()
48 | {
49 | $this->expectException(Exception::class);
50 |
51 | $groupByParameter = new GroupByParameter([], $this->builder, $this->modelConfig);
52 | $groupByParameter->run();
53 | }
54 |
55 | /** @test */
56 | public function produces_query()
57 | {
58 | $groupByParameter = new GroupByParameter([true], $this->builder, $this->modelConfig);
59 | $groupByParameter->run();
60 |
61 | $query = 'select * group by "1"';
62 |
63 | $this->assertEquals($query, $this->builder->toSql());
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/tests/Unit/RequestParameters/LimitParameterTest.php:
--------------------------------------------------------------------------------
1 | builder = app(Builder::class);
24 |
25 | $this->modelConfig = Mockery::mock(ModelConfig::class);
26 | }
27 |
28 | /** @test */
29 | public function has_a_name()
30 | {
31 | $limitParameter = new LimitParameter([], $this->builder, $this->modelConfig);
32 |
33 | $this->assertEquals('limit', $limitParameter::getParameterName());
34 | }
35 |
36 | /** @test */
37 | public function accepts_valid_arguments()
38 | {
39 | $limitParameter = new LimitParameter([15], $this->builder, $this->modelConfig);
40 | $limitParameter->run();
41 |
42 | $this->assertTrue(true);
43 | }
44 |
45 | /** @test */
46 | public function rejects_non_numeric_argument()
47 | {
48 | $this->expectException(Exception::class);
49 |
50 | $limitParameter = new LimitParameter(['invalid'], $this->builder, $this->modelConfig);
51 | $limitParameter->run();
52 | }
53 |
54 | /** @test */
55 | public function rejects_multiple_arguments()
56 | {
57 | $this->expectException(Exception::class);
58 |
59 | $limitParameter = new LimitParameter([1, 1], $this->builder, $this->modelConfig);
60 | $limitParameter->run();
61 | }
62 |
63 | /** @test */
64 | public function produces_query()
65 | {
66 | $limitParameter = new LimitParameter([1], $this->builder, $this->modelConfig);
67 | $limitParameter->run();
68 |
69 | $query = 'select * limit 1';
70 |
71 | $this->assertEquals($query, $this->builder->toSql());
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/tests/Unit/RequestParameters/OffsetParameterTest.php:
--------------------------------------------------------------------------------
1 | builder = app(Builder::class);
24 |
25 | $this->modelConfig = Mockery::mock(ModelConfig::class);
26 | }
27 |
28 | /** @test */
29 | public function has_a_name()
30 | {
31 | $offsetParameter = new OffsetParameter([], $this->builder, $this->modelConfig);
32 |
33 | $this->assertEquals('offset', $offsetParameter::getParameterName());
34 | }
35 |
36 | /** @test */
37 | public function accepts_valid_arguments()
38 | {
39 | $offsetParameter = new OffsetParameter([15], $this->builder, $this->modelConfig);
40 | $offsetParameter->run();
41 |
42 | $this->assertTrue(true);
43 | }
44 |
45 | /** @test */
46 | public function rejects_non_numeric_argument()
47 | {
48 | $this->expectException(Exception::class);
49 |
50 | $offsetParameter = new OffsetParameter(['invalid'], $this->builder, $this->modelConfig);
51 | $offsetParameter->run();
52 | }
53 |
54 | /** @test */
55 | public function rejects_multiple_arguments()
56 | {
57 | $this->expectException(Exception::class);
58 |
59 | $offsetParameter = new OffsetParameter([1, 1], $this->builder, $this->modelConfig);
60 | $offsetParameter->run();
61 | }
62 |
63 | /** @test */
64 | public function produces_query()
65 | {
66 | $offsetParameter = new OffsetParameter([1], $this->builder, $this->modelConfig);
67 | $offsetParameter->run();
68 |
69 | $query = 'select * offset 1';
70 |
71 | $this->assertEquals($query, $this->builder->toSql());
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/tests/Unit/RequestParameters/OrderByParameterTest.php:
--------------------------------------------------------------------------------
1 | builder = app(Builder::class);
24 |
25 | $this->modelConfig = Mockery::mock(ModelConfig::class);
26 | }
27 |
28 | /** @test */
29 | public function has_a_name()
30 | {
31 | $orderByParameter = new OrderByParameter([], $this->builder, $this->modelConfig);
32 |
33 | $this->assertEquals('order_by', $orderByParameter::getParameterName());
34 | }
35 |
36 | /** @test */
37 | public function accepts_valid_arguments()
38 | {
39 | $orderByParameter = new OrderByParameter(
40 | ['attribute1', 'attribute2' => 'desc'], $this->builder, $this->modelConfig);
41 | $orderByParameter->run();
42 |
43 | $this->assertTrue(true);
44 | }
45 |
46 | /** @test */
47 | public function rejects_empty_argument()
48 | {
49 | $this->expectException(Exception::class);
50 |
51 | $orderByParameter = new OrderByParameter([], $this->builder, $this->modelConfig);
52 | $orderByParameter->run();
53 | }
54 |
55 | /** @test */
56 | public function produces_query()
57 | {
58 | $orderByParameter = new OrderByParameter(
59 | ['attribute1', 'attribute2' => 'desc'], $this->builder, $this->modelConfig);
60 | $orderByParameter->run();
61 |
62 | $query = 'select * order by "attribute1" asc, "attribute2" desc';
63 |
64 | $this->assertEquals($query, $this->builder->toSql());
65 | }
66 |
67 | /** @test */
68 | public function produces_query_2()
69 | {
70 | $orderByParameter = new OrderByParameter(
71 | ['attribute1' => 'desc', 'attribute2' => 'asc'], $this->builder, $this->modelConfig);
72 | $orderByParameter->run();
73 |
74 | $query = 'select * order by "attribute1" desc, "attribute2" asc';
75 |
76 | $this->assertEquals($query, $this->builder->toSql());
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/tests/Unit/RequestParameters/RelationsParameterTest.php:
--------------------------------------------------------------------------------
1 | builder = app(Builder::class);
24 |
25 | $this->modelConfig = Mockery::mock(ModelConfig::class);
26 | }
27 |
28 | /** @test */
29 | public function has_a_name()
30 | {
31 | $relationsParameter = new RelationsParameter([], $this->builder, $this->modelConfig);
32 |
33 | $this->assertEquals('relations', $relationsParameter::getParameterName());
34 | }
35 |
36 | /** @test */
37 | public function accepts_valid_arguments()
38 | {
39 | $relationsParameter = new RelationsParameter(
40 | ['attribute1', 'attribute2'], $this->builder, $this->modelConfig);
41 | $relationsParameter->run();
42 |
43 | $this->assertTrue(true);
44 | }
45 |
46 | /** @test */
47 | public function rejects_empty_argument()
48 | {
49 | $this->expectException(Exception::class);
50 |
51 | $relationsParameter = new RelationsParameter([], $this->builder, $this->modelConfig);
52 | $relationsParameter->run();
53 | }
54 |
55 | /** @test */
56 | public function relations_do_not_produce_query_like_this_so_this_test_is_useless()
57 | {
58 | $relationsParameter = new RelationsParameter(
59 | ['attribute1', 'attribute2'], $this->builder, $this->modelConfig);
60 | $relationsParameter->run();
61 |
62 | $query = 'select *';
63 |
64 | $this->assertEquals($query, $this->builder->toSql());
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/tests/Unit/RequestParameters/ReturnsParameterTest.php:
--------------------------------------------------------------------------------
1 | builder = app(Builder::class);
24 |
25 | $this->modelConfig = Mockery::mock(ModelConfig::class);
26 | }
27 |
28 | /** @test */
29 | public function has_a_name()
30 | {
31 | $returnsParameter = new ReturnsParameter([], $this->builder, $this->modelConfig);
32 |
33 | $this->assertEquals('returns', $returnsParameter::getParameterName());
34 | }
35 |
36 | /** @test */
37 | public function accepts_valid_arguments()
38 | {
39 | $returnsParameter = new ReturnsParameter(
40 | ['attribute1', 'attribute2'], $this->builder, $this->modelConfig);
41 | $returnsParameter->run();
42 |
43 | $this->assertTrue(true);
44 | }
45 |
46 | /** @test */
47 | public function rejects_empty_argument()
48 | {
49 | $this->expectException(Exception::class);
50 |
51 | $returnsParameter = new ReturnsParameter([], $this->builder, $this->modelConfig);
52 | $returnsParameter->run();
53 | }
54 |
55 | /** @test */
56 | public function produces_query()
57 | {
58 | $returnsParameter = new ReturnsParameter(
59 | ['attribute1', 'attribute2'], $this->builder, $this->modelConfig);
60 | $returnsParameter->run();
61 |
62 | $query = 'select "attribute1", "attribute2"';
63 |
64 | $this->assertEquals($query, $this->builder->toSql());
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/tests/Unit/RequestParameters/SearchParameterTest.php:
--------------------------------------------------------------------------------
1 | builder = app(Builder::class);
25 | $this->builder->setModel(new TestModel());
26 |
27 | $this->modelConfig = Mockery::mock(ModelConfig::class);
28 | $this->modelConfig->shouldReceive('getForbidden')->andReturn([]);
29 | $this->modelConfig->shouldReceive('getModelColumns')->andReturn([]);
30 | }
31 |
32 | protected function createSearchParameter(array $arguments): SearchParameter
33 | {
34 | return new SearchParameter($arguments, $this->builder, $this->modelConfig);
35 | }
36 |
37 | /** @test */
38 | public function has_a_name()
39 | {
40 | $searchParameter = $this->createSearchParameter([]);
41 |
42 | $this->assertEquals('search', $searchParameter::getParameterName());
43 | }
44 |
45 | /** @test */
46 | public function accepts_valid_arguments()
47 | {
48 | $arguments = [
49 | 'attribute1' => '=123',
50 | 'attribute2' => '=456',
51 | ];
52 |
53 | $searchParameter = $this->createSearchParameter($arguments);
54 | $searchParameter->run();
55 |
56 | $this->assertTrue(true);
57 | }
58 |
59 | /** @test */
60 | public function rejects_empty_argument()
61 | {
62 | $this->expectException(Exception::class);
63 |
64 | $searchParameter = $this->createSearchParameter([]);
65 | $searchParameter->run();
66 | }
67 |
68 | /** @test */
69 | public function produces_where_in_query()
70 | {
71 | $arguments = [
72 | 'attribute1' => '=123',
73 | 'attribute2' => '=456',
74 | ];
75 |
76 | $searchParameter = $this->createSearchParameter($arguments);
77 | $searchParameter->run();
78 |
79 | $query = 'select * from "test" where ((("attribute1" in (?))) and (("attribute2" in (?))))';
80 |
81 | $this->assertEquals($query, $this->builder->toSql());
82 | }
83 |
84 | /** @test */
85 | public function produces_where_in_multiple_query()
86 | {
87 | $arguments = [
88 | 'attribute1' => '=123;456',
89 | ];
90 |
91 | $searchParameter = $this->createSearchParameter($arguments);
92 | $searchParameter->run();
93 |
94 | $query = 'select * from "test" where ((("attribute1" in (?, ?))))';
95 |
96 | $this->assertEquals($query, $this->builder->toSql());
97 | }
98 |
99 | /** @test */
100 | public function produces_where_not_in_query()
101 | {
102 | $arguments = [
103 | 'attribute1' => '!=123',
104 | ];
105 |
106 | $searchParameter = $this->createSearchParameter($arguments);
107 | $searchParameter->run();
108 |
109 | $query = 'select * from "test" where ((("attribute1" not in (?))))';
110 |
111 | $this->assertEquals($query, $this->builder->toSql());
112 | }
113 |
114 | /** @test */
115 | public function produces_where_not_in_multiple_query()
116 | {
117 | $arguments = [
118 | 'attribute1' => '!=123;456',
119 | ];
120 |
121 | $searchParameter = $this->createSearchParameter($arguments);
122 | $searchParameter->run();
123 |
124 | $query = 'select * from "test" where ((("attribute1" not in (?, ?))))';
125 |
126 | $this->assertEquals($query, $this->builder->toSql());
127 | }
128 |
129 | /** @test */
130 | public function produces_less_than_query()
131 | {
132 | $arguments = [
133 | 'attribute1' => '<123',
134 | ];
135 |
136 | $searchParameter = $this->createSearchParameter($arguments);
137 | $searchParameter->run();
138 |
139 | $query = 'select * from "test" where ((("attribute1" < ?)))';
140 |
141 | $this->assertEquals($query, $this->builder->toSql());
142 | }
143 |
144 | /** @test */
145 | public function produces_less_than_or_equals_query()
146 | {
147 | $arguments = [
148 | 'attribute1' => '<=123',
149 | ];
150 |
151 | $searchParameter = $this->createSearchParameter($arguments);
152 | $searchParameter->run();
153 |
154 | $query = 'select * from "test" where ((("attribute1" <= ?)))';
155 |
156 | $this->assertEquals($query, $this->builder->toSql());
157 | }
158 |
159 | /** @test */
160 | public function produces_greater_than_query()
161 | {
162 | $arguments = [
163 | 'attribute1' => '>123',
164 | ];
165 |
166 | $searchParameter = $this->createSearchParameter($arguments);
167 | $searchParameter->run();
168 |
169 | $query = 'select * from "test" where ((("attribute1" > ?)))';
170 |
171 | $this->assertEquals($query, $this->builder->toSql());
172 | }
173 |
174 | /** @test */
175 | public function produces_greater_than_or_equals_query()
176 | {
177 | $arguments = [
178 | 'attribute1' => '>=123',
179 | ];
180 |
181 | $searchParameter = $this->createSearchParameter($arguments);
182 | $searchParameter->run();
183 |
184 | $query = 'select * from "test" where ((("attribute1" >= ?)))';
185 |
186 | $this->assertEquals($query, $this->builder->toSql());
187 | }
188 |
189 | /** @test */
190 | public function produces_between_query()
191 | {
192 | $arguments = [
193 | 'attribute1' => '<>123;456',
194 | ];
195 |
196 | $searchParameter = $this->createSearchParameter($arguments);
197 | $searchParameter->run();
198 |
199 | $query = 'select * from "test" where ((("attribute1" between ? and ?)))';
200 |
201 | $this->assertEquals($query, $this->builder->toSql());
202 | }
203 |
204 | /** @test */
205 | public function produces_not_between_query()
206 | {
207 | $arguments = [
208 | 'attribute1' => '!<>123;456',
209 | ];
210 |
211 | $searchParameter = $this->createSearchParameter($arguments);
212 | $searchParameter->run();
213 |
214 | $query = 'select * from "test" where ((("attribute1" not between ? and ?)))';
215 |
216 | $this->assertEquals($query, $this->builder->toSql());
217 | }
218 |
219 | /** @test */
220 | public function produces_correct_relations_query_one()
221 | {
222 | $arguments = [
223 | 'relationsOne.attribute1' => '=ABC',
224 | ];
225 |
226 | $searchParameter = $this->createSearchParameter($arguments);
227 | $searchParameter->run();
228 |
229 | $producedSql = $this->builder->toSql();
230 |
231 | $query = 'select * from "test" where (((exists (select * from "test_relation_one" where "test"."id" = "test_relation_one"."test_model_id" and ("attribute1" in (?))))))';
232 |
233 | $this->assertEquals($query, $producedSql);
234 | }
235 |
236 | /** @test */
237 | public function produces_correct_relations_query_for_begins_with()
238 | {
239 | $arguments = [
240 | 'relationsOne.attribute1' => 'starts_withABC',
241 | ];
242 |
243 | $searchParameter = $this->createSearchParameter($arguments);
244 | $searchParameter->run();
245 |
246 | $producedSql = $this->builder->toSql();
247 |
248 | $query = 'select * from "test" where (((exists (select * from "test_relation_one" where "test"."id" = "test_relation_one"."test_model_id" and ("attribute1" LIKE ?)))))';
249 |
250 | $this->assertEquals($query, $producedSql);
251 | }
252 |
253 | /** @test */
254 | public function produces_correct_relations_query_for_top_level_or()
255 | {
256 | $arguments = [
257 | '||' => [
258 | 'attribute1' => '=AAA',
259 | 'relationsOne.attribute1' => 'starts_withBBB',
260 | ],
261 | ];
262 |
263 | $searchParameter = $this->createSearchParameter($arguments);
264 | $searchParameter->run();
265 |
266 | $producedSql = $this->builder->toSql();
267 |
268 | $query = 'select * from "test" where ((("attribute1" in (?))) or ((exists (select * from "test_relation_one" where "test"."id" = "test_relation_one"."test_model_id" and ("attribute1" LIKE ?)))))';
269 |
270 | $this->assertEquals($query, $producedSql);
271 | }
272 | }
273 |
--------------------------------------------------------------------------------
/tests/Unit/RequestParameters/SoftDeletedParameterTest.php:
--------------------------------------------------------------------------------
1 | builder = app(Builder::class);
24 |
25 | $this->modelConfig = Mockery::mock(ModelConfig::class);
26 | }
27 |
28 | /** @test */
29 | public function has_a_name()
30 | {
31 | $countParameter = new SoftDeletedParameter([], $this->builder, $this->modelConfig);
32 |
33 | $this->assertEquals('soft_deleted', $countParameter::getParameterName());
34 | }
35 |
36 | /** @test */
37 | public function accepts_valid_arguments()
38 | {
39 | foreach ([1, '1', true, 'true'] as $validArgument) {
40 | $countParameter = new SoftDeletedParameter([$validArgument], $this->builder, $this->modelConfig);
41 | $countParameter->run();
42 | }
43 |
44 | $this->assertTrue(true);
45 | }
46 |
47 | /** @test */
48 | public function rejects_non_bool_argument()
49 | {
50 | $this->expectException(Exception::class);
51 |
52 | $countParameter = new SoftDeletedParameter(['invalid'], $this->builder, $this->modelConfig);
53 | $countParameter->run();
54 | }
55 |
56 | /** @test */
57 | public function rejects_multiple_arguments()
58 | {
59 | $this->expectException(Exception::class);
60 |
61 | $countParameter = new SoftDeletedParameter([1, 1], $this->builder, $this->modelConfig);
62 | $countParameter->run();
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/tests/Unit/SearchCallbacks/BetweenTest.php:
--------------------------------------------------------------------------------
1 | builder = app(Builder::class);
27 |
28 | $this->searchParser = Mockery::mock(SearchParser::class);
29 | $this->searchParser->type = 'test';
30 | $this->searchParser->column = 'test';
31 | }
32 |
33 | /** @test */
34 | public function produces_query()
35 | {
36 | $this->searchParser->values = ['123', '456'];
37 |
38 | new Between($this->builder, $this->searchParser);
39 |
40 | $sql = 'select * where "test" between ? and ?';
41 |
42 | $this->assertEquals($sql, $this->builder->toSql());
43 | }
44 |
45 | /** @test */
46 | public function fails_on_invalid_parameters()
47 | {
48 | $this->expectException(Exception::class);
49 |
50 | $this->searchParser->values = ['invalid'];
51 |
52 | new Between($this->builder, $this->searchParser);
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/tests/Unit/SearchCallbacks/EqualsTest.php:
--------------------------------------------------------------------------------
1 | builder = app(Builder::class);
26 |
27 | $this->searchParser = Mockery::mock(SearchParser::class);
28 | $this->searchParser->type = 'test';
29 | $this->searchParser->column = 'test';
30 | }
31 |
32 | /** @test */
33 | public function produces_query()
34 | {
35 | $this->searchParser->values = ['123'];
36 |
37 | new Equals($this->builder, $this->searchParser);
38 |
39 | $sql = 'select * where "test" in (?)';
40 |
41 | $this->assertEquals($sql, $this->builder->toSql());
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/tests/Unit/SearchCallbacks/GreaterThanOrEqualTest.php:
--------------------------------------------------------------------------------
1 | builder = app(Builder::class);
26 |
27 | $this->searchParser = Mockery::mock(SearchParser::class);
28 | $this->searchParser->type = 'test';
29 | $this->searchParser->column = 'test';
30 | }
31 |
32 | /** @test */
33 | public function produces_query()
34 | {
35 | $this->searchParser->values = ['123'];
36 |
37 | new GreaterThanOrEqual($this->builder, $this->searchParser);
38 |
39 | $sql = 'select * where "test" >= ?';
40 |
41 | $this->assertEquals($sql, $this->builder->toSql());
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/tests/Unit/SearchCallbacks/GreaterThanTest.php:
--------------------------------------------------------------------------------
1 | builder = app(Builder::class);
26 |
27 | $this->searchParser = Mockery::mock(SearchParser::class);
28 | $this->searchParser->type = 'test';
29 | $this->searchParser->column = 'test';
30 | }
31 |
32 | /** @test */
33 | public function produces_query()
34 | {
35 | $this->searchParser->values = ['123'];
36 |
37 | new GreaterThan($this->builder, $this->searchParser);
38 |
39 | $sql = 'select * where "test" > ?';
40 |
41 | $this->assertEquals($sql, $this->builder->toSql());
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/tests/Unit/SearchCallbacks/LessThanOrEqualTest.php:
--------------------------------------------------------------------------------
1 | builder = app(Builder::class);
26 |
27 | $this->searchParser = Mockery::mock(SearchParser::class);
28 | $this->searchParser->type = 'test';
29 | $this->searchParser->column = 'test';
30 | }
31 |
32 | /** @test */
33 | public function produces_query()
34 | {
35 | $this->searchParser->values = ['123'];
36 |
37 | new LessThanOrEqual($this->builder, $this->searchParser);
38 |
39 | $sql = 'select * where "test" <= ?';
40 |
41 | $this->assertEquals($sql, $this->builder->toSql());
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/tests/Unit/SearchCallbacks/LessThanTest.php:
--------------------------------------------------------------------------------
1 | builder = app(Builder::class);
26 |
27 | $this->searchParser = Mockery::mock(SearchParser::class);
28 | $this->searchParser->type = 'test';
29 | $this->searchParser->column = 'test';
30 | }
31 |
32 | /** @test */
33 | public function produces_query()
34 | {
35 | $this->searchParser->values = ['123'];
36 |
37 | new LessThan($this->builder, $this->searchParser);
38 |
39 | $sql = 'select * where "test" < ?';
40 |
41 | $this->assertEquals($sql, $this->builder->toSql());
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/tests/Unit/SearchCallbacks/NotBetweenTest.php:
--------------------------------------------------------------------------------
1 | builder = app(Builder::class);
27 |
28 | $this->searchParser = Mockery::mock(SearchParser::class);
29 | $this->searchParser->type = 'test';
30 | $this->searchParser->column = 'test';
31 | }
32 |
33 | /** @test */
34 | public function produces_query()
35 | {
36 | $this->searchParser->values = ['123', '456'];
37 |
38 | new NotBetween($this->builder, $this->searchParser);
39 |
40 | $sql = 'select * where "test" not between ? and ?';
41 |
42 | $this->assertEquals($sql, $this->builder->toSql());
43 | }
44 |
45 | /** @test */
46 | public function fails_on_one_parameter()
47 | {
48 | $this->expectException(Exception::class);
49 |
50 | $this->searchParser->values = ['1 parameter only'];
51 |
52 | new NotBetween($this->builder, $this->searchParser);
53 | }
54 |
55 | /** @test */
56 | public function fails_on_more_than_two_parameters()
57 | {
58 | $this->expectException(Exception::class);
59 |
60 | $this->searchParser->values = ['1', '2', '3'];
61 |
62 | new NotBetween($this->builder, $this->searchParser);
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/tests/Unit/SearchCallbacks/NotEqualsTest.php:
--------------------------------------------------------------------------------
1 | builder = app(Builder::class);
26 |
27 | $this->searchParser = Mockery::mock(SearchParser::class);
28 | $this->searchParser->type = 'test';
29 | $this->searchParser->column = 'test';
30 | }
31 |
32 | /** @test */
33 | public function produces_query()
34 | {
35 | $this->searchParser->values = ['123'];
36 |
37 | new NotEquals($this->builder, $this->searchParser);
38 |
39 | $sql = 'select * where "test" not in (?)';
40 |
41 | $this->assertEquals($sql, $this->builder->toSql());
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/tests/Unit/SearchParserTest.php:
--------------------------------------------------------------------------------
1 | shouldReceive('getForbidden')->andReturn([]);
24 | $modelConfig->shouldReceive('getModelColumns')->andReturn([
25 | 'test' => 'string',
26 | ]);
27 |
28 | $this->searchParser = new SearchParser(
29 | $modelConfig, new OperatorsConfig(), 'test', '=123;456');
30 | }
31 |
32 | /** @test */
33 | public function it_extracts_column()
34 | {
35 | $this->assertEquals('test', $this->searchParser->column);
36 | }
37 |
38 | /** @test */
39 | public function it_extracts_values_from_argument_splitting_by_separator()
40 | {
41 | $this->assertEquals(['123', '456'], $this->searchParser->values);
42 | }
43 |
44 | /** @test */
45 | public function it_extracts_column_types()
46 | {
47 | $this->assertEquals('string', $this->searchParser->type);
48 | }
49 |
50 | /** @test */
51 | public function it_extracts_operator_from_argument()
52 | {
53 | $this->assertEquals('=', $this->searchParser->operator);
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/tests/Unit/Types/BooleanTypeTest.php:
--------------------------------------------------------------------------------
1 | prepare([1, '1', 'true', 'yes', 'on', 0, '0', 'false', 'no', 'off']);
20 |
21 | $this->assertEquals($expected, $actual);
22 | }
23 |
24 | /** @test */
25 | public function throws_on_invalid_input()
26 | {
27 | $this->expectException(Exception::class);
28 |
29 | $type = new BooleanType();
30 |
31 | $type->prepare(['non_boolean_value']);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------