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