├── .editorconfig ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CONFIG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── VALIDATION.md ├── composer.json ├── phpunit.xml.dist ├── src ├── AbstractNestedParser.php ├── Contracts │ ├── HandlesUnguardedAttributesInterface.php │ ├── ModelUpdaterFactoryInterface.php │ ├── ModelUpdaterInterface.php │ ├── NestedParserInterface.php │ ├── NestedValidatorFactoryInterface.php │ ├── NestedValidatorInterface.php │ ├── NestingConfigInterface.php │ ├── TemporaryIdsInterface.php │ └── TracksTemporaryIdsInterface.php ├── Data │ ├── RelationInfo.php │ ├── TemporaryId.php │ ├── TemporaryIds.php │ └── UpdateResult.php ├── Exceptions │ ├── DisallowedNestedActionException.php │ ├── InvalidNestedDataException.php │ ├── ModelSaveFailureException.php │ ├── NestedModelNotFoundException.php │ └── StoresNestedKeyTrait.php ├── Factories │ ├── ModelUpdaterFactory.php │ └── NestedValidatorFactory.php ├── ModelUpdater.php ├── NestedModelUpdaterServiceProvider.php ├── NestedValidator.php ├── NestingConfig.php ├── Requests │ └── AbstractNestedDataRequest.php ├── Traits │ ├── NestedUpdatable.php │ └── TracksTemporaryIds.php └── config │ └── nestedmodelupdater.php └── tests ├── BasicModelUpdaterTest.php ├── ElaborateModelUpdaterTest.php ├── Helpers ├── AlternativeUpdater.php ├── ArrayableData.php ├── Models │ ├── Author.php │ ├── Comment.php │ ├── Genre.php │ ├── Post.php │ ├── Special.php │ └── Tag.php ├── Requests │ ├── AbstractNestedTestRequest.php │ └── NestedPostRequest.php └── Rules │ ├── AuthorRules.php │ ├── CommentRules.php │ ├── GenreRules.php │ ├── PostRules.php │ └── TagRules.php ├── ModelUpdaterTemporaryIdsTest.php ├── NestedUpdatableTraitTest.php ├── NestedValidatorFormRequestTest.php ├── NestedValidatorTest.php ├── NestingConfigTest.php └── TestCase.php /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_size = 4 9 | indent_style = space 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = true 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.lock 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 8.1 5 | 6 | install: 7 | - COMPOSER_MEMORY_LIMIT=-1 travis_retry composer install --prefer-dist --no-interaction 8 | 9 | script: 10 | - mkdir -p build/logs 11 | - vendor/bin/phpunit 12 | 13 | notifications: 14 | email: 15 | on_success: never 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [3.0.0] - 2022-10-17 4 | 5 | Breaking changes: refactored entirely for PHP 8.1. 6 | Many interfaces and method signatures are updated, strict typing is enforced. 7 | 8 | ## [2.0.4] - 2019-10-24 9 | 10 | Added Dennis' feature to better handle soft deleting models, including a configuration option that 11 | controls whether updating trashed records is allowed. 12 | 13 | ## [2.0.3] - 2019-10-21 14 | 15 | Added Dennis' feature to allow `forceCreate()` and `forceUpdate()`, which ignores fillable guarding. 16 | 17 | ## [2.0.2] - 2019-09-27 18 | 19 | Fixed a few incorrect method signatures (not compatible with the PHP 7+ strict hints). 20 | Replaced a reference to the `App` alias with a direct reference to the facade. 21 | 22 | ## [2.0.1] - 2019-09-27 23 | 24 | Fixed an issue with BelongsToMany where saving on the relation causes duplicates to be added (or SQL errors to occur). 25 | 26 | ## [2.0.0] - 2019-09-21 27 | 28 | Introduced strict return types and scalar typehints. 29 | Added test setup for Laravel 6.0 context. 30 | 31 | 32 | [3.0.0]: https://github.com/czim/laravel-nestedupdater/compare/2.0.4...3.0.0 33 | [2.0.4]: https://github.com/czim/laravel-nestedupdater/compare/2.0.3...2.0.4 34 | [2.0.3]: https://github.com/czim/laravel-nestedupdater/compare/2.0.2...2.0.3 35 | [2.0.2]: https://github.com/czim/laravel-nestedupdater/compare/2.0.1...2.0.2 36 | [2.0.1]: https://github.com/czim/laravel-nestedupdater/compare/2.0.0...2.0.1 37 | [2.0.0]: https://github.com/czim/laravel-nestedupdater/compare/1.5.0...2.0.0 38 | -------------------------------------------------------------------------------- /CONFIG.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | ## Database Transactions 4 | 5 | When a nested model update process is started, it is, by default, run in a database transaction. 6 | This means that if any exception is thrown, all changes will be rolled back. If you do not want 7 | this to happen, unset the `database-transactions` option in the configuration file, or call 8 | `disableDatabaseTransactions()` on the model updater before running the process. 9 | 10 | 11 | ## Relations Configuration 12 | 13 | Each relation may have a configuration section array in which specific options may be set for updating the relations with the model updater. 14 | If `true` is used instead of an array, default options are used. 15 | 16 | ```php 17 | [ 20 | 'comments' => [ 21 | // options defined here ... 22 | ] 23 | ], 24 | ``` 25 | 26 | Note that 'comments' in the above example refers to the key in the array that contains the nested data, *not* the relation method name. See the `method` option in the list below. 27 | 28 | ## Relation options 29 | 30 | The options that may be be set are as follows: 31 | 32 | - `link-only` (boolean): 33 | Enable this to only allow (re-)linking nested models, but not updating them or creating new (default is `false`) 34 | - `update-only` (boolean): 35 | Enable this to only allow updating existing models through nesting, but not creating new (default is `false`) 36 | - `detach` (boolean): 37 | Set this to `false` or `true` to control whether records omitted from a set of nested records for the relation 38 | are detached from their parent model. If this is `true`, detaching is forced. 39 | (default is `null`, which defaults to `true` for `BelongsToMany` and `false` for `HasMany` type relations) 40 | - `delete-detached` (boolean): 41 | If this is enabled, models that are omitted (and would be detached if `detach` is enabled), will be deleted instead. 42 | There is a simple check in place to prevent models that are still 'in use' are not deleted, but use at your own risk! 43 | (default is `false`) 44 | - `method` (string): 45 | By default, the relation method called on the model is the attribute key for the relation, camelCased. 46 | If the relation method does not follow this pattern, define the method with this option. 47 | (default is `null`) 48 | - `updater` (string): 49 | If you want your own implementation of the `ModelUpdaterInterface` to handle nested update or create actions for 50 | the relation, you can set the fully qualified namespace for it here. 51 | (default is `null`, uses the default package `ModelUpdater` class) 52 | 53 | And for validation: 54 | 55 | - `validator` (string): 56 | If you want your own implementation of the `NestedUpdaterInterface` to handle nested validation for 57 | the relation, you can set the fully qualified namespace for it here. 58 | (default is `null`, uses the default package `NestedValidator` class. 59 | - `rules` (string): 60 | If you want to override the default validation rules class (see validation configuration options) for 61 | the relation, you can set a fully qualified namespace for a class here. 62 | (default is `null`) 63 | - `rules-method` (string): 64 | If you want to override the default validation rules method to be called on the rules class 65 | (see validation configuration options) for the relation, you can set the method here. 66 | (default is `rules`) 67 | 68 | 69 | ## Validation configuration 70 | 71 | The above relations options for validation overrule the validator defaults. 72 | The validation defaults are configured in the `nestedmodelupdater.validation` section of the config. 73 | 74 | 75 | ### Rules class fallback 76 | 77 | The default fallback for rules classes ([see the readme section on validation](VALIDATION.md)) works as follows: 78 | Given a model, say `App\Models\Post`, the class name will be constructed as follows: 79 | 80 | model-rules-namespace + basename of model class + optional postfix 81 | 82 | For example: 83 | 84 | App\Http\\Requests\Rules\ + Post + Rules = App\Http\\Requests\Rules\PostRules 85 | 86 | The namespace and postfix may be configured in the `valiation` section: 87 | 88 | ```php 89 | 'App\\Http\\Requests\\Rules', 91 | 'model-rules-postfix' => 'Rules', 92 | ``` 93 | 94 | Note that using this fallback option is entirely optional. 95 | `model-rules` and/or `relations` settings may be used to prevent the fallback from ever being used. 96 | 97 | 98 | ### Allowing missing rules classes 99 | 100 | By default, if a rules class fallback is not found or instantiable, an empty set of rules is silently used. 101 | This behavior may be altered by changing the value for `validation.allow-missing-rules`: 102 | 103 | ```php 104 | false, 106 | ``` 107 | 108 | When set to false, this will cause an `UnexpectedValueException` to be thrown if no rules class is available. 109 | Note that exceptions will always be thrown if a class is available, but the *method* is not, or cannot be used. 110 | 111 | 112 | ### Rules method 113 | 114 | The indicated class will be instantiated, and a call to the `rules()` method will be performed on it. 115 | This default method may be changed: 116 | 117 | ```php 118 | 'customMethod', 121 | ``` 122 | 123 | ### Model Rules 124 | 125 | It is also possible to set rules classes and methods on a per-model basis, in the `validation.model-rules` array. 126 | These will apply for any validation of the model's data, regardless of its nested relation context. 127 | 128 | ```php 129 | [ 131 | // If a string value is used, it should be the rules class FQN 132 | // the default rules method would be used in this case. 133 | App\Models\Post::class => Your\RulesClass::class, 134 | 135 | // If a rules method needs to be defined, use an array for the 136 | // value and set it as follows. Note that 'class' and 'method' 137 | // are both optional; the default/fallback will be used for any 138 | // option not specified. 139 | App\Models\Comment::class => [ 140 | 'class' => Your\RulesClass::class, 141 | 'method' => 'rulesForComment', 142 | ], 143 | ], 144 | ``` 145 | 146 | Note that the model-specific settings are overruled by relation-specific rules settings. 147 | 148 | 149 | ### A note on detaching 150 | 151 | If the `detach` option is enabled, `BelongsToMany` relations will be synced with detaching enabled. 152 | 153 | When detaching `HasMany` or `HasOne` relations, the foreign keys for the detached models will be set to `NULL`. 154 | This will of course only work for records that have nullable foreign keys. If that is not the case, the operation will fail with a generic SQL error. 155 | In that case you may decide to use the `delete-detached` option, so the 'detached' records will be deleted instead. 156 | 157 | 158 | ### Extending the `ModelUpdater` 159 | 160 | A note on the data returned by `handleNestedSingleUpdateOrCreate()`: 161 | This will normally always return an instance of `UpdateResult` with a model set (the model updated or created). 162 | However, if the result has no model set (`null`), this is a valid result, and the updater will not fail when this happens. 163 | This is done so that, optionally, in an extension of the updater, models may conditionally not be created, or deleted. 164 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/czim/laravel-nestedupdater). 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)**. 11 | 12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 13 | 14 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 15 | 16 | - **Create feature branches** - Don't ask us to pull from your master branch. 17 | 18 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 19 | 20 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. 21 | 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Coen Zimmerman 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 13 | > all 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 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Eloquent Nested Model Updater for Laravel 2 | 3 | [![Latest Version on Packagist][ico-version]][link-packagist] 4 | [![Latest Stable Version](http://img.shields.io/packagist/v/czim/laravel-nestedupdater.svg)](https://packagist.org/packages/czim/laravel-nestedupdater) 5 | [![Software License][ico-license]](LICENSE.md) 6 | [![Build Status](https://travis-ci.org/czim/laravel-nestedupdater.svg?branch=master)](https://travis-ci.org/czim/laravel-nestedupdater) 7 | [![Coverage Status](https://coveralls.io/repos/github/czim/laravel-nestedupdater/badge.svg?branch=master)](https://coveralls.io/github/czim/laravel-nestedupdater?branch=master) 8 | [![SensioLabsInsight](https://insight.sensiolabs.com/projects/cb05e233-afac-486e-b276-2765d1461cd6/mini.png)](https://insight.sensiolabs.com/projects/cb05e233-afac-486e-b276-2765d1461cd6) 9 | 10 | Package for updating nested eloquent model relations using a single data array. 11 | 12 | This package will make it easy to create or update a group of nested, related models through a single method call. 13 | For example, when passing in the following data for an update of a Post model ... 14 | 15 | ```php 16 | 'updated title', 20 | 'comments' => [ 21 | 17, 22 | [ 23 | 'id' => 18, 24 | 'body' => 'updated comment body', 25 | 'author' => [ 26 | 'name' => 'John', 27 | ], 28 | ], 29 | [ 30 | 'body' => 'totally new comment', 31 | 'author' => 512, 32 | ], 33 | ], 34 | ]; 35 | 36 | ``` 37 | 38 | ... this would set a new title for Post model being updated, but additionally: 39 | 40 | - link comment #17 to the post, 41 | - link and/or update comment #18 to the post, setting a new body text for the comment, 42 | - create a new author named 'John' and linking it to comment #18, 43 | - create a new comment for the post and linking author #512 to it 44 | 45 | Any combination of nested creates and updates is supported; the nesting logic follows that of Eloquent relationships and is highly customizable. 46 | 47 | Additionally, this package provides support for validating data with nested relations all at once. 48 | 49 | 50 | ## Version Compatibility 51 | 52 | | Laravel | PHP | Package | 53 | |:--------------|------------|:--------| 54 | | 5.3 and lower | | 1.0 | 55 | | 5.4 to 5.6 | | 1.4 | 56 | | 5.7 to 5.8 | | 1.5 | 57 | | 6.0 and up | 7.4 and up | 2.0 | 58 | | 9.0 and up | 8.1 and up | 3.0 | 59 | 60 | ## Change log 61 | 62 | [View the changelog](CHANGELOG.md). 63 | 64 | ## Install 65 | 66 | Via Composer 67 | 68 | ``` bash 69 | $ composer require czim/laravel-nestedupdater 70 | ``` 71 | 72 | Add this line of code to the providers array located in your `config/app.php` file: 73 | 74 | ```php 75 | Czim\NestedModelUpdater\NestedModelUpdaterServiceProvider::class, 76 | ``` 77 | 78 | Publish the configuration: 79 | 80 | ``` bash 81 | $ php artisan vendor:publish 82 | ``` 83 | 84 | ## Usage 85 | 86 | Note that this package will not do any nested updates without setting up at least a 87 | configuration for the relations that you want to allow nested updates for. 88 | Configuration must be set before this can be used at all. See the configuration section below. 89 | 90 | 91 | ### NestedUpdatable Trait 92 | 93 | An easy way to set up a model for processing nested updates is by using the `NestedUpdatable` trait: 94 | 95 | ```php 96 | 121 | */ 122 | protected $modelUpdaterClass = \Your\UpdaterClass\Here::class; 123 | 124 | /** 125 | * Additionally, optionally, you can set a class to be used 126 | * for the configuration, if you need to override how relation 127 | * configuration is determined. 128 | * 129 | * This class must implement 130 | * \Czim\NestedModelUpdater\Contracts\NestingConfigurationInterface 131 | * 132 | * @var class-string<\Czim\NestedModelUpdater\Contracts\NestingConfigurationInterface> 133 | */ 134 | protected $modelUpdaterConfigClass = \Your\UpdaterConfigClass::class; 135 | 136 | ``` 137 | 138 | 139 | ### Manual ModelUpdater Usage 140 | 141 | Alternatively, you can use the `ModelUpdater` manually, by creating an instance. 142 | 143 | ```php 144 | create([ 'some' => 'create', 'data' => 'here' ]); 153 | 154 | // Perform a nested data update on an existing model 155 | $updater->update([ 'some' => 'update', 'data' => 'here' ], $model); 156 | ``` 157 | 158 | 159 | ## Configuration 160 | 161 | In the `nestedmodelupdater.php` config, configure your relations per model under the `relations` key. 162 | Add keys of the fully qualified namespace of each model that you want to allow nested updates for. 163 | Under each, add keys for the attribute names that you want your nested structure to have for each relation's data. 164 | Finally, for each of those, either add `true` to enable nested updates with all default settings, or override settings in an array. 165 | 166 | As a simple example, if you wish to add comments when creating a post, your setup might look like the following. 167 | 168 | The updating data would be something like this: 169 | 170 | ```php 171 | 'new post title', 174 | 'comments' => [ 175 | [ 176 | 'body' => 'new comment body text', 177 | 'author' => $existingAuthorId 178 | ], 179 | $existingCommentId 180 | ] 181 | ], 182 | ``` 183 | 184 | This could be used to update a post (or create a new post) with a title, create a new comment (which is linked to an existing author) and link an existing comment, and link both to the post model. 185 | 186 | The `relations`-configuration to make this work would look like this: 187 | 188 | ```php 189 | [ 191 | // The model class: 192 | App\Models\Post::class => [ 193 | // the data nested relation attribute 194 | // with a value of true to allow updates with default settings 195 | 'comments' => true 196 | ], 197 | 198 | App\Models\Comment::class => [ 199 | // this time, the defaults are overruled to only allow linking, 200 | // not direct updates of authors through nesting 201 | 'author' => [ 202 | 'link-only' => true 203 | ] 204 | ] 205 | ], 206 | ``` 207 | 208 | Note that any relation not present in the config will be ignored for nesting, and passed as fill data into the main model on which the create or update action is performed. 209 | 210 | More [information on relation configuration](CONFIG.md). 211 | Also check out [the configuration file](https://github.com/czim/laravel-nestedupdater/blob/master/src/config/nestedmodelupdater.php) for further notes. 212 | 213 | 214 | ## Validation 215 | 216 | Validation is not automatically performed by the model updater. This package offers nested validation as a separate process, 217 | that may be implemented as freely as that of the updater itself. 218 | A `NestedValidator` class may be used to perform validation or return validation rules based on the data provided and the 219 | relations configuration set. This will reflect update- or link-only rights and rules for records existing on using primary 220 | keys when updating. 221 | 222 | [Further information on setting up validation here](VALIDATION.md), including different approaches for intiating validation. 223 | 224 | 225 | 226 | ## Non-incrementing primary keys 227 | 228 | The behavior for dealing with models that have non-incrementing primary keys is slightly different. 229 | Normally, the presence of a primary key attribute in a data set will make the model updater assume that an existing record needs to be linked or updated, and it will throw an exception if it cannot find the model. Instead, for non-incrementing keys, it is assumed that any key that does not already exist is to be added to the database. 230 | 231 | If you do not want this, you will have to filter out these occurrences before passing in data to the updater, 232 | or make your own configuration option to make this an optional setting. 233 | 234 | 235 | ## Temporary IDs: Referencing to-be created models in nested data 236 | 237 | When creating models through nested updates, it may be necessary to create a single new model once, 238 | but link it to multiple other parents. Take the following data example: 239 | 240 | ```php 241 | 'New post title', 245 | 'comments' => [ 246 | [ 247 | 'body' => 'Some comment', 248 | 'author' => [ 249 | 'name' => 'Howard Hawks' 250 | ] 251 | ], 252 | [ 253 | 'body' => 'Another comment', 254 | 'author' => [ 255 | 'name' => 'Howard Hawks' 256 | ] 257 | ] 258 | ] 259 | ]; 260 | ``` 261 | 262 | The above data would create two new authors with the same name, which is likely undesirable. 263 | If only one new author should be created, and connected to both comments, this may be done using 264 | temporary IDs: 265 | 266 | ```php 267 | 'New post title', 271 | 'comments' => [ 272 | [ 273 | 'body' => 'Some comment', 274 | 'author' => [ 275 | '_tmp_id' => 1, 276 | 'name' => 'Howard Hawks' 277 | ] 278 | ], 279 | [ 280 | 'body' => 'Another comment', 281 | 'author' => [ 282 | '_tmp_id' => 1, 283 | ] 284 | ] 285 | ] 286 | ]; 287 | ``` 288 | 289 | This would create a single new author with the given name and connect it to both comments. 290 | 291 | The `_tmp_id` reference must be unique for one to-be created model. 292 | It may be an integer or a string value, but it *must not* contain a period (`.`). 293 | 294 | Because there is a (minor) performance cost to checking for temporary IDs, this is disabled by default. 295 | To enable it, simply set `allow-temporary-ids` to `true` in the configuration. 296 | 297 | There are no deep checks for cyclical references or (fairly unlikely) dependency issues for multiple interrelated temporary ID create operations, so be careful with this or perform in-depth validation manually beforehand. 298 | 299 | 300 | ## Unguarded Attributes 301 | 302 | Updates and creates adhere to the `fillable` guards for the relevant models by default. 303 | 304 | There are two ways to circumvent this. 305 | 306 | ### Force fill attributes 307 | 308 | It is possible to let the model updater entirely disregard the fillable guard. 309 | 310 | You can do this either by calling `force()` on the updater before `update()` or `create()`, 311 | or directly by calling `forceUpdate()` or `forceCreate()`. 312 | 313 | ```php 314 | forceCreate([ 'user_id' => 1, 'some' => 'create', 'data' => 'here' ]); 320 | ``` 321 | 322 | 323 | ### Setting specific values for top-level model attributes 324 | 325 | It is possible to prepare the model updater to set attributes bypassing the guard for specific 326 | model attributes, by passing in the values to be set on the top-level model separately. 327 | This may be done using the `setUnguardedAttribute()` method on the model updater, before calling `update()` or `create()`. 328 | 329 | This allows settings some specific values without changing the main data tree. 330 | 331 | Example: 332 | 333 | ```php 334 | setUnguardedAttribute('user_id', 1); 340 | 341 | // Perform a nested data create operation 342 | $model = $updater->create([ 'some' => 'create', 'data' => 'here' ]); 343 | ``` 344 | 345 | In this case the `user_id` would be stored directly on the newly created model. 346 | 347 | As a safety measure, any previously set unguarded attributes will be cleared automatically after a successful model update or create operation. 348 | Set them again for each subsequent update/create to be performed. 349 | 350 | It is also possible to set an entire array of unguarded attributes to assign at once: 351 | 352 | ```php 353 | setUnguardedAttributes([ 355 | 'some_attribute' => 'example', 356 | 'another' => 'value', 357 | ]); 358 | ``` 359 | 360 | Currently queued unguarded attributes to be assigned may be retrieved using `getUnguardedAttributes()`. 361 | The unguarded attributes may also be cleared at any time using `clearUnguardedAttributes()`. 362 | 363 | 364 | ## Associative array data 365 | 366 | Be careful submitting associative arrays for entries of plural relations. 367 | They are supported, but can easily break validation: 368 | Problems will arise when data is submitted with associative keys that contain `'.'` like so: 369 | 370 | ```php 371 | [ 374 | 'some.key' => [ 375 | 'body' => 'Some comment', 376 | ], 377 | 'another-key' => [ 378 | 'body' => 'Another comment', 379 | ] 380 | ] 381 | ]; 382 | ``` 383 | 384 | Since Laravel's `Arr::get()` and other dot-notation based lookup methods are used, 385 | nested validation will fail to properly validate data entries with such keys. 386 | 387 | Note that such associative keys serve no purpose for the model updater itself. 388 | The best way to avoid problems is to normalize your data so that all plural relation arrays are non-associative. 389 | Alternatively, replace any `.` in the array's keys with a placeholder. 390 | See 'Extending functionality' below for tips. 391 | 392 | 393 | ## Extending functionality 394 | 395 | The `ModelUpdater` class should be considered a prime candidate for customization. 396 | The `normalizeData()` method may be overridden to manipulate the data array passed in before it is parsed. 397 | Additionally check out `deleteFormerlyRelatedModel()`, which may be useful to set up in cases where conditions for deleting need to be refined. 398 | 399 | Note that it is your own ModelUpdater extension may be set for specific relations by using the `updater` attribute. 400 | 401 | The validator shares much of the updater's structure, so it should be equally easy to extend. 402 | 403 | 404 | ## Contributing 405 | 406 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 407 | 408 | 409 | ## Credits 410 | 411 | - [Coen Zimmerman][link-author] 412 | - [All Contributors][link-contributors] 413 | 414 | ## License 415 | 416 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 417 | 418 | [ico-version]: https://img.shields.io/packagist/v/czim/laravel-nestedupdater.svg?style=flat-square 419 | [ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square 420 | [ico-downloads]: https://img.shields.io/packagist/dt/czim/laravel-nestedupdater.svg?style=flat-square 421 | 422 | [link-packagist]: https://packagist.org/packages/czim/laravel-nestedupdater 423 | [link-downloads]: https://packagist.org/packages/czim/laravel-nestedupdater 424 | [link-author]: https://github.com/czim 425 | [link-contributors]: ../../contributors 426 | -------------------------------------------------------------------------------- /VALIDATION.md: -------------------------------------------------------------------------------- 1 | # Validation 2 | 3 | ## Model rules 4 | 5 | The validator will automatically determine rules for validating primary keys and the general nested structure. 6 | Specific rules for models must be provided separately, through classes with a `rules()` method that return 7 | rules for that model. 8 | 9 | The [nested model updater configuration](CONFIG.md) may be used to set a default namespace and naming scheme 10 | to look for classes that contain rules, or specific rules classes may be defined for specific models, or for 11 | specific nested relations. 12 | 13 | **Important**: do _not_ use Laravel FormRequest classes as rule classes, and do not perform nested validation logic 14 | in rules classes on construction. Otherwise, you run the risk of endless recursion as nested validation is performed 15 | by the rules class, which needs to instantiate a rules class, which performs validation, etc. etc. 16 | 17 | However it is set up, the end result should be a class that may be instantiated and provide a method like 18 | this: 19 | 20 | ```php 21 | 24 | */ 25 | public function rules(): array 26 | { 27 | return [ 28 | 'name' => 'string|max:50' 29 | ]; 30 | } 31 | ``` 32 | 33 | The `rules` method may optionally use a `$type` parameter, to differentiate between update and create 34 | validation rules: 35 | 36 | ```php 37 | 41 | */ 42 | public function rules(string $type = 'create') 43 | { 44 | if ($type === 'create') { 45 | return [ 46 | 'name' => 'required|string|max:50' 47 | ]; 48 | } 49 | 50 | return [ 51 | 'name' => 'string' 52 | ]; 53 | } 54 | ``` 55 | 56 | Currently type will always be either `'create'` or `'update'`, and reflects the nested relation action 57 | that would be performed by processing the nested data structure with the model updater. 58 | 59 | 60 | ## Manually setting up the validator 61 | 62 | To Do: add an example here 63 | 64 | Setting up a validator is very much like using the `ModelUpdater`: 65 | 66 | ```php 67 | validate([ 'some' => 'create', 'data' => 'here' ], true); 76 | 77 | // or update 78 | $success = $validator->validate([ 'some' => 'create', 'data' => 'here' ], false); 79 | 80 | // If validation fails, the error messages may be retrieved. 81 | // If validation succeeds, the messages() response will always be an empty MessageBag instance. 82 | if (! $success) { 83 | $errors = $validator->messages(); 84 | 85 | dd($errors); 86 | } 87 | ``` 88 | 89 | 90 | ### Retrieving validation rules 91 | 92 | Alternatively, it is possible to extract the validation rules without performing validation directly: 93 | 94 | ```php 95 | validationRules([ 'some' => 'create', 'data' => 'here' ], true); 101 | 102 | // or update 103 | $rules = $validator->validationRules([ 'some' => 'create', 'data' => 'here' ], false); 104 | ``` 105 | 106 | The rules are returned as a flat associative array. 107 | 108 | 109 | ## Form Requests 110 | 111 | An abstract form request class is provided to make it easier to set up custom nested data 112 | form requests. To use it, extend `Czim\NestedModelUpdater\Requests\AbstractNestedDataRequest`: 113 | 114 | ```php 115 | 124 | */ 125 | protected function getNestedModelClass(): string 126 | { 127 | return \App\Model\YourModel::class; 128 | } 129 | 130 | protected function isCreating(): bool 131 | { 132 | // As an example, the difference between creating and updating here is 133 | // simulated as that of the difference between using a POST and PUT method. 134 | 135 | return request()->getMethod() != 'PUT' && request()->getMethod() != 'PATCH'; 136 | } 137 | } 138 | ``` 139 | 140 | All the usual rules for using Form Requests apply, including the `authorize()` method and 141 | the redirection behaviour for failed validation. 142 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "czim/laravel-nestedupdater", 3 | "description": "Eloquent model updater for nested relations data for Laravel.", 4 | "keywords": [ 5 | "eloquent", 6 | "model", 7 | "relations" 8 | ], 9 | "homepage": "https://github.com/czim/laravel-nestedupdater", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Coen Zimmerman", 14 | "email": "coen.zimmerman@endeavour.nl", 15 | "homepage": "https://github.com/czim", 16 | "role": "Developer" 17 | } 18 | ], 19 | "require": { 20 | "php": "^8.1" 21 | }, 22 | "require-dev": { 23 | "phpunit/phpunit": "^9.5", 24 | "mockery/mockery": "^1.4", 25 | "orchestra/testbench": "^7.0", 26 | "orchestra/database": "^7.0" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "Czim\\NestedModelUpdater\\": "src" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "Czim\\NestedModelUpdater\\Test\\": "tests" 36 | } 37 | }, 38 | "scripts": { 39 | "test": "phpunit" 40 | }, 41 | "minimum-stability": "dev", 42 | "prefer-stable": true 43 | } 44 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | ./tests/ 15 | 16 | 17 | 18 | 19 | 20 | ./src/ 21 | 22 | ./tests/ 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/AbstractNestedParser.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | abstract class AbstractNestedParser implements NestedParserInterface 25 | { 26 | use TracksTemporaryIds; 27 | 28 | protected NestingConfigInterface $config; 29 | 30 | /** 31 | * The FQN for the main model being created or updated 32 | * 33 | * @var class-string 34 | */ 35 | protected string $modelClass; 36 | 37 | /** 38 | * Model being updated or created. 39 | * 40 | * @var TModel|null 41 | */ 42 | protected ?Model $model = null; 43 | 44 | /** 45 | * If available the FQN of the parent model (may be set while parentModel instance is not). 46 | * 47 | * @var class-string|null 48 | */ 49 | protected ?string $parentModelClass; 50 | 51 | /** 52 | * If available, the (future) parent model of this record. 53 | * 54 | * @var TParent|null 55 | */ 56 | protected ?Model $parentModel = null; 57 | 58 | /** 59 | * If available, the relation attribute on the parent model that may be used to 60 | * look up the nested config relation info. 61 | * 62 | * @var string|null 63 | */ 64 | protected ?string $parentAttribute; 65 | 66 | /** 67 | * Dot-notation key, if relevant, representing the record currently updated or created. 68 | * 69 | * @var string|null 70 | */ 71 | protected ?string $nestedKey; 72 | 73 | /** 74 | * Information about the nested relationships. If a key in the data array is present as a key in this array, 75 | * it should be considered a nested relation's data. 76 | * 77 | * @var array> keyed by nested attribute data key 78 | */ 79 | protected array $relationInfo = []; 80 | 81 | /** 82 | * The information about the relation on the parent's attribute, based on parentModel & parentAttribute. 83 | * Only set if not top-level. 84 | * 85 | * @var RelationInfo|null 86 | */ 87 | protected ?RelationInfo $parentRelationInfo = null; 88 | 89 | /** 90 | * Whether the relations in the data array have been analyzed. 91 | * 92 | * @var bool 93 | */ 94 | protected bool $relationsAnalyzed = false; 95 | 96 | /** 97 | * Data passed in for the create or update process. 98 | * 99 | * @var array 100 | */ 101 | protected array $data = []; 102 | 103 | 104 | /** 105 | * @param class-string $modelClass FQN for model 106 | * @param string|null $parentAttribute the name of the attribute on the parent's data array 107 | * @param string|null $nestedKey dot-notation key for tree data (ex.: 'blog.comments.2.author') 108 | * @param TParent|null $parentModel the parent model, if this is a recursive/nested call 109 | * @param NestingConfigInterface|null $config 110 | * @param class-string|null $parentModelClass if the parentModel is not known, but its class is, set this 111 | */ 112 | public function __construct( 113 | string $modelClass, 114 | ?string $parentAttribute = null, 115 | ?string $nestedKey = null, 116 | Model $parentModel = null, 117 | NestingConfigInterface $config = null, 118 | ?string $parentModelClass = null 119 | ) { 120 | if ($config === null) { 121 | /** @var NestingConfigInterface $config */ 122 | $config = app(NestingConfigInterface::class); 123 | } 124 | 125 | $this->modelClass = $modelClass; 126 | $this->parentAttribute = $parentAttribute; 127 | $this->nestedKey = $nestedKey; 128 | $this->parentModel = $parentModel; 129 | $this->config = $config; 130 | $this->parentModelClass = $parentModel ? $parentModel::class : $parentModelClass; 131 | 132 | if ($parentAttribute && $this->parentModelClass) { 133 | $this->parentRelationInfo = $this->config->getRelationInfo($parentAttribute, $this->parentModelClass); 134 | } 135 | } 136 | 137 | /** 138 | * {@inheritDoc} 139 | */ 140 | public function getRelationInfoForDataKeyInDotNotation(string $key): RelationInfo|false 141 | { 142 | $explodedKeys = explode('.', $key); 143 | $nextLevelKey = array_shift($explodedKeys); 144 | $nextLevelIndex = null; 145 | 146 | if (count($explodedKeys) && is_numeric(head($explodedKeys))) { 147 | $nextLevelIndex = (int) array_shift($explodedKeys); 148 | } 149 | 150 | $remainingKey = implode('.', $explodedKeys); 151 | 152 | // prepare the next recursive step and pass on the key 153 | // get the info for the next key, make sure that the info is loaded 154 | 155 | /** @var RelationInfo $info */ 156 | $info = Arr::get($this->relationInfo, $nextLevelKey); 157 | 158 | if (! $info) { 159 | $info = $this->getRelationInfoForKey($nextLevelKey); 160 | } 161 | 162 | if (! $info) { 163 | return false; 164 | } 165 | 166 | // we only need the updater if we cannot derive the model 167 | // class directly from the relation info. 168 | if (empty($remainingKey)) { 169 | return $info; 170 | } 171 | 172 | $updater = $this->makeNestedParser($info->updater(), [ 173 | get_class($info->model()), 174 | $nextLevelKey, 175 | $this->appendNestedKey($nextLevelKey, $nextLevelIndex), 176 | $this->model, 177 | $this->config, 178 | ]); 179 | 180 | return $updater->getRelationInfoForDataKeyInDotNotation($remainingKey); 181 | } 182 | 183 | /** 184 | * Analyzes data to find nested relations data, and stores information about each. 185 | */ 186 | protected function analyzeNestedRelationsData(): void 187 | { 188 | $this->relationInfo = []; 189 | 190 | foreach ($this->data as $key => $value) { 191 | if (! $this->config->isKeyNestedRelation($key)) { 192 | continue; 193 | } 194 | 195 | $this->relationInfo[ $key ] = $this->getRelationInfoForKey($key); 196 | } 197 | 198 | $this->relationsAnalyzed = true; 199 | } 200 | 201 | /** 202 | * Returns data array containing only the data that should be stored on the main model being updated/created. 203 | * 204 | * @return array 205 | */ 206 | protected function getDirectModelData(): array 207 | { 208 | // this only works if the relations have been analyzed 209 | if (! $this->relationsAnalyzed) { 210 | $this->analyzeNestedRelationsData(); 211 | } 212 | 213 | return Arr::except($this->data, array_keys($this->relationInfo)); 214 | } 215 | 216 | /** 217 | * Makes a nested model parser or updater instance, for recursive use. 218 | * 219 | * @param class-string $class FQN of updater 220 | * @param array $parameters parameters for model updater constructor 221 | * @return NestedParserInterface 222 | */ 223 | abstract protected function makeNestedParser(string $class, array $parameters): NestedParserInterface; 224 | 225 | /** 226 | * Returns nested key for the current full-depth nesting. 227 | * 228 | * @param string $key 229 | * @param null|string|int $index 230 | * @return string 231 | */ 232 | protected function appendNestedKey(string $key, int|string|null $index = null): string 233 | { 234 | return ($this->nestedKey ? $this->nestedKey . '.' : '') 235 | . $key 236 | . ($index !== null ? '.' . $index : ''); 237 | } 238 | 239 | /** 240 | * Returns and stores relation info for a given nested model key. 241 | * 242 | * @param string $key 243 | * @return RelationInfo 244 | */ 245 | protected function getRelationInfoForKey(string $key): RelationInfo 246 | { 247 | $this->relationInfo[ $key ] = $this->config->getRelationInfo($key, $this->modelClass); 248 | 249 | return $this->relationInfo[ $key ]; 250 | } 251 | 252 | /** 253 | * Returns whether this instance is performing a top-level operation, 254 | * as opposed to a nested at any recursion depth below it. 255 | * 256 | * @return bool 257 | */ 258 | protected function isTopLevel(): bool 259 | { 260 | return $this->parentAttribute === null 261 | && $this->parentRelationInfo === null; 262 | } 263 | 264 | /** 265 | * @param mixed $id primary model key or lookup value 266 | * @param null|string $attribute primary model key name or lookup column, if null, uses find() method 267 | * @param null|class-string $modelClass optional, if not looking up the main model 268 | * @param null|string $nestedKey optional, if not looking up the main model 269 | * @param bool $exceptionIfNotFound 270 | * @param bool $withTrashed 271 | * @return Model|null 272 | */ 273 | protected function getModelByLookupAttribute( 274 | mixed $id, 275 | ?string $attribute = null, 276 | ?string $modelClass = null, 277 | ?string $nestedKey = null, 278 | bool $exceptionIfNotFound = true, 279 | bool $withTrashed = false, 280 | ): ?Model { 281 | $class = $modelClass ?: $this->modelClass; 282 | $model = new $class(); 283 | $nestedKey = $nestedKey ?: $this->nestedKey; 284 | 285 | if (! $model instanceof Model) { 286 | throw new UnexpectedValueException("Model class FQN expected, got {$class} instead."); 287 | } 288 | 289 | /** @var Builder $queryBuilder */ 290 | $queryBuilder = $model::query(); 291 | 292 | if ($withTrashed && $queryBuilder->hasMacro('withTrashed')) { 293 | $queryBuilder->withoutGlobalScope(SoftDeletingScope::class); 294 | } 295 | 296 | /** @var Model $model */ 297 | $model = $queryBuilder->where($attribute ?? $model->getKeyName(), $id)->first(); 298 | 299 | if (! $model && $exceptionIfNotFound) { 300 | throw (new NestedModelNotFoundException()) 301 | ->setModel($class) 302 | ->setNestedKey($nestedKey); 303 | } 304 | 305 | return $model; 306 | } 307 | 308 | /** 309 | * @param mixed $id primary model key or lookup value 310 | * @param null|string $attribute primary model key name or lookup column, if null, uses find() method 311 | * @param null|class-string $modelClass optional, if not looking up the main model 312 | * @return bool 313 | */ 314 | protected function checkModelExistsByLookupAtribute( 315 | mixed $id, 316 | ?string $attribute = null, 317 | ?string $modelClass = null 318 | ): bool { 319 | $class = $modelClass ?: $this->modelClass; 320 | $model = new $class(); 321 | 322 | if (! $model instanceof Model) { 323 | throw new UnexpectedValueException("Model class FQN expected, got {$class} instead."); 324 | } 325 | 326 | if ($attribute === null) { 327 | return null !== $model->query()->find($id); 328 | } 329 | 330 | return $model->query()->where($attribute, $id)->count() > 0; 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /src/Contracts/HandlesUnguardedAttributesInterface.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function getUnguardedAttributes(): array; 13 | 14 | /** 15 | * Sets a list of unguarded attributes to store directly on the model, bypassing the fillable guard. 16 | * 17 | * @param array $attributes associative key value pairs 18 | * @return $this 19 | */ 20 | public function setUnguardedAttributes(array $attributes): static; 21 | 22 | /** 23 | * Sets an unguarded attribute to store directly on the model, bypassing the fillable guard. 24 | * 25 | * @param string $key 26 | * @param mixed $value 27 | * @return $this 28 | */ 29 | public function setUnguardedAttribute(string $key, mixed $value): static; 30 | 31 | /** 32 | * Clears list of currently to be applied unguarded attributes. 33 | * 34 | * @return $this 35 | */ 36 | public function clearUnguardedAttributes(): static; 37 | } 38 | -------------------------------------------------------------------------------- /src/Contracts/ModelUpdaterFactoryInterface.php: -------------------------------------------------------------------------------- 1 | > $class 15 | * @param array $parameters constructor parameters for model updater 16 | * @return ModelUpdaterInterface 17 | */ 18 | public function make(string $class, array $parameters = []): ModelUpdaterInterface; 19 | } 20 | -------------------------------------------------------------------------------- /src/Contracts/ModelUpdaterInterface.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | interface ModelUpdaterInterface extends 16 | NestedParserInterface, 17 | TracksTemporaryIdsInterface, 18 | HandlesUnguardedAttributesInterface 19 | { 20 | /** 21 | * Creates a new model with (potential) nested data 22 | * 23 | * @param array $data 24 | * @return UpdateResult 25 | * @throws ModelSaveFailureException 26 | */ 27 | public function create(array $data): UpdateResult; 28 | 29 | /** 30 | * Force creates a new model with (potential) nested data 31 | * 32 | * @param array $data 33 | * @return UpdateResult 34 | * @throws ModelSaveFailureException 35 | */ 36 | public function forceCreate(array $data): UpdateResult; 37 | 38 | /** 39 | * Updates an existing model with (potential) nested update data 40 | * 41 | * @param array $data 42 | * @param int|string|TModel $model either an existing model or its ID 43 | * @param string|null $attribute lookup column, if not primary key, only if $model is int 44 | * @param array $saveOptions options to pass on to the save() Eloquent method 45 | * @return UpdateResult 46 | * @throws ModelSaveFailureException 47 | */ 48 | public function update( 49 | array $data, 50 | int|string|Model $model, 51 | string $attribute = null, 52 | array $saveOptions = [], 53 | ): UpdateResult; 54 | 55 | /** 56 | * Force updates an existing model with (potential) nested update data. 57 | * 58 | * @param array $data 59 | * @param int|string|TModel $model either an existing model or its ID 60 | * @param string|null $attribute lookup column, if not primary key, only if $model is int 61 | * @param array $saveOptions options to pass on to the save() Eloquent method 62 | * @return UpdateResult 63 | * @throws ModelSaveFailureException 64 | */ 65 | public function forceUpdate( 66 | array $data, 67 | int|string|Model $model, 68 | ?string $attribute = null, 69 | array $saveOptions = [], 70 | ): UpdateResult; 71 | 72 | /** 73 | * Sets the forceFill property on the current instance. 74 | * 75 | * When set to true, forceFill() will be used to set attributes on the model, rather than the regular fill(), 76 | * which takes guarded attributes into consideration. 77 | * 78 | * @param bool $force 79 | * @return $this 80 | */ 81 | public function force(bool $force): static; 82 | } 83 | -------------------------------------------------------------------------------- /src/Contracts/NestedParserInterface.php: -------------------------------------------------------------------------------- 1 | $modelClass FQN for model 16 | * @param null|string $parentAttribute the name of the attribute on the parent's data array 17 | * @param null|string $nestedKey dot-notation key for tree data (ex.: 'blog.comments.2.author') 18 | * @param null|TParent $parentModel the parent model, if this is a recursive/nested call 19 | * @param null|NestingConfigInterface $config 20 | * @param null|class-string $parentModelClass if the parentModel is not known, but its class is, set this 21 | */ 22 | public function __construct( 23 | string $modelClass, 24 | ?string $parentAttribute = null, 25 | ?string $nestedKey = null, 26 | ?Model $parentModel = null, 27 | ?NestingConfigInterface $config = null, 28 | ?string $parentModelClass = null, 29 | ); 30 | 31 | /** 32 | * Returns RelationInfo instance for nested data element by dot notation data key. 33 | * 34 | * @param string $key 35 | * @return RelationInfo|false false if data could not be determined 36 | */ 37 | public function getRelationInfoForDataKeyInDotNotation(string $key): RelationInfo|false; 38 | } 39 | -------------------------------------------------------------------------------- /src/Contracts/NestedValidatorFactoryInterface.php: -------------------------------------------------------------------------------- 1 | > $class 13 | * @param array $parameters constructor parameters for validator 14 | * @return NestedValidatorInterface 15 | */ 16 | public function make(string $class, array $parameters = []): NestedValidatorInterface; 17 | } 18 | -------------------------------------------------------------------------------- /src/Contracts/NestedValidatorInterface.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | interface NestedValidatorInterface extends NestedParserInterface 14 | { 15 | /** 16 | * Performs validation and returns whether it succeeds. 17 | * 18 | * @param array $data 19 | * @param bool $creating if false, validate for update 20 | * @return bool 21 | */ 22 | public function validate(array $data, bool $creating = true): bool; 23 | 24 | /** 25 | * Returns validation rules array for full nested data. 26 | * 27 | * @param array $data 28 | * @param bool $creating 29 | * @return array 30 | */ 31 | public function validationRules(array $data, bool $creating = true): array; 32 | 33 | /** 34 | * Returns validation messages, if validation has been performed. 35 | * 36 | * @return null|MessageBag 37 | */ 38 | public function messages(): ?MessageBag; 39 | 40 | /** 41 | * Returns validation rules for the current model only. 42 | * 43 | * @param bool $prefixNesting if true, prefixes the validation rules with the relevant key nesting. 44 | * @param bool $creating 45 | * @return array 46 | */ 47 | public function getDirectModelValidationRules(bool $prefixNesting = false, bool $creating = true): array; 48 | } 49 | -------------------------------------------------------------------------------- /src/Contracts/NestingConfigInterface.php: -------------------------------------------------------------------------------- 1 | $parentModel FQN of the parent model 18 | * @return $this 19 | */ 20 | public function setParentModel(string $parentModel): static; 21 | 22 | /** 23 | * Returns a container with information about the nested relation by key 24 | * 25 | * @param string $key 26 | * @param null|class-string $parentModel the FQN for the parent model 27 | * @return RelationInfo 28 | */ 29 | public function getRelationInfo(string $key, ?string $parentModel = null): RelationInfo; 30 | 31 | /** 32 | * Returns the FQN for the ModelUpdater to be used for a specific nested relation key 33 | * 34 | * @param string $key 35 | * @param null|class-string $parentModel the FQN for the parent model 36 | * @return string 37 | */ 38 | public function getUpdaterClassForKey(string $key, ?string $parentModel = null): string; 39 | 40 | /** 41 | * Returns whether a key, for the given model, is a nested relation at all. 42 | * 43 | * @param string $key 44 | * @param null|class-string $parentModel the FQN for the parent model 45 | * @return bool 46 | */ 47 | public function isKeyNestedRelation(string $key, ?string $parentModel = null): bool; 48 | 49 | /** 50 | * Returns whether a key, for the given model, is an updateable nested relation. 51 | * 52 | * Updatable relations are relations that may have their contents updated through 53 | * the nested update operation. This returns false if related models may only be 54 | * linked, but not modified. 55 | * 56 | * @param string $key 57 | * @param null|class-string $parentModel the FQN for the parent model 58 | * @return bool 59 | */ 60 | public function isKeyUpdatableNestedRelation(string $key, ?string $parentModel = null): bool; 61 | 62 | /** 63 | * Returns whether a key, for the given model, is a nested relation for which 64 | * new models may be created. 65 | * 66 | * @param string $key 67 | * @param null|class-string $parentModel the FQN for the parent model 68 | * @return bool 69 | */ 70 | public function isKeyCreatableNestedRelation(string $key, ?string $parentModel = null): bool; 71 | } 72 | -------------------------------------------------------------------------------- /src/Contracts/TemporaryIdsInterface.php: -------------------------------------------------------------------------------- 1 | 33 | */ 34 | public function getDataForId(string $key): ?array; 35 | 36 | /** 37 | * Sets (nested) data for a given temporary ID key. 38 | * 39 | * @param string $key 40 | * @param array $data 41 | * @return $this 42 | */ 43 | public function setDataForId(string $key, array $data): TemporaryIdsInterface; 44 | 45 | /** 46 | * Returns the model class associated with a temporary ID. 47 | * 48 | * @param string $key 49 | * @return null|class-string 50 | */ 51 | public function getModelClassForId(string $key): ?string; 52 | 53 | /** 54 | * Sets the model class associated with a temporary ID. 55 | * 56 | * @param string $key 57 | * @param class-string $class 58 | * @return $this 59 | */ 60 | public function setModelClassForId(string $key, string $class): TemporaryIdsInterface; 61 | 62 | /** 63 | * Gets created model instance set for given temporary ID. 64 | * 65 | * @param string $key 66 | * @return null|Model 67 | */ 68 | public function getModelForId(string $key): ?Model; 69 | 70 | /** 71 | * Sets a (created) model for a temporary ID. 72 | * 73 | * @param string $key 74 | * @param Model $model 75 | * @return $this 76 | */ 77 | public function setModelForId(string $key, Model $model): static; 78 | 79 | /** 80 | * Marks whether the model for a given temporary may be created. 81 | * 82 | * @param string $key 83 | * @param bool $allowed 84 | * @return $this 85 | */ 86 | public function markAllowedToCreateForId(string $key, bool $allowed = true): static; 87 | 88 | /** 89 | * Returns whether create is allowed for a given temporary ID. 90 | * 91 | * @param string $key 92 | * @return bool 93 | */ 94 | public function isAllowedToCreateForId(string $key): bool; 95 | } 96 | -------------------------------------------------------------------------------- /src/Contracts/TracksTemporaryIdsInterface.php: -------------------------------------------------------------------------------- 1 | > 30 | */ 31 | protected string $relationClass; 32 | 33 | /** 34 | * Whether the relationship is of a One, as opposed to a Many, type 35 | * 36 | * @var bool 37 | */ 38 | protected bool $singular = true; 39 | 40 | /** 41 | * Whether the relationship is of the belongsTo type, that is, whether 42 | * the foreign key for this relation is stored on the main/parent model. 43 | * 44 | * @var bool 45 | */ 46 | protected bool $belongsTo = false; 47 | 48 | /** 49 | * An instance of the child model for the relation 50 | * 51 | * @var TModel|null 52 | */ 53 | protected ?Model $model = null; 54 | 55 | /** 56 | * The FQN of the ModelUpdater that should handle update or create process 57 | * 58 | * @var class-string|null 59 | */ 60 | protected ?string $updater = null; 61 | 62 | /** 63 | * Whether it is allowed to update data (and relations) of the nested related records. 64 | * If this is false, only (dis)connecting relationships should be allowed. 65 | * 66 | * @var bool 67 | */ 68 | protected bool $updateAllowed = false; 69 | 70 | /** 71 | * Whether it is allowed to create nested records for this relation. 72 | * 73 | * @var bool 74 | */ 75 | protected bool $createAllowed = false; 76 | 77 | /** 78 | * Whether missing records in a set of nested data should be detached. 79 | * If null, default is true for BelongsToMany and false for everything else. 80 | * 81 | * @var bool|null 82 | */ 83 | protected ?bool $detachMissing = null; 84 | 85 | /** 86 | * Whether, if detachMissing is true, detached models should be deleted instead of merely dissociated. 87 | * 88 | * @var bool 89 | */ 90 | protected bool $deleteDetached = false; 91 | 92 | /** 93 | * @var class-string|null FQN for nested validator that should handle nested validation 94 | */ 95 | protected ?string $validator = null; 96 | 97 | /** 98 | * @var null|class-string FQN for the class that provides the rules for the model 99 | */ 100 | protected ?string $rulesClass = null; 101 | 102 | /** 103 | * @var string|null name of the method that provides the array with rules 104 | */ 105 | protected ?string $rulesMethod = null; 106 | 107 | 108 | public function relationMethod(): string 109 | { 110 | return $this->relationMethod; 111 | } 112 | 113 | /** 114 | * @param string $relationMethod 115 | * @return $this 116 | */ 117 | public function setRelationMethod(string $relationMethod): static 118 | { 119 | $this->relationMethod = $relationMethod; 120 | 121 | return $this; 122 | } 123 | 124 | /** 125 | * @return class-string> 126 | */ 127 | public function relationClass(): string 128 | { 129 | return $this->relationClass; 130 | } 131 | 132 | /** 133 | * @param class-string> $relationClass 134 | * @return $this 135 | */ 136 | public function setRelationClass(string $relationClass): static 137 | { 138 | $this->relationClass = $relationClass; 139 | 140 | return $this; 141 | } 142 | 143 | public function isSingular(): bool 144 | { 145 | return $this->singular; 146 | } 147 | 148 | /** 149 | * @param bool $singular 150 | * @return $this 151 | */ 152 | public function setSingular(bool $singular): static 153 | { 154 | $this->singular = $singular; 155 | 156 | return $this; 157 | } 158 | 159 | public function isBelongsTo(): bool 160 | { 161 | return $this->belongsTo; 162 | } 163 | 164 | /** 165 | * @param bool $belongsTo 166 | * @return $this 167 | */ 168 | public function setBelongsTo(bool $belongsTo): static 169 | { 170 | $this->belongsTo = $belongsTo; 171 | 172 | return $this; 173 | } 174 | 175 | /** 176 | * @return TModel|null 177 | */ 178 | public function model(): ?Model 179 | { 180 | return $this->model; 181 | } 182 | 183 | /** 184 | * @param null|TModel $model 185 | * @return $this 186 | */ 187 | public function setModel(?Model $model): static 188 | { 189 | $this->model = $model; 190 | 191 | return $this; 192 | } 193 | 194 | /** 195 | * @return class-string|null 196 | */ 197 | public function updater(): ?string 198 | { 199 | return $this->updater; 200 | } 201 | 202 | /** 203 | * @param class-string|null $updater 204 | * @return $this 205 | */ 206 | public function setUpdater(?string $updater): static 207 | { 208 | $this->updater = $updater; 209 | 210 | return $this; 211 | } 212 | 213 | public function isUpdateAllowed(): bool 214 | { 215 | return $this->updateAllowed; 216 | } 217 | 218 | /** 219 | * @param bool $updateAllowed 220 | * @return $this 221 | */ 222 | public function setUpdateAllowed(bool $updateAllowed): static 223 | { 224 | $this->updateAllowed = $updateAllowed; 225 | 226 | return $this; 227 | } 228 | 229 | public function isCreateAllowed(): bool 230 | { 231 | return $this->createAllowed; 232 | } 233 | 234 | /** 235 | * @param bool $createAllowed 236 | * @return $this 237 | */ 238 | public function setCreateAllowed(bool $createAllowed): static 239 | { 240 | $this->createAllowed = $createAllowed; 241 | 242 | return $this; 243 | } 244 | 245 | public function isDeleteDetached(): bool 246 | { 247 | return $this->deleteDetached; 248 | } 249 | 250 | /** 251 | * @param bool $deleteDetached 252 | * @return $this 253 | */ 254 | public function setDeleteDetached(bool $deleteDetached): static 255 | { 256 | $this->deleteDetached = $deleteDetached; 257 | 258 | return $this; 259 | } 260 | 261 | public function getDetachMissing(): ?bool 262 | { 263 | return $this->detachMissing; 264 | } 265 | 266 | /** 267 | * @param bool|null $detachMissing 268 | * @return $this 269 | */ 270 | public function setDetachMissing(?bool $detachMissing): static 271 | { 272 | $this->detachMissing = $detachMissing; 273 | 274 | return $this; 275 | } 276 | 277 | /**] 278 | * @return class-string|null 279 | */ 280 | public function validator(): ?string 281 | { 282 | return $this->validator; 283 | } 284 | 285 | /** 286 | * @param class-string|null $validator 287 | * @return $this 288 | */ 289 | public function setValidator(?string $validator): RelationInfo 290 | { 291 | $this->validator = $validator; 292 | 293 | return $this; 294 | } 295 | 296 | /** 297 | * @return class-string|null 298 | */ 299 | public function rulesClass(): ?string 300 | { 301 | return $this->rulesClass; 302 | } 303 | 304 | /** 305 | * @param class-string|null $rulesClass 306 | * @return $this 307 | */ 308 | public function setRulesClass(?string $rulesClass): static 309 | { 310 | $this->rulesClass = $rulesClass; 311 | 312 | return $this; 313 | } 314 | 315 | public function rulesMethod(): ?string 316 | { 317 | return $this->rulesMethod; 318 | } 319 | 320 | /** 321 | * @param string|null $rulesMethod 322 | * @return $this 323 | */ 324 | public function setRulesMethod(?string $rulesMethod): static 325 | { 326 | $this->rulesMethod = $rulesMethod; 327 | 328 | return $this; 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /src/Data/TemporaryId.php: -------------------------------------------------------------------------------- 1 | |null 27 | */ 28 | protected ?array $data = null; 29 | 30 | /** 31 | * The created model, if it is created. 32 | * 33 | * @var TModel|null 34 | */ 35 | protected ?Model $model = null; 36 | 37 | /** 38 | * The model class FQN, if it is known. 39 | * 40 | * @var class-string|null 41 | */ 42 | protected ?string $modelClass = null; 43 | 44 | /** 45 | * Whether any of the temporary ID usages allow the model to be created. 46 | * This should be true if ANY of the nested usages allow this; all the others 47 | * may be treated as linking the model created only once. 48 | * 49 | * @var bool 50 | */ 51 | protected bool $allowedToCreate = false; 52 | 53 | 54 | /** 55 | * Sets whether the model was created. 56 | * 57 | * @param bool $created 58 | * @return $this 59 | */ 60 | public function setCreated(bool $created = true): static 61 | { 62 | $this->created = $created; 63 | 64 | return $this; 65 | } 66 | 67 | /** 68 | * Returns whether the model has been created so far. 69 | * 70 | * @return bool 71 | */ 72 | public function isCreated(): bool 73 | { 74 | return $this->created; 75 | } 76 | 77 | /** 78 | * Marks whether the temporary ID's data may be used to create anything. 79 | * 80 | * @param bool $allowedToCreate 81 | * @return $this 82 | */ 83 | public function setAllowedToCreate(bool $allowedToCreate = true): static 84 | { 85 | $this->allowedToCreate = $allowedToCreate; 86 | 87 | return $this; 88 | } 89 | 90 | /** 91 | * Returns whether the temporary ID is allowed to be created at any point. 92 | * 93 | * @return bool 94 | */ 95 | public function isAllowedToCreate(): bool 96 | { 97 | return $this->allowedToCreate; 98 | } 99 | 100 | /** 101 | * @param array $data 102 | * @return $this 103 | */ 104 | public function setData(array $data): static 105 | { 106 | $this->data = $data; 107 | 108 | return $this; 109 | } 110 | 111 | /** 112 | * @return array|null 113 | */ 114 | public function getData(): ?array 115 | { 116 | return $this->data; 117 | } 118 | 119 | /** 120 | * @param TModel $model 121 | * @return $this 122 | */ 123 | public function setModel(Model $model): static 124 | { 125 | $this->model = $model; 126 | 127 | if ($model->exists) { 128 | $this->created = true; 129 | } 130 | 131 | return $this; 132 | } 133 | 134 | /** 135 | * @return TModel|null 136 | */ 137 | public function getModel(): ?Model 138 | { 139 | return $this->model; 140 | } 141 | 142 | /** 143 | * @param class-string|null $class 144 | * @return $this 145 | */ 146 | public function setModelClass(?string $class): static 147 | { 148 | $this->modelClass = $class; 149 | 150 | return $this; 151 | } 152 | 153 | /** 154 | * @return class-string|null 155 | */ 156 | public function getModelClass(): ?string 157 | { 158 | return $this->modelClass; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/Data/TemporaryIds.php: -------------------------------------------------------------------------------- 1 | keyed by temporary key attribute 17 | */ 18 | protected array $temporaryIds = []; 19 | 20 | /** 21 | * Returns all keys for temporary IDs. 22 | * 23 | * @return string[] 24 | */ 25 | public function getKeys(): array 26 | { 27 | return array_keys($this->temporaryIds); 28 | } 29 | 30 | /** 31 | * @param string $key 32 | * @return bool 33 | */ 34 | public function hasId(string $key): bool 35 | { 36 | return array_key_exists($key, $this->temporaryIds); 37 | } 38 | 39 | /** 40 | * Sets (nested) data for a given temporary ID key. 41 | * 42 | * @param string $key 43 | * @param array $data 44 | * @return $this 45 | */ 46 | public function setDataForId(string $key, array $data): static 47 | { 48 | // do not overwrite if we have a model already 49 | if ($this->hasId($key) && $this->getByKey($key)->isCreated()) { 50 | return $this; 51 | } 52 | 53 | $this->getOrCreateByKey($key)->setData($data); 54 | 55 | return $this; 56 | } 57 | 58 | /** 59 | * Sets a (created) model for a temporary ID 60 | * 61 | * @param string $key 62 | * @param Model $model 63 | * @return $this 64 | */ 65 | public function setModelForId(string $key, Model $model): static 66 | { 67 | $this->getOrCreateByKey($key)->setModel($model); 68 | 69 | return $this; 70 | } 71 | 72 | /** 73 | * Sets the model class associated with a temporary ID. 74 | * 75 | * @param string $key 76 | * @param class-string $class 77 | * @return $this 78 | */ 79 | public function setModelClassForId(string $key, string $class): static 80 | { 81 | $this->getOrCreateByKey($key)->setModelClass($class); 82 | 83 | return $this; 84 | } 85 | 86 | public function isCreatedForKey(string $key): bool 87 | { 88 | $temp = $this->getByKey($key); 89 | 90 | return $temp && $temp->isCreated(); 91 | } 92 | 93 | /** 94 | * Gets nested data set for given temporary ID. 95 | * 96 | * @param string $key 97 | * @return array|null 98 | */ 99 | public function getDataForId(string $key): ?array 100 | { 101 | return $this->getByKey($key)?->getData(); 102 | } 103 | 104 | /** 105 | * Gets created model instance set for given temporary ID. 106 | * 107 | * @param string $key 108 | * @return Model|null 109 | */ 110 | public function getModelForId(string $key): ?Model 111 | { 112 | return $this->getByKey($key)?->getModel(); 113 | } 114 | 115 | /** 116 | * Returns the model class associated with a temporary ID. 117 | * 118 | * @param string $key 119 | * @return class-string|null 120 | */ 121 | public function getModelClassForId(string $key): ?string 122 | { 123 | return $this->getByKey($key)?->getModelClass(); 124 | } 125 | 126 | /** 127 | * Returns temporary ID container for a given key 128 | * 129 | * @param string $key 130 | * @return TemporaryId|null 131 | */ 132 | protected function getByKey(string $key): ?TemporaryId 133 | { 134 | if ( ! $this->hasId($key)) { 135 | return null; 136 | } 137 | 138 | return $this->temporaryIds[$key]; 139 | } 140 | 141 | /** 142 | * Returns temporary ID container or creates a new container if it does not exist. 143 | * 144 | * @param string $key 145 | * @return TemporaryId 146 | */ 147 | protected function getOrCreateByKey(string $key): TemporaryId 148 | { 149 | $temporaryId = $this->getByKey($key); 150 | 151 | if ( ! $temporaryId) { 152 | $temporaryId = $this->temporaryIds[$key] = new TemporaryId; 153 | } 154 | 155 | return $temporaryId; 156 | } 157 | 158 | /** 159 | * @return array 160 | */ 161 | public function toArray(): array 162 | { 163 | return $this->temporaryIds; 164 | } 165 | 166 | /** 167 | * Marks whether the model for a given temporary may be created. 168 | * 169 | * @param string $key 170 | * @param bool $allowed 171 | * @return $this 172 | */ 173 | public function markAllowedToCreateForId(string $key, bool $allowed = true): static 174 | { 175 | $this->getOrCreateByKey($key)->setAllowedToCreate($allowed); 176 | 177 | return $this; 178 | } 179 | 180 | /** 181 | * Returns whether create is allowed for a given temporary ID. 182 | * 183 | * @param string $key 184 | * @return bool 185 | */ 186 | public function isAllowedToCreateForId(string $key): bool 187 | { 188 | $temp = $this->getByKey($key); 189 | 190 | return $temp && $temp->isAllowedToCreate(); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/Data/UpdateResult.php: -------------------------------------------------------------------------------- 1 | model = $model; 38 | 39 | return $this; 40 | } 41 | 42 | public function model(): ?Model 43 | { 44 | return $this->model; 45 | } 46 | 47 | /** 48 | * @param bool $success 49 | * @return $this 50 | */ 51 | public function setSuccess(bool $success): static 52 | { 53 | $this->success = $success; 54 | 55 | return $this; 56 | } 57 | 58 | public function success(): bool 59 | { 60 | return $this->success; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Exceptions/DisallowedNestedActionException.php: -------------------------------------------------------------------------------- 1 | nestedKey = $nestedKey ?? ''; 25 | 26 | if ($nestedKey) { 27 | $this->message .= " (nesting: {$nestedKey})"; 28 | } 29 | 30 | return $this; 31 | } 32 | 33 | /** 34 | * Get the dot-notation nested key for the affected model. 35 | * 36 | * @return string 37 | */ 38 | public function getNestedKey(): string 39 | { 40 | return $this->nestedKey; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Factories/ModelUpdaterFactory.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class ModelUpdaterFactory implements ModelUpdaterFactoryInterface 21 | { 22 | /** 23 | * @param class-string> $class 24 | * @param array $parameters constructor parameters for model updater 25 | * @return ModelUpdaterInterface 26 | */ 27 | public function make(string $class, array $parameters = []): ModelUpdaterInterface 28 | { 29 | if ($class === ModelUpdaterInterface::class) { 30 | $class = $this->getDefaultUpdaterClass(); 31 | } 32 | 33 | if (! count($parameters)) { 34 | $updater = app($class); 35 | } else { 36 | try { 37 | /** @var ReflectionClass> $reflectionClass */ 38 | $reflectionClass = new ReflectionClass($class); 39 | $updater = $reflectionClass->newInstanceArgs($parameters); 40 | } catch (Throwable $exception) { 41 | $updater = $exception->getMessage(); 42 | } 43 | } 44 | 45 | if (! $updater) { 46 | throw new UnexpectedValueException( 47 | "Expected ModelUpdaterInterface instance, got nothing for '{$class}'" 48 | ); 49 | } 50 | 51 | if (! $updater instanceof ModelUpdaterInterface) { 52 | throw new UnexpectedValueException( 53 | 'Expected ModelUpdaterInterface instance, got ' . get_class($updater) . ' instead' 54 | ); 55 | } 56 | 57 | return $updater; 58 | } 59 | 60 | /** 61 | * Returns the default class to use, if the interface is given as a class. 62 | * 63 | * @return class-string 64 | */ 65 | protected function getDefaultUpdaterClass(): string 66 | { 67 | return ModelUpdater::class; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Factories/NestedValidatorFactory.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | class NestedValidatorFactory implements NestedValidatorFactoryInterface 22 | { 23 | /** 24 | * @param class-string> $class 25 | * @param array $parameters constructor parameters for validator 26 | * @return NestedValidatorInterface 27 | */ 28 | public function make(string $class, array $parameters = []): NestedValidatorInterface 29 | { 30 | if ($class === NestedValidatorInterface::class) { 31 | $class = $this->getDefaultValidatorClass(); 32 | } 33 | 34 | if (! count($parameters)) { 35 | $validator = app($class); 36 | } else { 37 | try { 38 | /** @var ReflectionClass> $reflectionClass */ 39 | $reflectionClass = new ReflectionClass($class); 40 | $validator = $reflectionClass->newInstanceArgs($parameters); 41 | } catch (Throwable $exception) { 42 | $validator = $exception->getMessage(); 43 | } 44 | } 45 | 46 | if (! $validator) { 47 | throw new UnexpectedValueException( 48 | "Expected NestedValidatorInterface instance, got nothing for '{$class}'" 49 | ); 50 | } 51 | 52 | if (! $validator instanceof NestedValidatorInterface) { 53 | throw new UnexpectedValueException( 54 | 'Expected NestedValidatorInterface instance, got ' . get_class($validator) . ' instead' 55 | ); 56 | } 57 | 58 | return $validator; 59 | } 60 | 61 | /** 62 | * Returns the default class to use, if the interface is given as a class. 63 | * 64 | * @return class-string 65 | */ 66 | protected function getDefaultValidatorClass(): string 67 | { 68 | return NestedValidator::class; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/NestedModelUpdaterServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 21 | __DIR__ . '/config/nestedmodelupdater.php' => config_path('nestedmodelupdater.php'), 22 | ]); 23 | } 24 | 25 | public function register(): void 26 | { 27 | $this->mergeConfigFrom( 28 | __DIR__ . '/config/nestedmodelupdater.php', 'nestedmodelupdater' 29 | ); 30 | 31 | $this->registerInterfaceBindings(); 32 | } 33 | 34 | protected function registerInterfaceBindings(): void 35 | { 36 | $this->app->bind(ModelUpdaterFactoryInterface::class, ModelUpdaterFactory::class); 37 | $this->app->bind(NestedValidatorFactoryInterface::class, NestedValidatorFactory::class); 38 | $this->app->bind(NestingConfigInterface::class, NestingConfig::class); 39 | $this->app->bind(TemporaryIdsInterface::class, TemporaryIds::class); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/NestedValidator.php: -------------------------------------------------------------------------------- 1 | 26 | * @implements NestedValidatorInterface 27 | */ 28 | class NestedValidator extends AbstractNestedParser implements NestedValidatorInterface 29 | { 30 | protected bool $validates = true; 31 | protected MessageBagContract $messages; 32 | 33 | 34 | /** 35 | * Performs validation and returns whether it succeeds. 36 | * 37 | * @param array $data 38 | * @param bool $creating if false, validate for update 39 | * @return bool 40 | */ 41 | public function validate(array $data, bool $creating = true): bool 42 | { 43 | $this->relationsAnalyzed = false; 44 | 45 | $this->validates = true; 46 | $this->messages = new MessageBag(); 47 | $this->data = $data; 48 | $this->model = null; 49 | 50 | $rules = $this->getValidationRules($creating); 51 | 52 | $validator = $this->getValidationFactory()->make($data, $rules); 53 | 54 | if ($validator->fails()) { 55 | $this->validates = false; 56 | $this->messages = $validator->getMessageBag(); 57 | } 58 | 59 | return $this->validates; 60 | } 61 | 62 | /** 63 | * Returns validation rules array for full nested data. 64 | * 65 | * @param array $data 66 | * @param bool $creating 67 | * @return array 68 | */ 69 | public function validationRules(array $data, bool $creating = true): array 70 | { 71 | $this->relationsAnalyzed = false; 72 | 73 | $this->data = $data; 74 | 75 | return $this->getValidationRules($creating); 76 | } 77 | 78 | /** 79 | * Returns validation messages, if validation has been performed. 80 | * 81 | * @return MessageBagContract|null 82 | */ 83 | public function messages(): ?MessageBagContract 84 | { 85 | if (! isset($this->messages)) { 86 | return null; 87 | } 88 | 89 | return $this->messages; 90 | } 91 | 92 | /** 93 | * Returns validation rules for the current model only 94 | * 95 | * @param bool $prefixNesting if true, prefixes the validation rules with the relevant key nesting. 96 | * @param bool $creating 97 | * @return array 98 | */ 99 | public function getDirectModelValidationRules(bool $prefixNesting = false, bool $creating = true): array 100 | { 101 | $rulesInstance = $this->makeModelRulesInstance(); 102 | 103 | if (! $rulesInstance) { 104 | return []; 105 | } 106 | 107 | $method = $this->determineModelRulesMethod(); 108 | 109 | if (! method_exists($rulesInstance, $method)) { 110 | throw new UnexpectedValueException($rulesInstance::class . " has no method '{$method}'"); 111 | } 112 | 113 | $rules = $rulesInstance->{$method}($creating ? 'create' : 'update'); 114 | 115 | if (! is_array($rules)) { 116 | throw new UnexpectedValueException($rulesInstance::class . "::{$method} did not return array"); 117 | } 118 | 119 | if ($prefixNesting) { 120 | $rules = $this->prefixAllKeysInArray($rules); 121 | } 122 | 123 | return $rules; 124 | } 125 | 126 | /** 127 | * Returns nested validation rules for the entire nested data structure. 128 | * 129 | * @param bool $creating 130 | * @return array 131 | */ 132 | protected function getValidationRules(bool $creating = true): array 133 | { 134 | $this->config->setParentModel($this->modelClass); 135 | $this->analyzeNestedRelationsData(); 136 | 137 | // Get validation rules for this model/level, prepend keys correctly with current nested key & index. 138 | $rules = $this->getDirectModelValidationRules(true, $creating); 139 | 140 | // For any child relations, created a nested validator and merge its validation rules. 141 | return array_merge($rules, $this->getNestedRelationValidationRules()); 142 | } 143 | 144 | /** 145 | * @return array 146 | */ 147 | protected function getNestedRelationValidationRules(): array 148 | { 149 | $rules = []; 150 | 151 | foreach ($this->relationInfo as $attribute => $info) { 152 | if (! Arr::has($this->data, $attribute)) { 153 | continue; 154 | } 155 | 156 | if (! $info->isSingular()) { 157 | // Make sure we force an array if we're expecting a plural relation, 158 | // and make data-based rules for each item in the array. 159 | 160 | $rules[ $this->getNestedKeyPrefix() . $attribute ] = 'array'; 161 | 162 | if (is_array($this->data[ $attribute ])) { 163 | foreach (array_keys($this->data[ $attribute ]) as $index) { 164 | $rules = array_merge( 165 | $rules, 166 | $this->getNestedRelationValidationRulesForSingleItem( 167 | $info, 168 | $attribute, 169 | $index, 170 | ) 171 | ); 172 | } 173 | } 174 | } else { 175 | $rules = array_merge( 176 | $rules, 177 | $this->getNestedRelationValidationRulesForSingleItem($info, $attribute) 178 | ); 179 | } 180 | } 181 | 182 | return $rules; 183 | } 184 | 185 | /** 186 | * @param RelationInfo $info 187 | * @param string $attribute key of attribute 188 | * @param mixed|null $index if data is plural for this attribute, the index for it 189 | * @return array 190 | */ 191 | protected function getNestedRelationValidationRulesForSingleItem( 192 | RelationInfo $info, 193 | string $attribute, 194 | mixed $index = null, 195 | ): array { 196 | $rules = []; 197 | 198 | $dotKey = $attribute . ($index !== null ? '.' . $index : ''); 199 | 200 | $data = Arr::get($this->data, $dotKey); 201 | 202 | // If the data is scalar, it is treated as the primary key in a link-only operation, which should be allowed 203 | // if the relation is allowed in nesting at all -- if the data is null, it should be considered a detach 204 | // operation, which is allowed as well. 205 | if (is_scalar($data) || $data === null) { 206 | // Add rule if we know that the primary key should be an integer. 207 | if ($info->model()->getIncrementing()) { 208 | $rules[ $this->getNestedKeyPrefix() . $dotKey ] = 'nullable|integer'; 209 | } 210 | 211 | return $rules; 212 | } 213 | 214 | // If not a scalar or null, the only other value allowed is an array. 215 | $rules[ $this->getNestedKeyPrefix() . $dotKey ] = 'array'; 216 | 217 | $keyName = $info->model()->getKeyName(); 218 | $keyIsRequired = false; 219 | $keyMustExist = false; 220 | 221 | // If it is a link-only or update-only nested relation, require a primary key field. 222 | // It also helps to check whether the key actually exists, to prevent problems with 223 | // a non-existant non-incrementing keys, which would be interpreted as a create action. 224 | if (! $info->isCreateAllowed()) { 225 | $keyIsRequired = true; 226 | $keyMustExist = true; 227 | } elseif (! $info->model()->getIncrementing()) { 228 | // If create is allowed, then the primary key is only required for non-incrementing key models, 229 | // for which it should always be present. 230 | $keyIsRequired = true; 231 | } 232 | 233 | // If the primary key is not present, this is a create operation, so we must apply the model's create rules 234 | // otherwise, it's an update operation -- if the model is non-incrementing, however, the create/update 235 | // distinction depends on whether the given key exists. 236 | if ($info->model()->getIncrementing()) { 237 | $creating = ! Arr::has($data, $keyName); 238 | } else { 239 | $key = Arr::get($data, $keyName); 240 | $creating = ! $key || ! $this->checkModelExistsByLookupAtribute($key, $keyName, get_class($info->model())); 241 | } 242 | 243 | if (! $creating) { 244 | $keyMustExist = true; 245 | } 246 | 247 | 248 | // Build up rules for primary key. 249 | $keyRules = []; 250 | 251 | if ($info->model()->getIncrementing()) { 252 | $keyRules[] = 'integer'; 253 | } 254 | 255 | if ($keyIsRequired) { 256 | $keyRules[] = 'required'; 257 | } 258 | 259 | if ($keyMustExist) { 260 | $keyRules[] = 'exists:' . $info->model()->getTable() . ',' . $keyName; 261 | } 262 | 263 | if (count($keyRules)) { 264 | $rules[ $this->getNestedKeyPrefix() . $dotKey . '.' . $keyName ] = $keyRules; 265 | } 266 | 267 | 268 | // Get and merge rules for model fields by deferring to a nested validator. 269 | 270 | /** @var NestedValidatorInterface $validator */ 271 | $validator = $this->makeNestedParser($info->validator(), [ 272 | $info->model()::class, 273 | $attribute, 274 | $this->appendNestedKey($attribute, $index), 275 | $this->model, 276 | $this->config, 277 | $this->modelClass, 278 | ]); 279 | 280 | return $this->mergeInherentRulesWithCustomModelRules($rules, $validator->validationRules($data, $creating)); 281 | } 282 | 283 | 284 | /** 285 | * Merges validation rules intelligently, on a per-rule basis, giving preference to custom-set validation rules. 286 | * 287 | * @param array $inherent 288 | * @param array $custom 289 | * @return array 290 | */ 291 | protected function mergeInherentRulesWithCustomModelRules(array $inherent, array $custom): array 292 | { 293 | foreach ($custom as $key => $ruleSet) { 294 | // If it does not exist in the inherent set, add the custom rule. 295 | if (! array_key_exists($key, $inherent)) { 296 | $inherent[ $key ] = $ruleSet; 297 | continue; 298 | } 299 | 300 | // Otherwise: normalize and merge the rules, removing duplicates. 301 | $mergedRules = array_merge( 302 | $this->normalizeRulesForKeyAsArray($inherent[ $key ]), 303 | $this->normalizeRulesForKeyAsArray($ruleSet) 304 | ); 305 | 306 | $inherent[ $key ] = array_unique($mergedRules); 307 | } 308 | 309 | // Return inherent set, which now has custom rules merged in. 310 | return $inherent; 311 | } 312 | 313 | /** 314 | * Normalizes ruleset for a single attribute key to an array of strings. 315 | * 316 | * @param string|string[] $rules 317 | * @return string[] 318 | */ 319 | protected function normalizeRulesForKeyAsArray(string|array $rules): array 320 | { 321 | if (! is_array($rules)) { 322 | $rules = explode('|', $rules); 323 | } 324 | 325 | return $rules; 326 | } 327 | 328 | protected function getNestedKeyPrefix(): string 329 | { 330 | return $this->nestedKey ? $this->nestedKey . '.' : ''; 331 | } 332 | 333 | /** 334 | * Prefixes all keys in an associative array with a string. 335 | * 336 | * @param array $array 337 | * @param null|string $prefix if not given, prefixes with nesting prefix for this validator level 338 | * @return array 339 | */ 340 | protected function prefixAllKeysInArray(array $array, ?string $prefix = null): array 341 | { 342 | $prefix = $prefix ?? $this->getNestedKeyPrefix(); 343 | 344 | return array_combine( 345 | array_map( 346 | static fn ($key) => $prefix . $key, 347 | array_keys($array) 348 | ), 349 | array_values($array) 350 | ); 351 | } 352 | 353 | /** 354 | * Returns FQN of rules class. 355 | * 356 | * @return class-string 357 | */ 358 | protected function determineModelRulesClass(): string 359 | { 360 | $rulesClass = $this->parentRelationInfo ? $this->parentRelationInfo->rulesClass() : null; 361 | 362 | // Default: use per-model class. 363 | if (! $rulesClass) { 364 | $modelConfig = Config::get('nestedmodelupdater.validation.model-rules.' . $this->modelClass); 365 | 366 | if (is_array($modelConfig)) { 367 | $rulesClass = Arr::get($modelConfig, 'class'); 368 | } else { 369 | $rulesClass = $modelConfig; 370 | } 371 | } 372 | 373 | // fallback: use namespace & postfix, using model's basename 374 | if (! $rulesClass) { 375 | $namespace = Config::get('nestedmodelupdater.validation.model-rules-namespace'); 376 | $postFix = Config::get('nestedmodelupdater.validation.model-rules-postfix'); 377 | 378 | $rulesClass = rtrim($namespace, '\\') . '\\' . class_basename($this->modelClass) . $postFix; 379 | } 380 | 381 | return $rulesClass; 382 | } 383 | 384 | /** 385 | * Returns method for rules on the rules class. 386 | * 387 | * @return string 388 | */ 389 | protected function determineModelRulesMethod(): string 390 | { 391 | $rulesMethod = $this->parentRelationInfo ? $this->parentRelationInfo->rulesMethod() : null; 392 | 393 | // use per-model method, if defined 394 | if (! $rulesMethod) { 395 | $modelConfig = Config::get('nestedmodelupdater.validation.model-rules.' . $this->modelClass); 396 | 397 | if (is_array($modelConfig)) { 398 | $rulesMethod = Arr::get($modelConfig, 'method'); 399 | } 400 | } 401 | 402 | return $rulesMethod ?: Config::get('nestedmodelupdater.validation.model-rules-method', 'rules'); 403 | } 404 | 405 | /** 406 | * Makes instance of class that should contain the rules method. 407 | * 408 | * @return object|false 409 | */ 410 | protected function makeModelRulesInstance(): object|false 411 | { 412 | $class = $this->determineModelRulesClass(); 413 | 414 | try { 415 | $instance = app($class); 416 | } catch (BindingResolutionException|ReflectionException $e) { 417 | $instance = null; 418 | } 419 | 420 | if ($instance === null) { 421 | if (! Config::get('nestedmodelupdater.validation.allow-missing-rules', true)) { 422 | throw new UnexpectedValueException("{$class} is not bound as a usable rules object"); 423 | } 424 | 425 | return false; 426 | } 427 | 428 | if (! is_object($instance)) { 429 | throw new UnexpectedValueException("{$class} is not a usable rules object"); 430 | } 431 | 432 | return $instance; 433 | } 434 | 435 | /** 436 | * {@inheritdoc} 437 | * @return NestedValidatorInterface 438 | */ 439 | protected function makeNestedParser(string $class, array $parameters): NestedParserInterface 440 | { 441 | return $this->getNestedValidatorFactory()->make($class, $parameters); 442 | } 443 | 444 | protected function getValidationFactory(): Factory 445 | { 446 | return app(Factory::class); 447 | } 448 | 449 | /** 450 | * @return NestedValidatorFactoryInterface 451 | */ 452 | protected function getNestedValidatorFactory(): NestedValidatorFactoryInterface 453 | { 454 | return app(NestedValidatorFactoryInterface::class); 455 | } 456 | } 457 | -------------------------------------------------------------------------------- /src/NestingConfig.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | class NestingConfig implements NestingConfigInterface 26 | { 27 | /** 28 | * @var class-string|null 29 | */ 30 | protected ?string $parentModel = null; 31 | 32 | /** 33 | * Sets the parent model FQN to be used if not explicitly provided 34 | * in other methods 35 | * 36 | * @param class-string $parentModel FQN of the parent model 37 | * @return $this 38 | */ 39 | public function setParentModel(string $parentModel): static 40 | { 41 | $this->parentModel = $parentModel; 42 | 43 | return $this; 44 | } 45 | 46 | /** 47 | * Returns a container with information about the nested relation by key 48 | * 49 | * @param string $key 50 | * @param class-string|null $parentModel the FQN for the parent model 51 | * @return RelationInfo 52 | */ 53 | public function getRelationInfo(string $key, ?string $parentModel = null): RelationInfo 54 | { 55 | if (! $this->isKeyNestedRelation($key, $parentModel)) { 56 | throw new RuntimeException( 57 | "{$key} is not a nested relation, cannot gather data" 58 | . ' for model ' . ($parentModel ?: $this->parentModel) 59 | ); 60 | } 61 | 62 | $parent = $this->makeParentModel($parentModel); 63 | 64 | $relationMethod = $this->getRelationMethod($key, $parentModel); 65 | $relation = $parent->{$relationMethod}(); 66 | 67 | return (new RelationInfo()) 68 | ->setRelationMethod($relationMethod) 69 | ->setRelationClass(get_class($relation)) 70 | ->setModel($this->getModelForRelation($relation)) 71 | ->setSingular($this->isRelationSingular($relation)) 72 | ->setBelongsTo($this->isRelationBelongsTo($relation)) 73 | ->setUpdater($this->getUpdaterClassForKey($key, $parentModel)) 74 | ->setUpdateAllowed($this->isKeyUpdatableNestedRelation($key, $parentModel)) 75 | ->setCreateAllowed($this->isKeyCreatableNestedRelation($key, $parentModel)) 76 | ->setDetachMissing($this->isKeyDetachingNestedRelation($key, $parentModel)) 77 | ->setDeleteDetached($this->isKeyDeletingNestedRelation($key, $parentModel)) 78 | ->setValidator($this->getValidatorClassForKey($key, $parentModel)) 79 | ->setRulesClass($this->getRulesClassForKey($key, $parentModel)) 80 | ->setRulesMethod($this->getRulesMethodForKey($key, $parentModel)); 81 | } 82 | 83 | /** 84 | * @param string $key 85 | * @param class-string|null $parentModel 86 | * @return array|bool 87 | */ 88 | public function getNestedRelationConfigByKey(string $key, ?string $parentModel = null): array|bool 89 | { 90 | $parentModel = $parentModel ?: $this->parentModel; 91 | 92 | return Config::get('nestedmodelupdater.relations.' . $parentModel . '.' . $key, false); 93 | } 94 | 95 | /** 96 | * Returns whether a key, for the given model, is a nested relation at all. 97 | * 98 | * @param string $key 99 | * @param class-string|null $parentModel the FQN for the parent model 100 | * @return bool 101 | */ 102 | public function isKeyNestedRelation(string $key, ?string $parentModel = null): bool 103 | { 104 | $config = $this->getNestedRelationConfigByKey($key, $parentModel); 105 | 106 | return (bool) $config; 107 | } 108 | 109 | /** 110 | * Returns whether a key, for the given model, is an update able nested relation. 111 | * 112 | * Updatable relations are relations that may have their contents updated through 113 | * the nested update operation. This returns false if related models may only be 114 | * linked, but not modified. 115 | * 116 | * @param string $key 117 | * @param class-string|null $parentModel the FQN for the parent model 118 | * @return bool 119 | */ 120 | public function isKeyUpdatableNestedRelation(string $key, ?string $parentModel = null): bool 121 | { 122 | $config = $this->getNestedRelationConfigByKey($key, $parentModel); 123 | 124 | if ($config === true) { 125 | return true; 126 | } 127 | 128 | if (! is_array($config)) { 129 | return false; 130 | } 131 | 132 | return ! Arr::get($config, 'link-only', false); 133 | } 134 | 135 | /** 136 | * Returns whether a key, for the given model, is a nested relation for which new models may be created. 137 | * 138 | * @param string $key 139 | * @param class-string|null $parentModel the FQN for the parent model 140 | * @return bool 141 | */ 142 | public function isKeyCreatableNestedRelation(string $key, ?string $parentModel = null): bool 143 | { 144 | if (! $this->isKeyUpdatableNestedRelation($key, $parentModel)) { 145 | return false; 146 | } 147 | 148 | $config = $this->getNestedRelationConfigByKey($key, $parentModel); 149 | 150 | if ($config === true) { 151 | return true; 152 | } 153 | 154 | if (! is_array($config)) { 155 | return false; 156 | } 157 | 158 | return ! Arr::get($config, 'update-only', false); 159 | } 160 | 161 | /** 162 | * Returns whether a nested relation detaches missing records in update data. 163 | * 164 | * @param string $key 165 | * @param class-string|null $parentModel the FQN for the parent model 166 | * @return bool|null 167 | */ 168 | public function isKeyDetachingNestedRelation(string $key, ?string $parentModel = null): ?bool 169 | { 170 | $config = $this->getNestedRelationConfigByKey($key, $parentModel); 171 | 172 | if (! is_array($config)) { 173 | return null; 174 | } 175 | 176 | $detach = Arr::get($config, 'detach'); 177 | 178 | return $detach === null ? null : (bool) $detach; 179 | } 180 | 181 | /** 182 | * Returns whether a nested relation deletes detached missing records in update data. 183 | * 184 | * @param string $key 185 | * @param class-string|null $parentModel the FQN for the parent model 186 | * @return bool 187 | */ 188 | public function isKeyDeletingNestedRelation(string $key, ?string $parentModel = null): bool 189 | { 190 | $config = $this->getNestedRelationConfigByKey($key, $parentModel); 191 | 192 | if (! is_array($config)) { 193 | return false; 194 | } 195 | 196 | return (bool) Arr::get($config, 'delete-detached', false); 197 | } 198 | 199 | /** 200 | * Returns the name of the method on the parent model for the relation. 201 | * 202 | * @param string $key 203 | * @param class-string|null $parentModel the FQN for the parent model 204 | * @return string|false 205 | */ 206 | public function getRelationMethod(string $key, ?string $parentModel = null): string|false 207 | { 208 | return $this->getStringValueForKey($key, 'method', Str::camel($key), $parentModel); 209 | } 210 | 211 | /** 212 | * Returns the FQN for the ModelUpdater to be used for a specific nested relation key 213 | * 214 | * @param string $key 215 | * @param class-string|null $parentModel the FQN for the parent model 216 | * @return string 217 | */ 218 | public function getUpdaterClassForKey(string $key, ?string $parentModel = null): string 219 | { 220 | return $this->getStringValueForKey($key, 'updater', ModelUpdaterInterface::class, $parentModel); 221 | } 222 | 223 | 224 | /** 225 | * Returns a fresh instance of the parent model for the relation. 226 | * 227 | * @param class-string|null $parentClass 228 | * @return TParent 229 | */ 230 | protected function makeParentModel(?string $parentClass = null): Model 231 | { 232 | $parentClass = $parentClass ?: $this->parentModel; 233 | 234 | if (! $parentClass) { 235 | throw new BadMethodCallException("Could not create parent model, no class name given."); 236 | } 237 | 238 | $model = new $parentClass; 239 | 240 | if (! $model instanceof Model) { 241 | throw new UnexpectedValueException("Expected Model for parentModel, got {$parentClass} instead."); 242 | } 243 | 244 | return $model; 245 | } 246 | 247 | /** 248 | * Returns FQN for related model. 249 | * 250 | * @param Relation $relation 251 | * @return Model 252 | */ 253 | protected function getModelForRelation(Relation $relation): Model 254 | { 255 | return $relation->getRelated(); 256 | } 257 | 258 | /** 259 | * Returns wether relation is of singular type. 260 | * 261 | * @param Relation $relation 262 | * @return bool 263 | */ 264 | protected function isRelationSingular(Relation $relation): bool 265 | { 266 | return in_array( 267 | get_class($relation), 268 | Config::get('nestedmodelupdater.singular-relations', []), 269 | true 270 | ); 271 | } 272 | 273 | /** 274 | * Returns wether relation is of the 'belongs to' type (foreign key stored on the parent). 275 | * 276 | * @param Relation $relation 277 | * @return bool 278 | */ 279 | protected function isRelationBelongsTo(Relation $relation): bool 280 | { 281 | return in_array( 282 | get_class($relation), 283 | Config::get('nestedmodelupdater.belongs-to-relations', []), 284 | true 285 | ); 286 | } 287 | 288 | /** 289 | * Returns a string relation config value for a given nested data key. 290 | * 291 | * @param string $key 292 | * @param string $configKey 293 | * @param string|null $default 294 | * @param class-string|null $parentModel 295 | * @return string|false 296 | */ 297 | protected function getStringValueForKey( 298 | string $key, 299 | string $configKey, 300 | ?string $default = null, 301 | ?string $parentModel = null, 302 | ): string|false { 303 | 304 | if (! $this->isKeyNestedRelation($key, $parentModel)) { 305 | return false; 306 | } 307 | 308 | $config = $this->getNestedRelationConfigByKey($key, $parentModel); 309 | 310 | if (is_array($config) && Arr::has($config, $configKey)) { 311 | return Arr::get($config, $configKey); 312 | } 313 | 314 | return $default ?: false; 315 | } 316 | 317 | // ------------------------------------------------------------------------------ 318 | // Validation 319 | // ------------------------------------------------------------------------------ 320 | 321 | /** 322 | * Returns the FQN for the nested validator to be used for a specific nested relation key. 323 | * 324 | * @param string $key 325 | * @param class-string|null $parentModel the FQN for the parent model 326 | * @return string 327 | */ 328 | protected function getValidatorClassForKey(string $key, ?string $parentModel = null): string 329 | { 330 | return $this->getStringValueForKey($key, 'validator', NestedValidatorInterface::class, $parentModel); 331 | } 332 | 333 | /** 334 | * Returns the FQN for the class that has the rules for the nested model. 335 | * 336 | * @param string $key 337 | * @param class-string|null $parentModel the FQN for the parent model 338 | * @return string|null 339 | */ 340 | protected function getRulesClassForKey(string $key, ?string $parentModel = null): ?string 341 | { 342 | return $this->getStringValueForKey($key, 'rules', null, $parentModel) ?: null; 343 | } 344 | 345 | /** 346 | * Returns the FQN for the method on the rules class that should be called to 347 | * get the rules array 348 | * 349 | * @param string $key 350 | * @param class-string|null $parentModel the FQN for the parent model 351 | * @return string|null 352 | */ 353 | protected function getRulesMethodForKey(string $key, ?string $parentModel = null): ?string 354 | { 355 | return $this->getStringValueForKey($key, 'rules-method', null, $parentModel) ?: null; 356 | } 357 | } 358 | -------------------------------------------------------------------------------- /src/Requests/AbstractNestedDataRequest.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | protected string $validatorClass = NestedValidatorInterface::class; 25 | 26 | 27 | /** 28 | * Returns FQN of model class to validate nested data for (at the top level). 29 | * 30 | * @return class-string 31 | */ 32 | abstract protected function getNestedModelClass(): string; 33 | 34 | /** 35 | * Returns whether we are creating, as opposed to updating, the top 36 | * level model in the nested data tree. 37 | * 38 | * @return bool 39 | */ 40 | abstract protected function isCreating(): bool; 41 | 42 | 43 | /** 44 | * Validate the class instance. 45 | */ 46 | public function validateResolved(): void 47 | { 48 | $validator = $this->makeNestedValidator(); 49 | 50 | if (! $this->passesAuthorization()) { 51 | $this->failedAuthorization(); 52 | } 53 | 54 | if (! $validator->validate($this->all(), $this->isCreating())) { 55 | $this->failedNestedValidation($validator->messages()); 56 | } 57 | } 58 | 59 | protected function failedNestedValidation(MessageBag $errors): never 60 | { 61 | throw new HttpResponseException( 62 | $this->response( 63 | $errors->toArray() 64 | ) 65 | ); 66 | } 67 | 68 | protected function makeNestedValidator(): NestedValidatorInterface 69 | { 70 | return $this->getNestedValidatorFactory() 71 | ->make($this->getNestedValidatorClass(), [$this->getNestedModelClass()]); 72 | } 73 | 74 | /** 75 | * Returns FQN for the nested validator class to validate the nested data. 76 | * 77 | * @return class-string 78 | */ 79 | protected function getNestedValidatorClass(): string 80 | { 81 | return $this->validatorClass; 82 | } 83 | 84 | /** 85 | * @return NestedValidatorFactoryInterface 86 | */ 87 | protected function getNestedValidatorFactory(): NestedValidatorFactoryInterface 88 | { 89 | return app(NestedValidatorFactoryInterface::class); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Traits/NestedUpdatable.php: -------------------------------------------------------------------------------- 1 | $attributes 20 | * @return Model|null 21 | */ 22 | public static function create(array $attributes = []) 23 | { 24 | /** @var NestedUpdatable&Model $this */ 25 | $model = new static; 26 | 27 | /** @var ModelUpdaterInterface $updater */ 28 | $updater = $model->getModelUpdaterInstance(); 29 | 30 | $result = $updater->create($attributes); 31 | 32 | return $result->model(); 33 | } 34 | 35 | /** 36 | * @param array $attributes 37 | * @param array $options 38 | * @return bool 39 | */ 40 | public function update(array $attributes = [], array $options = []) 41 | { 42 | /** @var NestedUpdatable&Model $this */ 43 | if ( ! $this->exists) { 44 | return false; 45 | } 46 | 47 | $updater = $this->getModelUpdaterInstance(); 48 | 49 | $result = $updater->update($attributes, $this, null, $options); 50 | 51 | return $result->success(); 52 | } 53 | 54 | /** 55 | * Makes an instance of the ModelUpdater. 56 | * 57 | * @return ModelUpdaterInterface 58 | */ 59 | protected function getModelUpdaterInstance(): ModelUpdaterInterface 60 | { 61 | $class = (property_exists($this, 'modelUpdaterClass')) 62 | ? $this->modelUpdaterClass 63 | : ModelUpdaterInterface::class; 64 | 65 | $config = (property_exists($this, 'modelUpdaterConfigClass')) 66 | ? app($this->modelUpdaterConfigClass) 67 | : null; 68 | 69 | return $this->getModelUpdaterFactory()->make($class, [ get_class($this), null, null, null, $config ]); 70 | } 71 | 72 | /** 73 | * @return ModelUpdaterFactoryInterface 74 | */ 75 | protected function getModelUpdaterFactory(): ModelUpdaterFactoryInterface 76 | { 77 | return app(ModelUpdaterFactoryInterface::class); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Traits/TracksTemporaryIds.php: -------------------------------------------------------------------------------- 1 | temporaryIds = $ids; 43 | 44 | return $this; 45 | } 46 | 47 | public function getTemporaryIds(): ?TemporaryIdsInterface 48 | { 49 | return $this->temporaryIds; 50 | } 51 | 52 | protected function hasTemporaryIds(): bool 53 | { 54 | return null !== $this->temporaryIds; 55 | } 56 | 57 | /** 58 | * Returns the attribute key for the temporary ID in nested data sets. 59 | * 60 | * @return string 61 | */ 62 | protected function getTemporaryIdAttributeKey(): string 63 | { 64 | return config('nestedmodelupdater.temporary-id-key'); 65 | } 66 | 67 | /** 68 | * Checks whether all the temporary ids are correctly set and 69 | * all of them have data that can be used. 70 | * 71 | * @return $this 72 | * @throws InvalidNestedDataException 73 | */ 74 | protected function checkTemporaryIdsUsage(): static 75 | { 76 | foreach ($this->temporaryIds->getKeys() as $key) { 77 | if ($this->temporaryIds->getDataForId($key) === null) { 78 | throw new InvalidNestedDataException("No create data defined for temporary ID '{$key}'"); 79 | } 80 | 81 | if (! $this->temporaryIds->isAllowedToCreateForId($key)) { 82 | throw new InvalidNestedDataException( 83 | "Not allowed to create new model for temporary ID '{$key}' for any referenced nested relation" 84 | ); 85 | } 86 | } 87 | 88 | return $this; 89 | } 90 | 91 | /** 92 | * Checks whether the attribute keys for the create data of a given temporary ID key. 93 | * 94 | * @param string $key 95 | * @param array $data 96 | * @return $this 97 | * @throws InvalidNestedDataException 98 | */ 99 | protected function checkDataAttributeKeysForTemporaryId(string $key, array $data): static 100 | { 101 | $modelClass = $this->temporaryIds->getModelClassForId($key); 102 | 103 | /** @var Model $model */ 104 | $model = new $modelClass(); 105 | 106 | // If the key is in the creating data, and it is an incrementing key, there is a mixup, 107 | // the data should not be for an update. 108 | if ($model->incrementing && array_key_exists($model->getKeyName(), $data)) { 109 | throw new InvalidNestedDataException( 110 | "Create data defined for temporary ID '{$key}' must not contain primary key value." 111 | ); 112 | } 113 | 114 | // If data is already set for the temporary ID, it should be exactly the same. 115 | $setData = $this->temporaryIds->getDataForId($key); 116 | if ($setData !== null && $data !== $setData) { 117 | throw new InvalidNestedDataException( 118 | "Multiple inconsistent create data definitions given for temporary ID '{$key}'." 119 | ); 120 | } 121 | 122 | return $this; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/config/nestedmodelupdater.php: -------------------------------------------------------------------------------- 1 | true, 13 | 14 | // Allows using temporary ids to refer to records that are not created yet. 15 | 'allow-temporary-ids' => false, 16 | 17 | // Allows updating of trashed (nested) models. 18 | 'allow-trashed' => false, 19 | 20 | // If allowed, the key to look for temporary create ids to look for 21 | 'temporary-id-key' => '_tmp_id', 22 | 23 | // List of FQNs of relation classes that are of the to One type. Every 24 | // other relation is considered plural. 25 | 'singular-relations' => [ 26 | BelongsTo::class, 27 | HasOne::class, 28 | MorphOne::class, 29 | MorphTo::class, 30 | BelongsToThrough::class, 31 | ], 32 | 33 | // List of FQNs of relation classes that have their ids stored as 34 | // foreign keys on the parent class of the relation. Any nested update 35 | // operation on one of these will be performed before updating or 36 | // creating the parent model. 37 | 'belongs-to-relations' => [ 38 | BelongsTo::class, 39 | MorphTo::class, 40 | ], 41 | 42 | // Definitions for nested updatable relations, per parent model FQN as key. 43 | // Each definition should be keyed by its attribute key (as it would be set in 44 | // an update data array (typically snake cased). 45 | // 46 | // This may store: 47 | // 48 | // link-only boolean true if we're not allowed to update through nesting (default: false). 49 | // update-only boolean true if we're not allowed to create through nesting (default: false). 50 | // updater string FQN of ModelUpdaterInterface class that should handle things. 51 | // method string method name for the relation, if not camelcased attribute key. 52 | // detach boolean if true, performs detaching sync for BelongsToMany, dissociates 53 | // children in HasMany, relations not present in the update data. 54 | // (default: true for BelongsToMany, false for HasMany) 55 | // delete-detached boolean if true, deletes instead of detaching. for HasMany relations this 56 | // means that instead of setting the foreign key NULL, for BelongsToMany 57 | // related models are deleted if they are not related to anything else 58 | // (default: false). 59 | // validator string FQN of NestedValidatorInterface that should handle validation. 60 | // rules string FQN of the class that provides the rules for the model 61 | // rules-method string method name on the rules class to use (default: 'rules') 62 | // 63 | // 64 | // 'Some\Model\Class' => [ 65 | // 'relation_key' => [ 'link-only' => true, 'updater' => 'Some\Updater\Class' ] 66 | // ] 67 | // 68 | // Alternatively, set the key's value to boolean true to use defaults and allow full updates. 69 | // 70 | // 'Some\Model\Class\' => [ 'relation_key' => true ] 71 | // 72 | // If a relation is not present in this config, no nested updating or linking will 73 | // be allowed at all. 74 | // 75 | 'relations' => [ 76 | ], 77 | 78 | // Settings for using the nested data validator 79 | 'validation' => [ 80 | 81 | // Default namespace to look for classes with rules in 82 | // If no rules class has been defined for a specific model, the 83 | // model name is expected in this namespace. 84 | // 85 | // The rules relation configuration option overrides this and the next 86 | // configuration option. 87 | 'model-rules-namespace' => 'App\\Http\\Requests\\Rules', 88 | 89 | // Postfix to use when composing model rules class FQNs. 90 | // If this is set to 'Rules', the class name loaded would 91 | // be Rules, f.i.: App\Http\Request\Rules\PostRules 92 | 'model-rules-postfix' => null, 93 | 94 | // Default rules method to call on the rules classes 95 | // should take one optional parameter with the type: 96 | // 'create', 'update', (or 'link') 97 | // 98 | // The rules-method relation configuration option overrides this. 99 | 'model-rules-method' => 'rules', 100 | 101 | // If true, does not throw exceptions if no rules model class can be 102 | // instantiated for a nested validation call. 103 | 'allow-missing-rules' => true, 104 | 105 | 106 | // Classes and/or methods to read validation rules from by default for a given model 107 | // these settings will override the validation rules defaults, but will in turn 108 | // be overriden by specific rules classes and/or methods defined in the relations 109 | // configuration above. 110 | // 111 | // Definitions should be set per model FQN. They may either be a string indicating 112 | // the rules class (the default model-rules-method will be accessed on this class: 113 | // 114 | // 'Some\Model\Class' => 'Some\Rules\Class' 115 | // 116 | // or the configuration may be an array, with two optional settings: 'class' and 'rules': 117 | // 118 | // 'Some\Model\Class' => [ 119 | // 'class' => 'Some\Rules\Class', 120 | // 'method' => 'rulesMethod', 121 | // ] 122 | // 123 | // Note that all classes defined here must be FQN's, they will *not* be namespaced by 124 | // the set model-rules-namespace. 125 | 126 | 'model-rules' => [ 127 | ], 128 | 129 | ], 130 | 131 | ]; 132 | -------------------------------------------------------------------------------- /tests/BasicModelUpdaterTest.php: -------------------------------------------------------------------------------- 1 | 'created', 29 | 'body' => 'fresh', 30 | ]; 31 | 32 | $updater = new ModelUpdater(Post::class); 33 | $result = $updater->create($data); 34 | 35 | static::assertTrue($result->model()->exists, 'Created model should exist'); 36 | 37 | $this->assertDatabaseHas('posts', [ 38 | 'id' => $result->model()->id, 39 | 'title' => 'created', 40 | 'body' => 'fresh', 41 | ]); 42 | } 43 | 44 | /** 45 | * @test 46 | */ 47 | public function it_updates_a_model_without_any_nested_relations(): void 48 | { 49 | $post = $this->createPost(); 50 | 51 | $data = [ 52 | 'title' => 'updated', 53 | 'body' => 'fresh', 54 | ]; 55 | 56 | $updater = new ModelUpdater(Post::class); 57 | $updater->update($data, $post); 58 | 59 | $this->assertDatabaseHas('posts', [ 60 | 'id' => $post->id, 61 | 'title' => 'updated', 62 | 'body' => 'fresh', 63 | ]); 64 | } 65 | 66 | /** 67 | * @test 68 | */ 69 | public function it_creates_a_new_nested_model_related_as_belongs_to(): void 70 | { 71 | $post = $this->createPost(); 72 | 73 | $data = [ 74 | 'title' => 'updated aswell', 75 | 'genre' => [ 76 | 'name' => 'New Genre', 77 | ], 78 | ]; 79 | 80 | $updater = new ModelUpdater(Post::class); 81 | $updater->update($data, $post); 82 | 83 | $this->assertDatabaseHas('posts', [ 84 | 'id' => $post->id, 85 | 'title' => 'updated aswell', 86 | ]); 87 | 88 | $post = Post::find($post->id); 89 | 90 | static::assertEquals(1, $post->genre_id, 'New Genre should be associated with Post'); 91 | 92 | $this->assertDatabaseHas('genres', [ 93 | 'name' => 'New Genre', 94 | ]); 95 | } 96 | 97 | /** 98 | * @test 99 | */ 100 | public function it_updates_a_new_nested_model_related_as_belongs_to_without_updating_parent(): void 101 | { 102 | $post = $this->createPost(); 103 | $genre = $this->createGenre('original name'); 104 | $post->genre()->associate($genre); 105 | $post->save(); 106 | 107 | // disallow full updates 108 | $this->app['config']->set('nestedmodelupdater.relations.' . Post::class . '.genre', [ 109 | 'link-only' => true, 110 | ]); 111 | 112 | $originalPostData = [ 113 | 'id' => $post->id, 114 | 'title' => $post->title, 115 | 'body' => $post->body, 116 | ]; 117 | 118 | $data = [ 119 | 'genre' => [ 120 | 'id' => $genre->id, 121 | 'name' => 'updated name', 122 | ], 123 | ]; 124 | 125 | $updater = new ModelUpdater(Post::class); 126 | $updater->update($data, $post); 127 | 128 | $this->assertDatabaseHas('posts', $originalPostData); 129 | 130 | $this->assertDatabaseHas('genres', [ 131 | 'id' => $genre->id, 132 | 'name' => 'original name', 133 | ]); 134 | } 135 | 136 | /** 137 | * @test 138 | */ 139 | public function it_only_links_a_related_model_if_no_update_is_allowed(): void 140 | { 141 | $post = $this->createPost(); 142 | $genre = $this->createGenre(); 143 | $post->genre()->associate($genre); 144 | $post->save(); 145 | 146 | $originalPostData = [ 147 | 'id' => $post->id, 148 | 'title' => $post->title, 149 | 'body' => $post->body, 150 | ]; 151 | 152 | $data = [ 153 | 'genre' => [ 154 | 'id' => $genre->id, 155 | 'name' => 'updated', 156 | ], 157 | ]; 158 | 159 | $updater = new ModelUpdater(Post::class); 160 | $updater->update($data, $post); 161 | 162 | $this->assertDatabaseHas('posts', $originalPostData); 163 | 164 | $this->assertDatabaseHas('genres', [ 165 | 'id' => $genre->id, 166 | 'name' => 'updated', 167 | ]); 168 | } 169 | 170 | /** 171 | * @test 172 | */ 173 | public function it_dissociates_a_belongs_to_relation_if_empty_data_is_passed_in(): void 174 | { 175 | $post = $this->createPost(); 176 | $genre = $this->createGenre(); 177 | $post->genre()->associate($genre); 178 | $post->save(); 179 | 180 | $data = [ 181 | 'genre' => [], 182 | ]; 183 | 184 | $updater = new ModelUpdater(Post::class); 185 | $updater->update($data, $post); 186 | 187 | $this->assertDatabaseHas('posts', [ 188 | 'id' => $post->id, 189 | 'genre_id' => null, 190 | ]); 191 | } 192 | 193 | /** 194 | * @test 195 | */ 196 | public function it_attaches_has_many_related_models_that_were_related_to_a_different_model(): void 197 | { 198 | $post = $this->createPost(); 199 | $otherPost = $this->createPost(); 200 | $commentA = $this->createComment($otherPost); 201 | $commentB = $this->createComment($otherPost); 202 | 203 | $this->assertDatabaseHas('comments', ['id' => $commentA->id, 'post_id' => $otherPost->id]); 204 | $this->assertDatabaseHas('comments', ['id' => $commentB->id, 'post_id' => $otherPost->id]); 205 | 206 | $data = [ 207 | 'comments' => [ 208 | $commentA->id, 209 | ['id' => $commentB->id], 210 | ], 211 | ]; 212 | 213 | $updater = new ModelUpdater(Post::class); 214 | $updater->update($data, $post); 215 | 216 | $this->assertDatabaseHas('comments', ['id' => $commentA->id, 'post_id' => $post->id]); 217 | $this->assertDatabaseHas('comments', ['id' => $commentB->id, 'post_id' => $post->id]); 218 | } 219 | 220 | // ------------------------------------------------------------------------------ 221 | // Trashed model updating 222 | // ------------------------------------------------------------------------------ 223 | 224 | /** 225 | * @test 226 | */ 227 | public function it_throws_an_exception_when_updating_trashed_models_is_not_allowed(): void 228 | { 229 | $this->app['config']->set('nestedmodelupdater.allow-trashed', false); 230 | $genre = $this->createGenre(); 231 | $genre->delete(); 232 | 233 | $this->expectException(NestedModelNotFoundException::class); 234 | $this->expectExceptionMessage("No query results for model [" . Genre::class . "]"); 235 | $updater = new ModelUpdater(Genre::class); 236 | 237 | $updater->update(['name' => 'Some new name'], $genre); 238 | } 239 | 240 | /** 241 | * @test 242 | */ 243 | public function it_updates_a_single_trashed_model_when_updating_trashed_models_is_allowed(): void 244 | { 245 | $this->app['config']->set('nestedmodelupdater.allow-trashed', true); 246 | $genre = $this->createGenre(); 247 | $genre->delete(); 248 | 249 | $updater = new ModelUpdater(Genre::class); 250 | $updater->update(['name' => 'Some new name'], $genre); 251 | 252 | $this->assertDatabaseHas('genres', [ 253 | 'id' => $genre->id, 254 | 'name' => 'Some new name', 255 | ]); 256 | } 257 | 258 | /** 259 | * @test 260 | */ 261 | public function it_throws_an_exception_trying_to_updated_nested_trashed_models_when_updating_trashed_models_is_not_allowed(): void 262 | { 263 | $this->app['config']->set('nestedmodelupdater.allow-trashed', false); 264 | 265 | $post = $this->createPost(); 266 | $genre = $this->createGenre('original name'); 267 | $post->genre()->associate($genre); 268 | $post->save(); 269 | $genre->delete(); 270 | 271 | $data = [ 272 | 'genre' => [ 273 | 'id' => $genre->id, 274 | 'name' => 'updated', 275 | ], 276 | ]; 277 | 278 | $this->expectException(NestedModelNotFoundException::class); 279 | $this->expectExceptionMessage("No query results for model [" . Genre::class . "]. (nesting: genre)"); 280 | $updater = new ModelUpdater(Post::class); 281 | $updater->update($data, $post); 282 | } 283 | 284 | /** 285 | * @test 286 | */ 287 | public function it_updates_a_nested_trashed_model_when_updating_trashed_models_is_allowed(): void 288 | { 289 | $this->app['config']->set('nestedmodelupdater.allow-trashed', true); 290 | 291 | $post = $this->createPost(); 292 | $genre = $this->createGenre('original name'); 293 | $post->genre()->associate($genre); 294 | $post->save(); 295 | $genre->delete(); 296 | 297 | $data = [ 298 | 'genre' => [ 299 | 'id' => $genre->id, 300 | 'name' => 'updated', 301 | ], 302 | ]; 303 | 304 | $updater = new ModelUpdater(Post::class); 305 | $updater->update($data, $post); 306 | 307 | $this->assertDatabaseHas('genres', [ 308 | 'id' => $genre->id, 309 | 'name' => 'updated', 310 | ]); 311 | } 312 | 313 | // ------------------------------------------------------------------------------ 314 | // Force updating / creating 315 | // ------------------------------------------------------------------------------ 316 | 317 | /** 318 | * @test 319 | */ 320 | public function it_force_updates_deleted_at_on_an_existing_model_through_force_update(): void 321 | { 322 | $genre = $this->createGenre(); 323 | 324 | $updater = new ModelUpdater(Genre::class); 325 | $updater->forceUpdate([ 326 | 'deleted_at' => '2019-10-12 09:00:00', 327 | ], $genre); 328 | 329 | $this->assertDatabaseHas('genres', [ 330 | 'id' => $genre->id, 331 | 'deleted_at' => '2019-10-12 09:00:00', 332 | ]); 333 | } 334 | 335 | /** 336 | * @test 337 | */ 338 | public function it_force_updates_deleted_at_on_an_existing_model_through_setting_force_first(): void 339 | { 340 | $genre = $this->createGenre(); 341 | 342 | $updater = new ModelUpdater(Genre::class); 343 | $updater->force(true); 344 | $updater->update([ 345 | 'deleted_at' => '2019-10-12 09:00:00', 346 | ], $genre); 347 | 348 | $this->assertDatabaseHas('genres', [ 349 | 'id' => $genre->id, 350 | 'deleted_at' => '2019-10-12 09:00:00', 351 | ]); 352 | } 353 | 354 | /** 355 | * @test 356 | */ 357 | public function it_force_creates_model_with_deleted_at_through_force_create(): void 358 | { 359 | $updater = new ModelUpdater(Genre::class); 360 | $result = $updater->forceCreate([ 361 | 'name' => 'Test genre', 362 | 'deleted_at' => '2019-10-12 09:00:00', 363 | ]); 364 | 365 | $this->assertInstanceOf(UpdateResult::class, $result); 366 | $this->assertTrue($result->model()->exists, "Created model should exist"); 367 | 368 | $this->assertDatabaseHas('genres', [ 369 | 'id' => $result->model()->id, 370 | 'name' => 'Test genre', 371 | 'deleted_at' => '2019-10-12 09:00:00', 372 | ]); 373 | } 374 | 375 | /** 376 | * @test 377 | */ 378 | public function it_force_creates_model_with_deleted_at_through_setting_force_first(): void 379 | { 380 | $updater = new ModelUpdater(Genre::class); 381 | $updater->force(true); 382 | $result = $updater->create([ 383 | 'name' => 'Test genre', 384 | 'deleted_at' => '2019-10-12 09:00:00', 385 | ]); 386 | 387 | $this->assertInstanceOf(UpdateResult::class, $result); 388 | $this->assertTrue($result->model()->exists, "Created model should exist"); 389 | 390 | $this->assertDatabaseHas('genres', [ 391 | 'id' => $result->model()->id, 392 | 'name' => 'Test genre', 393 | 'deleted_at' => '2019-10-12 09:00:00', 394 | ]); 395 | } 396 | 397 | // ------------------------------------------------------------------------------ 398 | // Normalization 399 | // ------------------------------------------------------------------------------ 400 | 401 | /** 402 | * @test 403 | */ 404 | public function it_normalizes_nested_data_for_null_value(): void 405 | { 406 | $post = $this->createPost(); 407 | $post->genre()->associate($post); 408 | $post->save(); 409 | 410 | $data = [ 411 | 'genre' => null, 412 | ]; 413 | 414 | $updater = new ModelUpdater(Post::class); 415 | $updater->update($data, $post); 416 | 417 | $this->assertDatabaseHas('posts', [ 418 | 'id' => $post->id, 419 | 'genre_id' => null, 420 | ]); 421 | } 422 | 423 | /** 424 | * @test 425 | */ 426 | public function it_normalizes_nested_data_for_scalar_link_value(): void 427 | { 428 | $post = $this->createPost(); 429 | $genre = $this->createGenre('original name'); 430 | 431 | $data = [ 432 | 'genre' => $genre->id, 433 | ]; 434 | 435 | $updater = new ModelUpdater(Post::class); 436 | $updater->update($data, $post); 437 | 438 | $this->assertDatabaseHas('posts', [ 439 | 'id' => $post->id, 440 | 'genre_id' => $genre->id, 441 | ]); 442 | } 443 | 444 | /** 445 | * @test 446 | */ 447 | public function it_normalizes_nested_data_for_arrayable_content(): void 448 | { 449 | $post = $this->createPost(); 450 | $genre = $this->createGenre('original name'); 451 | 452 | $data = [ 453 | 'genre' => new ArrayableData([ 454 | 'id' => $genre->id, 455 | 'name' => 'updated', 456 | ]), 457 | ]; 458 | 459 | $updater = new ModelUpdater(Post::class); 460 | $updater->update($data, $post); 461 | 462 | $this->assertDatabaseHas('posts', [ 463 | 'id' => $post->id, 464 | 'genre_id' => $genre->id, 465 | ]); 466 | 467 | $this->assertDatabaseHas('genres', [ 468 | 'id' => $genre->id, 469 | 'name' => 'updated', 470 | ]); 471 | } 472 | 473 | 474 | // ------------------------------------------------------------------------------ 475 | // Problems and exceptions 476 | // ------------------------------------------------------------------------------ 477 | 478 | /** 479 | * @test 480 | */ 481 | public function it_throws_an_exception_if_nested_relation_data_is_of_incorrect_type(): void 482 | { 483 | $this->expectException(UnexpectedValueException::class); 484 | $this->expectExceptionMessageMatches('#genre\)#i'); 485 | 486 | $post = $this->createPost(); 487 | 488 | $data = [ 489 | 'genre' => (object) ['incorrect' => 'data'], 490 | ]; 491 | 492 | $updater = new ModelUpdater(Post::class); 493 | $updater->update($data, $post); 494 | } 495 | 496 | /** 497 | * @test 498 | */ 499 | public function it_throws_an_exception_if_it_cannot_find_the_top_level_model_by_id(): void 500 | { 501 | $this->expectException(NestedModelNotFoundException::class); 502 | $this->expectExceptionMessageMatches('#Czim\\\\NestedModelUpdater\\\\Test\\\\Helpers\\\\Models\\\\Post#'); 503 | 504 | $data = [ 505 | 'genre' => [ 506 | 'name' => 'updated', 507 | ], 508 | ]; 509 | 510 | $updater = new ModelUpdater(Post::class); 511 | $updater->update($data, 999); 512 | } 513 | 514 | /** 515 | * @test 516 | */ 517 | public function it_throws_an_exception_with_nested_key_if_it_cannot_find_a_nested_model_by_id(): void 518 | { 519 | $this->expectException(NestedModelNotFoundException::class); 520 | $this->expectExceptionMessageMatches('#Czim\\\\NestedModelUpdater\\\\Test\\\\Helpers\\\\Models\\\\Genre.*\(nesting: genre\)#i'); 521 | 522 | $post = $this->createPost(); 523 | 524 | $data = [ 525 | 'genre' => [ 526 | 'id' => 999, // does not exist 527 | 'name' => 'updated', 528 | ], 529 | ]; 530 | 531 | $updater = new ModelUpdater(Post::class); 532 | $updater->update($data, $post); 533 | } 534 | 535 | /** 536 | * @test 537 | */ 538 | public function it_throws_an_exception_if_not_allowed_to_create_a_nested_model_record_that_has_no_id(): void 539 | { 540 | $this->expectException(DisallowedNestedActionException::class); 541 | $this->expectExceptionMessageMatches('#authors\.0#i'); 542 | 543 | $data = [ 544 | 'title' => 'Problem Post', 545 | 'body' => 'Body', 546 | 'authors' => [ 547 | ['name' => 'New Name'], 548 | ], 549 | ]; 550 | 551 | $updater = new ModelUpdater(Post::class); 552 | $updater->create($data); 553 | } 554 | 555 | /** 556 | * @test 557 | */ 558 | public function it_throws_an_exception_if_not_allowed_to_create_an_update_only_nested_model_record(): void 559 | { 560 | $this->expectException(DisallowedNestedActionException::class); 561 | $this->expectExceptionMessageMatches('#authors\.0#i'); 562 | 563 | $this->app['config']->set('nestedmodelupdater.relations.' . Post::class . '.authors', [ 564 | 'link-only' => false, 565 | 'update-only' => true, 566 | ]); 567 | 568 | $data = [ 569 | 'title' => 'Problem Post', 570 | 'body' => 'Body', 571 | 'authors' => [ 572 | ['name' => 'New Name'], 573 | ], 574 | ]; 575 | 576 | $updater = new ModelUpdater(Post::class); 577 | $updater->create($data); 578 | } 579 | 580 | /** 581 | * @test 582 | */ 583 | public function it_rolls_back_changes_if_exception_is_thrown(): void 584 | { 585 | $post = $this->createPost(); 586 | 587 | $data = [ 588 | 'title' => 'this should be', 589 | 'body' => 'rolled back', 590 | // comments is a HasMany relation, so the model is 591 | // updated and persisted before this is parsed 592 | 'comments' => [ 593 | [ 594 | 'id' => 999, // does not exist 595 | ], 596 | ], 597 | ]; 598 | 599 | $updater = new ModelUpdater(Post::class); 600 | 601 | try { 602 | $updater->update($data, $post); 603 | 604 | // should never get here 605 | $this->fail('Exception should have been thrown while attempting update'); 606 | 607 | } catch (NestedModelNotFoundException) { 608 | // expected 609 | } 610 | 611 | // unchanged data 612 | $this->assertDatabaseMissing('posts', [ 613 | 'title' => 'this should be', 614 | 'body' => 'rolled back', 615 | ]); 616 | } 617 | 618 | /** 619 | * @test 620 | */ 621 | public function it_can_be_configured_not_to_use_database_transactions(): void 622 | { 623 | $post = $this->createPost(); 624 | 625 | $data = [ 626 | 'title' => 'this should be', 627 | 'body' => 'rolled back', 628 | // comments is a HasMany relation, so the model is 629 | // updated and persisted before this is parsed 630 | 'comments' => [ 631 | [ 632 | 'id' => 999, // does not exist 633 | ], 634 | ], 635 | ]; 636 | 637 | $updater = new ModelUpdater(Post::class); 638 | $updater->disableDatabaseTransaction(); 639 | 640 | try { 641 | $updater->update($data, $post); 642 | 643 | // should never get here 644 | $this->fail('Exception should have been thrown while attempting update'); 645 | 646 | } catch (NestedModelNotFoundException) { 647 | // expected 648 | } 649 | 650 | // unchanged data 651 | $this->assertDatabaseHas('posts', [ 652 | 'title' => 'this should be', 653 | 'body' => 'rolled back', 654 | ]); 655 | } 656 | } 657 | -------------------------------------------------------------------------------- /tests/Helpers/AlternativeUpdater.php: -------------------------------------------------------------------------------- 1 | $array 13 | */ 14 | public function __construct(protected array $array) 15 | { 16 | } 17 | 18 | /** 19 | * Get the instance as an array. 20 | * 21 | * @return array 22 | */ 23 | public function toArray(): array 24 | { 25 | return $this->array; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/Helpers/Models/Author.php: -------------------------------------------------------------------------------- 1 | belongsToMany(Post::class); 29 | } 30 | 31 | public function comments(): HasMany 32 | { 33 | return $this->hasMany(Comment::class); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Helpers/Models/Comment.php: -------------------------------------------------------------------------------- 1 | belongsTo(Post::class); 29 | } 30 | 31 | public function author(): BelongsTo 32 | { 33 | return $this->belongsTo(Author::class); 34 | } 35 | 36 | public function tags(): MorphMany 37 | { 38 | return $this->morphMany(Tag::class, 'taggable'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Helpers/Models/Genre.php: -------------------------------------------------------------------------------- 1 | hasMany(Post::class); 29 | } 30 | 31 | /** 32 | * @return array 33 | */ 34 | public function customRulesMethod(): array 35 | { 36 | return [ 37 | 'name' => 'in:custom,rules,work', 38 | ]; 39 | } 40 | 41 | public function brokenCustomRulesMethod(): string 42 | { 43 | return 'something other than an array'; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Helpers/Models/Post.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | use NestedUpdatable; 26 | 27 | /** 28 | * @var string[] 29 | */ 30 | protected $fillable = [ 31 | 'title', 32 | 'body', 33 | ]; 34 | 35 | public function authors(): BelongsToMany 36 | { 37 | return $this->belongsToMany(Author::class); 38 | } 39 | 40 | public function comments(): HasMany 41 | { 42 | return $this->hasMany(Comment::class); 43 | } 44 | 45 | public function genre(): BelongsTo 46 | { 47 | return $this->belongsTo(Genre::class); 48 | } 49 | 50 | public function tags(): MorphMany 51 | { 52 | return $this->morphMany(Tag::class, 'taggable'); 53 | } 54 | 55 | 56 | public function someOtherRelationMethod(): BelongsTo 57 | { 58 | return $this->belongsTo(Genre::class); 59 | } 60 | 61 | public function commentHasOne(): HasOne 62 | { 63 | return $this->hasOne(Comment::class); 64 | } 65 | 66 | public function specials(): HasMany 67 | { 68 | return $this->hasMany(Special::class); 69 | } 70 | 71 | /** 72 | * @return array 73 | */ 74 | public function customRulesMethod(): array 75 | { 76 | return [ 77 | 'title' => 'in:custom,post,rules', 78 | ]; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tests/Helpers/Models/Special.php: -------------------------------------------------------------------------------- 1 | belongsTo(Post::class); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/Helpers/Models/Tag.php: -------------------------------------------------------------------------------- 1 | morphTo(Post::class, 'taggable'); 28 | } 29 | 30 | public function comments(): MorphTo 31 | { 32 | return $this->morphTo(Comment::class, 'taggable'); 33 | } 34 | 35 | /** 36 | * @return array 37 | */ 38 | public function rules(): array 39 | { 40 | return [ 41 | 'name' => 'in:custom,tag,rules', 42 | ]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Helpers/Requests/AbstractNestedTestRequest.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | abstract class AbstractNestedTestRequest extends AbstractNestedDataRequest 16 | { 17 | /** 18 | * Override to prevent redirect response for easier testing. 19 | * 20 | * @param array $errors 21 | * @return Response 22 | */ 23 | public function response(array $errors): Response 24 | { 25 | return response($errors, 422); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/Helpers/Requests/NestedPostRequest.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class NestedPostRequest extends AbstractNestedTestRequest 13 | { 14 | public function authorize(): bool 15 | { 16 | return true; 17 | } 18 | 19 | protected function getNestedModelClass(): string 20 | { 21 | return Post::class; 22 | } 23 | 24 | protected function isCreating(): bool 25 | { 26 | // As an example, the difference between creating and updating here is 27 | // simulated as that of the difference between using a POST and PUT method. 28 | return request()->getMethod() !== 'PUT' 29 | && request()->getMethod() !== 'PATCH'; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Helpers/Rules/AuthorRules.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function rules(): array 13 | { 14 | return [ 15 | 'name' => 'string', 16 | ]; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/Helpers/Rules/CommentRules.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function rules(): array 13 | { 14 | return [ 15 | 'title' => 'string', 16 | 'body' => 'required', 17 | ]; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/Helpers/Rules/GenreRules.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function rules(): array 13 | { 14 | return [ 15 | 'id' => 'integer', 16 | 'name' => 'string|unique:genres,name', 17 | ]; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/Helpers/Rules/PostRules.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function rules(string $type = 'create'): array 13 | { 14 | if ($type !== 'create') { 15 | return [ 16 | 'title' => 'string|max:10', 17 | 'body' => 'required|string', 18 | ]; 19 | } 20 | 21 | return [ 22 | 'title' => 'required|string|max:50', 23 | 'body' => 'string', 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/Helpers/Rules/TagRules.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function rules(string $type = 'create'): array 13 | { 14 | if ($type !== 'create') { 15 | return [ 16 | // Added deliberately weird rules to test merging of inherent + custom model rules. 17 | 'id' => 'integer|min:2|exists:genres,id', 18 | 'name' => 'string|max:30|unique:tags', 19 | ]; 20 | } 21 | 22 | return [ 23 | 'name' => 'string|max:30|unique:tags', 24 | ]; 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /tests/ModelUpdaterTemporaryIdsTest.php: -------------------------------------------------------------------------------- 1 | createPost(); 30 | $comment = $this->createComment($post); 31 | 32 | $data = [ 33 | 'comments' => [ 34 | [ 35 | 'id' => $comment->id, 36 | 'title' => 'updated title', 37 | 'author' => [ 38 | '_tmp_id' => 'auth_1', 39 | 'name' => 'new shared author', 40 | ], 41 | ], 42 | [ 43 | 'title' => 'new title', 44 | 'body' => 'for new comment', 45 | 'author' => [ 46 | '_tmp_id' => 'auth_1', 47 | ] 48 | ] 49 | ] 50 | ]; 51 | 52 | $updater = new ModelUpdater(Post::class); 53 | $updater->update($data, $post); 54 | 55 | static::assertEquals(1, Author::count(), 'Exactly one author should have been created'); 56 | 57 | /** @var Author $author */ 58 | $author = Author::first(); 59 | 60 | $this->assertDatabaseHas('comments', [ 61 | 'id' => $comment->id, 62 | 'title' => 'updated title', 63 | ]); 64 | 65 | $this->assertDatabaseHas('comments', [ 66 | 'title' => 'new title', 67 | 'body' => 'for new comment', 68 | ]); 69 | 70 | $this->assertDatabaseHas('authors', [ 71 | 'id' => $author->id, 72 | 'name' => 'new shared author', 73 | ]); 74 | } 75 | 76 | /** 77 | * @test 78 | */ 79 | public function it_creates_and_updates_a_nested_relation_using_multiple_distinct_temporary_ids(): void 80 | { 81 | $post = $this->createPost(); 82 | $comment = $this->createComment($post); 83 | 84 | $data = [ 85 | 'comments' => [ 86 | [ 87 | 'id' => $comment->id, 88 | 'title' => 'updated title', 89 | 'author' => [ 90 | '_tmp_id' => 'auth_1', 91 | 'name' => 'new shared author', 92 | 'posts' => [ 93 | [ 94 | 'title' => 'new nested title', 95 | 'body' => 'new nested body', 96 | 'genre' => [ 97 | '_tmp_id' => 'genre_2' 98 | ] 99 | ] 100 | ] 101 | ] 102 | ], 103 | [ 104 | 'title' => 'new title', 105 | 'body' => 'for new comment', 106 | 'author' => [ 107 | '_tmp_id' => 'auth_1', 108 | ] 109 | ] 110 | ], 111 | 'genre' => [ 112 | '_tmp_id' => 'genre_2', 113 | 'name' => 'new shared genre', 114 | ] 115 | ]; 116 | 117 | $updater = new ModelUpdater(Post::class); 118 | $updater->update($data, $post); 119 | 120 | static::assertEquals(1, Author::count(), 'Exactly one author should have been created'); 121 | /** @var Author $author */ 122 | $author = Author::first(); 123 | 124 | static::assertEquals(1, Genre::count(), 'Exactly one tag should have been created'); 125 | /** @var Genre $genre */ 126 | $genre = Genre::first(); 127 | 128 | static::assertEquals(2, Post::count(), 'Exactly two posts should exist (1 created by nesting)'); 129 | /** @var Post $newPost */ 130 | $newPost = Post::orderBy('id', 'desc')->first(); 131 | 132 | $this->assertDatabaseHas('comments', [ 133 | 'id' => $comment->id, 134 | 'title' => 'updated title', 135 | ]); 136 | 137 | $this->assertDatabaseHas('comments', [ 138 | 'title' => 'new title', 139 | 'body' => 'for new comment', 140 | ]); 141 | 142 | $this->assertDatabaseHas('authors', [ 143 | 'id' => $author->id, 144 | 'name' => 'new shared author', 145 | ]); 146 | 147 | $this->assertDatabaseHas('posts', [ 148 | 'id' => $post->id, 149 | 'genre_id' => $genre->id, 150 | ]); 151 | 152 | $this->assertDatabaseHas('posts', [ 153 | 'id' => $newPost->id, 154 | 'title' => 'new nested title', 155 | 'genre_id' => $genre->id, 156 | ]); 157 | 158 | $this->assertDatabaseHas('genres', [ 159 | 'id' => $genre->id, 160 | 'name' => 'new shared genre', 161 | ]); 162 | } 163 | 164 | 165 | // ------------------------------------------------------------------------------ 166 | // Exceptions 167 | // ------------------------------------------------------------------------------ 168 | 169 | /** 170 | * @test 171 | */ 172 | public function it_throws_an_exception_if_a_temporary_id_is_used_for_different_models(): void 173 | { 174 | $this->expectException(InvalidNestedDataException::class); 175 | $this->expectExceptionMessageMatches('#[\'"]auth_1[\'"]#'); 176 | 177 | $post = $this->createPost(); 178 | $comment = $this->createComment($post); 179 | 180 | $data = [ 181 | 'comments' => [ 182 | [ 183 | 'id' => $comment->id, 184 | 'title' => 'updated title', 185 | 'author' => [ 186 | '_tmp_id' => 'auth_1', 187 | 'name' => 'new author', 188 | ] 189 | ], 190 | [ 191 | '_tmp_id' => 'auth_1', 192 | 'title' => 'new title', 193 | 'body' => 'for new comment', 194 | ] 195 | ] 196 | ]; 197 | 198 | $updater = new ModelUpdater(Post::class); 199 | $updater->update($data, $post); 200 | } 201 | 202 | /** 203 | * @test 204 | */ 205 | public function it_throws_an_exception_if_a_no_data_is_defined_for_a_temporary_id(): void 206 | { 207 | $this->expectException(InvalidNestedDataException::class); 208 | $this->expectExceptionMessageMatches('#data defined.*[\'"]auth_1[\'"]#'); 209 | 210 | $post = $this->createPost(); 211 | $comment = $this->createComment($post); 212 | 213 | $data = [ 214 | 'comments' => [ 215 | [ 216 | 'id' => $comment->id, 217 | 'title' => 'updated title', 218 | 'author' => [ 219 | '_tmp_id' => 'auth_1', 220 | ] 221 | ], 222 | [ 223 | 'title' => 'new title', 224 | 'body' => 'for new comment', 225 | 'author' => [ 226 | '_tmp_id' => 'auth_1', 227 | ] 228 | ] 229 | ] 230 | ]; 231 | 232 | $updater = new ModelUpdater(Post::class); 233 | $updater->update($data, $post); 234 | } 235 | 236 | /** 237 | * @test 238 | */ 239 | public function it_throws_an_exception_if_a_create_data_for_a_temporary_id_contains_a_primary_key_value(): void 240 | { 241 | $this->expectException(InvalidNestedDataException::class); 242 | $this->expectExceptionMessageMatches('#[\'"]auth_1[\'"].*primary key#'); 243 | 244 | $post = $this->createPost(); 245 | $comment = $this->createComment($post); 246 | 247 | $data = [ 248 | 'comments' => [ 249 | [ 250 | 'id' => $comment->id, 251 | 'title' => 'updated title', 252 | 'author' => [ 253 | '_tmp_id' => 'auth_1', 254 | ] 255 | ], 256 | [ 257 | 'title' => 'new title', 258 | 'body' => 'for new comment', 259 | 'author' => [ 260 | '_tmp_id' => 'auth_1', 261 | 'id' => 123, 262 | ] 263 | ] 264 | ] 265 | ]; 266 | 267 | $updater = new ModelUpdater(Post::class); 268 | $updater->update($data, $post); 269 | } 270 | 271 | /** 272 | * @test 273 | */ 274 | public function it_throws_an_exception_if_multiple_inconsistent_sets_of_create_data_for_a_temporary_id_are_defined(): void 275 | { 276 | $this->expectException(InvalidNestedDataException::class); 277 | $this->expectExceptionMessageMatches('#inconsistent.*[\'"]auth_1[\'"]#'); 278 | 279 | $post = $this->createPost(); 280 | $comment = $this->createComment($post); 281 | 282 | $data = [ 283 | 'comments' => [ 284 | [ 285 | 'id' => $comment->id, 286 | 'title' => 'updated title', 287 | 'author' => [ 288 | '_tmp_id' => 'auth_1', 289 | 'name' => 'Some Author Name', 290 | ] 291 | ], 292 | [ 293 | 'title' => 'new title', 294 | 'body' => 'for new comment', 295 | 'author' => [ 296 | '_tmp_id' => 'auth_1', 297 | 'name' => 'Not The Same Author Name', 298 | ] 299 | ] 300 | ] 301 | ]; 302 | 303 | $updater = new ModelUpdater(Post::class); 304 | $updater->update($data, $post); 305 | } 306 | 307 | /** 308 | * @test 309 | */ 310 | public function it_throws_an_exception_if_not_allowed_to_create_for_any_nested_use_of_a_temporary_id(): void 311 | { 312 | $this->expectException(InvalidNestedDataException::class); 313 | $this->expectExceptionMessageMatches('#allowed.*[\'"]auth_1[\'"]#'); 314 | 315 | Config::set('nestedmodelupdater.relations.' . Comment::class . '.author', [ 'update-only' => true ]); 316 | 317 | $post = $this->createPost(); 318 | $comment = $this->createComment($post); 319 | 320 | $data = [ 321 | 'comments' => [ 322 | [ 323 | 'id' => $comment->id, 324 | 'title' => 'updated title', 325 | 'author' => [ 326 | '_tmp_id' => 'auth_1', 327 | 'name' => 'Some Author Name', 328 | ] 329 | ], 330 | [ 331 | 'title' => 'new title', 332 | 'body' => 'for new comment', 333 | 'author' => [ 334 | '_tmp_id' => 'auth_1', 335 | ] 336 | ] 337 | ] 338 | ]; 339 | 340 | $updater = new ModelUpdater(Post::class); 341 | $updater->update($data, $post); 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /tests/NestedUpdatableTraitTest.php: -------------------------------------------------------------------------------- 1 | 'created', 19 | 'body' => 'fresh', 20 | ]; 21 | 22 | $post = Post::create($data); 23 | 24 | static::assertInstanceOf(Post::class, $post); 25 | static::assertTrue($post->exists); 26 | 27 | $this->assertDatabaseHas('posts', [ 28 | 'id' => $post->id, 29 | 'title' => 'created', 30 | 'body' => 'fresh', 31 | ]); 32 | } 33 | 34 | /** 35 | * @test 36 | */ 37 | public function it_allows_a_model_to_be_updated_without_any_nested_relations(): void 38 | { 39 | $post = $this->createPost(); 40 | 41 | $data = [ 42 | 'title' => 'updated', 43 | 'body' => 'fresh', 44 | ]; 45 | 46 | $result = $post->update($data); 47 | 48 | static::assertTrue($result, 'Update call should return boolean true'); 49 | 50 | $this->assertDatabaseHas('posts', [ 51 | 'id' => $post->id, 52 | 'title' => 'updated', 53 | 'body' => 'fresh', 54 | ]); 55 | } 56 | 57 | /** 58 | * @test 59 | */ 60 | public function it_creates_a_new_nested_model_related_as_belongs_to(): void 61 | { 62 | $data = [ 63 | 'title' => 'created', 64 | 'body' => 'fresh', 65 | 'comments' => [ 66 | [ 67 | 'title' => 'created comment', 68 | 'body' => 'comment body', 69 | 'author' => [ 70 | 'name' => 'new author', 71 | ] 72 | ] 73 | ], 74 | ]; 75 | 76 | $post = Post::create($data); 77 | 78 | static::assertInstanceOf(Post::class, $post); 79 | static::assertTrue($post->exists); 80 | 81 | $author = Author::latest()->first(); 82 | static::assertInstanceOf(Author::class, $author, 'Author model should have been created'); 83 | 84 | $this->assertDatabaseHas('posts', [ 85 | 'id' => $post->id, 86 | 'title' => 'created', 87 | 'body' => 'fresh', 88 | ]); 89 | 90 | $this->assertDatabaseHas('comments', [ 91 | 'post_id' => $post->id, 92 | 'title' => 'created comment', 93 | 'body' => 'comment body', 94 | 'author_id' => $author->id, 95 | ]); 96 | 97 | $this->assertDatabaseHas('authors', [ 98 | 'id' => $author->id, 99 | 'name' => 'new author', 100 | ]); 101 | } 102 | 103 | /** 104 | * @test 105 | */ 106 | public function it_updates_an_existing_nested_model_related_as_belongs_to(): void 107 | { 108 | $post = $this->createPost(); 109 | $comment = $this->createComment($post); 110 | 111 | $data = [ 112 | 'comments' => [ 113 | [ 114 | 'id' => $comment->id, 115 | 'title' => 'updated comment', 116 | ] 117 | ], 118 | ]; 119 | 120 | $result = $post->update($data); 121 | 122 | static::assertTrue($result, 'Update call should return boolean true'); 123 | 124 | $this->assertDatabaseHas('comments', [ 125 | 'id' => $comment->id, 126 | 'post_id' => $post->id, 127 | 'title' => 'updated comment', 128 | ]); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /tests/NestedValidatorFormRequestTest.php: -------------------------------------------------------------------------------- 1 | app['router']->any('testing', static function (NestedPostRequest $request) { 17 | return response('ok'); 18 | }); 19 | } 20 | 21 | /** 22 | * @test 23 | */ 24 | public function it_performs_validation_for_invalid_nested_create_data_through_a_form_request(): void 25 | { 26 | $response = $this->post('testing', [ 27 | 'title' => 'disallowed title that is way and way too long to be allowed ' 28 | . 'by the nested validator', 29 | 'genre' => [ 30 | 'id' => 999, 31 | 'name' => 'some genre name', 32 | ], 33 | ]); 34 | 35 | $response 36 | ->assertStatus(422) 37 | ->assertJson([ 38 | 'title' => ['The title must not be greater than 50 characters.'], 39 | 'genre.id' => ['The selected genre.id is invalid.'], 40 | ]); 41 | } 42 | 43 | /** 44 | * @test 45 | */ 46 | public function it_performs_validation_for_invalid_nested_update_data_through_a_form_request(): void 47 | { 48 | // the title for updates may be no longer than 10 characters, 49 | // for the create 50 | $response = $this->put('testing', [ 51 | 'title' => 'ten characters allowed', 52 | 'genre' => [ 53 | 'id' => 999, 54 | 'name' => 'some genre name', 55 | ], 56 | ]); 57 | 58 | $response 59 | ->assertStatus(422) 60 | ->assertJson([ 61 | 'body' => ['The body field is required.'], 62 | 'genre.id' => ['The selected genre.id is invalid.'], 63 | 'title' => ['The title must not be greater than 10 characters.'], 64 | ]); 65 | } 66 | 67 | /** 68 | * @test 69 | */ 70 | public function it_performs_validation_for_valid_nested_create_data_through_a_form_request(): void 71 | { 72 | $response = $this->post('testing', [ 73 | 'title' => 'allowed title', 74 | 'genre' => [ 75 | 'name' => 'allowed genre name', 76 | ], 77 | ]); 78 | 79 | $response->assertStatus(200); 80 | $this->assertEquals('ok', $response->getContent()); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/NestedValidatorTest.php: -------------------------------------------------------------------------------- 1 | 'allowed title', 25 | 'genre' => [ 26 | 'name' => 'allowed genre', 27 | ], 28 | ]; 29 | 30 | $validator = new NestedValidator(Post::class); 31 | 32 | static::assertTrue($validator->validate($data), 'Validation should succeed'); 33 | static::assertTrue($validator->messages()->isEmpty(), 'Validation messages should be empty'); 34 | } 35 | 36 | /** 37 | * @test 38 | */ 39 | public function it_performs_validation_on_a_nested_data_set_with_errors(): void 40 | { 41 | $this->createGenre('existing genre name'); 42 | 43 | $data = [ 44 | 'title' => 'disallowed title that is way and way too long to be allowed ' 45 | . 'by the nested validator', 46 | 'genre' => [ 47 | 'name' => 'existing genre name', 48 | ], 49 | ]; 50 | 51 | $validator = new NestedValidator(Post::class); 52 | 53 | static::assertFalse($validator->validate($data), 'Validation should fail'); 54 | 55 | $messages = $validator->messages(); 56 | 57 | $this->assertHasValidationErrorLike($messages, 'title', '50 characters'); 58 | $this->assertHasValidationErrorRegex($messages, 'genre.name', '(unique|been taken)'); 59 | } 60 | 61 | /** 62 | * @test 63 | */ 64 | public function it_performs_validation_on_a_deeply_nested_data_set_with_errors(): void 65 | { 66 | $data = [ 67 | 'title' => 'disallowed title that is way and way too long to be allowed ' 68 | . 'by the nested validator', 69 | 'comments' => [ 70 | [ 71 | 'title' => 12, 72 | 'author' => [ 73 | 'name' => 13 74 | ] 75 | ], 76 | [ 77 | 'id' => 999, 78 | 'title' => 'updated comment title', 79 | ], 80 | 'erroneous string' 81 | ], 82 | 'genre' => [ 83 | 'name' => 'allowed genre', 84 | ], 85 | 'tags' => 'not an array', 86 | ]; 87 | 88 | $validator = new NestedValidator(Post::class); 89 | 90 | static::assertFalse($validator->validate($data), 'Validation should fail'); 91 | 92 | $messages = $validator->messages(); 93 | 94 | $this->assertHasValidationErrorLike($messages, 'title', '50 characters'); 95 | $this->assertHasValidationErrorLike($messages, 'comments.0.title', 'string'); 96 | $this->assertHasValidationErrorLike($messages, 'comments.0.body', 'required'); 97 | $this->assertHasValidationErrorLike($messages, 'comments.0.author.name', 'string'); 98 | $this->assertHasValidationErrorLike($messages, 'comments.1.id', 'invalid'); 99 | $this->assertHasValidationErrorLike($messages, 'comments.1.body', 'required'); 100 | $this->assertHasValidationErrorLike($messages, 'comments.2', 'integer'); 101 | $this->assertHasValidationErrorLike($messages, 'tags', 'array'); 102 | 103 | static::assertCount(8, $messages); 104 | } 105 | 106 | /** 107 | * @test 108 | */ 109 | public function it_performs_validation_correctly_for_associative_plural_relation_array(): void 110 | { 111 | $data = [ 112 | 'title' => 'required', 113 | 'comments' => [ 114 | 'test' => [ 115 | 'title' => 12, 116 | 'author' => [ 117 | 'name' => 13 118 | ] 119 | ], 120 | 3948 => [ 121 | 'id' => 999, 122 | 'title' => 'updated comment title', 123 | ] 124 | ] 125 | ]; 126 | 127 | $validator = new NestedValidator(Post::class); 128 | 129 | static::assertFalse($validator->validate($data), 'Validation should fail'); 130 | 131 | $messages = $validator->messages(); 132 | 133 | $this->assertHasValidationErrorLike($messages, 'comments.test.title', 'string'); 134 | $this->assertHasValidationErrorLike($messages, 'comments.test.body', 'required'); 135 | $this->assertHasValidationErrorLike($messages, 'comments.test.author.name', 'string'); 136 | $this->assertHasValidationErrorLike($messages, 'comments.3948.id', 'invalid'); 137 | $this->assertHasValidationErrorLike($messages, 'comments.3948.body', 'required'); 138 | 139 | static::assertCount(5, $messages); 140 | } 141 | 142 | 143 | // ------------------------------------------------------------------------------ 144 | // Rules 145 | // ------------------------------------------------------------------------------ 146 | 147 | /** 148 | * @test 149 | */ 150 | public function it_returns_validation_rules_for_a_nested_data_set_with_a_belongs_to_create_data_set(): void 151 | { 152 | $data = [ 153 | 'title' => 'allowed title', 154 | 'genre' => [ 155 | 'name' => 'allowed genre', 156 | ], 157 | ]; 158 | 159 | $validator = new NestedValidator(Post::class); 160 | $rules = $validator->validationRules($data); 161 | 162 | $this->assertHasValidationRules($rules, [ 163 | 'title' => ['required', 'string', 'max:50'], 164 | 'body' => 'string', 165 | 'genre' => 'array', 166 | 'genre.name' => ['string', 'unique:genres,name'], 167 | ], true); 168 | } 169 | 170 | /** 171 | * @test 172 | */ 173 | public function it_returns_validation_rules_for_a_nested_data_set_with_a_belongs_to_update_data_set(): void 174 | { 175 | $data = [ 176 | 'title' => 'allowed title', 177 | 'genre' => [ 178 | 'id' => 13, 179 | 'name' => 'allowed genre', 180 | ], 181 | ]; 182 | 183 | $validator = new NestedValidator(Post::class); 184 | $rules = $validator->validationRules($data); 185 | 186 | $this->assertHasValidationRules($rules, [ 187 | 'title' => ['required', 'string', 'max:50'], 188 | 'body' => 'string', 189 | 'genre' => 'array', 190 | 'genre.id' => ['exists:genres,id', 'integer'], 191 | 'genre.name' => ['string', 'unique:genres,name'], 192 | ], true, true); 193 | } 194 | 195 | /** 196 | * Inherent nesting rules for primary keys (key is required, must exist in database) 197 | * are built up on the basis of data present and what is allowed at a nesting level. 198 | * This tests whether rules-class set custom rules for the primary key are correctly 199 | * merged with those rules -- regardless of the format used. 200 | * 201 | * @test 202 | */ 203 | public function it_correctly_merges_custom_model_primary_key_rules_with_inherent_nesting_rules(): void 204 | { 205 | Config::set('nestedmodelupdater.relations.' . Post::class . '.tags', [ 'link-only' => true ]); 206 | 207 | $data = [ 208 | 'tags' => [ 209 | [ 210 | 'id' => 13, 211 | 'name' => 'allowed tag', 212 | ], 213 | [ 214 | 'name' => 'new tag!' 215 | ] 216 | ] 217 | ]; 218 | 219 | $validator = new NestedValidator(Post::class); 220 | $rules = $validator->validationRules($data); 221 | 222 | 223 | // 'integer' and 'required' should be set and kept inherently by the validator (link-only incrementing key) 224 | // 'min:2' is a custom rule 225 | // 'exists:genres,id' is a custom rule that should override the inherently set 'exists:tags,id' rule 226 | 227 | $this->assertHasValidationRules($rules, [ 228 | 'tags.0.id' => ['integer', 'required', 'exists:tags,id', 'min:2', 'exists:genres,id'], 229 | 'tags.1.id' => ['integer', 'required', 'exists:tags,id'], 230 | ], true); 231 | } 232 | 233 | /** 234 | * @test 235 | */ 236 | public function it_returns_correct_validation_rules_for_non_incrementing_nested_relation_model(): void 237 | { 238 | $this->createSpecial('special-1'); 239 | 240 | $data = [ 241 | 'specials' => [ 242 | [ 243 | 'special' => 'special-1', 244 | 'name' => 'updated special', 245 | ], 246 | [ 247 | 'special' => 'special-2', 248 | 'name' => 'updated special', 249 | ], 250 | ] 251 | ]; 252 | 253 | $validator = new NestedValidator(Post::class); 254 | $rules = $validator->validationRules($data); 255 | 256 | // non-incrementing keys are always required, but should only be 257 | // checked for existance if it can be considered an update 258 | $this->assertHasValidationRules($rules, [ 259 | 'specials.0.special' => ['required', 'exists:specials,special'], 260 | 'specials.1.special' => ['required'], 261 | ], true); 262 | } 263 | 264 | /** 265 | * @test 266 | */ 267 | public function it_uses_custom_rules_for_a_related_nested_model_if_configured_to(): void 268 | { 269 | Config::set('nestedmodelupdater.relations.' . Post::class . '.genre', [ 270 | 'rules' => Genre::class, 271 | 'rules-method' => 'customRulesMethod', 272 | ]); 273 | 274 | $data = [ 275 | 'title' => 'allowed title', 276 | 'genre' => [ 277 | 'name' => 'non-custom allowed genre', 278 | ], 279 | ]; 280 | 281 | $validator = new NestedValidator(Post::class); 282 | $rules = $validator->validationRules($data); 283 | 284 | $this->assertHasValidationRules($rules, [ 285 | 'genre.name' => ['in:custom,rules,work'], 286 | ], true); 287 | } 288 | 289 | /** 290 | * @test 291 | */ 292 | public function it_uses_model_specific_rules_if_configured_to_unless_nested_relation_rules_overrule_them(): void 293 | { 294 | // set up some models to use specific rules classes & methods 295 | // and set up a single overruling nested relation rules class & method 296 | 297 | Config::set('nestedmodelupdater.validation.model-rules', [ 298 | Post::class => [ 299 | 'class' => Post::class, 300 | 'method' => 'customRulesMethod', 301 | ], 302 | Tag::class => Tag::class, 303 | Genre::class => [ 304 | 'class' => Genre::class, 305 | 'method' => 'notUsedRulesMethod', 306 | ] 307 | ]); 308 | 309 | Config::set('nestedmodelupdater.relations.' . Post::class . '.genre', [ 310 | 'rules' => Genre::class, 311 | 'rules-method' => 'customRulesMethod', 312 | ]); 313 | 314 | $data = [ 315 | 'title' => 'allowed title', 316 | 'genre' => [ 317 | 'name' => 'non-custom allowed genre', 318 | ], 319 | 'tags' => [ 320 | [ 321 | 'id' => 123, 322 | 'name' => 'some tag', 323 | ] 324 | ] 325 | ]; 326 | 327 | $validator = new NestedValidator(Post::class); 328 | $rules = $validator->validationRules($data); 329 | 330 | $this->assertHasValidationRules($rules, [ 331 | // set for model with array: class & method 332 | 'title' => 'in:custom,post,rules', 333 | // set for model with just a class string 334 | 'tags.0.name' => 'in:custom,tag,rules', 335 | // if genre would not be overridden, it would error on not finding 'notUsedRulesMethod' 336 | 'genre.name' => 'in:custom,rules,work', 337 | ], true); 338 | } 339 | 340 | 341 | // ------------------------------------------------------------------------------ 342 | // Helper methods 343 | // ------------------------------------------------------------------------------ 344 | 345 | /** 346 | * @test 347 | */ 348 | public function it_returns_create_validation_rules_for_a_model(): void 349 | { 350 | $validator = new NestedValidator(Post::class); 351 | $rules = $validator->getDirectModelValidationRules(false); 352 | 353 | static::assertIsArray($rules); 354 | static::assertEquals('required|string|max:50', Arr::get($rules, 'title')); 355 | } 356 | 357 | /** 358 | * @test 359 | */ 360 | public function it_returns_update_validation_rules_for_a_model(): void 361 | { 362 | $validator = new NestedValidator(Post::class); 363 | $rules = $validator->getDirectModelValidationRules(false, false); 364 | 365 | static::assertIsArray($rules); 366 | static::assertEquals('string|max:10', Arr::get($rules, 'title')); 367 | } 368 | 369 | /** 370 | * @test 371 | */ 372 | public function it_returns_empty_validation_rules_if_rules_model_not_found(): void 373 | { 374 | $validator = new NestedValidator(Special::class); 375 | $rules = $validator->getDirectModelValidationRules(); 376 | 377 | static::assertIsArray($rules); 378 | static::assertCount(0, $rules); 379 | } 380 | 381 | /** 382 | * @test 383 | */ 384 | public function it_throws_an_exception_when_attempting_to_retrieve_nonexistent_rules_if_configured_to(): void 385 | { 386 | $this->expectException(UnexpectedValueException::class); 387 | $this->expectExceptionMessageMatches('#not bound#i'); 388 | 389 | Config::set('nestedmodelupdater.validation.allow-missing-rules', false); 390 | 391 | $validator = new NestedValidator(Special::class); 392 | $validator->getDirectModelValidationRules(); 393 | } 394 | 395 | 396 | // ------------------------------------------------------------------------------ 397 | // Exceptions 398 | // ------------------------------------------------------------------------------ 399 | 400 | /** 401 | * @test 402 | */ 403 | public function it_throws_an_exception_if_a_rules_class_for_a_model_does_not_have_the_rules_method(): void 404 | { 405 | $this->expectException(UnexpectedValueException::class); 406 | $this->expectExceptionMessageMatches('#no method \'rules\'#i'); 407 | 408 | // set a 'rules' class that does not have rules() 409 | Config::set('nestedmodelupdater.relations.' . Post::class . '.genre', [ 'rules' => Post::class ]); 410 | 411 | $data = [ 412 | 'title' => 'allowed title', 413 | 'genre' => [ 414 | 'name' => 'genre' 415 | ], 416 | ]; 417 | 418 | $validator = new NestedValidator(Post::class); 419 | $validator->validate($data); 420 | } 421 | 422 | /** 423 | * @test 424 | */ 425 | public function it_throws_an_exception_if_a_rules_class_method_does_not_return_an_array(): void 426 | { 427 | $this->expectException(UnexpectedValueException::class); 428 | $this->expectExceptionMessageMatches('#array#i'); 429 | 430 | Config::set('nestedmodelupdater.relations.' . Post::class . '.genre', [ 431 | 'rules' => Genre::class, 432 | 'rules-method' => 'brokenCustomRulesMethod', 433 | ]); 434 | 435 | $data = [ 436 | 'title' => 'allowed title', 437 | 'genre' => [ 438 | 'name' => 'genre' 439 | ], 440 | ]; 441 | 442 | $validator = new NestedValidator(Post::class); 443 | $validator->validate($data); 444 | } 445 | } 446 | -------------------------------------------------------------------------------- /tests/NestingConfigTest.php: -------------------------------------------------------------------------------- 1 | isKeyNestedRelation('genre', Post::class)); 27 | static::assertFalse($config->isKeyNestedRelation('does_not_exist', Post::class)); 28 | } 29 | 30 | /** 31 | * @test 32 | */ 33 | function it_returns_relation_info_object() 34 | { 35 | $config = new NestingConfig(); 36 | 37 | $info = $config->getRelationInfo('genre', Post::class); 38 | 39 | static::assertTrue($info->isBelongsTo(), 'genre should have belongsTo = true'); 40 | static::assertTrue($info->isSingular(), 'genre should have singular = true'); 41 | static::assertTrue($info->isUpdateAllowed(), 'genre should be allowed updates'); 42 | static::assertNull($info->getDetachMissing(), 'genre should have detach missing null'); 43 | static::assertFalse($info->isDeleteDetached(), 'genre should not delete detached'); 44 | 45 | static::assertInstanceOf(Genre::class, $info->model()); 46 | static::assertEquals('genre', $info->relationMethod()); 47 | static::assertEquals(BelongsTo::class, $info->relationClass()); 48 | static::assertEquals(ModelUpdaterInterface::class, $info->updater()); 49 | 50 | static::assertEquals(NestedValidatorInterface::class, $info->validator()); 51 | static::assertEquals(false, $info->rulesClass()); 52 | static::assertEquals(null, $info->rulesMethod()); 53 | } 54 | 55 | /** 56 | * @test 57 | */ 58 | function it_returns_relation_info_object_for_exceptions() 59 | { 60 | $config = new NestingConfig(); 61 | 62 | // check exception for updater 63 | $info = $config->getRelationInfo('comments', Author::class); 64 | static::assertEquals(AlternativeUpdater::class, $info->updater()); 65 | 66 | // check exception for relation method 67 | $info = $config->getRelationInfo('exceptional_attribute_name', Post::class); 68 | static::assertEquals('someOtherRelationMethod', $info->relationMethod()); 69 | 70 | // only allow links 71 | $info = $config->getRelationInfo('authors', Post::class); 72 | static::assertFalse($info->isUpdateAllowed()); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | migrateDatabase(); 29 | $this->seedDatabase(); 30 | } 31 | 32 | /** 33 | * @param Application $app 34 | */ 35 | protected function getEnvironmentSetUp($app): void 36 | { 37 | $app->register(NestedModelUpdaterServiceProvider::class); 38 | 39 | // Setup default database to use sqlite :memory: 40 | $app['config']->set('database.default', 'testbench'); 41 | $app['config']->set('database.connections.testbench', [ 42 | 'driver' => 'sqlite', 43 | 'database' => ':memory:', 44 | 'prefix' => '', 45 | ]); 46 | 47 | // Setup basic config for nested relation testing 48 | $app['config']->set('nestedmodelupdater.relations', [ 49 | Author::class => [ 50 | 'posts' => true, 51 | 'comments' => [ 52 | 'updater' => AlternativeUpdater::class, 53 | ], 54 | ], 55 | Comment::class => [ 56 | // post left out deliberately 57 | 'author' => true, 58 | 'tags' => true, 59 | ], 60 | Post::class => [ 61 | 'comments' => true, 62 | 'genre' => true, 63 | 'authors' => [ 64 | 'link-only' => true, 65 | ], 66 | 'tags' => true, 67 | 'exceptional_attribute_name' => [ 68 | 'method' => 'someOtherRelationMethod', 69 | ], 70 | 'comment_has_one' => true, 71 | 'specials' => true, 72 | ], 73 | ]); 74 | 75 | $app['config']->set('nestedmodelupdater.validation.model-rules-namespace', 'Czim\\NestedModelUpdater\\Test\\Helpers\\Rules'); 76 | $app['config']->set('nestedmodelupdater.validation.model-rules-postfix', 'Rules'); 77 | $app['config']->set('nestedmodelupdater.validation.allow-missing-rules', true); 78 | } 79 | 80 | protected function migrateDatabase(): void 81 | { 82 | Schema::create('genres', static function (Blueprint $table) { 83 | $table->increments('id'); 84 | $table->string('name', 50); 85 | $table->timestamps(); 86 | $table->softDeletes(); 87 | }); 88 | 89 | Schema::create('authors', static function (Blueprint $table) { 90 | $table->increments('id'); 91 | $table->string('name', 255); 92 | $table->enum('gender', ['m', 'f'])->default('f'); 93 | $table->timestamps(); 94 | }); 95 | 96 | Schema::create('posts', static function (Blueprint $table) { 97 | $table->increments('id'); 98 | $table->integer('genre_id')->nullable()->unsigned(); 99 | $table->string('title', 50); 100 | $table->text('body'); 101 | $table->string('unfillable', 20)->nullable(); 102 | $table->timestamps(); 103 | }); 104 | 105 | Schema::create('comments', static function (Blueprint $table) { 106 | $table->increments('id'); 107 | $table->integer('post_id')->unsigned(); 108 | $table->integer('author_id')->nullable()->unsigned(); 109 | $table->string('title', 50); 110 | $table->text('body'); 111 | $table->timestamps(); 112 | }); 113 | 114 | Schema::create('author_post', static function (Blueprint $table) { 115 | $table->increments('id'); 116 | $table->integer('author_id')->unsigned(); 117 | $table->integer('post_id')->unsigned(); 118 | }); 119 | 120 | Schema::create('tags', static function (Blueprint $table) { 121 | $table->increments('id'); 122 | $table->integer('taggable_id')->unsigned()->nullable(); 123 | $table->string('taggable_type', 255)->nullable(); 124 | $table->string('name', 50); 125 | $table->timestamps(); 126 | }); 127 | 128 | Schema::create('specials', static function (Blueprint $table) { 129 | $table->string('special', 20)->unique(); 130 | $table->integer('post_id')->unsigned()->nullable(); 131 | $table->string('name', 50); 132 | $table->timestamps(); 133 | $table->primary(['special']); 134 | }); 135 | } 136 | 137 | protected function seedDatabase(): void 138 | { 139 | } 140 | 141 | 142 | protected function createAuthor(string $name = 'Test Author', string $gender = 'm'): Author 143 | { 144 | return Author::create([ 145 | 'name' => $name, 146 | 'gender' => $gender, 147 | ]); 148 | } 149 | 150 | protected function createGenre(string $name = 'testing genre'): Genre 151 | { 152 | return Genre::create([ 153 | 'name' => $name, 154 | ]); 155 | } 156 | 157 | protected function createPost(string $title = 'testing title', string $body = 'testing body'): Post 158 | { 159 | // do not use create() method to prevent trait nested update from being applied 160 | $post = new Post([ 161 | 'title' => $title, 162 | 'body' => $body, 163 | ]); 164 | 165 | $post->save(); 166 | 167 | return $post; 168 | } 169 | 170 | protected function createComment( 171 | Post $post, 172 | string $title = 'testing title', 173 | string $body = 'testing body', 174 | ?Author $author = null 175 | ): Comment { 176 | 177 | $comment = new Comment([ 178 | 'title' => $title, 179 | 'body' => $body, 180 | ]); 181 | 182 | if ($author) { 183 | $comment->author()->associate($author); 184 | } 185 | 186 | return $post->comments()->save($comment); 187 | } 188 | 189 | protected function createTag(Model $taggable = null, string $name = 'test tag'): Tag 190 | { 191 | $tag = new Tag([ 192 | 'name' => $name, 193 | ]); 194 | 195 | if ($taggable) { 196 | $tag->taggable_id = $taggable->getKey(); 197 | $tag->taggable_type = get_class($taggable); 198 | } 199 | 200 | $tag->save(); 201 | 202 | return $tag; 203 | } 204 | 205 | protected function createSpecial(string $key, string $name = 'testing special'): Special 206 | { 207 | return Special::create([ 208 | 'special' => $key, 209 | 'name' => $name, 210 | ]); 211 | } 212 | 213 | /** 214 | * Asserts that a given MessageBag contains a validation error for a key, 215 | * based on a loosy or regular expression match. 216 | * 217 | * @param mixed $messages 218 | * @param string $key 219 | * @param string $like 220 | * @param bool $isRegex if true, $like is already a regex string 221 | */ 222 | protected function assertHasValidationErrorLike( 223 | mixed $messages, 224 | string $key, 225 | string $like, 226 | bool $isRegex = false, 227 | ): void { 228 | if (! $messages instanceof MessageBag) { 229 | $this->fail("Messages should be a MessageBag instance, cannot look up presence of '{$like}' for '{$key}'."); 230 | } 231 | 232 | /** @var MessageBag $messages */ 233 | if (! $messages->has($key)) { 234 | $this->fail("Messages does not contain key '{$key}' (cannot look up presence of '{$like}')."); 235 | } 236 | 237 | $regex = $isRegex ? $like : '#' . preg_quote($like, '#') . '#i'; 238 | 239 | $matched = array_filter( 240 | $messages->get($key), 241 | static function ($message) use ($regex) { 242 | return preg_match($regex, $message); 243 | } 244 | ); 245 | 246 | if (! count($matched)) { 247 | $this->fail("Messages does not contain error for key '{$key}' that matches '{$regex}'."); 248 | } 249 | } 250 | 251 | /** 252 | * Asserts that a given MessageBag contains a validation error for a key, 253 | * based on a regular expression match. 254 | * 255 | * @param mixed $messages 256 | * @param string $key 257 | * @param string $regex 258 | */ 259 | protected function assertHasValidationErrorRegex(mixed $messages, string $key, string $regex): void 260 | { 261 | $this->assertHasValidationErrorLike($messages, $key, $regex, true); 262 | } 263 | 264 | /** 265 | * Asserts whether a set of validation rules per key are present in an array with validation rules. 266 | * 267 | * @param array|mixed $rules 268 | * @param array $findRules associative array with key => rules to find 269 | * @param bool $strictPerKey if true, the rules for each key present should match strictly 270 | * @param bool $strictKeys if true, only the keys must be present in the rules, and no more 271 | */ 272 | protected function assertHasValidationRules( 273 | mixed $rules, 274 | array $findRules, 275 | bool $strictPerKey = false, 276 | bool $strictKeys = false, 277 | ): void { 278 | foreach ($findRules as $key => $findRule) { 279 | $this->assertHasValidationRule($rules, $key, $findRule, $strictPerKey); 280 | } 281 | 282 | if ($strictKeys && count($rules) > count($findRules)) { 283 | 284 | $this->fail( 285 | 'Not strictly the same rules: ' 286 | . (count($rules) - count($findRules)) . ' more keys present than expected' 287 | . ' (' . implode(', ', array_diff(array_keys($rules), array_keys($findRules))) . ').' 288 | ); 289 | } 290 | } 291 | 292 | /** 293 | * Asserts whether a given single validation rule is present in an array with 294 | * validation rules, for a given key. Does not care whether the format is 295 | * pipe-separate string or array. 296 | * 297 | * @param array|mixed $rules 298 | * @param string $key 299 | * @param string|array $findRules full validation rule string ('max:50'), or array of them 300 | * @param bool $strict only the given rules should be present, no others 301 | */ 302 | protected function assertHasValidationRule(mixed $rules, string $key, mixed $findRules, bool $strict = false): void 303 | { 304 | if (! is_array($findRules)) { 305 | $findRules = [$findRules]; 306 | } 307 | 308 | $this->assertIsArray( 309 | $rules, 310 | "Rules should be an array, can not look up value '{$findRules[0]}' for '{$key}'." 311 | ); 312 | 313 | $this->assertArrayHasKey( 314 | $key, 315 | $rules, 316 | "Rules array does not contain key '{$key}' (cannot find rule '{$findRules[0]}')." 317 | ); 318 | 319 | $rulesForKey = $rules[ $key ]; 320 | 321 | if (! is_array($rulesForKey)) { 322 | $rulesForKey = explode('|', $rulesForKey); 323 | } 324 | 325 | foreach ($findRules as $findRule) { 326 | $this->assertContains( 327 | $findRule, 328 | $rulesForKey, 329 | "Rules array does not contain rule '{$findRule}' for key '{$key}'." 330 | ); 331 | } 332 | 333 | if ($strict) { 334 | $this->assertLessThanOrEqual( 335 | count($rulesForKey), 336 | count($findRules), 337 | "Not strictly the same rules for '{$key}': " 338 | . (count($rulesForKey) - count($findRules)) . ' more present than expected' 339 | . ' (' . implode(', ', array_diff(array_values($rulesForKey), array_values($findRules))) . ').' 340 | ); 341 | } 342 | } 343 | } 344 | --------------------------------------------------------------------------------