├── .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 | --------------------------------------------------------------------------------