├── .gitignore ├── .editorconfig ├── .php_cs ├── CHANGELOG.md ├── .travis.yml ├── src ├── Model.php └── Traits │ └── HasNestedAttributesTrait.php ├── phpunit.xml ├── LICENSE.md ├── tests ├── ModelTest.php └── HasNestedAttributesTraitTest.php ├── CONTRIBUTING.md ├── composer.json ├── CODE_OF_CONDUCT.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .php_cs.cache 2 | .phpunit.result.cache 3 | /composer.lock 4 | /phpunit.xml 5 | /vendor/ -------------------------------------------------------------------------------- /.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 = false 16 | -------------------------------------------------------------------------------- /.php_cs: -------------------------------------------------------------------------------- 1 | setFinder( 7 | PhpCsFixer\Finder::create()->in('./') 8 | ) 9 | ->setRules([ 10 | '@Laravel' => true, 11 | '@Laravel:risky' => true, 12 | 'concat_space' => [ 'spacing' => 'one' ], 13 | ]) 14 | ->setRiskyAllowed(true); 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All Notable changes to `:package_name` will be documented in this file. 4 | 5 | Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) principles. 6 | 7 | ## NEXT - YYYY-MM-DD 8 | 9 | ### Added 10 | - Nothing 11 | 12 | ### Deprecated 13 | - Nothing 14 | 15 | ### Fixed 16 | - Nothing 17 | 18 | ### Removed 19 | - Nothing 20 | 21 | ### Security 22 | - Nothing 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | env: 4 | - SH=bash 5 | 6 | sudo: false 7 | 8 | git: 9 | depth: 2 10 | 11 | matrix: 12 | include: 13 | - php: 7.2 14 | - php: 7.3 15 | - php: 7.4 16 | fast_finish: true 17 | 18 | cache: 19 | directories: 20 | - $HOME/.composer/cache 21 | 22 | before_script: 23 | - phpenv config-rm xdebug.ini || true 24 | - travis_retry composer self-update 25 | - travis_retry composer install --no-interaction 26 | 27 | script: 28 | - composer test -------------------------------------------------------------------------------- /src/Model.php: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 | ./tests/ 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Piotr Krajewski 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 | -------------------------------------------------------------------------------- /tests/ModelTest.php: -------------------------------------------------------------------------------- 1 | 10 | * @license https://github.com/esensi/model/blob/master/license.md MIT License 11 | */ 12 | class ModelTest extends TestCase 13 | { 14 | /** 15 | * Set Up and Prepare Tests. 16 | */ 17 | public function setUp(): void 18 | { 19 | // Mock the Model that uses the custom traits 20 | $this->model = Mockery::mock('ModelStub'); 21 | $this->model->makePartial(); 22 | } 23 | 24 | /** 25 | * Tear Down and Clean Up Tests. 26 | */ 27 | public function tearDown(): void 28 | { 29 | Mockery::close(); 30 | } 31 | 32 | /** 33 | * Test that the Model uses the traits and in the right order. 34 | */ 35 | public function testModelUsesTraits(): void 36 | { 37 | // Get the traits off the model 38 | $traits = function_exists('class_uses_recursive') ? 39 | class_uses_recursive(get_class($this->model)) : class_uses(get_class($this->model)); 40 | 41 | // Check Model uses the Validating trait 42 | $this->assertContains('Eloquent\NestedAttributes\Traits\HasNestedAttributesTrait', $traits); 43 | } 44 | } 45 | 46 | /** 47 | * Model Stub for Model Tests. 48 | */ 49 | class ModelStub extends Model 50 | { 51 | } 52 | -------------------------------------------------------------------------------- /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/:vendor/:package_name). 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)** - Check the code style with ``$ composer check-style`` and fix it with ``$ composer fix-style``. 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 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 17 | 18 | - **Create feature branches** - Don't ask us to pull from your master branch. 19 | 20 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 21 | 22 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 23 | 24 | 25 | ## Running Tests 26 | 27 | ``` bash 28 | $ composer test 29 | ``` 30 | 31 | 32 | **Happy coding**! 33 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mits87/eloquent-nested-attributes", 3 | "type": "library", 4 | "description": "Nested attributes allow you to save attributes on associated records through the parent. By default nested attribute updating is turned off and you can enable it using the $nested attribute. When you enable nested attributes an attribute writer is defined on the model.", 5 | "keywords": [ 6 | "Laravel", "Lumen", "Eloquent", "Nested Attributes" 7 | ], 8 | "version": "0.0.7", 9 | "homepage": "https://github.com/mits87/eloquent-nested-attributes", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Piotr Krajewski", 14 | "email": "mits87@gmail.com" 15 | } 16 | ], 17 | "require": { 18 | "php": ">=7.2", 19 | "illuminate/database": "^7.0", 20 | "illuminate/support": "^7.0" 21 | }, 22 | "require-dev": { 23 | "phpunit/phpunit": "^8.5", 24 | "matt-allan/laravel-code-style": "^0.5.0", 25 | "mockery/mockery": "^1.3" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "Eloquent\\NestedAttributes\\": "src" 30 | } 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "Eloquent\\NestedAttributes\\": "tests" 35 | } 36 | }, 37 | "scripts": { 38 | "test": "vendor/bin/phpunit --colors=always", 39 | "csfix": "php-cs-fixer fix" 40 | }, 41 | "config": { 42 | "sort-packages": true 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/HasNestedAttributesTraitTest.php: -------------------------------------------------------------------------------- 1 | model = Mockery::mock('ModelEloquentStub'); 15 | $this->model->makePartial(); 16 | 17 | // Mock the Model without fillable array set 18 | $this->modelWithoutFillable = Mockery::mock('ModelEloquentStubWithoutFillable'); 19 | $this->modelWithoutFillable->makePartial(); 20 | 21 | // Default payload for Model 22 | $this->payload = [ 23 | 'model_bar' => ['text' => 'bar'], 24 | 'model_foos' => [ 25 | ['text' => 'foo1'], 26 | ['text' => 'foo2'], 27 | ], 28 | ]; 29 | } 30 | 31 | /** 32 | * Tear Down and Clean Up Tests. 33 | */ 34 | public function tearDown(): void 35 | { 36 | Mockery::close(); 37 | } 38 | 39 | /** 40 | * The fillable test. 41 | */ 42 | public function testFillable(): void 43 | { 44 | $this->model->fill(array_merge($this->payload, ['title' => 'foo', 'not_exists' => []])); 45 | 46 | $this->assertEquals(['title' => 'foo'], $this->model->getAttributes()); 47 | $this->assertEquals([ 48 | 'model_bar' => ['text' => 'bar'], 49 | 'model_foos' => [ 50 | ['text' => 'foo1'], 51 | ['text' => 'foo2'], 52 | ], 53 | ], $this->model->getAcceptNestedAttributesFor()); 54 | } 55 | 56 | /** 57 | * Test that a model with nested attributes can still save without fillable array. 58 | */ 59 | public function testModelWithNestedAttributesCanSaveWithoutFillableArraySet(): void 60 | { 61 | $this->modelWithoutFillable->fill($this->payload); 62 | 63 | $this->assertEquals([ 64 | 'model_bar' => ['text' => 'bar'], 65 | 'model_foos' => [ 66 | ['text' => 'foo1'], 67 | ['text' => 'foo2'], 68 | ], 69 | ], $this->modelWithoutFillable->getAcceptNestedAttributesFor()); 70 | } 71 | } 72 | 73 | class ModelEloquentStubWithoutFillable extends Model 74 | { 75 | protected $table = 'stubs'; 76 | protected $nested = ['model_bar', 'model_foos']; 77 | 78 | public function modelBar() 79 | { 80 | return $this->hasOne(ModelBarStub::class); 81 | } 82 | 83 | public function modelFoos() 84 | { 85 | return $this->hasOne(ModelFooStub::class); 86 | } 87 | } 88 | 89 | class ModelEloquentStub extends ModelEloquentStubWithoutFillable 90 | { 91 | protected $fillable = ['title']; 92 | } 93 | 94 | class ModelBarStub extends Model 95 | { 96 | protected $fillable = ['text']; 97 | 98 | public function parent() 99 | { 100 | return $this->belongsTo(ModelEloquentStub::class); 101 | } 102 | } 103 | 104 | class ModelFooStub extends Model 105 | { 106 | protected $fillable = ['text']; 107 | 108 | public function parent() 109 | { 110 | return $this->belongsTo(ModelEloquentStub::class); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at `:author_email`. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eloquent-nested-attributes 2 | 3 | [![Latest Version on Packagist][ico-version]][link-packagist] 4 | [![Software License][ico-license]](LICENSE.md) 5 | [![Build Status][ico-travis]][link-travis] 6 | [![Total Downloads][ico-downloads]][link-downloads] 7 | 8 | Nested attributes allow you to save attributes on associated records through the parent. By default nested attribute updating is turned off and you can enable it using the $nested attribute. When you enable nested attributes an attribute writer is defined on the model. 9 | 10 | ## Structure 11 | 12 | If any of the following are applicable to your project, then the directory structure should follow industry best practises by being named the following. 13 | 14 | ``` 15 | src/ 16 | tests/ 17 | vendor/ 18 | ``` 19 | 20 | 21 | ## Install 22 | 23 | Via Composer 24 | 25 | ``` bash 26 | $ composer require mits87/eloquent-nested-attributes 27 | ``` 28 | 29 | ## Usage 30 | 31 | ``` php 32 | namespace App; 33 | 34 | use Eloquent\NestedAttributes\Model; 35 | 36 | class Post extends Model 37 | { 38 | protected $fillable = ['title']; 39 | 40 | protected $nested = ['option', 'comments']; 41 | 42 | public function option() { 43 | //it can be also morphOne 44 | return $this->hasOne('App\Option'); 45 | } 46 | 47 | public function comments() { 48 | //it can be also morphMany 49 | return $this->hasMany('App\Comment'); 50 | } 51 | } 52 | ``` 53 | 54 | or 55 | 56 | ``` php 57 | namespace App; 58 | 59 | use Illuminate\Database\Eloquent\Model; 60 | use Eloquent\NestedAttributes\Traits\HasNestedAttributesTrait; 61 | 62 | class Post extends Model 63 | { 64 | use HasNestedAttributesTrait; 65 | 66 | ... 67 | } 68 | ``` 69 | 70 | usage: 71 | 72 | ``` php 73 | \App\Post::create([ 74 | 'title' => 'Some text', 75 | 76 | 'option' => [ 77 | 'info' => 'some info' 78 | ], 79 | 80 | 'comments' => [ 81 | [ 82 | 'text' => 'Comment 1' 83 | ], [ 84 | 'text' => 'Comment 2' 85 | ], 86 | ] 87 | ]); 88 | 89 | 90 | \App\Post::findOrFail(1)->update([ 91 | 'title' => 'Better text', 92 | 93 | 'option' => [ 94 | 'info' => 'better info' 95 | ], 96 | 97 | 'comments' => [ 98 | [ 99 | 'id' => 2, 100 | 'text' => 'Comment 2' 101 | ], 102 | ] 103 | ]); 104 | ``` 105 | 106 | to delete nested row you should pass `_destroy` attribute: 107 | 108 | ``` php 109 | \App\Post::findOrFail(1)->update([ 110 | 'title' => 'Better text', 111 | 112 | 'option' => [ 113 | 'info' => 'better info' 114 | ], 115 | 116 | 'comments' => [ 117 | [ 118 | 'id' => 2, 119 | '_destroy' => true 120 | ], 121 | ] 122 | ]); 123 | ``` 124 | 125 | 126 | ## Change log 127 | 128 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 129 | 130 | ## Testing 131 | 132 | ``` bash 133 | $ composer test 134 | ``` 135 | 136 | ## Contributing 137 | 138 | Please see [CONTRIBUTING](CONTRIBUTING.md) and [CODE_OF_CONDUCT](CODE_OF_CONDUCT.md) for details. 139 | 140 | ## Security 141 | 142 | If you discover any security related issues, please email mits87@gmail.com instead of using the issue tracker. 143 | 144 | ## Credits 145 | 146 | - [Piotr Krajewski][link-author] 147 | - [All Contributors][link-contributors] 148 | 149 | ## License 150 | 151 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 152 | 153 | [ico-version]: https://img.shields.io/packagist/v/mits87/eloquent-nested-attributes.svg?style=flat-square 154 | [ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square 155 | [ico-travis]: https://img.shields.io/travis/mits87/eloquent-nested-attributes/master.svg?style=flat-square 156 | [ico-downloads]: https://img.shields.io/packagist/dt/mits87/eloquent-nested-attributes.svg?style=flat-square 157 | 158 | [link-packagist]: https://packagist.org/packages/mits87/eloquent-nested-attributes 159 | [link-travis]: https://travis-ci.org/mits87/eloquent-nested-attributes 160 | [link-downloads]: https://packagist.org/packages/mits87/eloquent-nested-attributes 161 | [link-author]: https://github.com/mits87 162 | [link-contributors]: ../../contributors 163 | -------------------------------------------------------------------------------- /src/Traits/HasNestedAttributesTrait.php: -------------------------------------------------------------------------------- 1 | acceptNestedAttributesFor; 37 | } 38 | 39 | /** 40 | * Fill the model with an array of attributes. 41 | * 42 | * @param array $attributes 43 | * @return $this 44 | * 45 | * @throws \Illuminate\Database\Eloquent\MassAssignmentException 46 | */ 47 | public function fill(array $attributes): self 48 | { 49 | if (! empty($this->nested)) { 50 | $this->acceptNestedAttributesFor = []; 51 | 52 | foreach ($this->nested as $attr) { 53 | if (isset($attributes[$attr])) { 54 | $this->acceptNestedAttributesFor[$attr] = $attributes[$attr]; 55 | unset($attributes[$attr]); 56 | } 57 | } 58 | } 59 | 60 | return parent::fill($attributes); 61 | } 62 | 63 | /** 64 | * Save the model to the database. 65 | * 66 | * @param array $options 67 | * @return bool 68 | */ 69 | public function save(array $options = []): bool 70 | { 71 | DB::beginTransaction(); 72 | 73 | if (! parent::save($options)) { 74 | return false; 75 | } 76 | 77 | foreach ($this->getAcceptNestedAttributesFor() as $attribute => $stack) { 78 | $methodName = lcfirst(implode(array_map('ucfirst', explode('_', $attribute)))); 79 | 80 | if (! method_exists($this, $methodName)) { 81 | throw new Exception('The nested atribute relation "' . $methodName . '" does not exists.'); 82 | } 83 | 84 | $relation = $this->$methodName(); 85 | 86 | if ($relation instanceof HasOne || $relation instanceof MorphOne) { 87 | if (! $this->saveNestedAttributes($relation, $stack)) { 88 | return false; 89 | } 90 | } elseif ($relation instanceof HasMany || $relation instanceof MorphMany) { 91 | foreach ($stack as $params) { 92 | if (! $this->saveManyNestedAttributes($this->$methodName(), $params)) { 93 | return false; 94 | } 95 | } 96 | } else { 97 | throw new Exception('The nested atribute relation is not supported for "' . $methodName . '".'); 98 | } 99 | } 100 | 101 | DB::commit(); 102 | 103 | return true; 104 | } 105 | 106 | /** 107 | * Save the hasOne nested relation attributes to the database. 108 | * 109 | * @param Illuminate\Database\Eloquent\Relations $relation 110 | * @param array $params 111 | * @return bool 112 | */ 113 | protected function saveNestedAttributes(Relations $relation, array $params): bool 114 | { 115 | if ($this->exists && $model = $relation->first()) { 116 | if ($this->allowDestroyNestedAttributes($params)) { 117 | return $model->delete(); 118 | } 119 | 120 | return $model->update($params); 121 | } elseif ($relation->create($params)) { 122 | return true; 123 | } 124 | 125 | return false; 126 | } 127 | 128 | /** 129 | * Save the hasMany nested relation attributes to the database. 130 | * 131 | * @param Illuminate\Database\Eloquent\Relations $relation 132 | * @param array $params 133 | * @return bool 134 | */ 135 | protected function saveManyNestedAttributes($relation, array $params): bool 136 | { 137 | if (isset($params['id']) && $this->exists) { 138 | $model = $relation->findOrFail($params['id']); 139 | 140 | if ($this->allowDestroyNestedAttributes($params)) { 141 | return $model->delete(); 142 | } 143 | 144 | return $model->update($params); 145 | } elseif ($relation->create($params)) { 146 | return true; 147 | } 148 | 149 | return false; 150 | } 151 | 152 | /** 153 | * Check can we delete nested data. 154 | * 155 | * @param array $params 156 | * @return bool 157 | */ 158 | protected function allowDestroyNestedAttributes(array $params): bool 159 | { 160 | return isset($params[$this->destroyNestedKey]) && (bool) $params[$this->destroyNestedKey] == true; 161 | } 162 | } 163 | --------------------------------------------------------------------------------