├── .codeclimate.yml ├── .gitignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── composer.json ├── composer.lock ├── config └── magic-box.php ├── phpmd.rulesets.xml ├── phpunit.xml ├── src ├── .gitkeep ├── Contracts │ ├── FilterInterface.php │ ├── MagicBoxResource.php │ ├── ModelResolver.php │ └── Repository.php ├── EloquentRepository.php ├── Exception │ └── ModelNotResolvedException.php ├── Filter.php ├── Middleware │ └── RepositoryMiddleware.php ├── Providers │ └── RepositoryServiceProvider.php └── Utility │ ├── ChecksRelations.php │ ├── ExplicitModelResolver.php │ └── RouteGuessingModelResolver.php └── tests ├── .gitkeep ├── DBTestCase.php ├── EloquentRepositoryTest.php ├── ExplicitModelResolverTest.php ├── FilterTest.php ├── Models ├── NotIncludable.php ├── Post.php ├── Profile.php ├── Tag.php └── User.php ├── TestCase.php ├── migrations ├── 2015_01_01_000001_create_users_table.php ├── 2015_01_01_000002_create_posts_table.php ├── 2015_01_01_000003_create_profile_table.php └── 2015_01_01_000004_create_tags_table.php └── seeds └── FilterDataSeeder.php /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | engines: 2 | duplication: 3 | enabled: true 4 | config: 5 | languages: 6 | - ruby 7 | - javascript 8 | - python 9 | - php 10 | fixme: 11 | enabled: true 12 | phpmd: 13 | enabled: true 14 | checks: 15 | Controversial/CamelCaseVariableName: 16 | enabled: false 17 | Controversial/CamelCasePropertyName: 18 | enabled: false 19 | Controversial/CamelCaseMethodName: 20 | enabled: false 21 | Controversial/CamelCaseParameterName: 22 | enabled: false 23 | config: 24 | file_extensions: "php" 25 | rulesets: "unusedcode,codesize,phpmd.rulesets.xml" 26 | ratings: 27 | paths: 28 | - "**.inc" 29 | - "**.js" 30 | - "**.jsx" 31 | - "**.module" 32 | - "**.php" 33 | - "**.py" 34 | - "**.rb" 35 | exclude_paths: 36 | - tests/ 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /components 3 | /coverage 4 | /tests/coverage 5 | composer.phar 6 | /tests/coverage 7 | .DS_Store 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | php: 3 | - '7.0' 4 | - '7.1' 5 | install: 6 | - composer install -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2016 Fuzz Productions LLC 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Laravel Magic Box 2 | ================== 3 | 4 | # ⛔️ DEPRECATED ⛔️ 5 | _This project is no longer supported or maintained. If you need a modern version of Magic Box that is compatible with newer versions of Laravel please consider using the spiritual successor to this project — [Koala Pouch](https://github.com/koala-labs/pouch)._ 6 | 7 | --- 8 | 9 | Magic Box modularizes Fuzz's magical implementation of Laravel's Eloquent models as injectable, masked resource repositories. 10 | 11 | ##### Magic Box has two goals: 12 | 1. To create a two-way interchange format, so that the JSON representations of models broadcast by APIs can be re-applied back to their originating models for updating existing resources and creating new resources. 13 | 2. Provide an interface for API clients to request exactly the data they want in the way they want. 14 | 15 | ## Installation/Setup 16 | 1. `composer require fuzz/magic-box` 17 | 1. Use or extend `Fuzz\MagicBox\Middleware\RepositoryMiddleware` into your project and register your class under the `$routeMiddleware` array in `app/Http/Kernel.php`. `RepositoryMiddleware` contains a variety of configuration options that can be overridden 18 | 1. If you're using `fuzz/api-server`, you can use magical routing by updating `app/Providers/RouteServiceProvider.php`, `RouteServiceProvider@map`, to include: 19 | 20 | ```php 21 | /** 22 | * Define the routes for the application. 23 | * 24 | * @param \Illuminate\Routing\Router $router 25 | * @return void 26 | */ 27 | public function map(Router $router) 28 | { 29 | // Register a handy macro for registering resource routes 30 | $router->macro('restful', function ($model_name, $resource_controller = 'ResourceController') use ($router) { 31 | $alias = Str::lower(Str::snake(Str::plural(class_basename($model_name)), '-')); 32 | 33 | $router->resource($alias, $resource_controller, [ 34 | 'only' => [ 35 | 'index', 36 | 'store', 37 | 'show', 38 | 'update', 39 | 'destroy', 40 | ], 41 | ]); 42 | }); 43 | 44 | $router->group(['namespace' => $this->namespace], function ($router) { 45 | require app_path('Http/routes.php'); 46 | }); 47 | } 48 | ``` 49 | 1. Set up your MagicBox resource routes under the middleware key you assign to your chosen `RepositoryMiddleware` class 50 | 1. Set up a `YourAppNamespace\Http\Controllers\ResourceController`, [here is what a ResourceController might look like](https://gist.github.com/SimantovYousoufov/dea19adb1dfd8f05c1fcad9db976c247) . 51 | 1. Set up models according to `Model Setup` section 52 | 53 | ## Testing 54 | Just run `phpunit` after you `composer install`. 55 | 56 | ## Eloquent Repository 57 | `Fuzz\MagicBox\EloquentRepository` implements a CRUD repository that cascades through relationships, 58 | whether or not related models have been created yet. 59 | 60 | Consider a simple model where a User has many Posts. EloquentRepository's basic usage is as follows: 61 | 62 | Create a User with the username Steve who has a single Post with the title Stuff. 63 | 64 | ```php 65 | $repository = (new EloquentRepository) 66 | ->setModelClass('User') 67 | ->setInput([ 68 | 'username' => 'steve', 69 | 'nonsense' => 'tomfoolery', 70 | 'posts' => [ 71 | 'title' => 'Stuff', 72 | ], 73 | ]); 74 | 75 | $user = $repository->save(); 76 | ``` 77 | 78 | When `$repository->save()` is invoked, a User will be created with the username "Steve", and a Post will 79 | be created with the `user_id` belonging to that User. The nonsensical "nonsense" property is simply 80 | ignored, because it does not actually exist on the table storing Users. 81 | 82 | By itself, EloquentRepository is a blunt weapon with no access controls that should be avoided in any 83 | public APIs. It will clobber every relationship it touches without prejudice. For example, the following 84 | is a BAD way to add a new Post for the user we just created. 85 | 86 | ```php 87 | $repository 88 | ->setInput([ 89 | 'id' => $user->id, 90 | 'posts' => [ 91 | ['title' => 'More Stuff'], 92 | ], 93 | ]) 94 | ->save(); 95 | ``` 96 | 97 | This will delete poor Steve's first post—not the intended effect. The safe(r) way to append a Post 98 | would be either of the following: 99 | 100 | ```php 101 | $repository 102 | ->setInput([ 103 | 'id' => $user->id, 104 | 'posts' => [ 105 | ['id' => $user->posts->first()->id], 106 | ['title' => 'More Stuff'], 107 | ], 108 | ]) 109 | ->save(); 110 | ``` 111 | 112 | ```php 113 | $post = $repository 114 | ->setModelClass('Post') 115 | ->setInput([ 116 | 'title' => 'More Stuff', 117 | 'user' => [ 118 | 'id' => $user->id, 119 | ], 120 | ]) 121 | ->save(); 122 | ``` 123 | 124 | Generally speaking, the latter is preferred and is less likely to explode in your face. 125 | 126 | The public API methods that return models from a repository are: 127 | 128 | 1. `create` 129 | 1. `read` 130 | 1. `update` 131 | 1. `delete` 132 | 1. `save`, which will either call `create` or `update` depending on the state of its input 133 | 1. `find`, which will find a model by ID 134 | 1. `findOrFail`, which will find a model by ID or throw `\Illuminate\Database\Eloquent\ModelNotFoundException` 135 | 136 | The public API methods that return an `\Illuminate\Database\Eloquent\Collection` are: 137 | 138 | 1. `all` 139 | 140 | ## Filtering 141 | `Fuzz\MagicBox\Filter` handles Eloquent Query Builder modifications based on filter values passed through the `filters` 142 | parameter. 143 | 144 | Tokens and usage: 145 | 146 | | Token | Description | Example | 147 | |:----------:|:-------------------------------:|:----------------------------------------------:| 148 | | `^` | Field starts with | `https://api.yourdomain.com/1.0/users?filters[name]=^John` | 149 | | `$` | Field ends with | `https://api.yourdomain.com/1.0/users?filters[name]=$Smith` | 150 | | `~` | Field contains | `https://api.yourdomain.com/1.0/users?filters[favorite_cheese]=~cheddar` | 151 | | `<` | Field is less than | `https://api.yourdomain.com/1.0/users?filters[lifetime_value]=<50` | 152 | | `>` | Field is greater than | `https://api.yourdomain.com/1.0/users?filters[lifetime_value]=>50` | 153 | | `>=` | Field is greater than or equals | `https://api.yourdomain.com/1.0/users?filters[lifetime_value]=>=50` | 154 | | `<=` | Field is less than or equals | `https://api.yourdomain.com/1.0/users?filters[lifetime_value]=<=50` | 155 | | `=` | Field is equal to | `https://api.yourdomain.com/1.0/users?filters[username]==Specific%20Username` | 156 | | `!=` | Field is not equal to | `https://api.yourdomain.com/1.0/users?filters[username]=!=common%20username` | 157 | | `[...]` | Field is one or more of | `https://api.yourdomain.com/1.0/users?filters[id]=[1,5,10]` | 158 | | `![...]` | Field is not one of | `https://api.yourdomain.com/1.0/users?filters[id]=![1,5,10]` | 159 | | `NULL` | Field is null | `https://api.yourdomain.com/1.0/users?filters[address]=NULL` | 160 | | `NOT_NULL` | Field is not null | `https://api.yourdomain.com/1.0/users?filters[email]=NOT_NULL` | 161 | 162 | ### Filtering relations 163 | Assuming we have users and their related tables resembling the following structure: 164 | 165 | ```php 166 | [ 167 | 'username' => 'Bobby', 168 | 'profile' => [ 169 | 'hobbies' => [ 170 | ['name' => 'Hockey'], 171 | ['name' => 'Programming'], 172 | ['name' => 'Cooking'] 173 | ] 174 | ] 175 | ] 176 | ``` 177 | 178 | We can filter by users' hobbies with `users?filters[profile.hobbies.name]=^Cook`. Relationships can be of arbitrary 179 | depth. 180 | 181 | ### Filter conjuctions 182 | We can use `AND` and `OR` statements to build filters such as `users?filters[username]==Bobby&filters[or][username]==Johnny&filters[and][profile.favorite_cheese]==Gouda`. The PHP array that's built from this filter is: 183 | 184 | ```php 185 | [ 186 | 'username' => '=Bobby', 187 | 'or' => [ 188 | 'username' => '=Johnny', 189 | 'and' => [ 190 | 'profile.favorite_cheese' => '=Gouda', 191 | ] 192 | ] 193 | ] 194 | ``` 195 | 196 | and this filter can be read as `select (users with username Bobby) OR (users with username Johnny who's profile.favorite_cheese attribute is Gouda)`. 197 | 198 | ## Model Setup 199 | Models need to implement `Fuzz\MagicBox\Contracts\MagicBoxResource` before MagicBox will allow them to be exposed as a MagicBox resource. This is done so exposure is an explicit process and no more is exposed than is needed. 200 | 201 | Models also need to define their own `$fillable` array including attributes and relations that can be filled through this model. For example, if a User has many posts and has many comments but an API consumer should only be able to update comments through a user, the `$fillable` array would look like: 202 | 203 | ``` 204 | protected $fillable = ['username', 'password', 'name', 'comments']; 205 | ``` 206 | 207 | MagicBox will only modify attributes/relations that are explicitly defined. 208 | 209 | ## Resolving models 210 | Magic Box is great and all, but we don't want to resolve model classes ourselves before we can instantiate a repository... 211 | 212 | If you've configured a RESTful URI structure with pluralized resources (i.e. `https://api.mydowmain.com/1.0/users` maps to the User model), you can use `Fuzz\MagicBox\Utility\Modeler` to resolve a model class name from a route name. 213 | 214 | ## Testing 215 | `phpunit` :) 216 | 217 | ### TODO 218 | 1. Route service provider should be pre-setup 219 | 1. Support more relationships (esp. polymorphic relations) through cascading saves. 220 | 1. Support paginating nested relations 221 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fuzz/magic-box", 3 | "description": "A magical implementation of Laravel's Eloquent models as injectable, masked resource repositories.", 4 | "license": "MIT", 5 | "homepage": "https://fuzzproductions.com/", 6 | "require": { 7 | "php": "^7.0.0", 8 | "illuminate/database": "^5.0" 9 | }, 10 | "require-dev": { 11 | "phpunit/phpunit": "~5.6", 12 | "orchestra/testbench": "3.3.*", 13 | "fuzz/http-exception": "1.0.*", 14 | "mockery/mockery": "0.9.*" 15 | }, 16 | "authors": [ 17 | { 18 | "name": "Fuzz Productions", 19 | "email": "fuzzweb@fuzzproductions.com" 20 | } 21 | ], 22 | "autoload": { 23 | "psr-4": { 24 | "Fuzz\\MagicBox\\": "src/" 25 | } 26 | }, 27 | "autoload-dev": { 28 | "psr-4": { 29 | "Fuzz\\MagicBox\\Tests\\": "tests/", 30 | "Fuzz\\MagicBox\\Tests\\Seeds\\": "tests/seeds" 31 | } 32 | }, 33 | "scripts": { 34 | "test": [ 35 | "vendor/bin/phpunit" 36 | ], 37 | "test-coverage": [ 38 | "vendor/bin/phpunit --coverage-html tests/coverage" 39 | ], 40 | "open-coverage": [ 41 | "open -a \"Google Chrome\" tests/coverage/index.html" 42 | ] 43 | }, 44 | "minimum-stability": "stable" 45 | } 46 | -------------------------------------------------------------------------------- /config/magic-box.php: -------------------------------------------------------------------------------- 1 | 1 21 | ]; -------------------------------------------------------------------------------- /phpmd.rulesets.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | Custom rules for checking my project 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 25 | 3 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | ./tests/ 16 | 17 | 18 | 19 | 20 | src 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-archive/magic-box/7d9a6e99a904de519f8d8b0d9ec40fb184b26bfe/src/.gitkeep -------------------------------------------------------------------------------- /src/Contracts/FilterInterface.php: -------------------------------------------------------------------------------- 1 | model_class = $model_class; 145 | 146 | /** @var \Illuminate\Database\Eloquent\Model|\Fuzz\MagicBox\Contracts\MagicBoxResource $instance */ 147 | $instance = new $model_class; 148 | 149 | // @todo use set methods 150 | $this->setFillable($instance->getRepositoryFillable()); 151 | $this->setIncludable($instance->getRepositoryIncludable()); 152 | $this->setFilterable($instance->getRepositoryFilterable()); 153 | 154 | $this->key_name = $instance->getKeyName(); 155 | 156 | return $this; 157 | } 158 | 159 | /** 160 | * Get the PK name 161 | * 162 | * @return string 163 | */ 164 | public function getKeyName(): string 165 | { 166 | return $this->key_name; 167 | } 168 | 169 | /** 170 | * Determine if the model exists 171 | * 172 | * @return bool 173 | */ 174 | public function exists(): bool 175 | { 176 | return array_key_exists($this->getKeyName(), $this->getInput()); 177 | } 178 | 179 | /** 180 | * Get the model class. 181 | * 182 | * @return string 183 | */ 184 | public function getModelClass(): string 185 | { 186 | return $this->model_class; 187 | } 188 | 189 | /** 190 | * Set input manually. 191 | * 192 | * @param array $input 193 | * @return \Fuzz\MagicBox\Contracts\Repository 194 | */ 195 | public function setInput(array $input): Repository 196 | { 197 | $this->input = $input; 198 | 199 | return $this; 200 | } 201 | 202 | /** 203 | * Get input. 204 | * 205 | * @return array 206 | */ 207 | public function getInput(): array 208 | { 209 | return $this->input; 210 | } 211 | 212 | /** 213 | * Set eager load manually. 214 | * 215 | * @param array $eager_loads 216 | * @return \Fuzz\MagicBox\Contracts\Repository 217 | */ 218 | public function setEagerLoads(array $eager_loads): Repository 219 | { 220 | $this->eager_loads = $eager_loads; 221 | 222 | return $this; 223 | } 224 | 225 | /** 226 | * Get eager loads. 227 | * 228 | * @return array 229 | */ 230 | public function getEagerLoads(): array 231 | { 232 | return $this->eager_loads; 233 | } 234 | 235 | /** 236 | * Get the eager load depth property. 237 | * 238 | * @return int 239 | */ 240 | public function getDepthRestriction(): int 241 | { 242 | return $this->depth_restriction; 243 | } 244 | 245 | /** 246 | * Set the eager load depth property. 247 | * This will limit how deep relationships can be included. 248 | * 249 | * @param int $depth 250 | * 251 | * @return \Fuzz\MagicBox\Contracts\Repository 252 | */ 253 | public function setDepthRestriction($depth): Repository 254 | { 255 | $this->depth_restriction = $depth; 256 | 257 | return $this; 258 | } 259 | 260 | /** 261 | * Set filters manually. 262 | * 263 | * @param array $filters 264 | * @return \Fuzz\MagicBox\Contracts\Repository 265 | */ 266 | public function setFilters(array $filters): Repository 267 | { 268 | $this->filters = $filters; 269 | 270 | return $this; 271 | } 272 | 273 | /** 274 | * Get filters. 275 | * 276 | * @return array 277 | */ 278 | public function getFilters(): array 279 | { 280 | return $this->filters; 281 | } 282 | 283 | /** 284 | * Add filters to already existing filters without overwriting them. 285 | * 286 | * @param array $filters 287 | * @return \Fuzz\MagicBox\Contracts\Repository 288 | */ 289 | public function addFilters(array $filters): Repository 290 | { 291 | foreach ($filters as $key => $value) { 292 | $this->addFilter($key, $value); 293 | } 294 | 295 | return $this; 296 | } 297 | 298 | /** 299 | * Add a single filter to already existing filters without overwriting them. 300 | * 301 | * @param string $key 302 | * @param string $value 303 | * @return \Fuzz\MagicBox\Contracts\Repository 304 | */ 305 | public function addFilter(string $key, string $value): Repository 306 | { 307 | $this->filters[$key] = $value; 308 | 309 | return $this; 310 | } 311 | 312 | 313 | /** 314 | * Get group by. 315 | * 316 | * @return array 317 | */ 318 | public function getGroupBy(): array 319 | { 320 | return $this->group_by; 321 | } 322 | 323 | /** 324 | * Set group by manually. 325 | * 326 | * @param array $group_by 327 | * 328 | * @return \Fuzz\MagicBox\Contracts\Repository 329 | */ 330 | public function setGroupBy(array $group_by): Repository 331 | { 332 | $this->group_by = $group_by; 333 | 334 | return $this; 335 | } 336 | 337 | /** 338 | * @return array 339 | */ 340 | public function getAggregate(): array 341 | { 342 | return $this->aggregate; 343 | } 344 | 345 | /** 346 | * Set aggregate functions. 347 | * 348 | * @param array $aggregate 349 | * 350 | * @return \Fuzz\MagicBox\Contracts\Repository 351 | */ 352 | public function setAggregate(array $aggregate): Repository 353 | { 354 | $this->aggregate = $aggregate; 355 | 356 | return $this; 357 | } 358 | 359 | /** 360 | * Set sort order manually. 361 | * 362 | * @param array $sort_order 363 | * @return \Fuzz\MagicBox\Contracts\Repository 364 | */ 365 | public function setSortOrder(array $sort_order): Repository 366 | { 367 | $this->sort_order = $sort_order; 368 | 369 | return $this; 370 | } 371 | 372 | /** 373 | * Get sort order. 374 | * 375 | * @return array 376 | */ 377 | public function getSortOrder(): array 378 | { 379 | return $this->sort_order; 380 | } 381 | 382 | /** 383 | * Add a single modifier 384 | * 385 | * @param \Closure $modifier 386 | * 387 | * @return \Fuzz\MagicBox\Contracts\Repository 388 | */ 389 | public function addModifier(Closure $modifier): Repository 390 | { 391 | $this->modifiers[] = $modifier; 392 | 393 | return $this; 394 | } 395 | 396 | /** 397 | * Set modifiers. 398 | * 399 | * @param array $modifiers 400 | * @return \Fuzz\MagicBox\Contracts\Repository 401 | */ 402 | public function setModifiers(array $modifiers): Repository 403 | { 404 | $this->modifiers = $modifiers; 405 | 406 | return $this; 407 | } 408 | 409 | /** 410 | * Get modifiers. 411 | * 412 | * @return array 413 | */ 414 | public function getModifiers(): array 415 | { 416 | return $this->modifiers; 417 | } 418 | 419 | /** 420 | * Set the fillable array 421 | * 422 | * @param array $fillable 423 | * 424 | * @return \Fuzz\MagicBox\Contracts\Repository 425 | */ 426 | public function setFillable(array $fillable): Repository 427 | { 428 | if ($fillable === self::ALLOW_ALL) { 429 | $this->fillable = self::ALLOW_ALL; 430 | 431 | return $this; 432 | } 433 | 434 | // Reset fillable 435 | $this->fillable = []; 436 | 437 | foreach ($fillable as $allowed_field) { 438 | $this->fillable[$allowed_field] = true; 439 | } 440 | 441 | return $this; 442 | } 443 | 444 | /** 445 | * Get the fillable attributes 446 | * 447 | * @param bool $assoc 448 | * 449 | * @return array 450 | */ 451 | public function getFillable(bool $assoc = false): array 452 | { 453 | if ($this->fillable === self::ALLOW_ALL) { 454 | return self::ALLOW_ALL; 455 | } 456 | 457 | return $assoc ? $this->fillable : array_keys($this->fillable); 458 | } 459 | 460 | /** 461 | * Add a fillable attribute 462 | * 463 | * @param string $fillable 464 | * 465 | * @return \Fuzz\MagicBox\Contracts\Repository 466 | */ 467 | public function addFillable(string $fillable): Repository 468 | { 469 | $this->fillable[$fillable] = true; 470 | 471 | return $this; 472 | } 473 | 474 | /** 475 | * Add many fillable fields 476 | * 477 | * @param array $fillable 478 | * 479 | * @return \Fuzz\MagicBox\Contracts\Repository 480 | */ 481 | public function addManyFillable(array $fillable): Repository 482 | { 483 | foreach ($fillable as $allowed_field) { 484 | $this->addFillable($allowed_field); 485 | } 486 | 487 | return $this; 488 | } 489 | 490 | /** 491 | * Remove a fillable attribute 492 | * 493 | * @param string $fillable 494 | * 495 | * @return \Fuzz\MagicBox\Contracts\Repository 496 | */ 497 | public function removeFillable(string $fillable): Repository 498 | { 499 | unset($this->fillable[$fillable]); 500 | 501 | return $this; 502 | } 503 | 504 | /** 505 | * Remove many fillable fields 506 | * 507 | * @param array $fillable 508 | * 509 | * @return \Fuzz\MagicBox\Contracts\Repository 510 | */ 511 | public function removeManyFillable(array $fillable): Repository 512 | { 513 | foreach ($fillable as $disallowed_field) { 514 | $this->removeFillable($disallowed_field); 515 | } 516 | 517 | return $this; 518 | } 519 | 520 | /** 521 | * Determine whether a given key is fillable 522 | * 523 | * @param string $key 524 | * 525 | * @return bool 526 | */ 527 | public function isFillable(string $key): bool 528 | { 529 | if ($this->fillable === self::ALLOW_ALL) { 530 | return true; 531 | } 532 | 533 | return isset($this->fillable[$key]) && $this->fillable[$key]; 534 | } 535 | 536 | /** 537 | * Set the relationships which can be included by the model 538 | * 539 | * @param array $includable 540 | * 541 | * @return \Fuzz\MagicBox\Contracts\Repository 542 | */ 543 | public function setIncludable(array $includable): Repository 544 | { 545 | if ($includable === self::ALLOW_ALL) { 546 | $this->includable = self::ALLOW_ALL; 547 | 548 | return $this; 549 | } 550 | 551 | // Reset includable 552 | $this->includable = []; 553 | 554 | foreach ($includable as $allowed_include) { 555 | $this->includable[$allowed_include] = true; 556 | } 557 | 558 | return $this; 559 | } 560 | 561 | /** 562 | * Get the includable relationships 563 | * 564 | * @param bool $assoc 565 | * 566 | * @return array 567 | */ 568 | public function getIncludable(bool $assoc = false): array 569 | { 570 | if ($this->includable === self::ALLOW_ALL) { 571 | return self::ALLOW_ALL; 572 | } 573 | 574 | return $assoc ? $this->includable : array_keys($this->includable); 575 | } 576 | 577 | /** 578 | * Add an includable relationship 579 | * 580 | * @param string $includable 581 | * 582 | * @return \Fuzz\MagicBox\Contracts\Repository 583 | */ 584 | public function addIncludable(string $includable): Repository 585 | { 586 | $this->includable[$includable] = true; 587 | 588 | return $this; 589 | } 590 | 591 | /** 592 | * Add many includable fields 593 | * 594 | * @param array $includable 595 | * 596 | * @return \Fuzz\MagicBox\Contracts\Repository 597 | */ 598 | public function addManyIncludable(array $includable): Repository 599 | { 600 | foreach ($includable as $allowed_include) { 601 | $this->addIncludable($allowed_include); 602 | } 603 | 604 | return $this; 605 | } 606 | 607 | /** 608 | * Remove an includable relationship 609 | * 610 | * @param string $includable 611 | * 612 | * @return \Fuzz\MagicBox\Contracts\Repository 613 | */ 614 | public function removeIncludable(string $includable): Repository 615 | { 616 | unset($this->includable[$includable]); 617 | 618 | return $this; 619 | } 620 | 621 | /** 622 | * Remove many includable relationships 623 | * 624 | * @param array $includable 625 | * 626 | * @return \Fuzz\MagicBox\Contracts\Repository 627 | */ 628 | public function removeManyIncludable(array $includable): Repository 629 | { 630 | foreach ($includable as $disallowed_include) { 631 | $this->removeIncludable($disallowed_include); 632 | } 633 | 634 | return $this; 635 | } 636 | 637 | /** 638 | * Determine whether a given key is includable 639 | * 640 | * @param string $key 641 | * 642 | * @return bool 643 | */ 644 | public function isIncludable(string $key): bool 645 | { 646 | if ($this->includable === self::ALLOW_ALL) { 647 | return true; 648 | } 649 | 650 | return isset($this->includable[$key]) && $this->includable[$key]; 651 | } 652 | 653 | /** 654 | * Set the fields which can be filtered on the model 655 | * 656 | * @param array $filterable 657 | * 658 | * @return \Fuzz\MagicBox\Contracts\Repository 659 | */ 660 | public function setFilterable(array $filterable): Repository 661 | { 662 | if ($filterable === self::ALLOW_ALL) { 663 | $this->filterable = self::ALLOW_ALL; 664 | 665 | return $this; 666 | } 667 | 668 | // Reset filterable 669 | $this->filterable = []; 670 | 671 | foreach ($filterable as $allowed_field) { 672 | $this->filterable[$allowed_field] = true; 673 | } 674 | 675 | return $this; 676 | } 677 | 678 | /** 679 | * Get the filterable fields 680 | * 681 | * @param bool $assoc 682 | * 683 | * @return array 684 | */ 685 | public function getFilterable(bool $assoc = false): array 686 | { 687 | if ($this->filterable === self::ALLOW_ALL) { 688 | return self::ALLOW_ALL; 689 | } 690 | 691 | return $assoc ? $this->filterable : array_keys($this->filterable); 692 | } 693 | 694 | /** 695 | * Add a filterable field 696 | * 697 | * @param string $filterable 698 | * 699 | * @return \Fuzz\MagicBox\Contracts\Repository 700 | */ 701 | public function addFilterable(string $filterable): Repository 702 | { 703 | $this->filterable[$filterable] = true; 704 | 705 | return $this; 706 | } 707 | 708 | /** 709 | * Add many filterable fields 710 | * 711 | * @param array $filterable 712 | * 713 | * @return \Fuzz\MagicBox\Contracts\Repository 714 | */ 715 | public function addManyFilterable(array $filterable): Repository 716 | { 717 | foreach ($filterable as $allowed_field) { 718 | $this->addFilterable($allowed_field); 719 | } 720 | 721 | return $this; 722 | } 723 | 724 | /** 725 | * Remove a filterable field 726 | * 727 | * @param string $filterable 728 | * 729 | * @return \Fuzz\MagicBox\Contracts\Repository 730 | */ 731 | public function removeFilterable(string $filterable): Repository 732 | { 733 | unset($this->filterable[$filterable]); 734 | 735 | return $this; 736 | } 737 | 738 | /** 739 | * Remove many filterable fields 740 | * 741 | * @param array $filterable 742 | * 743 | * @return \Fuzz\MagicBox\Contracts\Repository 744 | */ 745 | public function removeManyFilterable(array $filterable): Repository 746 | { 747 | foreach ($filterable as $disallowed_field) { 748 | $this->removeFilterable($disallowed_field); 749 | } 750 | 751 | return $this; 752 | } 753 | 754 | /** 755 | * Determine whether a given key is filterable 756 | * 757 | * @param string $key 758 | * 759 | * @return bool 760 | */ 761 | public function isFilterable(string $key): bool 762 | { 763 | if ($this->filterable === self::ALLOW_ALL) { 764 | return true; 765 | } 766 | 767 | return isset($this->filterable[$key]) && $this->filterable[$key]; 768 | } 769 | 770 | /** 771 | * Return a model's fields. 772 | * 773 | * @param \Illuminate\Database\Eloquent\Model $instance 774 | * @return array 775 | */ 776 | public static function getFields(Model $instance): array 777 | { 778 | return Schema::getColumnListing($instance->getTable()); 779 | } 780 | 781 | /** 782 | * Base query for all behaviors within this repository. 783 | * 784 | * @return \Illuminate\Database\Eloquent\Builder 785 | */ 786 | protected function query() 787 | { 788 | $query = forward_static_call( 789 | [ 790 | $this->getModelClass(), 791 | 'query', 792 | ] 793 | ); 794 | 795 | $this->modifyQuery($query); 796 | 797 | $eager_loads = $this->getEagerLoads(); 798 | 799 | if ( !empty($eager_loads)) { 800 | $this->safeWith($query, $eager_loads); 801 | } 802 | 803 | if ( !empty($modifiers = $this->getModifiers())) { 804 | foreach ($modifiers as $modifier) { 805 | $modifier($query); 806 | } 807 | } 808 | 809 | return $query; 810 | } 811 | 812 | /** 813 | * Process filter and sort modifications on $query 814 | * 815 | * @param \Illuminate\Database\Eloquent\Builder $query 816 | * @return void 817 | */ 818 | protected function modifyQuery($query) 819 | { 820 | // Only include filters which have been whitelisted in $this->filterable 821 | $filters = $this->getFilterable() === self::ALLOW_ALL ? 822 | $this->getFilters() : 823 | Filter::intersectAllowedFilters($this->getFilters(), $this->getFilterable(true)); 824 | $sort_order_options = $this->getSortOrder(); 825 | $group_by = $this->getGroupBy(); 826 | $aggregate = $this->getAggregate(); 827 | 828 | // Check if filters or sorts are requested 829 | $filters_exist = !empty($filters); 830 | $sorts_exist = !empty($sort_order_options); 831 | $group_exist = !empty($group_by); 832 | $aggregate_exist = !empty($aggregate); 833 | 834 | // No modifications to apply 835 | if ( !$filters_exist && !$sorts_exist && !$group_exist && !$aggregate_exist) { 836 | return; 837 | } 838 | 839 | // Make a mock instance so we can describe its columns 840 | $model_class = $this->getModelClass(); 841 | $temp_instance = new $model_class; 842 | $columns = $this->getFields($temp_instance); 843 | 844 | if ($filters_exist) { 845 | // Apply depth restrictions to each filter 846 | foreach ($filters as $filter => $value) { 847 | // Filters deeper than the depth restriction + 1 are not allowed 848 | // Depth restriction is offset by 1 because filters terminate with a column 849 | // i.e. 'users.posts.title' => '=Great Post' but the depth we expect is 'users.posts' 850 | if (count(explode(self::GLUE, $filter)) > ($this->getDepthRestriction() + 1)) { 851 | // Unset the disallowed filter 852 | unset($filters[$filter]); 853 | } 854 | } 855 | 856 | Filter::applyQueryFilters($query, $filters, $columns, $temp_instance->getTable()); 857 | } 858 | 859 | // Modify the query with a group by condition. 860 | if ($group_exist) { 861 | $group = explode(',', reset($group_by)); 862 | $group = array_map('trim', $group); 863 | $valid_group = array_intersect($group, $columns); 864 | 865 | $query->groupBy($valid_group); 866 | } 867 | 868 | // Run an aggregate function. We will only run one, no matter how many were submitted. 869 | if ($aggregate_exist) { 870 | $allowed_aggregations = [ 871 | 'count', 872 | 'min', 873 | 'max', 874 | 'sum', 875 | 'avg', 876 | ]; 877 | $allowed_columns = $columns; 878 | $column = reset($aggregate); 879 | $function = strtolower(key($aggregate)); 880 | 881 | if (in_array($function, $allowed_aggregations, true) && in_array($column, $allowed_columns, true)) { 882 | $query->addSelect(DB::raw($function . '(' . $column . ') as aggregate')); 883 | 884 | if ($group_exist) { 885 | $query->addSelect($valid_group); 886 | } 887 | } 888 | } 889 | 890 | if ($sorts_exist) { 891 | $this->sortQuery($query, $sort_order_options, $temp_instance, $columns); 892 | } 893 | 894 | unset($temp_instance); 895 | } 896 | 897 | /** 898 | * Apply a sort to a database query 899 | * 900 | * @param \Illuminate\Database\Eloquent\Builder $query 901 | * @param array $sort_order_options 902 | * @param \Illuminate\Database\Eloquent\Model $temp_instance 903 | * @param array $columns 904 | */ 905 | protected function sortQuery(Builder $query, array $sort_order_options, Model $temp_instance, array $columns) 906 | { 907 | $allowed_directions = [ 908 | 'ASC', 909 | 'DESC', 910 | ]; 911 | 912 | foreach ($sort_order_options as $order_by => $direction) { 913 | if (in_array(strtoupper($direction), $allowed_directions)) { 914 | $split = explode(self::GLUE, $order_by); 915 | 916 | // Sorts deeper than the depth restriction + 1 are not allowed 917 | // Depth restriction is offset by 1 because sorts terminate with a column 918 | // i.e. 'users.posts.title' => 'asc' but the depth we expect is 'users.posts' 919 | if (count($split) > ($this->getDepthRestriction() + 1)) { 920 | // Unset the disallowed sort 921 | unset($sort_order_options[$order_by]); 922 | continue; 923 | } 924 | 925 | if (in_array($order_by, $columns)) { 926 | $query->orderBy($order_by, $direction); 927 | } else { 928 | // Pull out orderBy field 929 | $field = array_pop($split); 930 | 931 | // Select only the base table fields, don't select relation data. Desired relation data 932 | // should be explicitly included 933 | $base_table = $temp_instance->getTable(); 934 | $query->selectRaw("$base_table.*"); 935 | 936 | $this->applyNestedJoins($query, $split, $temp_instance, $field, $direction); 937 | } 938 | } 939 | } 940 | } 941 | 942 | /** 943 | * Apply a depth restriction to an exploded dot-nested string (eager load, filter, etc) 944 | * 945 | * @param array $array 946 | * @return array 947 | */ 948 | protected function applyDepthRestriction(array $array, $offset = 0) 949 | { 950 | return array_slice($array, 0, $this->getDepthRestriction() + $offset); 951 | } 952 | 953 | /** 954 | * "Safe" version of with eager-loading. 955 | * 956 | * Checks if relations exist before loading them. 957 | * 958 | * @param \Illuminate\Database\Eloquent\Builder $query 959 | * @param string|array $relations 960 | */ 961 | protected function safeWith(Builder $query, $relations) 962 | { 963 | if (is_string($relations)) { 964 | $relations = func_get_args(); 965 | array_shift($relations); 966 | } 967 | 968 | // Loop through all relations to check for valid relationship signatures 969 | foreach ($relations as $name => $constraints) { 970 | // Constraints may be passed in either form: 971 | // 2 => 'relation.nested' 972 | // or 973 | // 'relation.nested' => function() { ... } 974 | $constraints_are_name = is_numeric($name); 975 | $relation_name = $constraints_are_name ? $constraints : $name; 976 | 977 | // If this relation is not includable, skip 978 | // We expect to see foo.nested.relation in includable if the 3 level nested relationship is includable 979 | if (! $this->isIncludable($relation_name)) { 980 | unset($relations[$name]); 981 | continue; 982 | } 983 | 984 | // Expand the dot-notation to see all relations 985 | $nested_relations = explode(self::GLUE, $relation_name); 986 | $model = $query->getModel(); 987 | 988 | // Don't allow eager loads beyond the eager load depth 989 | $nested_relations = $this->applyDepthRestriction($nested_relations); 990 | 991 | // We want to apply the depth restricted relations to the original relations array 992 | $cleaned_relation = join(self::GLUE, $nested_relations); 993 | if ($cleaned_relation === '') { 994 | unset($relations[$name]); 995 | } elseif ($constraints_are_name) { 996 | $relations[$name] = $cleaned_relation; 997 | } else { 998 | $relations[$cleaned_relation] = $constraints; 999 | unset($relations[$name]); 1000 | } 1001 | 1002 | foreach ($nested_relations as $index => $relation) { 1003 | 1004 | if ($this->isRelation($model, $relation, get_class($model))) { 1005 | // Iterate through relations if they actually exist 1006 | $model = $model->$relation()->getRelated(); 1007 | } elseif ($index > 0) { 1008 | // If we found any valid relations, pass them through 1009 | $safe_relation = implode(self::GLUE, array_slice($nested_relations, 0, $index)); 1010 | if ($constraints_are_name) { 1011 | $relations[$name] = $safe_relation; 1012 | } else { 1013 | unset($relations[$name]); 1014 | $relations[$safe_relation] = $constraints; 1015 | } 1016 | } else { 1017 | // If we didn't, remove this relation specification 1018 | unset($relations[$name]); 1019 | break; 1020 | } 1021 | } 1022 | } 1023 | 1024 | $query->with($relations); 1025 | } 1026 | 1027 | /** 1028 | * Apply nested joins to allow nested sorting for select relationship combinations 1029 | * 1030 | * @param \Illuminate\Database\Eloquent\Builder $query 1031 | * @param array $relations 1032 | * @param \Illuminate\Database\Eloquent\Model $instance 1033 | * @param $field 1034 | * @param string $direction 1035 | * @return void 1036 | */ 1037 | public function applyNestedJoins(Builder $query, array $relations, Model $instance, $field, $direction = 'asc') 1038 | { 1039 | $base_table = $instance->getTable(); 1040 | 1041 | // The current working relation 1042 | $relation = $relations[0]; 1043 | 1044 | // Current working table 1045 | $table = Str::plural($relation); 1046 | $singular = Str::singular($relation); 1047 | $class = get_class($instance); 1048 | 1049 | // If the relation exists, determine which type (singular, multiple) 1050 | if ($this->isRelation($instance, $singular, $class)) { 1051 | $related = $instance->$singular(); 1052 | } elseif ($this->isRelation($instance, $relation, $class)) { 1053 | $related = $instance->$relation(); 1054 | } else { 1055 | // This relation does not exist 1056 | return; 1057 | } 1058 | 1059 | $foreign_key = $related->getForeignKey(); 1060 | 1061 | // Join tables differently depending on relationship type 1062 | switch (get_class($related)) { 1063 | case BelongsToMany::class: 1064 | /** 1065 | * @var \Illuminate\Database\Eloquent\Relations\BelongsToMany $related 1066 | */ 1067 | $base_table_key = $instance->getKeyName(); 1068 | $relation_primary_key = $related->getModel()->getKeyName(); 1069 | 1070 | // Join through the pivot table 1071 | $query->join($related->getTable(), "$base_table.$base_table_key", '=', $foreign_key); 1072 | $query->join($table, $related->getOtherKey(), '=', "$relation.$relation_primary_key"); 1073 | break; 1074 | case HasMany::class: 1075 | /** 1076 | * @var \Illuminate\Database\Eloquent\Relations\HasMany $related 1077 | */ 1078 | $base_table_key = $instance->getKeyName(); 1079 | 1080 | // Join child's table 1081 | $query->join($table, "$base_table.$base_table_key", '=', $foreign_key); 1082 | break; 1083 | case BelongsTo::class: 1084 | /** 1085 | * @var \Illuminate\Database\Eloquent\Relations\BelongsTo $related 1086 | */ 1087 | $relation_key = $related->getOtherKey(); 1088 | 1089 | // Join related's table on the base table's foreign key 1090 | $query->join($table, "$base_table.$foreign_key", '=', "$table.$relation_key"); 1091 | break; 1092 | case HasOne::class: 1093 | /** 1094 | * @var \Illuminate\Database\Eloquent\Relations\HasOne $related 1095 | */ 1096 | $parent_key = $instance->getKeyName(); 1097 | 1098 | // Join related's table on the base table's foreign key 1099 | $query->join($table, "$base_table.$parent_key", '=', "$foreign_key"); 1100 | break; 1101 | } 1102 | 1103 | // @todo is it necessary to allow nested relationships further than the first/second degrees? 1104 | array_shift($relations); 1105 | 1106 | if (count($relations) >= 1) { 1107 | $this->applyNestedJoins($query, $relations, $related->getModel(), $field, $direction); 1108 | } else { 1109 | $query->orderBy("$table.$field", $direction); 1110 | } 1111 | } 1112 | 1113 | /** 1114 | * Find an instance of a model by ID. 1115 | * 1116 | * @param int $id 1117 | * @return \Illuminate\Database\Eloquent\Model 1118 | */ 1119 | public function find($id) 1120 | { 1121 | return $this->query()->find($id); 1122 | } 1123 | 1124 | /** 1125 | * Find an instance of a model by ID, or fail. 1126 | * 1127 | * @param int $id 1128 | * @return \Illuminate\Database\Eloquent\Model 1129 | */ 1130 | public function findOrFail($id): Model 1131 | { 1132 | return $this->query()->findOrFail($id); 1133 | } 1134 | 1135 | /** 1136 | * Get all elements against the base query. 1137 | * 1138 | * @return \Illuminate\Database\Eloquent\Collection 1139 | */ 1140 | public function all(): Collection 1141 | { 1142 | return $this->query()->get(); 1143 | } 1144 | 1145 | /** 1146 | * Return paginated response. 1147 | * 1148 | * @param int $per_page 1149 | * @return \Illuminate\Contracts\Pagination\Paginator 1150 | */ 1151 | public function paginate($per_page): Paginator 1152 | { 1153 | return $this->query()->paginate($per_page); 1154 | } 1155 | 1156 | /** 1157 | * Count all elements against the base query. 1158 | * 1159 | * @return int 1160 | */ 1161 | public function count(): int 1162 | { 1163 | return $this->query()->count(); 1164 | } 1165 | 1166 | /** 1167 | * Determine if the base query returns a nonzero count. 1168 | * 1169 | * @return bool 1170 | */ 1171 | public function hasAny(): bool 1172 | { 1173 | return $this->count() > 0; 1174 | } 1175 | 1176 | /** 1177 | * Get a random value. 1178 | * 1179 | * @return \Illuminate\Database\Eloquent\Model 1180 | */ 1181 | public function random(): Model 1182 | { 1183 | return $this->query()->orderByRaw('RAND()')->first(); 1184 | } 1185 | 1186 | /** 1187 | * Get the primary key from input. 1188 | * 1189 | * @return mixed 1190 | */ 1191 | public function getInputId() 1192 | { 1193 | $input = $this->getInput(); 1194 | 1195 | /** @var Model $model */ 1196 | $model = $this->getModelClass(); 1197 | 1198 | // If the model or the input is not set, then we cannot get an id. 1199 | if (! $model || ! $input) { 1200 | return null; 1201 | } 1202 | 1203 | return array_get($input, (new $model)->getKeyName()); 1204 | } 1205 | 1206 | /** 1207 | * Fill an instance of a model with all known fields. 1208 | * 1209 | * @param \Illuminate\Database\Eloquent\Model $instance 1210 | * @return mixed 1211 | * @todo support more relationship types, such as polymorphic ones! 1212 | */ 1213 | protected function fill(Model $instance): bool 1214 | { 1215 | $input = $this->getInput(); 1216 | $model_fields = $this->getFields($instance); 1217 | $before_relations = []; 1218 | $after_relations = []; 1219 | $instance_model = get_class($instance); 1220 | $safe_instance = new $instance_model; 1221 | 1222 | $input = ($safe_instance->getIncrementing()) ? array_except($input, [$instance->getKeyName()]) : $input; 1223 | 1224 | foreach ($input as $key => $value) { 1225 | if (($relation = $this->isRelation($instance, $key, $instance_model)) && $this->isFillable($key)) { 1226 | $relation_type = get_class($relation); 1227 | 1228 | switch ($relation_type) { 1229 | case BelongsTo::class: 1230 | $before_relations[] = [ 1231 | 'relation' => $relation, 1232 | 'value' => $value, 1233 | ]; 1234 | break; 1235 | case HasOne::class: 1236 | case HasMany::class: 1237 | case BelongsToMany::class: 1238 | $after_relations[] = [ 1239 | 'relation' => $relation, 1240 | 'value' => $value, 1241 | ]; 1242 | break; 1243 | } 1244 | } elseif ((in_array($key, $model_fields) || $instance->hasSetMutator($key)) && $this->isFillable($key)) { 1245 | $instance->{$key} = $value; 1246 | } 1247 | } 1248 | 1249 | unset($safe_instance); 1250 | 1251 | $this->applyRelations($before_relations, $instance); 1252 | $instance->save(); 1253 | $this->applyRelations($after_relations, $instance); 1254 | 1255 | return true; 1256 | } 1257 | 1258 | /** 1259 | * Apply relations from an array to an instance model. 1260 | * 1261 | * @param array $specs 1262 | * @param \Illuminate\Database\Eloquent\Model $instance 1263 | * @return void 1264 | */ 1265 | protected function applyRelations(array $specs, Model $instance) 1266 | { 1267 | foreach ($specs as $spec) { 1268 | $this->cascadeRelation($spec['relation'], $spec['value'], $instance); 1269 | } 1270 | } 1271 | 1272 | /** 1273 | * Cascade relations through saves on a model. 1274 | * 1275 | * @param \Illuminate\Database\Eloquent\Relations\Relation $relation 1276 | * @param array $input 1277 | * @param \Illuminate\Database\Eloquent\Model $parent 1278 | * 1279 | * @return void 1280 | */ 1281 | protected function cascadeRelation(Relation $relation, array $input, Model $parent = null) 1282 | { 1283 | // Make a child repository for containing the cascaded relationship through saves 1284 | $target_model_class = get_class($relation->getQuery()->getModel()); 1285 | $relation_repository = (new self)->setModelClass($target_model_class); 1286 | 1287 | switch (get_class($relation)) { 1288 | case BelongsTo::class: 1289 | /** 1290 | * @var \Illuminate\Database\Eloquent\Relations\BelongsTo $relation 1291 | */ 1292 | // For BelongsTo, simply associate by foreign key. 1293 | // (We don't have to assume the parent model exists to do this.) 1294 | $related = $relation_repository->setInput($input)->save(); 1295 | $relation->associate($related); 1296 | break; 1297 | case HasMany::class: 1298 | /** 1299 | * @var \Illuminate\Database\Eloquent\Relations\HasMany $relation 1300 | */ 1301 | // The parent model "owns" child models; any not specified here should be deleted. 1302 | $current_ids = $relation->pluck($this->getKeyName())->toArray(); 1303 | $new_ids = array_filter(array_column($input, $this->getKeyName())); 1304 | $removed_ids = array_diff($current_ids, $new_ids); 1305 | if ( !empty($removed_ids)) { 1306 | $relation->whereIn($this->getKeyName(), $removed_ids)->delete(); 1307 | } 1308 | 1309 | // Set foreign keys on the children from the parent, and save. 1310 | foreach ($input as $sub_input) { 1311 | $sub_input[$this->getRelationsForeignKeyName($relation)] = $parent->{$this->getKeyName()}; 1312 | $relation_repository->setInput($sub_input)->save(); 1313 | } 1314 | break; 1315 | case HasOne::class: 1316 | /** 1317 | * @var \Illuminate\Database\Eloquent\Relations\HasOne $relation 1318 | */ 1319 | // The parent model "owns" the child model; if we have a new and/or different 1320 | // existing child model, delete the old one. 1321 | $current = $relation->getResults(); 1322 | if ( !is_null($current) 1323 | && ( !isset($input[$this->getKeyName()]) || $current->{$this->getKeyName()} !== intval($input[$this->getKeyName()])) 1324 | ) { 1325 | $relation->delete(); 1326 | } 1327 | 1328 | // Set foreign key on the child from the parent, and save. 1329 | $input[$this->getRelationsForeignKeyName($relation)] = $parent->{$this->getKeyName()}; 1330 | $relation_repository->setInput($input)->save(); 1331 | break; 1332 | case BelongsToMany::class: 1333 | /** 1334 | * @var \Illuminate\Database\Eloquent\Relations\BelongsToMany $relation 1335 | */ 1336 | // Find all the IDs to sync. 1337 | $ids = []; 1338 | 1339 | foreach ($input as $sub_input) { 1340 | $id = $relation_repository->setInput($sub_input)->save()->{$this->getKeyName()}; 1341 | 1342 | // If we were passed pivot data, pass it through accordingly. 1343 | if (isset($sub_input['pivot'])) { 1344 | $ids[$id] = (array)$sub_input['pivot']; 1345 | } else { 1346 | $ids[] = $id; 1347 | } 1348 | } 1349 | 1350 | // Sync to save pivot table and optional extra data. 1351 | $relation->sync($ids); 1352 | break; 1353 | } 1354 | } 1355 | 1356 | /** 1357 | * Create a model. 1358 | * 1359 | * @return Model | Collection 1360 | */ 1361 | public function create(): Model 1362 | { 1363 | $model_class = $this->getModelClass(); 1364 | $instance = new $model_class; 1365 | $this->fill($instance); 1366 | 1367 | return $instance; 1368 | } 1369 | 1370 | /** 1371 | * Create many models. 1372 | * 1373 | * @return Collection 1374 | */ 1375 | public function createMany(): Collection 1376 | { 1377 | $collection = new Collection(); 1378 | 1379 | foreach ($this->getInput() as $item) { 1380 | $repository = clone $this; 1381 | $repository->setInput($item); 1382 | $collection->add($repository->create()); 1383 | } 1384 | 1385 | return $collection; 1386 | } 1387 | 1388 | /** 1389 | * Read a model. 1390 | * 1391 | * @param int|string|null $id 1392 | * 1393 | * @return \Illuminate\Database\Eloquent\Model 1394 | */ 1395 | public function read($id = null): Model 1396 | { 1397 | return $this->findOrFail($id ?? $this->getInputId()); 1398 | } 1399 | 1400 | /** 1401 | * Update a model. 1402 | * 1403 | * @param int|string|null $id 1404 | * 1405 | * @return Model|Collection 1406 | */ 1407 | public function update($id = null): Model 1408 | { 1409 | $instance = $this->read($id); 1410 | $this->fill($instance); 1411 | 1412 | return $this->read($instance->getKey()); 1413 | } 1414 | 1415 | /** 1416 | * Updates many models. 1417 | * 1418 | * @return Collection 1419 | */ 1420 | public function updateMany(): Collection 1421 | { 1422 | $collection = new Collection(); 1423 | 1424 | foreach ($this->getInput() as $item) { 1425 | $repository = clone $this; 1426 | $repository->setInput($item); 1427 | $collection->add($repository->update($repository->getInputId())); 1428 | } 1429 | 1430 | return $collection; 1431 | } 1432 | 1433 | /** 1434 | * Delete a model. 1435 | * 1436 | * @param int|string|null $id 1437 | * 1438 | * @return bool 1439 | * 1440 | * @throws \Exception 1441 | */ 1442 | public function delete($id = null): bool 1443 | { 1444 | $instance = $this->read($id); 1445 | 1446 | return $instance->delete(); 1447 | } 1448 | 1449 | /** 1450 | * Save a model, regardless of whether or not it is "new". 1451 | * 1452 | * @param int|string|null $id 1453 | * 1454 | * @return Model|Collection 1455 | */ 1456 | public function save($id = null): Model 1457 | { 1458 | $id = $id ?? $this->getInputId(); 1459 | 1460 | if ($id) { 1461 | return $this->update($id); 1462 | } 1463 | 1464 | return $this->create(); 1465 | } 1466 | 1467 | /** 1468 | * Checks if the input has many items. 1469 | * 1470 | * @return bool 1471 | */ 1472 | public function isManyOperation(): bool 1473 | { 1474 | return ($this->getInput() && array_keys($this->getInput()) === range(0, count($this->getInput()) - 1)); 1475 | } 1476 | 1477 | /** 1478 | * A helper method for backwards compatibility. 1479 | * 1480 | * In laravel 5.4 they renamed the method `getPlainForeignKey` to `getForeignKeyName` 1481 | * 1482 | * @param HasOneOrMany $relation 1483 | * 1484 | * @return string 1485 | */ 1486 | private function getRelationsForeignKeyName(HasOneOrMany $relation): string 1487 | { 1488 | return method_exists($relation, 'getForeignKeyName') ? $relation->getForeignKeyName() : $relation->getPlainForeignKey(); 1489 | } 1490 | } -------------------------------------------------------------------------------- /src/Exception/ModelNotResolvedException.php: -------------------------------------------------------------------------------- 1 | 'startsWith', 16 | '~' => 'contains', 17 | '$' => 'endsWith', 18 | '<' => 'lessThan', 19 | '>' => 'greaterThan', 20 | '>=' => 'greaterThanOrEquals', 21 | '<=' => 'lessThanOrEquals', 22 | '=' => 'equals', 23 | '!=' => 'notEquals', 24 | '![' => 'notIn', 25 | '[' => 'in', 26 | ]; 27 | 28 | /** 29 | * Tokens that accept non-scalar filters. 30 | * ex: [One,Two,Three,Fuzz] 31 | * 32 | * @var array 33 | */ 34 | protected static $non_scalar_tokens = [ 35 | '![', 36 | '[', 37 | ]; 38 | 39 | /** 40 | * Container for base table prefix. Always specify table. 41 | * 42 | * @var null 43 | */ 44 | protected static $table_prefix = null; 45 | 46 | /** 47 | * Clean a set of filters by checking them against an array of allowed filters 48 | * 49 | * This is similar to an array intersect, if a $filter is present in $allowed and set to true, 50 | * then it is an allowed filter. 51 | * 52 | * $filters = [ 53 | * 'foo' => 'bar', 54 | * 'and' => [ 55 | * 'baz' => 'bat' 56 | * 'or' => [ 57 | * 'bag' => 'boo' 58 | * ] 59 | * ], 60 | * 'or' => [ 61 | * 'bar' => 'foo' 62 | * ], 63 | * ]; 64 | * 65 | * $allowed = [ 66 | * 'foo' => true, 67 | * 'baz' => true, 68 | * 'baz' => true, 69 | * 'bar' => true, 70 | * ]; 71 | * 72 | * $result = [ 73 | * 'foo' => 'bar', 74 | * 'and' => [ 75 | * 'baz' => 'bat' 76 | * ], 77 | * 'or' => [ 78 | * 'bar' => 'foo' 79 | * ], 80 | * ]; 81 | * 82 | * @param array $filters 83 | * @param array $allowed 84 | * 85 | * @return array 86 | */ 87 | public static function intersectAllowedFilters(array $filters, array $allowed) 88 | { 89 | foreach ($filters as $filter => $value) { 90 | // We want to recursively go down and check all OR conjuctions to ensure they're all whitlisted 91 | if ($filter === 'or') { 92 | $filters['or'] = self::intersectAllowedFilters($filters['or'], $allowed); 93 | 94 | // If there are no more filters under this OR, we can safely unset it 95 | if (count($filters['or']) === 0) { 96 | unset($filters['or']); 97 | } 98 | continue; 99 | } 100 | 101 | // We want to recursively go down and check all AND conjuctions to ensure they're all whitlisted 102 | if ($filter === 'and') { 103 | $filters['and'] = self::intersectAllowedFilters($filters['and'], $allowed); 104 | 105 | // If there are no more filters under this AND, we can safely unset it 106 | if (count($filters['and']) === 0) { 107 | unset($filters['and']); 108 | } 109 | continue; 110 | } 111 | 112 | // A whitelisted filter looks like 'filter_name' => true in $allowed 113 | if (! isset($allowed[$filter]) || ! $allowed[$filter]) { 114 | unset($filters[$filter]); 115 | } 116 | } 117 | 118 | return $filters; 119 | } 120 | 121 | /** 122 | * Funnel for rest of filter methods 123 | * 124 | * @param \Illuminate\Database\Eloquent\Builder $query 125 | * @param array $filters 126 | * @param array $columns 127 | * @param string $table 128 | */ 129 | public static function applyQueryFilters($query, $filters, $columns, $table) 130 | { 131 | // Wrap in a complex where so we don't break soft delete checks 132 | $query->where( 133 | function ($query) use ($filters, $columns, $table) { 134 | self::filterQuery($query, $filters, $columns, $table); 135 | }); 136 | } 137 | 138 | /** 139 | * Funnel method to filter queries. 140 | * 141 | * First check for a dot nested string in the place of a filter column and use the appropriate method 142 | * and relation combination. 143 | * 144 | * @param \Illuminate\Database\Eloquent\Builder $query 145 | * @param array $filters 146 | * @param array $columns 147 | * @param string $table 148 | */ 149 | public static function filterQuery($query, $filters, $columns, $table) 150 | { 151 | if (! is_null($table)) { 152 | self::$table_prefix = $table; 153 | } 154 | 155 | foreach ($filters as $column => $filter) { 156 | if (strtolower($column) === 'or' || strtolower($column) === 'and') { 157 | $nextConjunction = $column === 'or'; 158 | $method = self::determineMethod('where', $nextConjunction); 159 | 160 | // orWhere should only occur on conjunctions. We want filters in the same nesting level to attach as 161 | // 'AND'. 'OR' should nest. 162 | $query->$method( 163 | function ($query) use ($filters, $columns, $column, $table) { 164 | self::filterQuery($query, $filters[$column], $columns, $table); 165 | }); 166 | continue; 167 | } 168 | 169 | $nested_relations = self::parseRelations($column); 170 | 171 | if (is_array($nested_relations)) { 172 | // Create a dot nested string of relations 173 | $relation = implode('.', array_splice($nested_relations, 0, count($nested_relations) - 1)); 174 | // Set up the column at the end of the dot nested relation 175 | $column = end($nested_relations); 176 | } 177 | 178 | if ($token = self::determineTokenType($filter)) { 179 | // We check to see if the filter string is a valid filter. 180 | $filter = self::cleanAndValidateFilter($token, $filter); 181 | 182 | // If it is not a valid filter we continue to the next 183 | // iteration in the array. 184 | if ($filter === false) { 185 | continue; 186 | } 187 | 188 | $method = self::$supported_tokens[$token]; 189 | 190 | // Querying a dot nested relation 191 | if (is_array($nested_relations)) { 192 | 193 | $query->whereHas( 194 | $relation, function ($query) use ($method, $column, $filter) { 195 | 196 | // Check if the column is a primary key of the model 197 | // within the query. If it is, we should use the 198 | // qualified key instead. It's important when this is a 199 | // many to many relationship query. 200 | if ($column === $query->getModel()->getKeyName()) { 201 | $column = $query->getModel()->getQualifiedKeyName(); 202 | } 203 | 204 | self::$method($column, $filter, $query); 205 | }); 206 | } else { 207 | $column = self::applyTablePrefix($column); 208 | self::$method($column, $filter, $query); 209 | } 210 | } elseif ($filter === 'true' || $filter === 'false') { 211 | // Is a boolean filter, coerce to boolean. 212 | $filter = ($filter === 'true'); 213 | 214 | // Querying a dot nested relation 215 | if (is_array($nested_relations)) { 216 | $query->whereHas( 217 | $relation, function ($query) use ($filter, $column) { 218 | $where = camel_case('where' . $column); 219 | $query->$where($filter); 220 | }); 221 | } else { 222 | $column = self::applyTablePrefix($column); 223 | $where = camel_case('where' . $column); 224 | $query->$where($filter); 225 | } 226 | } elseif ($filter === 'NULL' || $filter === 'NOT_NULL') { 227 | // Querying a dot nested relation 228 | if (is_array($nested_relations)) { 229 | $query->whereHas( 230 | $relation, function ($query) use ($column, $filter) { 231 | self::nullMethod($column, $filter, $query); 232 | }); 233 | } else { 234 | $column = self::applyTablePrefix($column); 235 | self::nullMethod($column, $filter, $query); 236 | } 237 | } else { 238 | // @todo Unsupported type 239 | } 240 | } 241 | } 242 | 243 | /** 244 | * Parse a string of dot nested relations, if applicable 245 | * 246 | * Ex: users?filters[posts.comments.rating]=>4 247 | * 248 | * @param string $filter_name 249 | * 250 | * @return array 251 | */ 252 | protected static function parseRelations($filter_name) 253 | { 254 | // Determine if we're querying a dot nested relationships of arbitrary depth (ex: user.post.tags.label) 255 | $parse_relations = explode('.', $filter_name); 256 | 257 | return count($parse_relations) === 1 ? $parse_relations[0] : $parse_relations; 258 | } 259 | 260 | /** 261 | * Query for items that begin with a string. 262 | * 263 | * Ex: users?filters[name]=^John 264 | * 265 | * @param string $column 266 | * @param string $filter 267 | * @param \Illuminate\Database\Eloquent\Builder $query 268 | * @param bool $or 269 | */ 270 | protected static function startsWith($column, $filter, $query, $or = false) 271 | { 272 | $method = self::determineMethod('where', $or); 273 | $query->$method($column, 'LIKE', $filter . '%'); 274 | } 275 | 276 | /** 277 | * Query for items that end with a string. 278 | * 279 | * Ex: users?filters[name]=$Smith 280 | * 281 | * @param string $column 282 | * @param string $filter 283 | * @param \Illuminate\Database\Eloquent\Builder $query 284 | * @param bool $or 285 | */ 286 | protected static function endsWith($column, $filter, $query, $or = false) 287 | { 288 | $method = self::determineMethod('where', $or); 289 | $query->$method($column, 'LIKE', '%' . $filter); 290 | } 291 | 292 | /** 293 | * Query for items that contain a string. 294 | * 295 | * Ex: users?filters[favorite_cheese]=~cheddar 296 | * 297 | * @param string $column 298 | * @param string $filter 299 | * @param \Illuminate\Database\Eloquent\Builder $query 300 | * @param bool $or 301 | */ 302 | protected static function contains($column, $filter, $query, $or = false) 303 | { 304 | $method = self::determineMethod('where', $or); 305 | $query->$method($column, 'LIKE', '%' . $filter . '%'); 306 | } 307 | 308 | /** 309 | * Query for items with a value less than a filter. 310 | * 311 | * Ex: users?filters[lifetime_value]=<50 312 | * 313 | * @param string $column 314 | * @param string $filter 315 | * @param \Illuminate\Database\Eloquent\Builder $query 316 | * @param bool $or 317 | */ 318 | protected static function lessThan($column, $filter, $query, $or = false) 319 | { 320 | $method = self::determineMethod('where', $or); 321 | $query->$method($column, '<', $filter); 322 | } 323 | 324 | /** 325 | * Query for items with a value greater than a filter. 326 | * 327 | * Ex: users?filters[lifetime_value]=>50 328 | * 329 | * @param string $column 330 | * @param string $filter 331 | * @param \Illuminate\Database\Eloquent\Builder $query 332 | * @param bool $or 333 | */ 334 | protected static function greaterThan($column, $filter, $query, $or = false) 335 | { 336 | $method = self::determineMethod('where', $or); 337 | $query->$method($column, '>', $filter); 338 | } 339 | 340 | /** 341 | * Query for items with a value greater than or equal to a filter. 342 | * 343 | * Ex: users?filters[lifetime_value]=>=50 344 | * 345 | * @param string $column 346 | * @param string $filter 347 | * @param \Illuminate\Database\Eloquent\Builder $query 348 | * @param bool $or 349 | */ 350 | protected static function greaterThanOrEquals($column, $filter, $query, $or = false) 351 | { 352 | $method = self::determineMethod('where', $or); 353 | $query->$method($column, '>=', $filter); 354 | } 355 | 356 | /** 357 | * Query for items with a value less than or equal to a filter. 358 | * 359 | * Ex: users?filters[lifetime_value]=<=50 360 | * 361 | * @param string $column 362 | * @param string $filter 363 | * @param \Illuminate\Database\Eloquent\Builder $query 364 | * @param bool $or 365 | */ 366 | protected static function lessThanOrEquals($column, $filter, $query, $or = false) 367 | { 368 | $method = self::determineMethod('where', $or); 369 | $query->$method($column, '<=', $filter); 370 | } 371 | 372 | /** 373 | * Query for items with a value equal to a filter. 374 | * 375 | * Ex: users?filters[username]==Specific%20Username 376 | * 377 | * @param string $column 378 | * @param string $filter 379 | * @param \Illuminate\Database\Eloquent\Builder $query 380 | * @param bool $or 381 | */ 382 | protected static function equals($column, $filter, $query, $or = false) 383 | { 384 | $method = self::determineMethod('where', $or); 385 | 386 | if ($filter === 'true' || $filter === 'false') { 387 | $filter = $filter === 'true'; 388 | } 389 | 390 | $query->$method($column, '=', $filter); 391 | } 392 | 393 | /** 394 | * Query for items with a value not equal to a filter. 395 | * 396 | * Ex: users?filters[username]=!=common%20username 397 | * 398 | * @param string $column 399 | * @param string $filter 400 | * @param \Illuminate\Database\Eloquent\Builder $query 401 | * @param bool $or 402 | */ 403 | protected static function notEquals($column, $filter, $query, $or = false) 404 | { 405 | $method = self::determineMethod('where', $or); 406 | $query->$method($column, '!=', $filter); 407 | } 408 | 409 | /** 410 | * Query for items that are either null or not null. 411 | * 412 | * Ex: users?filters[email]=NOT_NULL 413 | * Ex: users?filters[address]=NULL 414 | * 415 | * @param string $column 416 | * @param string $filter 417 | * @param \Illuminate\Database\Eloquent\Builder $query 418 | * @param bool $or 419 | */ 420 | protected static function nullMethod($column, $filter, $query, $or = false) 421 | { 422 | if ($filter === 'NULL') { 423 | $method = self::determineMethod('whereNull', $or); 424 | $query->$method($column); 425 | } else { 426 | $method = self::determineMethod('whereNotNull', $or); 427 | $query->$method($column); 428 | } 429 | } 430 | 431 | /** 432 | * Query for items that are in a list. 433 | * 434 | * Ex: users?filters[id]=[1,5,10] 435 | * 436 | * @param string $column 437 | * @param string|array $filter 438 | * @param \Illuminate\Database\Eloquent\Builder $query 439 | * @param bool $or 440 | */ 441 | protected static function in($column, $filter, $query, $or = false) 442 | { 443 | $method = self::determineMethod('whereIn', $or); 444 | $query->$method($column, $filter); 445 | } 446 | 447 | /** 448 | * Query for items that are not in a list. 449 | * 450 | * Ex: users?filters[id]=![1,5,10] 451 | * 452 | * @param string $column 453 | * @param string|array $filter 454 | * @param \Illuminate\Database\Eloquent\Builder $query 455 | * @param bool $or 456 | */ 457 | protected static function notIn($column, $filter, $query, $or = false) 458 | { 459 | $method = self::determineMethod('whereNotIn', $or); 460 | $query->$method($column, $filter); 461 | } 462 | 463 | /** 464 | * Determine the token (if any) to use for the query 465 | * 466 | * @param string $filter 467 | * 468 | * @return bool|string 469 | */ 470 | private static function determineTokenType($filter) 471 | { 472 | if (in_array(substr($filter, 0, 2), array_keys(self::$supported_tokens))) { 473 | // Two character token (<=, >=, etc) 474 | return substr($filter, 0, 2); 475 | } elseif (in_array($filter[0], array_keys(self::$supported_tokens))) { 476 | // Single character token (>, ^, $) 477 | return $filter[0]; 478 | } 479 | 480 | // No token 481 | return false; 482 | } 483 | 484 | /** 485 | * Determine if a token should accept a scalar value 486 | * 487 | * @param string $token 488 | * 489 | * @return bool 490 | */ 491 | private static function shouldBeScalar($token) 492 | { 493 | // Is token in array of tokens that can be non-scalar 494 | return ! in_array($token, self::$non_scalar_tokens); 495 | } 496 | 497 | /** 498 | * Parse a filter string and confirm that it has a scalar value if it should. 499 | * 500 | * @param string $token 501 | * @param string $filter 502 | * 503 | * @return array|bool 504 | */ 505 | private static function cleanAndValidateFilter($token, $filter) 506 | { 507 | $filter_should_be_scalar = self::shouldBeScalar($token); 508 | 509 | // Format the filter, cutting off the trailing ']' if appropriate 510 | $filter = $filter_should_be_scalar ? explode(',', substr($filter, strlen($token))) : 511 | explode(',', substr($filter, strlen($token), -1)); 512 | 513 | if ($filter_should_be_scalar) { 514 | if (count($filter) > 1) { 515 | return false; 516 | } 517 | 518 | // Set to first index if should be scalar 519 | $filter = $filter[0]; 520 | } 521 | 522 | return $filter; 523 | } 524 | 525 | /** 526 | * Determine whether to apply a table prefix to prevent ambiguous columns 527 | * 528 | * @param $column 529 | * 530 | * @return string 531 | */ 532 | private static function applyTablePrefix($column) 533 | { 534 | return is_null(self::$table_prefix) ? $column : self::$table_prefix . '.' . $column; 535 | } 536 | 537 | /** 538 | * Determine whether this an 'or' method or not 539 | * 540 | * @param string $base_name 541 | * @param bool $or 542 | * 543 | * @return string 544 | */ 545 | private static function determineMethod($base_name, $or) 546 | { 547 | return $or ? camel_case('or_' . $base_name) : $base_name; 548 | } 549 | } 550 | -------------------------------------------------------------------------------- /src/Middleware/RepositoryMiddleware.php: -------------------------------------------------------------------------------- 1 | buildRepository($request); 22 | 23 | return $next($request); 24 | } 25 | 26 | /** 27 | * Build a repository based on inbound request data. 28 | * 29 | * @param \Illuminate\Http\Request $request 30 | * @return \Fuzz\MagicBox\EloquentRepository 31 | */ 32 | public function buildRepository(Request $request): EloquentRepository 33 | { 34 | $input = []; 35 | 36 | /** @var \Illuminate\Routing\Route $route */ 37 | $route = $request->route(); 38 | 39 | // Resolve the model class if possible. And setup the repository. 40 | /** @var \Illuminate\Database\Eloquent\Model $model_class */ 41 | $model_class = resolve(ModelResolver::class)->resolveModelClass($route); 42 | 43 | // Look for /{model-class}/{id} RESTful requests 44 | $parameters = $route->parametersWithoutNulls(); 45 | if (! empty($parameters)) { 46 | $id = reset($parameters); 47 | $input = compact('id'); 48 | } 49 | 50 | // If the method is not GET lets get the input from everywhere. 51 | // @TODO hmm, need to verify what happens on DELETE and PATCH. 52 | if ($request->method() !== 'GET') { 53 | $input += $request->all(); 54 | } 55 | 56 | // Resolve an eloquent repository bound to our standardized route parameter 57 | $repository = resolve(Repository::class); 58 | 59 | $repository->setModelClass($model_class) 60 | ->setFilters((array) $request->get('filters')) 61 | ->setSortOrder((array) $request->get('sort')) 62 | ->setGroupBy((array) $request->get('group')) 63 | ->setEagerLoads((array) $request->get('include')) 64 | ->setAggregate((array) $request->get('aggregate')) 65 | ->setDepthRestriction(config('magic-box.eager_load_depth')) 66 | ->setInput($input); 67 | return $repository; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Providers/RepositoryServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([$this->configPath() => config_path('magic-box.php')], 'config'); 21 | } 22 | 23 | /** 24 | * Register any application services. 25 | * 26 | * @return void 27 | */ 28 | public function register() 29 | { 30 | app()->singleton(Repository::class, function() { 31 | return new EloquentRepository; 32 | }); 33 | 34 | app()->bind(ModelResolver::class, function() { 35 | return new ExplicitModelResolver; 36 | }); 37 | } 38 | 39 | /** 40 | * Get the config path 41 | * 42 | * @return string 43 | */ 44 | protected function configPath() 45 | { 46 | return realpath(__DIR__ . '/../../config/magic-box.php'); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Utility/ChecksRelations.php: -------------------------------------------------------------------------------- 1 | __toString(); 36 | 37 | $supported_relations = [ 38 | BelongsTo::class, 39 | HasOne::class, 40 | HasMany::class, 41 | BelongsToMany::class, 42 | ]; 43 | 44 | // Find which, if any, of the supported relations are present in the reflected string 45 | foreach ($supported_relations as $supported_relation) { 46 | if (strpos($reflected_method, $supported_relation) !== false) { 47 | $relation = $instance->$key(); 48 | break; 49 | } 50 | } 51 | 52 | // If the ReflectionMethod guess fails, try to guess based on the concrete return type of a safe instance 53 | // of the model 54 | if (is_null($relation) && ($safe_instance->$key() instanceof Relation)) { 55 | // If the method returns a Relation, we can safely call it 56 | $relation = $instance->$key(); 57 | } 58 | 59 | return is_null($relation) ? false : $relation; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Utility/ExplicitModelResolver.php: -------------------------------------------------------------------------------- 1 | Route Group -> Route. 25 | */ 26 | public function resolveModelClass(Route $route): string 27 | { 28 | // If the route has a resource property we can instantly resolve the model. 29 | if ($this->routeHasResource($route)) { 30 | return $this->getRouteResource($route); 31 | } 32 | 33 | // If the action is a Closure instance, and the route does not have 34 | // a resource property then we can not resolve to a model. 35 | if ($this->actionIsCallable($route->getAction())) { 36 | // Model could not be resolved 37 | throw new ModelNotResolvedException; 38 | } 39 | 40 | // If the routes controller has a resource set then return that resource. 41 | if ($this->controllerHasResource($controller = $this->getRouteController($route))) { 42 | return $this->getControllerResource($controller); 43 | } 44 | 45 | // Model could not be resolved 46 | throw new ModelNotResolvedException; 47 | } 48 | /** 49 | * Checks the route for a resource property. 50 | * 51 | * @param \Illuminate\Routing\Route $route 52 | * 53 | * @return bool 54 | */ 55 | public function routeHasResource(Route $route) 56 | { 57 | return array_key_exists('resource', $route->getAction()); 58 | } 59 | /** 60 | * Get the resource property from a route. 61 | * 62 | * @param \Illuminate\Routing\Route $route 63 | * 64 | * @return string 65 | */ 66 | public function getRouteResource(Route $route) 67 | { 68 | return $route->getAction()['resource']; 69 | } 70 | /** 71 | * Checks if the action uses a callable. 72 | * 73 | * @param $action 74 | * 75 | * @return bool 76 | */ 77 | public function actionIsCallable($action) 78 | { 79 | return (is_callable($action['uses'])); 80 | } 81 | /** 82 | * Check if the controller has a resource. 83 | * 84 | * @param $controller 85 | * 86 | * @return bool 87 | */ 88 | public function controllerHasResource($controller) 89 | { 90 | return isset($controller::$resource); 91 | } 92 | /** 93 | * Get the routes controller. 94 | * 95 | * @param \Illuminate\Routing\Route $route 96 | * 97 | * @return string 98 | */ 99 | public function getRouteController(Route $route) 100 | { 101 | return explode('@', $route->getAction()['uses'])[0]; 102 | } 103 | /** 104 | * Get the controllers resource. 105 | * 106 | * @param $controller 107 | * 108 | * @return mixed 109 | */ 110 | public function getControllerResource($controller) 111 | { 112 | return $controller::$resource; 113 | } 114 | /** 115 | * Get the routes method. 116 | * 117 | * @param \Illuminate\Routing\Route $route 118 | * 119 | * @return mixed 120 | */ 121 | public function getRouteMethod(Route $route) 122 | { 123 | return explode('@', $route->getAction()['uses'])[1]; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Utility/RouteGuessingModelResolver.php: -------------------------------------------------------------------------------- 1 | getName(); 25 | 26 | if (! is_null($route_name) && strpos($route_name, '.') !== false) { 27 | list(, $alias) = array_reverse(explode('.', $route->getName())); 28 | 29 | $model_class = $this->namespaceModel(Str::studly(Str::singular($alias))); 30 | 31 | if (is_a($model_class, MagicBoxResource::class, true)) { 32 | return $model_class; 33 | } 34 | 35 | throw new \LogicException(sprintf('%s must be an instance of %s', $model_class, MagicBoxResource::class)); 36 | } 37 | 38 | throw new \LogicException('Unable to resolve model from improperly named route'); 39 | } 40 | 41 | /** 42 | * Attach the app namespace to the model and return it. 43 | * 44 | * @param string $model_class 45 | * @return string 46 | */ 47 | final public function namespaceModel($model_class) 48 | { 49 | return sprintf('%s%s', $this->getAppNamespace(), $model_class); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ml-archive/magic-box/7d9a6e99a904de519f8d8b0d9ec40fb184b26bfe/tests/.gitkeep -------------------------------------------------------------------------------- /tests/DBTestCase.php: -------------------------------------------------------------------------------- 1 | artisan = $this->app->make('Illuminate\Contracts\Console\Kernel'); 14 | $this->artisan->call( 15 | 'migrate', [ 16 | '--database' => 'testbench', 17 | '--path' => '../../../../tests/migrations', 18 | ] 19 | ); 20 | } 21 | 22 | protected function getEnvironmentSetUp($app) 23 | { 24 | parent::getEnvironmentSetUp($app); 25 | 26 | $app['config']->set('database.default', 'testbench'); 27 | $app['config']->set( 28 | 'database.connections.testbench', [ 29 | 'driver' => 'sqlite', 30 | 'database' => ':memory:', 31 | 'prefix' => '' 32 | ] 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/EloquentRepositoryTest.php: -------------------------------------------------------------------------------- 1 | setModelClass($model_class)->setDepthRestriction(3)->setInput($input); 30 | } 31 | 32 | return new EloquentRepository; 33 | } 34 | 35 | public function seedUsers() 36 | { 37 | $this->artisan->call('db:seed', [ 38 | '--class' => FilterDataSeeder::class 39 | ]); 40 | } 41 | 42 | /** 43 | * @expectedException \InvalidArgumentException 44 | */ 45 | public function testItRejectsUnfuzzyModels() 46 | { 47 | $repo = (new EloquentRepository)->setModelClass('NotVeryFuzzy'); 48 | } 49 | 50 | public function testItCanCreateASimpleModel() 51 | { 52 | $user = $this->getRepository('Fuzz\MagicBox\Tests\Models\User')->save(); 53 | $this->assertNotNull($user); 54 | $this->assertEquals($user->id, 1); 55 | } 56 | 57 | public function testItCanFindASimpleModel() 58 | { 59 | $repo = $this->getRepository('Fuzz\MagicBox\Tests\Models\User'); 60 | $user = $repo->save(); 61 | $found_user = $repo->find($user->id); 62 | $this->assertNotNull($found_user); 63 | $this->assertEquals($user->id, $found_user->id); 64 | } 65 | 66 | public function testItCountsCollections() 67 | { 68 | $repository = $this->getRepository('Fuzz\MagicBox\Tests\Models\User'); 69 | $this->assertEquals($repository->count(), 0); 70 | $this->assertFalse($repository->hasAny()); 71 | } 72 | 73 | public function testItPaginates() 74 | { 75 | $repository = $this->getRepository('Fuzz\MagicBox\Tests\Models\User'); 76 | $first_user = $repository->setInput(['username' => 'bob'])->save(); 77 | $second_user = $repository->setInput(['username' => 'sue'])->save(); 78 | 79 | $paginator = $repository->paginate(1); 80 | $this->assertInstanceOf('Illuminate\Pagination\LengthAwarePaginator', $paginator); 81 | $this->assertTrue($paginator->hasMorePages()); 82 | } 83 | 84 | public function testItEagerLoadsRelationsSafely() 85 | { 86 | $this->getRepository('Fuzz\MagicBox\Tests\Models\User', [ 87 | 'username' => 'joe', 88 | 'posts' => [ 89 | [ 90 | 'title' => 'Some Great Post', 91 | ], 92 | ] 93 | ])->save(); 94 | 95 | $user = $this->getRepository('Fuzz\MagicBox\Tests\Models\User')->setFilters(['username' => 'joe']) 96 | ->setEagerLoads([ 97 | 'posts.nothing', 98 | 'nada' 99 | ])->all()->first(); 100 | 101 | $this->assertNotNull($user); 102 | $this->assertInstanceOf('Illuminate\Database\Eloquent\Collection', $user->posts); 103 | $this->assertInstanceOf('Fuzz\MagicBox\Tests\Models\Post', $user->posts->first()); 104 | } 105 | 106 | public function testItCanFillModelFields() 107 | { 108 | $user = $this->getRepository('Fuzz\MagicBox\Tests\Models\User', ['username' => 'bob'])->save(); 109 | $this->assertNotNull($user); 110 | $this->assertEquals($user->username, 'bob'); 111 | } 112 | 113 | public function testItUpdatesExistingModels() 114 | { 115 | $user = $this->getRepository('Fuzz\MagicBox\Tests\Models\User', ['username' => 'bobby'])->save(); 116 | $this->assertEquals($user->id, 1); 117 | $this->assertEquals($user->username, 'bobby'); 118 | 119 | $user = $this->getRepository('Fuzz\MagicBox\Tests\Models\User', [ 120 | 'id' => 1, 121 | 'username' => 'sue' 122 | ])->save(); 123 | $this->assertEquals($user->id, 1); 124 | $this->assertEquals($user->username, 'sue'); 125 | } 126 | 127 | public function testItDeletesModels() 128 | { 129 | $user = $this->getRepository('Fuzz\MagicBox\Tests\Models\User', ['username' => 'spammer'])->save(); 130 | $this->assertEquals($user->id, 1); 131 | $this->assertTrue($user->exists()); 132 | 133 | $this->getRepository('Fuzz\MagicBox\Tests\Models\User', ['id' => 1])->delete(); 134 | $this->assertNull(User::find(1)); 135 | } 136 | 137 | public function testItFillsBelongsToRelations() 138 | { 139 | $post = $this->getRepository('Fuzz\MagicBox\Tests\Models\Post', [ 140 | 'title' => 'Some Great Post', 141 | 'user' => [ 142 | 'username' => 'jimmy', 143 | ], 144 | ])->save(); 145 | 146 | $this->assertNotNull($post->user); 147 | $this->assertEquals($post->user->username, 'jimmy'); 148 | } 149 | 150 | public function testItFillsHasManyRelations() 151 | { 152 | $user = $this->getRepository('Fuzz\MagicBox\Tests\Models\User', [ 153 | 'username' => 'joe', 154 | 'posts' => [ 155 | [ 156 | 'title' => 'Some Great Post', 157 | ], 158 | [ 159 | 'title' => 'Yet Another Great Post', 160 | ], 161 | ] 162 | ])->save(); 163 | 164 | $this->assertEquals($user->posts->pluck('id')->toArray(), [ 165 | 1, 166 | 2 167 | ]); 168 | 169 | $post = Post::find(2); 170 | $this->assertNotNull($post); 171 | $this->assertEquals($post->user_id, $user->id); 172 | $this->assertEquals($post->title, 'Yet Another Great Post'); 173 | 174 | $this->getRepository('Fuzz\MagicBox\Tests\Models\User', [ 175 | 'id' => $user->id, 176 | 'posts' => [ 177 | [ 178 | 'id' => 1, 179 | ], 180 | ], 181 | ])->save(); 182 | 183 | $user->load('posts'); 184 | 185 | $this->assertEquals($user->posts->pluck('id')->toArray(), [ 186 | 1, 187 | ]); 188 | 189 | $post = Post::find(2); 190 | $this->assertNull($post); 191 | } 192 | 193 | public function testItFillsHasOneRelations() 194 | { 195 | $user = $this->getRepository('Fuzz\MagicBox\Tests\Models\User', [ 196 | 'username' => 'joe', 197 | 'profile' => [ 198 | 'favorite_cheese' => 'brie', 199 | ], 200 | ])->save(); 201 | 202 | $this->assertNotNull($user->profile); 203 | $this->assertEquals($user->profile->favorite_cheese, 'brie'); 204 | $old_profile_id = $user->profile->id; 205 | 206 | $user = $this->getRepository('Fuzz\MagicBox\Tests\Models\User', [ 207 | 'id' => $user->id, 208 | 'profile' => [ 209 | 'favorite_cheese' => 'pepper jack', 210 | ], 211 | ])->save(); 212 | 213 | $this->assertNotNull($user->profile); 214 | $this->assertEquals($user->profile->favorite_cheese, 'pepper jack'); 215 | 216 | $this->assertNotEquals($user->profile->id, $old_profile_id); 217 | $this->assertNull(Profile::find($old_profile_id)); 218 | } 219 | 220 | public function testItCascadesThroughSupportedRelations() 221 | { 222 | $post = $this->getRepository('Fuzz\MagicBox\Tests\Models\Post', [ 223 | 'title' => 'All the Tags', 224 | 'user' => [ 225 | 'username' => 'simon', 226 | 'profile' => [ 227 | 'favorite_cheese' => 'brie', 228 | ], 229 | ], 230 | 'tags' => [ 231 | [ 232 | 'label' => 'Important Stuff', 233 | ], 234 | [ 235 | 'label' => 'Less Important Stuff', 236 | ], 237 | ], 238 | ])->save(); 239 | 240 | $this->assertEquals($post->tags()->count(), 2); 241 | $this->assertNotNull($post->user->profile); 242 | $this->assertNotNull($post->user->profile->favorite_cheese, 'brie'); 243 | } 244 | 245 | public function testItUpdatesBelongsToManyPivots() 246 | { 247 | $post = $this->getRepository('Fuzz\MagicBox\Tests\Models\Post', [ 248 | 'title' => 'All the Tags', 249 | 'user' => [ 250 | 'username' => 'josh', 251 | ], 252 | 'tags' => [ 253 | [ 254 | 'label' => 'Has Extra', 255 | 'pivot' => [ 256 | 'extra' => 'Meowth' 257 | ], 258 | ], 259 | ], 260 | ])->save(); 261 | 262 | $tag = $post->tags->first(); 263 | $this->assertEquals($tag->pivot->extra, 'Meowth'); 264 | 265 | $post = $this->getRepository('Fuzz\MagicBox\Tests\Models\Post', [ 266 | 'id' => $post->id, 267 | 'tags' => [ 268 | [ 269 | 'id' => $tag->id, 270 | 'pivot' => [ 271 | 'extra' => 'Pikachu', 272 | ], 273 | ], 274 | ], 275 | ])->save(); 276 | 277 | $tag = $post->tags->first(); 278 | $this->assertEquals($tag->pivot->extra, 'Pikachu'); 279 | } 280 | 281 | public function testItSorts() 282 | { 283 | $repository = $this->getRepository('Fuzz\MagicBox\Tests\Models\User'); 284 | $first_user = $repository->setInput([ 285 | 'username' => 'Bobby' 286 | ])->save(); 287 | $second_user = $repository->setInput([ 288 | 'username' => 'Robby' 289 | ])->save(); 290 | $this->assertEquals($repository->all()->count(), 2); 291 | 292 | $found_users = $repository->setSortOrder([ 293 | 'id' => 'desc' 294 | ])->all(); 295 | $this->assertEquals($found_users->count(), 2); 296 | $this->assertEquals($found_users->first()->id, 2); 297 | } 298 | 299 | public function testItSortsNested() 300 | { 301 | $repository = $this->getRepository('Fuzz\MagicBox\Tests\Models\User'); 302 | $first_user = $repository->setInput([ 303 | 'username' => 'Bobby', 304 | 'posts' => [ 305 | [ 306 | 'title' => 'First Post', 307 | 'tags' => [ 308 | ['label' => 'Tag1'] 309 | ] 310 | ] 311 | ] 312 | ])->save(); 313 | $second_user = $repository->setInput([ 314 | 'username' => 'Robby', 315 | 'posts' => [ 316 | [ 317 | 'title' => 'Zis is the final post alphabetically', 318 | 'tags' => [ 319 | ['label' => 'Tag2'] 320 | ] 321 | ] 322 | ] 323 | ])->save(); 324 | $third_user = $repository->setInput([ 325 | 'username' => 'Gobby', 326 | 'posts' => [ 327 | [ 328 | 'title' => 'Third Post', 329 | 'tags' => [ 330 | ['label' => 'Tag3'] 331 | ] 332 | ] 333 | ] 334 | ])->save(); 335 | $this->assertEquals($repository->all()->count(), 3); 336 | 337 | $found_users = $repository->setSortOrder([ 338 | 'posts.title' => 'desc' 339 | ])->all(); 340 | $this->assertEquals($found_users->count(), 3); 341 | $this->assertEquals($found_users->first()->username, 'Robby'); 342 | } 343 | 344 | public function testItModifiesQueries() 345 | { 346 | $repository = $this->getRepository('Fuzz\MagicBox\Tests\Models\User', ['username' => 'Billy']); 347 | $repository->save(); 348 | $this->assertEquals($repository->count(), 1); 349 | $repository->setModifiers([ 350 | function (Builder $query) { 351 | $query->whereRaw(DB::raw('0 = 1')); 352 | } 353 | ]); 354 | $this->assertEquals($repository->count(), 0); 355 | } 356 | 357 | public function testItAddsModifier() 358 | { 359 | $repository = $this->getRepository('Fuzz\MagicBox\Tests\Models\User', ['username' => 'Billy']); 360 | $repository->save(); 361 | $this->assertEquals($repository->count(), 1); 362 | $repository->addModifier(function (Builder $query) { 363 | $query->whereRaw(DB::raw('0 = 1')); 364 | }); 365 | 366 | $this->assertSame(1, count($repository->getModifiers())); 367 | $this->assertEquals($repository->count(), 0); 368 | } 369 | 370 | public function testItCanFilterOnFields() 371 | { 372 | $this->seedUsers(); 373 | 374 | // Test that the repository implements filters correctly 375 | $repository = $this->getRepository(User::class); 376 | $this->assertEquals($repository->all()->count(), 4); 377 | 378 | $found_users = $repository->setFilters(['username' => '=chewbaclava@galaxyfarfaraway.com'])->all(); 379 | $this->assertEquals($found_users->count(), 1); 380 | $this->assertEquals($found_users->first()->username, 'chewbaclava@galaxyfarfaraway.com'); 381 | } 382 | 383 | public function testItOnlyUpdatesFillableAttributesOnCreate() 384 | { 385 | $input = [ 386 | 'username' => 'javacup@galaxyfarfaraway.com', 387 | 'name' => 'Jabba The Hutt', 388 | 'hands' => 10, 389 | 'times_captured' => 0, 390 | 'not_fillable' => 'should be null', 391 | 'occupation' => 'Being Gross', 392 | 'profile' => [ 393 | 'favorite_cheese' => 'Cheddar', 394 | 'favorite_fruit' => 'Apples', 395 | 'is_human' => false 396 | ], 397 | ]; 398 | 399 | $user = $this->getRepository(User::class, $input)->save(); 400 | $this->assertNull($user->not_fillable); 401 | } 402 | 403 | public function testItOnlyUpdatesFillableAttributesOnUpdate() 404 | { 405 | $input = [ 406 | 'username' => 'javacup@galaxyfarfaraway.com', 407 | 'name' => 'Jabba The Hutt', 408 | 'hands' => 10, 409 | 'times_captured' => 0, 410 | 'not_fillable' => 'should be null', 411 | 'occupation' => 'Being Gross', 412 | 'profile' => [ 413 | 'favorite_cheese' => 'Cheddar', 414 | 'favorite_fruit' => 'Apples', 415 | 'is_human' => false 416 | ], 417 | ]; 418 | 419 | $user = $this->getRepository(User::class, $input)->save(); 420 | $this->assertNull($user->not_fillable); 421 | 422 | $input['id'] = $user->id; 423 | $user = $this->getRepository(User::class, $input)->update(); 424 | $this->assertNull($user->not_fillable); 425 | } 426 | 427 | public function testItOnlyUpdatesFillableAttributesForRelationsOnCreate() 428 | { 429 | $input = [ 430 | 'username' => 'javacup@galaxyfarfaraway.com', 431 | 'name' => 'Jabba The Hutt', 432 | 'hands' => 10, 433 | 'times_captured' => 0, 434 | 'not_fillable' => 'should be null', 435 | 'occupation' => 'Being Gross', 436 | 'profile' => [ 437 | 'favorite_cheese' => 'Cheddar', 438 | 'favorite_fruit' => 'Apples', 439 | 'is_human' => false, 440 | 'not_fillable' => 'should be null' 441 | ], 442 | ]; 443 | 444 | $user = $this->getRepository(User::class, $input)->save(); 445 | $this->assertNull($user->not_fillable); 446 | $this->assertNull($user->profile->not_fillable); 447 | } 448 | 449 | public function testItOnlyUpdatesFillableAttributesForRelationsOnUpdate() 450 | { 451 | $input = [ 452 | 'username' => 'javacup@galaxyfarfaraway.com', 453 | 'name' => 'Jabba The Hutt', 454 | 'hands' => 10, 455 | 'times_captured' => 0, 456 | 'not_fillable' => 'should be null', 457 | 'occupation' => 'Being Gross', 458 | 'profile' => [ 459 | 'favorite_cheese' => 'Cheddar', 460 | 'favorite_fruit' => 'Apples', 461 | 'is_human' => false, 462 | 'not_fillable' => 'should be null' 463 | ], 464 | ]; 465 | 466 | $user = $this->getRepository(User::class, $input)->save(); 467 | $this->assertNull($user->not_fillable); 468 | $this->assertNull($user->profile->not_fillable); 469 | 470 | $input['id'] = $user->id; 471 | $user = $this->getRepository(User::class, $input)->update(); 472 | $this->assertNull($user->not_fillable); 473 | $this->assertNull($user->profile->not_fillable); 474 | } 475 | 476 | public function testItDoesNotRunArbitraryMethodsOnActualInstance() 477 | { 478 | $input = [ 479 | 'username' => 'javacup@galaxyfarfaraway.com', 480 | 'name' => 'Jabba The Hutt', 481 | 'hands' => 10, 482 | 'times_captured' => 0, 483 | 'not_fillable' => 'should be null', 484 | 'occupation' => 'Being Gross', 485 | ]; 486 | 487 | $user = $this->getRepository(User::class, $input)->save(); 488 | $this->assertNotNull($user); 489 | 490 | $input['delete'] = 'doesn\'t matter but this should not be run'; 491 | $input['id'] = $user->id; 492 | 493 | // Since users are soft deletable, if this fails and we run a $user->delete(), magic box will delete the record 494 | // but then try to recreate it with the same ID and get a MySQL unique constraint error because the 495 | // original ID record exists but is soft deleted 496 | $user = $this->getRepository(User::class, $input)->update(); 497 | 498 | $database_user = User::find($user->id); 499 | 500 | $this->assertNotNull($database_user); 501 | $this->assertNull($user->deleted_at); 502 | } 503 | 504 | public function testItCanSetDepthRestriction() 505 | { 506 | $input = [ 507 | 'username' => 'javacup@galaxyfarfaraway.com', 508 | 'name' => 'Jabba The Hutt', 509 | 'hands' => 10, 510 | 'times_captured' => 0, 511 | 'not_fillable' => 'should be null', 512 | 'occupation' => 'Being Gross', 513 | ]; 514 | 515 | $repository = $this->getRepository(User::class, $input); 516 | $this->assertEquals(3, $repository->getDepthRestriction()); // getRepository sets 3 by default 517 | $repository->setDepthRestriction(5); 518 | $this->assertEquals(5, $repository->getDepthRestriction()); 519 | } 520 | 521 | public function testItDepthRestrictsEagerLoads() 522 | { 523 | $this->seedUsers(); 524 | 525 | $users = $this->getRepository(User::class) 526 | ->setDepthRestriction(0) 527 | ->setEagerLoads( 528 | [ 529 | 'posts.tags', 530 | ] 531 | )->all()->toArray(); // toArray so we don't pull relations 532 | 533 | foreach ($users as $user) { 534 | $this->assertTrue(!isset($user['posts'])); 535 | $this->assertTrue(!isset($user['posts']['tags'])); // We should load neither 536 | } 537 | 538 | $users = $this->getRepository(User::class) 539 | ->setDepthRestriction(1) 540 | ->setEagerLoads( 541 | [ 542 | 'posts', 543 | 'posts.tags', 544 | ] 545 | )->all()->toArray(); // toArray so we don't pull relations 546 | 547 | foreach ($users as $user) { 548 | $this->assertTrue(isset($user['posts'])); 549 | $this->assertTrue(isset($user['posts'][0])); 550 | $this->assertTrue(!isset($user['posts'][0]['tags'])); // We should load posts (1 level) but not tags (2 levels) 551 | } 552 | 553 | $users = $this->getRepository(User::class) 554 | ->setDepthRestriction(2) 555 | ->setEagerLoads( 556 | [ 557 | 'posts', 558 | 'posts.user', 559 | ] 560 | )->all()->toArray(); // toArray so we don't pull relations 561 | 562 | foreach ($users as $user) { 563 | $this->assertTrue(isset($user['posts'])); 564 | $this->assertTrue(isset($user['posts'][0])); 565 | $this->assertTrue(isset($user['posts'][0]['user'])); // We should load both 566 | } 567 | } 568 | 569 | public function testItDepthRestrictsFilters() 570 | { 571 | $this->seedUsers(); 572 | 573 | /** 574 | * Test with 0 depth, filter too long 575 | */ 576 | $users = $this->getRepository(User::class) 577 | ->setDepthRestriction(0) 578 | ->setFilters( 579 | [ 580 | 'posts.tags.label' => '=#mysonistheworst' 581 | ] 582 | ) 583 | ->all(); 584 | 585 | // Filter should not apply because depth restriction is 0 586 | $this->assertEquals(User::all()->count(), $users->count()); 587 | 588 | /** 589 | * Test with 1 depth, filter is allowed 590 | */ 591 | $users = $this->getRepository(User::class) 592 | ->setDepthRestriction(1) 593 | ->setFilters( 594 | [ 595 | 'posts.title' => '~10 Easy Ways to Clean' 596 | ] 597 | ) 598 | ->all(); 599 | 600 | // Filter should apply because depth restriction is 1 601 | $this->assertEquals(1, $users->count()); 602 | $this->assertEquals('solocup@galaxyfarfaraway.com', $users->first()->username); 603 | 604 | /** 605 | * Test with 1 depth, filter is too long 606 | */ 607 | $users = $this->getRepository(User::class) 608 | ->setDepthRestriction(1) 609 | ->setFilters( 610 | [ 611 | 'posts.tags.label' => '=#mysonistheworst' 612 | ] 613 | ) 614 | ->all(); 615 | 616 | // Filter should apply because depth restriction is 1 617 | $this->assertEquals(User::all()->count(), $users->count()); 618 | 619 | /** 620 | * Test with 1 depth, filter is okay 621 | */ 622 | $users = $this->getRepository(User::class) 623 | ->setDepthRestriction(2) 624 | ->setFilters( 625 | [ 626 | 'posts.tags.label' => '=#mysonistheworst' 627 | ] 628 | ) 629 | ->all(); 630 | 631 | // Filter should not apply because depth restriction is 2 632 | $this->assertEquals(2, $users->count()); 633 | 634 | foreach ($users as $user) { 635 | $this->assertTrue(in_array($user->username, ['solocup@galaxyfarfaraway.com', 'lorgana@galaxyfarfaraway.com'])); 636 | } 637 | } 638 | 639 | public function testItCanSortQueryAscending() 640 | { 641 | $this->seedUsers(); 642 | 643 | $users = $this->getRepository(User::class) 644 | ->setSortOrder(['times_captured' => 'asc']) 645 | ->all(); 646 | 647 | $this->assertEquals(User::all()->count(), $users->count()); 648 | 649 | $previous_user = null; 650 | foreach ($users as $index => $user) { 651 | if ($index > 0) { 652 | $this->assertTrue($user->times_captured > $previous_user->times_captured); 653 | } 654 | 655 | $previous_user = $user; 656 | } 657 | } 658 | 659 | public function testItCanSortQueryDescending() 660 | { 661 | $this->seedUsers(); 662 | 663 | $users = $this->getRepository(User::class) 664 | ->setSortOrder(['times_captured' => 'desc']) 665 | ->all(); 666 | 667 | $this->assertEquals(User::all()->count(), $users->count()); 668 | 669 | $previous_user = null; 670 | foreach ($users as $index => $user) { 671 | if ($index > 0) { 672 | $this->assertTrue($user->times_captured < $previous_user->times_captured); 673 | } 674 | 675 | $previous_user = $user; 676 | } 677 | } 678 | 679 | public function testItDepthRestrictsSorts() 680 | { 681 | $this->seedUsers(); 682 | 683 | /** 684 | * Sort depth zero, expect sorting by top level ID 685 | */ 686 | $users = $this->getRepository(User::class) 687 | ->setDepthRestriction(0) 688 | ->setSortOrder(['profile.favorite_cheese' => 'asc']) 689 | ->all(); 690 | 691 | $this->assertEquals(User::all()->count(), $users->count()); 692 | 693 | $previous_user = null; 694 | foreach ($users as $index => $user) { 695 | if ($index > 0) { 696 | $this->assertTrue($user->id > $previous_user->id); 697 | } 698 | 699 | $previous_user = $user; 700 | } 701 | 702 | /** 703 | * Sort depth 1, expect sorting by favorite cheese, asc alphabetical 704 | */ 705 | $users = $this->getRepository(User::class) 706 | ->setDepthRestriction(1) 707 | ->setSortOrder(['profile.favorite_cheese' => 'asc']) 708 | ->all(); 709 | 710 | $this->assertEquals(User::all()->count(), $users->count()); 711 | 712 | $previous_user = null; 713 | $order = []; 714 | foreach ($users as $index => $user) { 715 | $order[] = $user->username; 716 | if ($index > 0) { 717 | // String 1 (Gouda) should be greater than (comes later alphabetically) than string 2 (Cheddar) 718 | $this->assertTrue(strcmp($user->profile->favorite_cheese, $previous_user->profile->favorite_cheese) > 0); 719 | } 720 | 721 | $previous_user = $user; 722 | } 723 | 724 | /** 725 | * Sort depth 1, expect sorting by favorite cheese, desc alphabetical 726 | */ 727 | $users = $this->getRepository(User::class) 728 | ->setDepthRestriction(1) 729 | ->setSortOrder(['profile.favorite_cheese' => 'desc']) 730 | ->all(); 731 | 732 | $this->assertEquals(User::all()->count(), $users->count()); 733 | 734 | $previous_user = null; 735 | foreach ($users as $index => $user) { 736 | if ($index > 0) { 737 | // String 1 (Cheddar) should be less than (comes before alphabetically) than string 2 (Gouda) 738 | $this->assertTrue(strcmp($user->profile->favorite_cheese, $previous_user->profile->favorite_cheese) < 0); 739 | } 740 | 741 | $previous_user = $user; 742 | } 743 | } 744 | 745 | public function testItCanSortBelongsToRelation() 746 | { 747 | $this->seedUsers(); 748 | /** 749 | * Sort depth 1, expect sorting by favorite cheese, asc alphabetical 750 | */ 751 | $profiles = $this->getRepository(Profile::class) 752 | ->setSortOrder(['users.username' => 'asc']) 753 | ->setEagerLoads(['user']) 754 | ->all()->toArray(); 755 | 756 | $this->assertEquals(Profile::all()->count(), count($profiles)); 757 | 758 | $previous_profile = null; 759 | $order = []; 760 | foreach ($profiles as $index => $profile) { 761 | $order[] = $profile['user']['username']; 762 | if ($index > 0) { 763 | // String 1 (Gouda) should be greater than (comes later alphabetically) than string 2 (Cheddar) 764 | $this->assertTrue(strcmp($profile['user']['username'], $previous_profile['user']['username']) > 0); 765 | } 766 | 767 | $previous_profile = $profile; 768 | } 769 | } 770 | 771 | public function testItCanSortBelongsToManyRelation() 772 | { 773 | $this->seedUsers(); 774 | 775 | /** 776 | * Sort depth 1, expect sorting by favorite cheese, asc alphabetical 777 | */ 778 | $tags = $this->getRepository(Tag::class) 779 | ->setSortOrder(['posts.title' => 'asc']) 780 | ->setEagerLoads(['posts']) 781 | ->all()->toArray(); 782 | 783 | $this->assertEquals(Tag::all()->count(), count($tags)); 784 | 785 | foreach ($tags as $index => $tag) { 786 | $previous_post = null; 787 | $order = []; 788 | foreach ($tag['posts'] as $post) { 789 | $order[] = $post['title']; 790 | if ($index > 0) { 791 | // String 1 (Gouda) should be greater than (comes later alphabetically) than string 2 (Cheddar) 792 | $this->assertTrue(strcmp($post['title'], $previous_post['title']) > 0); 793 | } 794 | 795 | $previous_post = $post; 796 | } 797 | } 798 | } 799 | 800 | public function testItCanAddMultipleAdditionalFilters() 801 | { 802 | $this->seedUsers(); 803 | 804 | $repository = $this->getRepository(User::class); 805 | $this->assertEquals($repository->all()->count(), 4); 806 | 807 | $found_users = $repository->setFilters(['username' => '~galaxyfarfaraway.com'])->all(); 808 | $this->assertEquals($found_users->count(), 4); 809 | 810 | $additional_filters = [ 811 | 'profile.is_human' => '=true', 812 | 'times_captured' => '>2' 813 | ]; 814 | 815 | $found_users = $repository->addFilters($additional_filters)->all(); 816 | $this->assertEquals($found_users->count(), 2); 817 | 818 | $filters = $repository->getFilters(); 819 | $this->assertEquals([ 820 | 'username' => '~galaxyfarfaraway.com', 821 | 'profile.is_human' => '=true', 822 | 'times_captured' => '>2' 823 | ], $filters); 824 | } 825 | 826 | public function testItCanAddOneAdditionalFilter() 827 | { 828 | $this->seedUsers(); 829 | 830 | $repository = $this->getRepository(User::class); 831 | $this->assertEquals($repository->all()->count(), 4); 832 | 833 | $found_users = $repository->setFilters(['username' => '~galaxyfarfaraway.com'])->all(); 834 | $this->assertEquals($found_users->count(), 4); 835 | 836 | $found_users = $repository->addFilter('profile.is_human', '=true')->all(); 837 | $this->assertEquals($found_users->count(), 3); 838 | 839 | $filters = $repository->getFilters(); 840 | $this->assertEquals([ 841 | 'username' => '~galaxyfarfaraway.com', 842 | 'profile.is_human' => '=true', 843 | ], $filters); 844 | } 845 | 846 | public function testItCanSetFillable() 847 | { 848 | $repository = $this->getRepository(User::class); 849 | 850 | $this->assertSame(User::FILLABLE, $repository->getFillable()); 851 | 852 | $repository->setFillable(['foo']); 853 | 854 | $this->assertSame(['foo'], $repository->getFillable()); 855 | } 856 | 857 | public function testItCanAddFillable() 858 | { 859 | $repository = $this->getRepository(User::class); 860 | 861 | $this->assertSame(User::FILLABLE, $repository->getFillable()); 862 | 863 | $repository->addFillable('foo'); 864 | 865 | $expect = User::FILLABLE; 866 | $expect[] = 'foo'; 867 | 868 | $this->assertSame($expect, $repository->getFillable()); 869 | } 870 | 871 | public function testItCanAddManyFillable() 872 | { 873 | $repository = $this->getRepository(User::class); 874 | 875 | $this->assertSame(User::FILLABLE, $repository->getFillable()); 876 | 877 | $repository->addManyFillable(['foo', 'bar', 'baz']); 878 | 879 | $expect = User::FILLABLE; 880 | $expect[] = 'foo'; 881 | $expect[] = 'bar'; 882 | $expect[] = 'baz'; 883 | 884 | $this->assertSame($expect, $repository->getFillable()); 885 | } 886 | 887 | public function testItCanRemoveFillable() 888 | { 889 | $repository = $this->getRepository(User::class); 890 | 891 | $this->assertSame(User::FILLABLE, $repository->getFillable()); 892 | 893 | $repository->setFillable([ 894 | 'foo', 895 | 'baz', 896 | 'bag', 897 | ]); 898 | 899 | $this->assertSame([ 900 | 'foo', 901 | 'baz', 902 | 'bag', 903 | ], $repository->getFillable()); 904 | 905 | $repository->removeFillable('baz'); 906 | 907 | $this->assertSame(['foo', 'bag'], $repository->getFillable()); 908 | } 909 | 910 | public function testItCanRemoveManyFillable() 911 | { 912 | $repository = $this->getRepository(User::class); 913 | 914 | $this->assertSame(User::FILLABLE, $repository->getFillable()); 915 | 916 | $repository->setFillable([ 917 | 'foo', 918 | 'baz', 919 | 'bag', 920 | ]); 921 | 922 | $this->assertSame([ 923 | 'foo', 924 | 'baz', 925 | 'bag', 926 | ], $repository->getFillable()); 927 | 928 | $repository->removeManyFillable(['baz', 'bag']); 929 | 930 | $this->assertSame(['foo',], $repository->getFillable()); 931 | } 932 | 933 | public function testItCanDetermineIfIsFillable() 934 | { 935 | $repository = $this->getRepository(User::class); 936 | 937 | $this->assertSame(User::FILLABLE, $repository->getFillable()); 938 | 939 | $repository->setFillable([ 940 | '*' // allow all 941 | ]); 942 | 943 | $this->assertSame(EloquentRepository::ALLOW_ALL, $repository->getFillable()); 944 | 945 | $this->assertTrue($repository->isFillable('foobar')); 946 | 947 | $repository->setFillable([ 948 | 'foo', 949 | 'baz', 950 | 'bag', 951 | ]); 952 | 953 | $this->assertFalse($repository->isFillable('foobar')); 954 | $this->assertTrue($repository->isFillable('foo')); 955 | } 956 | 957 | public function testItCanSetIncludable() 958 | { 959 | $repository = $this->getRepository(User::class); 960 | 961 | $this->assertSame(User::INCLUDABLE, $repository->getIncludable()); 962 | 963 | $repository->setIncludable([ 964 | 'foo', 965 | 'bar', 966 | 'baz' 967 | ]); 968 | 969 | $this->assertSame(['foo', 'bar', 'baz'], $repository->getIncludable()); 970 | } 971 | 972 | public function testItCanAddIncludable() 973 | { 974 | $repository = $this->getRepository(User::class); 975 | 976 | $this->assertSame(User::INCLUDABLE, $repository->getIncludable()); 977 | 978 | $repository->setIncludable([ 979 | 'foo', 980 | 'bar', 981 | 'baz' 982 | ]); 983 | 984 | $repository->addIncludable('foobar'); 985 | 986 | $this->assertSame(['foo', 'bar', 'baz', 'foobar'], $repository->getIncludable()); 987 | } 988 | 989 | public function testItCanAddManyIncludable() 990 | { 991 | $repository = $this->getRepository(User::class); 992 | 993 | $this->assertSame(User::INCLUDABLE, $repository->getIncludable()); 994 | 995 | $repository->setIncludable([ 996 | 'foo', 997 | 'bar', 998 | 'baz' 999 | ]); 1000 | 1001 | $repository->addManyIncludable(['foobar', 'bazbat']); 1002 | 1003 | $this->assertSame(['foo', 'bar', 'baz', 'foobar', 'bazbat'], $repository->getIncludable()); 1004 | } 1005 | 1006 | public function testItCanRemoveIncludable() 1007 | { 1008 | $repository = $this->getRepository(User::class); 1009 | 1010 | $this->assertSame(User::INCLUDABLE, $repository->getIncludable()); 1011 | 1012 | $repository->setIncludable([ 1013 | 'foo', 1014 | 'bar', 1015 | 'baz' 1016 | ]); 1017 | 1018 | $repository->removeIncludable('foo'); 1019 | 1020 | $this->assertSame(['bar', 'baz'], $repository->getIncludable()); 1021 | } 1022 | 1023 | public function testItCanRemoveManyIncludable() 1024 | { 1025 | $repository = $this->getRepository(User::class); 1026 | 1027 | $this->assertSame(User::INCLUDABLE, $repository->getIncludable()); 1028 | 1029 | $repository->setIncludable([ 1030 | 'foo', 1031 | 'bar', 1032 | 'baz' 1033 | ]); 1034 | 1035 | $repository->removeManyIncludable(['foo', 'bar']); 1036 | 1037 | $this->assertSame(['baz'], $repository->getIncludable()); 1038 | } 1039 | 1040 | public function testItCanDetermineIsIncludable() 1041 | { 1042 | $repository = $this->getRepository(User::class); 1043 | 1044 | $this->assertSame(User::INCLUDABLE, $repository->getIncludable()); 1045 | 1046 | $repository->setIncludable([ 1047 | '*' // Allow all 1048 | ]); 1049 | 1050 | $this->assertSame(EloquentRepository::ALLOW_ALL, $repository->getIncludable()); 1051 | 1052 | $this->assertTrue($repository->isIncludable('foobar')); 1053 | 1054 | $repository->setIncludable([ 1055 | 'foo', 1056 | 'bar', 1057 | 'baz' 1058 | ]); 1059 | 1060 | $this->assertFalse($repository->isIncludable('foobar')); 1061 | $this->assertTrue($repository->isIncludable('foo')); 1062 | } 1063 | 1064 | public function testItCanSetFilterable() 1065 | { 1066 | $repository = $this->getRepository(User::class); 1067 | 1068 | $this->assertSame(User::FILTERABLE, $repository->getFilterable()); 1069 | 1070 | $repository->setFilterable([ 1071 | 'foo', 1072 | 'bar', 1073 | 'baz', 1074 | ]); 1075 | 1076 | $this->assertSame([ 1077 | 'foo', 1078 | 'bar', 1079 | 'baz', 1080 | ], $repository->getFilterable()); 1081 | } 1082 | 1083 | public function testItCanAddFilterable() 1084 | { 1085 | $repository = $this->getRepository(User::class); 1086 | 1087 | $this->assertSame(User::FILTERABLE, $repository->getFilterable()); 1088 | 1089 | $repository->setFilterable([ 1090 | 'foo', 1091 | 'bar', 1092 | 'baz', 1093 | ]); 1094 | 1095 | $repository->addFilterable('foobar'); 1096 | 1097 | $this->assertSame([ 1098 | 'foo', 1099 | 'bar', 1100 | 'baz', 1101 | 'foobar', 1102 | ], $repository->getFilterable()); 1103 | } 1104 | 1105 | public function testItCanAddManyFilterable() 1106 | { 1107 | $repository = $this->getRepository(User::class); 1108 | 1109 | $this->assertSame(User::FILTERABLE, $repository->getFilterable()); 1110 | 1111 | $repository->setFilterable([ 1112 | 'foo', 1113 | 'bar', 1114 | 'baz', 1115 | ]); 1116 | 1117 | $repository->addManyFilterable(['foobar', 'bazbat']); 1118 | 1119 | $this->assertSame([ 1120 | 'foo', 1121 | 'bar', 1122 | 'baz', 1123 | 'foobar', 1124 | 'bazbat', 1125 | ], $repository->getFilterable()); 1126 | } 1127 | 1128 | public function testItCanRemoveFilterable() 1129 | { 1130 | $repository = $this->getRepository(User::class); 1131 | 1132 | $this->assertSame(User::FILTERABLE, $repository->getFilterable()); 1133 | 1134 | $repository->setFilterable([ 1135 | 'foo', 1136 | 'bar', 1137 | 'baz', 1138 | ]); 1139 | 1140 | $repository->removeFilterable('bar'); 1141 | 1142 | $this->assertSame(['foo', 'baz',], $repository->getFilterable()); 1143 | } 1144 | 1145 | public function testItCanRemoveManyFilterable() 1146 | { 1147 | $repository = $this->getRepository(User::class); 1148 | 1149 | $this->assertSame(User::FILTERABLE, $repository->getFilterable()); 1150 | 1151 | $repository->setFilterable([ 1152 | 'foo', 1153 | 'bar', 1154 | 'baz', 1155 | ]); 1156 | 1157 | $repository->removeManyFilterable(['foo', 'baz']); 1158 | 1159 | $this->assertSame(['bar',], $repository->getFilterable()); 1160 | } 1161 | 1162 | public function testItCanDetermineIsFilterable() 1163 | { 1164 | $repository = $this->getRepository(User::class); 1165 | 1166 | $this->assertSame(User::FILTERABLE, $repository->getFilterable()); 1167 | 1168 | $repository->setFilterable([ 1169 | '*' 1170 | ]); 1171 | 1172 | $this->assertSame(EloquentRepository::ALLOW_ALL, $repository->getFilterable()); 1173 | 1174 | $this->assertTrue($repository->isFilterable('foobar')); 1175 | 1176 | $repository->setFilterable([ 1177 | 'foo', 1178 | 'bar', 1179 | 'baz', 1180 | ]); 1181 | 1182 | $this->assertFalse($repository->isFilterable('foobar')); 1183 | $this->assertTrue($repository->isFilterable('foo')); 1184 | } 1185 | 1186 | public function testItDoesNotFillFieldThatIsNotFillable() 1187 | { 1188 | $post = $this->getRepository( 1189 | Post::class, [ 1190 | 'title' => 'All the Tags', 1191 | 'not_fillable' => 'should not be set', 1192 | 'user' => [ 1193 | 'username' => 'simon', 1194 | 'not_fillable' => 'should not be set', 1195 | 'profile' => [ 1196 | 'favorite_cheese' => 'brie', 1197 | ], 1198 | ], 1199 | 'tags' => [ 1200 | [ 1201 | 'label' => 'Important Stuff', 1202 | 'not_fillable' => 'should not be set', 1203 | ], 1204 | [ 1205 | 'label' => 'Less Important Stuff', 1206 | 'not_fillable' => 'should not be set', 1207 | ], 1208 | ], 1209 | ] 1210 | )->save(); 1211 | 1212 | $this->assertEquals($post->tags()->count(), 2); 1213 | $this->assertNotNull($post->user->profile); 1214 | $this->assertNotNull($post->user->profile->favorite_cheese, 'brie'); 1215 | 1216 | $this->assertNull($post->not_fillable); 1217 | $this->assertNull($post->user->not_fillable); 1218 | $this->assertNull($post->tags->get(0)->not_fillable); 1219 | $this->assertNull($post->tags->get(1)->not_fillable); 1220 | } 1221 | 1222 | public function testItDoesNotIncludeRelationThatIsNotIncludable() 1223 | { 1224 | $this->getRepository( 1225 | User::class, [ 1226 | 'username' => 'joe', 1227 | 'posts' => [ 1228 | [ 1229 | 'title' => 'Some Great Post', 1230 | ], 1231 | ] 1232 | ] 1233 | )->save(); 1234 | 1235 | $user = $this->getRepository(User::class)->setFilters(['username' => 'joe']) 1236 | ->setEagerLoads( 1237 | [ 1238 | 'posts.nothing', 1239 | 'not_exists', 1240 | 'not_includable' 1241 | ] 1242 | )->all()->first(); 1243 | 1244 | $this->assertNotNull($user); 1245 | $this->assertInstanceOf(Collection::class, $user->posts); 1246 | $this->assertInstanceOf(Post::class, $user->posts->first()); 1247 | 1248 | $user = $user->toArray(); 1249 | 1250 | $this->assertTrue(! isset($user['posts'][0]['nothing'])); 1251 | $this->assertTrue(! isset($user['not_exists'])); 1252 | $this->assertTrue(! isset($user['not_includable'])); 1253 | } 1254 | 1255 | public function testItDoesNotFilterOnWhatIsNotFilterable() 1256 | { 1257 | $this->seedUsers(); 1258 | 1259 | // Test that the repository implements filters correctly 1260 | $repository = $this->getRepository(User::class); 1261 | $this->assertEquals($repository->all()->count(), 4); 1262 | 1263 | $found_users = $repository->setFilters([ 1264 | 'not_filterable' => '=foo', // Should not be applied 1265 | 'posts.not_filterable' => '=foo', // Should not be applied 1266 | ])->all(); 1267 | $this->assertEquals($found_users->count(), 4); // No filters applied, expect to get all 4 users 1268 | } 1269 | 1270 | public function testItFiltersWithAllFieldsIfAllowAllIsSet() 1271 | { 1272 | $this->seedUsers(); 1273 | 1274 | $repository = $this->getRepository(User::class); 1275 | $this->assertEquals($repository->all()->count(), 4); 1276 | 1277 | // Filters not applied 1278 | $repository->setFilterable([]); 1279 | $found_users = $repository->setFilters([ 1280 | 'profile.is_human' => '=true', 1281 | 'times_captured' => '>2' 1282 | ])->all(); 1283 | $this->assertEquals($found_users->count(), 4); 1284 | 1285 | // Filters now applied 1286 | $repository->setFilterable(EloquentRepository::ALLOW_ALL); 1287 | $found_users = $repository->setFilters([ 1288 | 'profile.is_human' => '=true', 1289 | 'times_captured' => '>2' 1290 | ])->all(); 1291 | $this->assertEquals($found_users->count(), 2); 1292 | } 1293 | 1294 | public function testItCanGetIncludableAsAssoc() 1295 | { 1296 | $repository = $this->getRepository(User::class); 1297 | 1298 | $repository->setIncludable([ 1299 | 'foo', 1300 | 'bar', 1301 | 'baz', 1302 | ]); 1303 | 1304 | $this->assertSame([ 1305 | 'foo' => true, 1306 | 'bar' => true, 1307 | 'baz' => true, 1308 | ], $repository->getIncludable(true)); 1309 | } 1310 | 1311 | public function testItCanGetFillableAsAssoc() 1312 | { 1313 | $repository = $this->getRepository(User::class); 1314 | 1315 | $repository->setFillable([ 1316 | 'foo', 1317 | 'bar', 1318 | 'baz', 1319 | ]); 1320 | 1321 | $this->assertSame([ 1322 | 'foo' => true, 1323 | 'bar' => true, 1324 | 'baz' => true, 1325 | ], $repository->getFillable(true)); 1326 | } 1327 | 1328 | public function testItCanGetFilterableAsAssoc() 1329 | { 1330 | $repository = $this->getRepository(User::class); 1331 | 1332 | $repository->setFilterable([ 1333 | 'foo', 1334 | 'bar', 1335 | 'baz', 1336 | ]); 1337 | 1338 | $this->assertSame([ 1339 | 'foo' => true, 1340 | 'bar' => true, 1341 | 'baz' => true, 1342 | ], $repository->getFilterable(true)); 1343 | } 1344 | 1345 | public function testItCanAggregateQueryCount() 1346 | { 1347 | 1348 | } 1349 | 1350 | public function testItCanAggregateQueryMin() 1351 | { 1352 | 1353 | } 1354 | 1355 | public function testItCanAggregateQueryMax() 1356 | { 1357 | 1358 | } 1359 | 1360 | public function testItCanAggregateQuerySum() 1361 | { 1362 | 1363 | } 1364 | 1365 | public function testItCanAggregateQueryAverage() 1366 | { 1367 | 1368 | } 1369 | 1370 | public function testItCanGroupQuery() 1371 | { 1372 | 1373 | } 1374 | 1375 | /** 1376 | * @test 1377 | * 1378 | * The repository can create many models given an array of items. 1379 | */ 1380 | public function testItCanCreateMany() 1381 | { 1382 | $data = [ 1383 | ['username' => 'sue'], 1384 | ['username' => 'dave'], 1385 | ]; 1386 | 1387 | $users = $this->getRepository(User::class, $data)->createMany(); 1388 | 1389 | $this->assertInstanceOf(Collection::class, $users); 1390 | $this->assertEquals($users->where('username', '=', 'sue')->first()->username, 'sue'); 1391 | $this->assertEquals($users->where('username', '=', 'dave')->first()->username, 'dave'); 1392 | } 1393 | 1394 | /** 1395 | * @test 1396 | * 1397 | * The repository can update many models given an array of items with an id. 1398 | */ 1399 | public function testItCanUpdateMany() 1400 | { 1401 | $userOne = $this->getRepository(User::class, ['username' => 'bobby'])->save(); 1402 | $userTwo = $this->getRepository(User::class, ['username' => 'sam'])->save(); 1403 | $this->assertEquals($userOne->getKey(), 1); 1404 | $this->assertEquals($userOne->username, 'bobby'); 1405 | $this->assertEquals($userTwo->getKey(), 2); 1406 | $this->assertEquals($userTwo->username, 'sam'); 1407 | 1408 | $users = $this->getRepository(User::class,[ 1409 | ['id' => 1, 'username' => 'sue'], 1410 | ['id' => 2, 'username' => 'dave'], 1411 | ])->updateMany(); 1412 | 1413 | $this->assertInstanceOf(Collection::class, $users); 1414 | $this->assertEquals($users->find(1)->id, 1); 1415 | $this->assertEquals($users->find(1)->username, 'sue'); 1416 | $this->assertEquals($users->find(2)->id, 2); 1417 | $this->assertEquals($users->find(2)->username, 'dave'); 1418 | } 1419 | 1420 | /** 1421 | * @test 1422 | * 1423 | * The repository can check if the input should be a many operation or not. 1424 | */ 1425 | public function testItCanCheckIfManyOperation() 1426 | { 1427 | $notManyOperationData = ['id' => 1, 'username' => 'bobby']; 1428 | $manyOperationData = [['id' => 1, 'username' => 'bobby'], ['id' => 2, 'username' => 'sam']]; 1429 | 1430 | $this->assertFalse($this->getRepository(User::class, [])->isManyOperation()); 1431 | $this->assertFalse($this->getRepository(User::class, $notManyOperationData)->isManyOperation()); 1432 | $this->assertTrue($this->getRepository(User::class, $manyOperationData)->isManyOperation()); 1433 | } 1434 | } 1435 | -------------------------------------------------------------------------------- /tests/ExplicitModelResolverTest.php: -------------------------------------------------------------------------------- 1 | get('/users', ['resource' => User::class, 'uses' => 'UserController@getUsers']); 25 | */ 26 | public function testCanResolveModelClassOnRoute() 27 | { 28 | $route = Mockery::mock(Route::class); 29 | $route->shouldReceive('getAction')->twice()->andReturn([ 30 | 'resource' => User::class 31 | ]); 32 | 33 | $model = (new ExplicitModelResolver())->resolveModelClass($route); 34 | 35 | $this->assertEquals(User::class, $model); 36 | $this->assertNotEquals(Post::class, $model); 37 | } 38 | 39 | /** 40 | * Given a controller has a static property named $resource 41 | * When the model is resolved 42 | * Then the resolver should return the properties value. 43 | * 44 | * Example: 45 | * $router->get('/users', ['uses' => 'UserController@getUsers']); 46 | * 47 | * class UserController extends BaseController { 48 | * ... 49 | * 50 | * public static $resource = User::class; 51 | * 52 | * ... 53 | * } 54 | */ 55 | public function testCanResolveModelClassOnController() 56 | { 57 | $route = Mockery::mock(Route::class); 58 | $route->shouldReceive('getAction')->zeroOrMoreTimes()->andReturn([ 59 | 'uses' => 'Fuzz\MagicBox\Tests\UserController@getUsers' 60 | ]); 61 | 62 | $model = (new ExplicitModelResolver())->resolveModelClass($route); 63 | 64 | $this->assertEquals(User::class, $model); 65 | $this->assertNotEquals(Post::class, $model); 66 | } 67 | 68 | /** 69 | * Given the route is a closure 70 | * When the model is resolved 71 | * Then the resolver should return false because it cannot be resolved. 72 | * 73 | * Example: 74 | * $router->get('/users', function($request) { 75 | * return response()->json([], 200); 76 | * }) 77 | */ 78 | public function testItThrowsModelNotResolvedExceptionIfModelCouldNotBeResolved() 79 | { 80 | $route = Mockery::mock(Route::class); 81 | $route->shouldReceive('getAction')->zeroOrMoreTimes()->andReturn([ 82 | 'uses' => function() { 83 | return null; 84 | } 85 | ]); 86 | 87 | $this->expectException(ModelNotResolvedException::class); 88 | $model = (new ExplicitModelResolver())->resolveModelClass($route); 89 | } 90 | } 91 | 92 | 93 | /** 94 | * Class UserController 95 | * 96 | * @package Fuzz\MagicBox\Tests 97 | */ 98 | class UserController extends Controller 99 | { 100 | public static $resource = User::class; 101 | } -------------------------------------------------------------------------------- /tests/FilterTest.php: -------------------------------------------------------------------------------- 1 | artisan->call( 29 | 'db:seed', [ 30 | '--class' => FilterDataSeeder::class 31 | ] 32 | ); 33 | } 34 | 35 | /** 36 | * Retrieve a query for the model 37 | * 38 | * @param string $model_class 39 | * @return \Illuminate\Database\Eloquent\Builder 40 | */ 41 | private function getQuery($model_class) 42 | { 43 | return $model_class::query(); 44 | } 45 | 46 | private function getModelColumns($model_class) 47 | { 48 | $temp_instance = new $model_class; 49 | 50 | return Schema::getColumnListing($temp_instance->getTable()); 51 | } 52 | 53 | public function testItModifiesQuery() 54 | { 55 | $this->assertEquals(User::all()->count(), $this->user_count); 56 | $filters = ['name' => '^lskywalker']; 57 | 58 | $model = User::class; 59 | $query = $this->getQuery($model); 60 | $original_query = clone $query; 61 | $columns = $this->getModelColumns($model); 62 | 63 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 64 | 65 | $this->assertNotSame($original_query, $query); 66 | } 67 | 68 | public function testItStartsWith() 69 | { 70 | $this->assertEquals(User::all()->count(), $this->user_count); 71 | $filters = ['username' => '^lskywalker']; 72 | 73 | $model = User::class; 74 | $query = $this->getQuery($model); 75 | $columns = $this->getModelColumns($model); 76 | 77 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 78 | 79 | $results = $query->get(); 80 | 81 | $this->assertEquals(1, count($results)); 82 | $this->assertEquals('lskywalker@galaxyfarfaraway.com', $results->first()->username); 83 | } 84 | 85 | public function testItFiltersOrStartsWith() 86 | { 87 | $this->assertEquals(User::all()->count(), $this->user_count); 88 | $filters = [ 89 | 'username' => '^lskywalker', 90 | 'or' => ['username' => '^solocup'] 91 | ]; 92 | 93 | $model = User::class; 94 | $query = $this->getQuery($model); 95 | $columns = $this->getModelColumns($model); 96 | 97 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 98 | 99 | $results = $query->get(); 100 | 101 | $this->assertEquals(2, count($results)); 102 | 103 | foreach ($results as $result){ 104 | $this->assertTrue(in_array($result->username, ['lskywalker@galaxyfarfaraway.com', 'solocup@galaxyfarfaraway.com'])); 105 | } 106 | } 107 | 108 | public function testItEndsWith() 109 | { 110 | $this->assertEquals(User::all()->count(), $this->user_count); 111 | $filters = ['name' => '$gana']; 112 | 113 | $model = User::class; 114 | $query = $this->getQuery($model); 115 | $columns = $this->getModelColumns($model); 116 | 117 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 118 | 119 | $results = $query->get(); 120 | 121 | $this->assertEquals(1, count($results)); 122 | $this->assertEquals('lorgana@galaxyfarfaraway.com', $results->first()->username); 123 | } 124 | 125 | public function testItFiltersOrEndsWith() 126 | { 127 | $this->assertEquals(User::all()->count(), $this->user_count); 128 | $filters = [ 129 | 'name' => '$gana', 130 | 'or' => ['name' => '$olo'] 131 | ]; 132 | 133 | $model = User::class; 134 | $query = $this->getQuery($model); 135 | $columns = $this->getModelColumns($model); 136 | 137 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 138 | 139 | $results = $query->get(); 140 | 141 | $this->assertEquals(2, count($results)); 142 | foreach ($results as $result){ 143 | $this->assertTrue(in_array($result->username, ['lorgana@galaxyfarfaraway.com', 'solocup@galaxyfarfaraway.com'])); 144 | } 145 | } 146 | 147 | public function testItContains() 148 | { 149 | $this->assertEquals(User::all()->count(), $this->user_count); 150 | $filters = ['username' => '~clava']; 151 | 152 | $model = User::class; 153 | $query = $this->getQuery($model); 154 | $columns = $this->getModelColumns($model); 155 | 156 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 157 | 158 | $results = $query->get(); 159 | 160 | $this->assertEquals(1, count($results)); 161 | $this->assertEquals('chewbaclava@galaxyfarfaraway.com', $results->first()->username); 162 | } 163 | 164 | public function testItFiltersOrContains() 165 | { 166 | $this->assertEquals(User::all()->count(), $this->user_count); 167 | $filters = [ 168 | 'username' => '~skywalker', 169 | 'or' => ['username' => '~clava'] 170 | ]; 171 | 172 | $model = User::class; 173 | $query = $this->getQuery($model); 174 | $columns = $this->getModelColumns($model); 175 | 176 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 177 | 178 | $results = $query->get(); 179 | 180 | $this->assertEquals(2, count($results)); 181 | foreach ($results as $result){ 182 | $this->assertTrue(in_array($result->username, ['lskywalker@galaxyfarfaraway.com', 'chewbaclava@galaxyfarfaraway.com'])); 183 | } 184 | } 185 | 186 | public function testItIsLessThan() 187 | { 188 | $this->assertEquals(User::all()->count(), $this->user_count); 189 | $filters = ['times_captured' => '<1']; 190 | 191 | $model = User::class; 192 | $query = $this->getQuery($model); 193 | $columns = $this->getModelColumns($model); 194 | 195 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 196 | 197 | $results = $query->get(); 198 | 199 | $this->assertEquals(1, count($results)); 200 | $this->assertEquals('chewbaclava@galaxyfarfaraway.com', $results->first()->username); 201 | } 202 | 203 | public function testItFiltersOrIsLessThan() 204 | { 205 | $this->assertEquals(User::all()->count(), $this->user_count); 206 | $filters = [ 207 | 'times_captured' => '<1', 208 | 'or' => ['times_captured' => '<3'] 209 | ]; 210 | 211 | $model = User::class; 212 | $query = $this->getQuery($model); 213 | $columns = $this->getModelColumns($model); 214 | 215 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 216 | 217 | $results = $query->get(); 218 | 219 | $this->assertEquals(2, count($results)); 220 | foreach ($results as $result){ 221 | $this->assertTrue(in_array($result->username, ['solocup@galaxyfarfaraway.com', 'chewbaclava@galaxyfarfaraway.com'])); 222 | } 223 | } 224 | 225 | public function testItIsGreaterThan() 226 | { 227 | $this->assertEquals(User::all()->count(), $this->user_count); 228 | $filters = ['hands' => '>1']; 229 | 230 | $model = User::class; 231 | $query = $this->getQuery($model); 232 | $columns = $this->getModelColumns($model); 233 | 234 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 235 | 236 | $results = $query->get(); 237 | 238 | $this->assertEquals(2, count($results)); 239 | foreach ($results as $result){ 240 | $this->assertTrue(in_array($result->username, ['solocup@galaxyfarfaraway.com', 'lorgana@galaxyfarfaraway.com'])); 241 | } 242 | } 243 | 244 | public function testItFiltersOrIsGreaterThan() 245 | { 246 | $this->assertEquals(User::all()->count(), $this->user_count); 247 | $filters = [ 248 | 'hands' => '>1', 249 | 'or' => ['hands' => '>0'] 250 | ]; 251 | 252 | $model = User::class; 253 | $query = $this->getQuery($model); 254 | $columns = $this->getModelColumns($model); 255 | 256 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 257 | 258 | $results = $query->get(); 259 | 260 | $this->assertEquals(3, count($results)); 261 | foreach ($results as $result){ 262 | $this->assertTrue(in_array($result->username, ['solocup@galaxyfarfaraway.com', 'lorgana@galaxyfarfaraway.com', 'lskywalker@galaxyfarfaraway.com'])); 263 | } 264 | } 265 | 266 | public function testItIsLessThanOrEquals() 267 | { 268 | $this->assertEquals(User::all()->count(), $this->user_count); 269 | $filters = ['hands' => '<=1']; 270 | 271 | $model = User::class; 272 | $query = $this->getQuery($model); 273 | $columns = $this->getModelColumns($model); 274 | 275 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 276 | 277 | $results = $query->get(); 278 | 279 | $this->assertEquals(2, count($results)); 280 | foreach ($results as $result){ 281 | $this->assertTrue(in_array($result->username, ['chewbaclava@galaxyfarfaraway.com', 'lskywalker@galaxyfarfaraway.com'])); 282 | } 283 | } 284 | 285 | public function testItFiltersOrIsLessThanOrEquals() 286 | { 287 | $this->assertEquals(User::all()->count(), $this->user_count); 288 | $filters = [ 289 | 'times_captured' => '<=2', 290 | 'or' => ['times_captured' => '<=5'] 291 | ]; 292 | 293 | $model = User::class; 294 | $query = $this->getQuery($model); 295 | $columns = $this->getModelColumns($model); 296 | 297 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 298 | 299 | $results = $query->get(); 300 | 301 | $this->assertEquals(3, count($results)); 302 | foreach ($results as $result){ 303 | $this->assertTrue(in_array($result->username, ['chewbaclava@galaxyfarfaraway.com', 'lskywalker@galaxyfarfaraway.com', 'solocup@galaxyfarfaraway.com'])); 304 | } 305 | } 306 | 307 | public function testItIsGreaterThanOrEquals() 308 | { 309 | $this->assertEquals(User::all()->count(), $this->user_count); 310 | $filters = [ 311 | 'times_captured' => '>=5', 312 | ]; 313 | 314 | $model = User::class; 315 | $query = $this->getQuery($model); 316 | $columns = $this->getModelColumns($model); 317 | 318 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 319 | 320 | $results = $query->get(); 321 | 322 | $this->assertEquals(1, count($results)); 323 | foreach ($results as $result){ 324 | $this->assertTrue(in_array($result->username, ['lorgana@galaxyfarfaraway.com'])); 325 | } 326 | } 327 | 328 | public function testItFiltersOrIsGreaterThanOrEquals() 329 | { 330 | $this->assertEquals(User::all()->count(), $this->user_count); 331 | $filters = [ 332 | 'times_captured' => '>=5', 333 | 'or' => ['times_captured' => '>=3'] 334 | ]; 335 | 336 | $model = User::class; 337 | $query = $this->getQuery($model); 338 | $columns = $this->getModelColumns($model); 339 | 340 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 341 | 342 | $results = $query->get(); 343 | 344 | $this->assertEquals(2, count($results)); 345 | foreach ($results as $result){ 346 | $this->assertTrue(in_array($result->username, ['lorgana@galaxyfarfaraway.com', 'lskywalker@galaxyfarfaraway.com'])); 347 | } 348 | } 349 | 350 | public function testItEqualsString() 351 | { 352 | $this->assertEquals(User::all()->count(), $this->user_count); 353 | $filters = [ 354 | 'username' => '=lskywalker@galaxyfarfaraway.com', 355 | ]; 356 | 357 | $model = User::class; 358 | $query = $this->getQuery($model); 359 | $columns = $this->getModelColumns($model); 360 | 361 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 362 | 363 | $results = $query->get(); 364 | 365 | $this->assertEquals(1, count($results)); 366 | foreach ($results as $result){ 367 | $this->assertTrue(in_array($result->username, ['lskywalker@galaxyfarfaraway.com'])); 368 | } 369 | } 370 | 371 | public function testItEqualsInt() 372 | { 373 | $this->assertEquals(User::all()->count(), $this->user_count); 374 | $filters = [ 375 | 'times_captured' => '=6', 376 | ]; 377 | 378 | $model = User::class; 379 | $query = $this->getQuery($model); 380 | $columns = $this->getModelColumns($model); 381 | 382 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 383 | 384 | $results = $query->get(); 385 | 386 | $this->assertEquals(1, count($results)); 387 | foreach ($results as $result){ 388 | $this->assertTrue(in_array($result->username, ['lorgana@galaxyfarfaraway.com'])); 389 | } 390 | } 391 | 392 | public function testItFiltersOrEqualsString() 393 | { 394 | $this->assertEquals(User::all()->count(), $this->user_count); 395 | $filters = [ 396 | 'username' => '=lskywalker@galaxyfarfaraway.com', 397 | 'or' => ['username' => '=lorgana@galaxyfarfaraway.com'] 398 | ]; 399 | 400 | $model = User::class; 401 | $query = $this->getQuery($model); 402 | $columns = $this->getModelColumns($model); 403 | 404 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 405 | 406 | $results = $query->get(); 407 | 408 | $this->assertEquals(2, count($results)); 409 | foreach ($results as $result){ 410 | $this->assertTrue(in_array($result->username, ['lorgana@galaxyfarfaraway.com', 'lskywalker@galaxyfarfaraway.com'])); 411 | } 412 | } 413 | 414 | public function testItFiltersOrEqualsInt() 415 | { 416 | $this->assertEquals(User::all()->count(), $this->user_count); 417 | $filters = [ 418 | 'times_captured' => '=4', 419 | 'or' => ['times_captured' => '=6'] 420 | ]; 421 | 422 | $model = User::class; 423 | $query = $this->getQuery($model); 424 | $columns = $this->getModelColumns($model); 425 | 426 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 427 | 428 | $results = $query->get(); 429 | 430 | $this->assertEquals(2, count($results)); 431 | foreach ($results as $result){ 432 | $this->assertTrue(in_array($result->username, ['lorgana@galaxyfarfaraway.com', 'lskywalker@galaxyfarfaraway.com'])); 433 | } 434 | } 435 | 436 | public function testItNotEqualsString() 437 | { 438 | $this->assertEquals(User::all()->count(), $this->user_count); 439 | $filters = ['username' => '!=lorgana@galaxyfarfaraway.com']; 440 | 441 | $model = User::class; 442 | $query = $this->getQuery($model); 443 | $columns = $this->getModelColumns($model); 444 | 445 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 446 | 447 | $results = $query->get(); 448 | 449 | $this->assertEquals(3, count($results)); 450 | foreach ($results as $result){ 451 | $this->assertTrue($result->username !== 'lorgana@galaxyfarfaraway.com'); 452 | } 453 | } 454 | 455 | public function testItNotEqualsInt() 456 | { 457 | $this->assertEquals(User::all()->count(), $this->user_count); 458 | $filters = ['times_captured' => '!=4']; 459 | 460 | $model = User::class; 461 | $query = $this->getQuery($model); 462 | $columns = $this->getModelColumns($model); 463 | 464 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 465 | 466 | $results = $query->get(); 467 | 468 | $this->assertEquals(3, count($results)); 469 | foreach ($results as $result){ 470 | $this->assertTrue($result->username !== 'lskywalker@galaxyfarfaraway.com'); 471 | } 472 | } 473 | 474 | public function testItFiltersOrNotEqualString() 475 | { 476 | $this->assertEquals(User::all()->count(), $this->user_count); 477 | $filters = [ 478 | 'username' => '=lorgana@galaxyfarfaraway.com', 479 | 'or' => ['username' => '!=lskywalker@galaxyfarfaraway.com'] 480 | ]; 481 | 482 | $model = User::class; 483 | $query = $this->getQuery($model); 484 | $columns = $this->getModelColumns($model); 485 | 486 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 487 | 488 | $results = $query->get(); 489 | 490 | $this->assertEquals(3, count($results)); 491 | foreach ($results as $result){ 492 | // The only one we shouldn't get is lskywalker@galaxyfarfaraway.com' 493 | $this->assertTrue(in_array($result->username, ['lorgana@galaxyfarfaraway.com', 'solocup@galaxyfarfaraway.com', 'chewbaclava@galaxyfarfaraway.com'])); 494 | } 495 | } 496 | 497 | public function testItFiltersOrNotEqualInt() 498 | { 499 | $this->assertEquals(User::all()->count(), $this->user_count); 500 | $filters = [ 501 | 'times_captured' => '=6', 502 | 'or' => ['times_captured' => '!=4'] 503 | ]; 504 | 505 | $model = User::class; 506 | $query = $this->getQuery($model); 507 | $columns = $this->getModelColumns($model); 508 | 509 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 510 | 511 | $results = $query->get(); 512 | 513 | $this->assertEquals(3, count($results)); 514 | foreach ($results as $result){ 515 | // The only one we shouldn't get is lskywalker@galaxyfarfaraway.com' 516 | $this->assertTrue(in_array($result->username, ['lorgana@galaxyfarfaraway.com', 'solocup@galaxyfarfaraway.com', 'chewbaclava@galaxyfarfaraway.com'])); 517 | } 518 | } 519 | 520 | public function testItNotNull() 521 | { 522 | $this->assertEquals(User::all()->count(), $this->user_count); 523 | $filters = ['occupation' => 'NOT_NULL']; 524 | 525 | $model = User::class; 526 | $query = $this->getQuery($model); 527 | $columns = $this->getModelColumns($model); 528 | 529 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 530 | 531 | $results = $query->get(); 532 | 533 | $this->assertEquals(3, count($results)); 534 | foreach ($results as $result){ 535 | $this->assertTrue(in_array($result->username, ['lskywalker@galaxyfarfaraway.com', 'solocup@galaxyfarfaraway.com', 'chewbaclava@galaxyfarfaraway.com'])); 536 | } 537 | } 538 | 539 | public function testItFiltersOrNotNull() 540 | { 541 | $this->assertEquals(User::all()->count(), $this->user_count); 542 | $filters = [ 543 | 'username' => '~lskywalker', 544 | 'or' => ['occupation' => 'NOT_NULL'] 545 | ]; 546 | 547 | $model = User::class; 548 | $query = $this->getQuery($model); 549 | $columns = $this->getModelColumns($model); 550 | 551 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 552 | 553 | $results = $query->get(); 554 | 555 | $this->assertEquals(3, count($results)); 556 | foreach ($results as $result){ 557 | $this->assertTrue(in_array($result->username, ['lskywalker@galaxyfarfaraway.com', 'solocup@galaxyfarfaraway.com', 'chewbaclava@galaxyfarfaraway.com'])); 558 | } 559 | } 560 | 561 | public function testItNull() 562 | { 563 | $this->assertEquals(User::all()->count(), $this->user_count); 564 | $filters = [ 565 | 'occupation' => 'NULL', 566 | ]; 567 | 568 | $model = User::class; 569 | $query = $this->getQuery($model); 570 | $columns = $this->getModelColumns($model); 571 | 572 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 573 | 574 | $results = $query->get(); 575 | 576 | $this->assertEquals(1, count($results)); 577 | foreach ($results as $result){ 578 | $this->assertTrue(in_array($result->username, ['lorgana@galaxyfarfaraway.com',])); 579 | } 580 | } 581 | 582 | public function testItFiltersOrNull() 583 | { 584 | $this->assertEquals(User::all()->count(), $this->user_count); 585 | $filters = [ 586 | 'occupation' => '=Jedi', 587 | 'or' => [ 588 | 'occupation' => 'NULL', 589 | ], 590 | ]; 591 | 592 | $model = User::class; 593 | $query = $this->getQuery($model); 594 | $columns = $this->getModelColumns($model); 595 | 596 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 597 | 598 | $results = $query->get(); 599 | 600 | $this->assertEquals(2, count($results)); 601 | foreach ($results as $result){ 602 | $this->assertTrue(in_array($result->username, ['lorgana@galaxyfarfaraway.com', 'lskywalker@galaxyfarfaraway.com'])); 603 | } 604 | } 605 | 606 | public function testItInString() 607 | { 608 | $this->assertEquals(User::all()->count(), $this->user_count); 609 | $filters = ['name' => '[Chewbacca,Leia Organa,Luke Skywalker]']; 610 | 611 | $model = User::class; 612 | $query = $this->getQuery($model); 613 | $columns = $this->getModelColumns($model); 614 | 615 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 616 | 617 | $results = $query->get(); 618 | 619 | $this->assertEquals(3, count($results)); 620 | foreach ($results as $result){ 621 | $this->assertTrue(in_array($result->username, ['lorgana@galaxyfarfaraway.com', 'lskywalker@galaxyfarfaraway.com', 'chewbaclava@galaxyfarfaraway.com'])); 622 | } 623 | } 624 | 625 | public function testItInInt() 626 | { 627 | $this->assertEquals(User::all()->count(), $this->user_count); 628 | $filters = ['times_captured' => '[0,4,6]']; 629 | 630 | $model = User::class; 631 | $query = $this->getQuery($model); 632 | $columns = $this->getModelColumns($model); 633 | 634 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 635 | 636 | $results = $query->get(); 637 | 638 | $this->assertEquals(3, count($results)); 639 | foreach ($results as $result){ 640 | $this->assertTrue(in_array($result->username, ['lorgana@galaxyfarfaraway.com', 'lskywalker@galaxyfarfaraway.com', 'chewbaclava@galaxyfarfaraway.com'])); 641 | } 642 | } 643 | 644 | public function testItFiltersOrInString() 645 | { 646 | $this->assertEquals(User::all()->count(), $this->user_count); 647 | $filters = [ 648 | 'name' => '[Chewbacca,Luke Skywalker]', 649 | 'or' => ['name' => '[Luke Skywalker,Leia Organa]'] 650 | ]; 651 | 652 | $model = User::class; 653 | $query = $this->getQuery($model); 654 | $columns = $this->getModelColumns($model); 655 | 656 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 657 | 658 | $results = $query->get(); 659 | 660 | $this->assertEquals(3, count($results)); 661 | foreach ($results as $result){ 662 | $this->assertTrue(in_array($result->username, ['lorgana@galaxyfarfaraway.com', 'lskywalker@galaxyfarfaraway.com', 'chewbaclava@galaxyfarfaraway.com'])); 663 | } 664 | } 665 | 666 | public function testItFiltersOrInInt() 667 | { 668 | $this->assertEquals(User::all()->count(), $this->user_count); 669 | $filters = [ 670 | 'times_captured' => '[0,4]', 671 | 'or' => ['times_captured' => '[4,6]'] 672 | ]; 673 | 674 | $model = User::class; 675 | $query = $this->getQuery($model); 676 | $columns = $this->getModelColumns($model); 677 | 678 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 679 | 680 | $results = $query->get(); 681 | 682 | $this->assertEquals(3, count($results)); 683 | foreach ($results as $result){ 684 | $this->assertTrue(in_array($result->username, ['lorgana@galaxyfarfaraway.com', 'lskywalker@galaxyfarfaraway.com', 'chewbaclava@galaxyfarfaraway.com'])); 685 | } 686 | } 687 | 688 | public function testItNotInString() 689 | { 690 | $this->assertEquals(User::all()->count(), $this->user_count); 691 | $filters = [ 692 | 'name' => '![Leia Organa,Chewbacca]', 693 | ]; 694 | 695 | $model = User::class; 696 | $query = $this->getQuery($model); 697 | $columns = $this->getModelColumns($model); 698 | 699 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 700 | 701 | $results = $query->get(); 702 | 703 | $this->assertEquals(2, count($results)); 704 | foreach ($results as $result){ 705 | $this->assertTrue(in_array($result->username, ['solocup@galaxyfarfaraway.com', 'lskywalker@galaxyfarfaraway.com',])); 706 | } 707 | } 708 | 709 | public function testItNotInInt() 710 | { 711 | $this->assertEquals(User::all()->count(), $this->user_count); 712 | $filters = [ 713 | 'times_captured' => '![6,0]', 714 | ]; 715 | 716 | $model = User::class; 717 | $query = $this->getQuery($model); 718 | $columns = $this->getModelColumns($model); 719 | 720 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 721 | 722 | $results = $query->get(); 723 | 724 | $this->assertEquals(2, count($results)); 725 | foreach ($results as $result){ 726 | $this->assertTrue(in_array($result->username, ['solocup@galaxyfarfaraway.com', 'lskywalker@galaxyfarfaraway.com',])); 727 | } 728 | } 729 | 730 | public function testItFiltersOrNotInString() 731 | { 732 | $this->assertEquals(User::all()->count(), $this->user_count); 733 | $filters = [ 734 | 'name' => '![Leia Organa,Chewbacca]', 735 | 'or' => [ 736 | 'name' => '![Leia Organa,Chewbacca,Han Solo]', 737 | ] 738 | ]; 739 | 740 | $model = User::class; 741 | $query = $this->getQuery($model); 742 | $columns = $this->getModelColumns($model); 743 | 744 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 745 | 746 | $results = $query->get(); 747 | 748 | $this->assertEquals(2, count($results)); 749 | foreach ($results as $result){ 750 | $this->assertTrue(in_array($result->username, ['solocup@galaxyfarfaraway.com', 'lskywalker@galaxyfarfaraway.com'])); 751 | } 752 | } 753 | 754 | public function testItFiltersOrNotInInt() 755 | { 756 | $this->assertEquals(User::all()->count(), $this->user_count); 757 | $filters = [ 758 | 'times_captured' => '![6,0]', 759 | 'or' => [ 760 | 'times_captured' => '![0,4,6]' 761 | ] 762 | ]; 763 | 764 | $model = User::class; 765 | $query = $this->getQuery($model); 766 | $columns = $this->getModelColumns($model); 767 | 768 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 769 | 770 | $results = $query->get(); 771 | 772 | $this->assertEquals(2, count($results)); 773 | foreach ($results as $result){ 774 | $this->assertTrue(in_array($result->username, ['solocup@galaxyfarfaraway.com', 'lskywalker@galaxyfarfaraway.com'])); 775 | } 776 | } 777 | 778 | public function testItFiltersNestedRelationships() 779 | { 780 | $this->assertEquals(User::all()->count(), $this->user_count); 781 | $filters = ['profile.favorite_cheese' => '~Gou']; 782 | 783 | $model = User::class; 784 | $query = $this->getQuery($model); 785 | $columns = $this->getModelColumns($model); 786 | 787 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 788 | 789 | $results = $query->get(); 790 | 791 | $this->assertEquals(1, count($results)); 792 | foreach ($results as $result){ 793 | $this->assertTrue(in_array($result->username, ['lskywalker@galaxyfarfaraway.com'])); 794 | } 795 | } 796 | 797 | public function testItProperlyDeterminesScalarFilters() 798 | { 799 | $this->assertEquals(User::all()->count(), $this->user_count); 800 | $filters = ['name' => '=Leia Organa,Luke Skywalker']; 801 | 802 | $model = User::class; 803 | $query = $this->getQuery($model); 804 | $columns = $this->getModelColumns($model); 805 | 806 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 807 | 808 | $results = $query->get(); 809 | 810 | $this->assertEquals(4, count($results)); // It does not filter anything because this is a scalar filter 811 | } 812 | 813 | public function testItFiltersFalse() 814 | { 815 | $this->assertEquals(User::all()->count(), $this->user_count); 816 | $filters = ['profile.is_human' => 'false']; 817 | 818 | $model = User::class; 819 | $query = $this->getQuery($model); 820 | $columns = $this->getModelColumns($model); 821 | 822 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 823 | 824 | $results = $query->get(); 825 | 826 | $this->assertEquals(1, count($results)); 827 | foreach ($results as $result){ 828 | $this->assertTrue(in_array($result->username, ['chewbaclava@galaxyfarfaraway.com'])); 829 | } 830 | } 831 | 832 | public function testItFiltersNestedTrue() 833 | { 834 | $this->assertEquals(User::all()->count(), $this->user_count); 835 | $filters = ['profile.is_human' => 'true']; 836 | 837 | $model = User::class; 838 | $query = $this->getQuery($model); 839 | $columns = $this->getModelColumns($model); 840 | 841 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 842 | 843 | $results = $query->get(); 844 | 845 | $this->assertEquals(3, count($results)); 846 | foreach ($results as $result){ 847 | $this->assertTrue(! in_array($result->username, ['chewbaclava@galaxyfarfaraway.com'])); 848 | } 849 | } 850 | 851 | public function testItFiltersNestedNull() 852 | { 853 | $this->assertEquals(User::all()->count(), $this->user_count); 854 | $filters = ['profile.favorite_fruit' => 'NULL']; 855 | 856 | $model = User::class; 857 | $query = $this->getQuery($model); 858 | $columns = $this->getModelColumns($model); 859 | 860 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 861 | 862 | $results = $query->get(); 863 | 864 | $this->assertEquals(2, count($results)); 865 | foreach ($results as $result){ 866 | $this->assertTrue(in_array($result->username, ['chewbaclava@galaxyfarfaraway.com', 'solocup@galaxyfarfaraway.com'])); 867 | } 868 | } 869 | 870 | /** 871 | * Check to see if filtering by id works with a many to many relationship. 872 | */ 873 | public function testItFiltersNestedBelongsToManyRelationships() 874 | { 875 | $this->assertEquals(User::all()->count(), $this->user_count); 876 | $filters = ['posts.tags.label' => '=#mysonistheworst']; 877 | 878 | $model = User::class; 879 | $query = $this->getQuery($model); 880 | $columns = $this->getModelColumns($model); 881 | 882 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 883 | 884 | $results = $query->get(); 885 | 886 | $this->assertEquals(2, count($results)); 887 | foreach ($results as $result){ 888 | $this->assertTrue(in_array($result->username, ['lorgana@galaxyfarfaraway.com', 'solocup@galaxyfarfaraway.com'])); 889 | } 890 | } 891 | 892 | public function testItFiltersNestedConjuctions() 893 | { 894 | $this->assertEquals(User::all()->count(), $this->user_count); 895 | $filters = [ 896 | 'username' => '^lskywalker', 897 | 'or' => [ 898 | 'name' => '$gana', 899 | 'and' => [ 900 | 'profile.favorite_cheese' => '=Provolone', 901 | 'username' => '$gana@galaxyfarfaraway.com' 902 | ], 903 | 'or' => [ 904 | 'username' => '=solocup@galaxyfarfaraway.com' 905 | ] 906 | ] 907 | ]; 908 | 909 | $model = User::class; 910 | $query = $this->getQuery($model); 911 | $columns = $this->getModelColumns($model); 912 | 913 | Filter::applyQueryFilters($query, $filters, $columns, (new $model)->getTable()); 914 | 915 | $results = $query->get(); 916 | 917 | $this->assertEquals(3, count($results)); 918 | foreach ($results as $result){ 919 | $this->assertTrue(in_array($result->username, ['lorgana@galaxyfarfaraway.com', 'solocup@galaxyfarfaraway.com', 'lskywalker@galaxyfarfaraway.com'])); 920 | } 921 | } 922 | 923 | public function testItCanIntersectAllowedFilters() 924 | { 925 | $filters = [ 926 | 'username' => '^lskywalker', 927 | 'or' => [ 928 | 'name' => '$gana', 929 | 'and' => [ 930 | 'profile.favorite_cheese' => '=Provolone', 931 | 'username' => '$gana@galaxyfarfaraway.com' 932 | ], 933 | 'or' => [ 934 | 'username' => '=solocup@galaxyfarfaraway.com', 935 | 'and' => [ 936 | 'profile.least_favorite_cheese' => '~gouda' 937 | ] 938 | ] 939 | ] 940 | ]; 941 | 942 | $allowed = [ 943 | 'username' => true, 944 | 'profile.favorite_cheese' => true, 945 | ]; 946 | 947 | $this->assertSame([ 948 | 'username' => '^lskywalker', 949 | 'or' => [ 950 | 'and' => [ 951 | 'profile.favorite_cheese' => '=Provolone', 952 | 'username' => '$gana@galaxyfarfaraway.com' 953 | ], 954 | 'or' => [ 955 | 'username' => '=solocup@galaxyfarfaraway.com', 956 | ] 957 | ] 958 | ], Filter::intersectAllowedFilters($filters, $allowed)); 959 | 960 | $filters = [ 961 | 'username' => '^lskywalker', 962 | 'or' => [ 963 | 'name' => '$gana', 964 | 'and' => [ 965 | 'profile.favorite_cheese' => '=Provolone', 966 | 'username' => '$gana@galaxyfarfaraway.com' 967 | ], 968 | 'or' => [ 969 | 'username' => '=solocup@galaxyfarfaraway.com', 970 | 'and' => [ 971 | 'profile.least_favorite_cheese' => '~gouda' 972 | ] 973 | ] 974 | ] 975 | ]; 976 | 977 | $allowed = [ 978 | // None 979 | ]; 980 | 981 | $this->assertSame([], Filter::intersectAllowedFilters($filters, $allowed)); 982 | 983 | $filters = [ 984 | 'username' => '^lskywalker', 985 | 'or' => [ 986 | 'name' => '$gana', 987 | 'and' => [ 988 | 'profile.favorite_cheese' => '=Provolone', 989 | 'username' => '$gana@galaxyfarfaraway.com' 990 | ], 991 | 'or' => [ 992 | 'username' => '=solocup@galaxyfarfaraway.com', 993 | 'and' => [ 994 | 'profile.least_favorite_cheese' => '~gouda' 995 | ] 996 | ] 997 | ] 998 | ]; 999 | 1000 | $allowed = [ 1001 | 'username' => true, 1002 | 'profile.favorite_cheese' => true, 1003 | 'profile.least_favorite_cheese' => true, 1004 | 'name' => true, 1005 | ]; 1006 | 1007 | $this->assertSame([ 1008 | 'username' => '^lskywalker', 1009 | 'or' => [ 1010 | 'name' => '$gana', 1011 | 'and' => [ 1012 | 'profile.favorite_cheese' => '=Provolone', 1013 | 'username' => '$gana@galaxyfarfaraway.com' 1014 | ], 1015 | 'or' => [ 1016 | 'username' => '=solocup@galaxyfarfaraway.com', 1017 | 'and' => [ 1018 | 'profile.least_favorite_cheese' => '~gouda' 1019 | ] 1020 | ] 1021 | ] 1022 | ], Filter::intersectAllowedFilters($filters, $allowed)); 1023 | } 1024 | } 1025 | -------------------------------------------------------------------------------- /tests/Models/NotIncludable.php: -------------------------------------------------------------------------------- 1 | belongsTo(NotIncludable::class); 51 | } 52 | 53 | /** 54 | * @return \Illuminate\Database\Eloquent\Relations\BelongsTo 55 | */ 56 | public function user() 57 | { 58 | return $this->belongsTo(User::class); 59 | } 60 | 61 | /** 62 | * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany 63 | */ 64 | public function tags() 65 | { 66 | return $this->belongsToMany(Tag::class)->withPivot('extra'); 67 | } 68 | 69 | /** 70 | * Get the list of fields fillable by the repository 71 | * 72 | * @return array 73 | */ 74 | public function getRepositoryFillable(): array 75 | { 76 | return self::FILLABLE; 77 | } 78 | 79 | /** 80 | * Get the list of relationships fillable by the repository 81 | * 82 | * @return array 83 | */ 84 | public function getRepositoryIncludable(): array 85 | { 86 | return self::INCLUDABLE; 87 | } 88 | 89 | /** 90 | * Get the list of fields filterable by the repository 91 | * 92 | * @return array 93 | */ 94 | public function getRepositoryFilterable(): array 95 | { 96 | return self::FILTERABLE; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tests/Models/Profile.php: -------------------------------------------------------------------------------- 1 | belongsTo(User::class); 52 | } 53 | 54 | /** 55 | * Get the list of fields fillable by the repository 56 | * 57 | * @return array 58 | */ 59 | public function getRepositoryFillable(): array 60 | { 61 | return self::FILLABLE; 62 | } 63 | 64 | /** 65 | * Get the list of relationships fillable by the repository 66 | * 67 | * @return array 68 | */ 69 | public function getRepositoryIncludable(): array 70 | { 71 | return self::INCLUDABLE; 72 | } 73 | 74 | /** 75 | * Get the list of fields filterable by the repository 76 | * 77 | * @return array 78 | */ 79 | public function getRepositoryFilterable(): array 80 | { 81 | return self::FILTERABLE; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/Models/Tag.php: -------------------------------------------------------------------------------- 1 | belongsToMany(Post::class)->withPivot('extra'); 59 | } 60 | 61 | /** 62 | * Get the list of fields fillable by the repository 63 | * 64 | * @return array 65 | */ 66 | public function getRepositoryFillable(): array 67 | { 68 | return self::FILLABLE; 69 | } 70 | 71 | /** 72 | * Get the list of relationships fillable by the repository 73 | * 74 | * @return array 75 | */ 76 | public function getRepositoryIncludable(): array 77 | { 78 | return self::INCLUDABLE; 79 | } 80 | 81 | /** 82 | * Get the list of fields filterable by the repository 83 | * 84 | * @return array 85 | */ 86 | public function getRepositoryFilterable(): array 87 | { 88 | return self::FILTERABLE; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/Models/User.php: -------------------------------------------------------------------------------- 1 | hasMany(Post::class); 61 | } 62 | 63 | /** 64 | * @return \Illuminate\Database\Eloquent\Relations\HasOne 65 | */ 66 | public function profile() 67 | { 68 | return $this->hasOne(Profile::class); 69 | } 70 | 71 | /** 72 | * For unit testing purposes 73 | * 74 | * @return array 75 | */ 76 | public function getFillable() 77 | { 78 | return $this->fillable; 79 | } 80 | 81 | /** 82 | * For unit testing purposes 83 | * 84 | * @param array $fillable 85 | * 86 | * @return $this 87 | */ 88 | public function setFillable(array $fillable) 89 | { 90 | $this->fillable = $fillable; 91 | 92 | return $this; 93 | } 94 | 95 | /** 96 | * Get the list of fields fillable by the repository 97 | * 98 | * @return array 99 | */ 100 | public function getRepositoryFillable(): array 101 | { 102 | return self::FILLABLE; 103 | } 104 | 105 | /** 106 | * Get the list of relationships fillable by the repository 107 | * 108 | * @return array 109 | */ 110 | public function getRepositoryIncludable(): array 111 | { 112 | return self::INCLUDABLE; 113 | } 114 | 115 | /** 116 | * Get the list of fields filterable by the repository 117 | * 118 | * @return array 119 | */ 120 | public function getRepositoryFilterable(): array 121 | { 122 | return self::FILTERABLE; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | increments('id'); 19 | $table->string('username')->nullable(); 20 | $table->string('name')->nullable(); 21 | $table->integer('hands')->nullable(); 22 | $table->integer('times_captured')->nullable(); 23 | $table->string('occupation')->nullable(); 24 | $table->string('not_fillable')->nullable(); 25 | $table->string('not_filterable')->nullable(); 26 | $table->softDeletes(); 27 | $table->timestamps(); 28 | } 29 | ); 30 | } 31 | 32 | /** 33 | * Reverse the migrations. 34 | * 35 | * @return void 36 | */ 37 | public function down() 38 | { 39 | Schema::dropIfExists('users'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/migrations/2015_01_01_000002_create_posts_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 19 | $table->string('title'); 20 | $table->string('not_fillable')->nullable(); 21 | $table->string('not_filterable')->nullable(); 22 | $table->unsignedInteger('user_id'); 23 | $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); 24 | $table->timestamps(); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | * 31 | * @return void 32 | */ 33 | public function down() 34 | { 35 | Schema::dropIfExists('posts'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/migrations/2015_01_01_000003_create_profile_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 19 | $table->unsignedInteger('user_id'); 20 | $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); 21 | $table->enum( 22 | 'favorite_cheese', [ 23 | 'brie', 24 | 'pepper jack', 25 | 'Gouda', 26 | 'Cheddar', 27 | 'Provolone', 28 | ]); 29 | $table->string('favorite_fruit')->nullable(); 30 | $table->boolean('is_human')->default(false); 31 | $table->string('not_fillable')->nullable(); 32 | $table->string('not_filterable')->nullable(); 33 | }); 34 | } 35 | 36 | /** 37 | * Reverse the migrations. 38 | * 39 | * @return void 40 | */ 41 | public function down() 42 | { 43 | Schema::dropIfExists('profiles'); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/migrations/2015_01_01_000004_create_tags_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 19 | $table->string('label'); 20 | $table->string('not_fillable')->nullable(); 21 | }); 22 | 23 | Schema::create( 24 | 'post_tag', function (Blueprint $table) { 25 | $table->increments('id'); 26 | $table->unsignedInteger('post_id'); 27 | $table->string('not_fillable')->nullable(); 28 | $table->string('not_filterable')->nullable(); 29 | $table->foreign('post_id')->references('id')->on('posts')->onDelete('cascade'); 30 | $table->unsignedInteger('tag_id'); 31 | $table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade'); 32 | $table->string('extra')->nullable(); 33 | }); 34 | } 35 | 36 | /** 37 | * Reverse the migrations. 38 | * 39 | * @return void 40 | */ 41 | public function down() 42 | { 43 | Schema::dropIfExists('post_tag'); 44 | Schema::dropIfExists('tags'); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/seeds/FilterDataSeeder.php: -------------------------------------------------------------------------------- 1 | users() as $user) { 20 | $user_instance = new User; 21 | 22 | foreach ( 23 | [ 24 | 'username', 25 | 'name', 26 | 'hands', 27 | 'times_captured', 28 | 'occupation', 29 | ] as $attribute 30 | ) { 31 | $user_instance->{$attribute} = $user[$attribute]; 32 | } 33 | 34 | $user_instance->save(); 35 | 36 | $profile = new Profile; 37 | foreach ($user['profile'] as $key => $value) { 38 | $profile->{$key} = $value; 39 | } 40 | $profile->user_id = $user_instance->id; 41 | $profile->save(); 42 | 43 | foreach ($user['posts'] as $post) { 44 | $post_instance = new Post; 45 | $post_instance->title = $post['title']; 46 | $post_instance->user_id = $user_instance->id; 47 | $post_instance->save(); 48 | 49 | $tag_ids = []; 50 | foreach ($post['tags'] as $tag) { 51 | $tag_instance = new Tag; 52 | $tag_instance->label = $tag['label']; 53 | $tag_instance->save(); 54 | $tag_ids[] = $tag_instance->id; 55 | } 56 | 57 | $post_instance->tags()->sync($tag_ids); 58 | } 59 | } 60 | 61 | $users = User::with( 62 | [ 63 | 'profile', 64 | 'posts.tags' 65 | ] 66 | )->get()->toArray(); 67 | $test = 'test'; 68 | } 69 | 70 | public function users() 71 | { 72 | return [ 73 | [ 74 | 'username' => 'lskywalker@galaxyfarfaraway.com', 75 | 'name' => 'Luke Skywalker', 76 | 'hands' => 1, 77 | 'times_captured' => 4, 78 | 'occupation' => 'Jedi', 79 | 'profile' => [ 80 | 'favorite_cheese' => 'Gouda', 81 | 'favorite_fruit' => 'Apples', 82 | 'is_human' => true 83 | ], 84 | 'posts' => [ 85 | [ 86 | 'title' => 'I Kissed a Princess and I Liked it', 87 | 'tags' => [ 88 | ['label' => '#peace',], 89 | ['label' => '#thelastjedi',] 90 | ] 91 | ] 92 | ] 93 | ], 94 | [ 95 | 'username' => 'lorgana@galaxyfarfaraway.com', 96 | 'name' => 'Leia Organa', 97 | 'hands' => 2, 98 | 'times_captured' => 6, 99 | 'occupation' => null, 100 | 'profile' => [ 101 | 'favorite_cheese' => 'Provolone', 102 | 'favorite_fruit' => 'Mystery Berries', 103 | 'is_human' => true 104 | ], 105 | 'posts' => [ 106 | [ 107 | 'title' => 'Smugglers: A Girl\'s Dream', 108 | 'tags' => [ 109 | ['label' => '#princess',], 110 | ['label' => '#mysonistheworst',], 111 | ] 112 | ] 113 | ] 114 | ], 115 | [ 116 | 'username' => 'solocup@galaxyfarfaraway.com', 117 | 'name' => 'Han Solo', 118 | 'hands' => 2, 119 | 'times_captured' => 1, 120 | 'occupation' => 'Smuggler', 121 | 'profile' => [ 122 | 'favorite_cheese' => 'Cheddar', 123 | 'favorite_fruit' => null, 124 | 'is_human' => true 125 | ], 126 | 'posts' => [ 127 | [ 128 | 'title' => '10 Easy Ways to Clean Fur From Couches', 129 | 'tags' => [ 130 | ['label' => '#iknow',], 131 | ['label' => '#triggerfinger',], 132 | ['label' => '#mysonistheworst',], 133 | ] 134 | ] 135 | ] 136 | ], 137 | [ 138 | 'username' => 'chewbaclava@galaxyfarfaraway.com', 139 | 'name' => 'Chewbacca', 140 | 'hands' => 0, 141 | 'times_captured' => 0, 142 | 'occupation' => 'Smuggler\'s Assistant', 143 | 'profile' => [ 144 | 'favorite_cheese' => 'brie', 145 | 'favorite_fruit' => null, 146 | 'is_human' => false 147 | ], 148 | 'posts' => [ 149 | [ 150 | 'title' => 'Rrrrrrr-ghghg Rrrr-ghghghghgh Rrrr-ghghghgh!', 151 | 'tags' => [ 152 | ['label' => '#starwarsfurlife',], 153 | ['label' => '#chewonthis',], 154 | ] 155 | ] 156 | ] 157 | ], 158 | ]; 159 | } 160 | } 161 | --------------------------------------------------------------------------------