├── .phive └── phars.xml ├── LICENSE ├── README.md ├── composer.json ├── phpcs.xml ├── phpstan.neon ├── psalm-baseline.xml ├── psalm.xml └── src ├── DuplicatablePlugin.php └── Model └── Behavior └── DuplicatableBehavior.php /.phive/phars.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 RIESENIA.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Duplicatable behavior for CakePHP 2 | 3 | [![CI](https://github.com/riesenia/cakephp-duplicatable/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/riesenia/cakephp-duplicatable/actions/workflows/ci.yml) 4 | [![Coverage Status](https://img.shields.io/codecov/c/github/riesenia/cakephp-duplicatable.svg?style=flat-square)](https://codecov.io/github/riesenia/cakephp-duplicatable) 5 | [![Latest Version](https://img.shields.io/packagist/v/riesenia/cakephp-duplicatable.svg?style=flat-square)](https://packagist.org/packages/riesenia/cakephp-duplicatable) 6 | [![Total Downloads](https://img.shields.io/packagist/dt/riesenia/cakephp-duplicatable.svg?style=flat-square)](https://packagist.org/packages/riesenia/cakephp-duplicatable) 7 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) 8 | 9 | This plugin contains a behavior that handles duplicating entities including related data. 10 | 11 | ## Installation 12 | 13 | Using composer 14 | 15 | ``` 16 | composer require riesenia/cakephp-duplicatable 17 | ``` 18 | 19 | Load plugin using 20 | 21 | ```sh 22 | bin/cake plugin load Duplicatable 23 | ``` 24 | 25 | ## Usage 26 | 27 | This behavior provides multiple methods for your `Table` objects. 28 | 29 | ### Method `duplicate` 30 | 31 | This behavior provides a `duplicate` method for the table. It takes the primary key of the record to duplicate as its only argument. 32 | Using this method will clone the record defined by the primary key provided as well as all related records as defined in the configuration. 33 | 34 | ### Method `duplicateEntity` 35 | 36 | This behavior provides a `duplicateEntity` method for the table. It mainly acts as the `duplicate` method except it does not save the duplicated record but returns the Entity to be saved instead. This is useful if you need to manipulate the Entity before saving it. 37 | 38 | ## Configuration options: 39 | 40 | * *finder* - finder to use to get entities. Set it to "translations" to fetch and duplicate translations, too. Defaults to "all". It is possible to set an array for more finders. 41 | * *contain* - set related entities that will be duplicated 42 | * *remove* - fields that will be removed from the entity 43 | * *set* - fields that will be set to provide value or callable to modify the value. If you provide a callable, it will take the entity being cloned as the only argument 44 | * *prepend* - fields that will have value prepended by the provided text 45 | * *append* - fields that will have value appended by provided text 46 | * *saveOptions* - options for save on primary table 47 | * *preserveJoinData* - if `_joinData` property in `BelongsToMany` relations should be preserved - defaults to `false` due to tricky nature of this association 48 | 49 | ## Examples 50 | 51 | ```php 52 | class InvoicesTable extends Table 53 | { 54 | public function initialize(array $config): void 55 | { 56 | parent::initialize($config); 57 | 58 | // add Duplicatable behavior 59 | $this->addBehavior('Duplicatable.Duplicatable', [ 60 | // table finder 61 | 'finder' => 'all', 62 | // duplicate also items and their properties 63 | 'contain' => ['InvoiceItems.InvoiceItemProperties'], 64 | // remove created field from both invoice and items 65 | 'remove' => ['created', 'invoice_items.created'], 66 | // mark invoice as copied 67 | 'set' => [ 68 | 'name' => function($entity) { 69 | return md5($entity->name) . ' ' . $entity->name; 70 | }, 71 | 'copied' => true 72 | ], 73 | // prepend properties name 74 | 'prepend' => ['invoice_items.invoice_items_properties.name' => 'NEW '], 75 | // append copy to the name 76 | 'append' => ['name' => ' - copy'] 77 | ]); 78 | 79 | // associations (InvoiceItems table hasMany InvoiceItemProperties) 80 | $this->hasMany('InvoiceItems', [ 81 | 'foreignKey' => 'invoice_id', 82 | 'className' => 'InvoiceItems' 83 | ]); 84 | } 85 | } 86 | 87 | // ... somewhere in the controller 88 | $this->Invoices->duplicate(4); 89 | ``` 90 | 91 | Sometimes you need to access the original entity, e.g. for setting an ancestor/parent id reference. 92 | In this case you can leverage the `$original` entity being passed in as 2nd argument: 93 | ```php 94 | 'set' => [ 95 | 'ancestor_id' => function ($entity, $original) { 96 | return $original->id; 97 | }, 98 | ], 99 | ``` 100 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "riesenia/cakephp-duplicatable", 3 | "description": "CakePHP ORM plugin for duplicating entities (including related entities)", 4 | "keywords": ["cakephp", "orm", "copy", "duplicate"], 5 | "type": "cakephp-plugin", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Tomas Saghy", 10 | "email": "segy@riesenia.com" 11 | } 12 | ], 13 | "require": { 14 | "cakephp/orm": "^5.0" 15 | }, 16 | "require-dev": { 17 | "cakephp/cakephp-codesniffer": "^5.0", 18 | "cakephp/cakephp": "^5.0", 19 | "phpunit/phpunit": "^10.1" 20 | }, 21 | "autoload": { 22 | "psr-4": { 23 | "Duplicatable\\": "src" 24 | } 25 | }, 26 | "autoload-dev": { 27 | "psr-4": { 28 | "Duplicatable\\Test\\": "tests", 29 | "TestApp\\": "tests/test_app/TestApp", 30 | "Cake\\Test\\": "./vendor/cakephp/cakephp/tests" 31 | } 32 | }, 33 | "scripts": { 34 | "cs-check": "phpcs --colors --parallel=16 -p src/ tests/", 35 | "cs-fix": "phpcbf --colors --parallel=16 -p src/ tests/", 36 | "phpstan": "tools/phpstan analyse", 37 | "psalm": "tools/psalm --show-info=false", 38 | "stan": [ 39 | "@phpstan", 40 | "@psalm" 41 | ], 42 | "stan-baseline": "tools/phpstan --generate-baseline", 43 | "psalm-baseline": "tools/psalm --set-baseline=psalm-baseline.xml", 44 | "stan-setup": "phive install", 45 | "test": "phpunit" 46 | }, 47 | "config": { 48 | "allow-plugins": { 49 | "dealerdirect/phpcodesniffer-composer-installer": true 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 8 3 | treatPhpDocTypesAsCertain: false 4 | bootstrapFiles: 5 | - tests/bootstrap.php 6 | paths: 7 | - src/ 8 | ignoreErrors: 9 | - identifier: missingType.iterableValue 10 | -------------------------------------------------------------------------------- /psalm-baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _joinData]]> 6 | _translations]]> 7 | _translations]]> 8 | _translations]]> 9 | _translations]]> 10 | _translations]]> 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/DuplicatablePlugin.php: -------------------------------------------------------------------------------- 1 | 33 | */ 34 | protected array $_defaultConfig = [ 35 | 'finder' => 'all', 36 | 'contain' => [], 37 | 'includeTranslations' => false, 38 | 'remove' => [], 39 | 'set' => [], 40 | 'prepend' => [], 41 | 'append' => [], 42 | 'saveOptions' => [], 43 | 'preserveJoinData' => false, 44 | ]; 45 | 46 | /** 47 | * Duplicate record. 48 | * 49 | * @param string|int $id Id of entity to duplicate. 50 | * @return \Cake\Datasource\EntityInterface|false New entity or false on failure 51 | */ 52 | public function duplicate(int|string $id): EntityInterface|false 53 | { 54 | return $this->_table->save( 55 | $this->duplicateEntity($id), 56 | $this->getConfig('saveOptions') + ['associated' => $this->getConfig('contain')] 57 | ); 58 | } 59 | 60 | /** 61 | * Creates duplicate Entity for given record id without saving it. 62 | * 63 | * @param string|int $id Id of entity to duplicate. 64 | * @return \Cake\Datasource\EntityInterface 65 | */ 66 | public function duplicateEntity(int|string $id): EntityInterface 67 | { 68 | $query = $this->_table->find(); 69 | foreach ($this->_getFinder() as $finder) { 70 | $query = $query->find($finder); 71 | } 72 | 73 | $contain = $this->_getContain(); 74 | 75 | if ($contain) { 76 | $query = $query->contain($contain); 77 | } 78 | 79 | /** @var string|int $primaryKey */ 80 | $primaryKey = $this->_table->getPrimaryKey(); 81 | 82 | /** @var \Cake\Datasource\EntityInterface $entity */ 83 | $entity = $query 84 | ->where([$this->_table->getAlias() . '.' . $primaryKey => $id]) 85 | ->firstOrFail(); 86 | $original = clone $entity; 87 | 88 | // process entity 89 | foreach ($this->getConfig('contain') as $contain) { 90 | $parts = explode('.', $contain); 91 | $this->_drillDownAssoc($entity, $this->_table, $parts); 92 | } 93 | 94 | $this->_modifyEntity($entity, $this->_table); 95 | 96 | foreach ($this->getConfig('remove') as $field) { 97 | $parts = explode('.', $field); 98 | $this->_drillDownEntity('remove', $entity, $original, $parts); 99 | } 100 | 101 | foreach (['set', 'prepend', 'append'] as $action) { 102 | foreach ($this->getConfig($action) as $field => $value) { 103 | $parts = explode('.', $field); 104 | $this->_drillDownEntity($action, $entity, $original, $parts, $value); 105 | } 106 | } 107 | 108 | return $entity; 109 | } 110 | 111 | /** 112 | * Return finder to use for fetching entities. 113 | * 114 | * @param string|null $assocPath Dot separated association path. E.g. Invoices.InvoiceItems 115 | * @return array 116 | */ 117 | protected function _getFinder(?string $assocPath = null): array 118 | { 119 | $finders = $this->getConfig('finder'); 120 | 121 | if (!is_array($finders)) { 122 | $finders = [$finders]; 123 | } 124 | 125 | // for backward compatibility 126 | if ($this->getConfig('includeTranslations')) { 127 | $finders[] = 'translations'; 128 | } 129 | 130 | if ($finders === ['all']) { 131 | return $finders; 132 | } 133 | 134 | $object = $this->_table; 135 | if ($assocPath !== null) { 136 | $parts = explode('.', $assocPath); 137 | foreach ($parts as $prop) { 138 | /** @var \Cake\ORM\Association $object */ 139 | $object = $object->{$prop}; 140 | } 141 | } 142 | 143 | $tmp = []; 144 | foreach ($finders as $finder) { 145 | if ($object->hasFinder($finder)) { 146 | $tmp[] = $finder; 147 | } 148 | } 149 | 150 | if ($tmp === []) { 151 | $tmp = ['all']; 152 | } 153 | 154 | return array_unique($tmp); 155 | } 156 | 157 | /** 158 | * Return the contain array modified to use custom finder as required. 159 | * 160 | * @return array 161 | */ 162 | protected function _getContain(): array 163 | { 164 | $contain = []; 165 | foreach ($this->getConfig('contain') as $assocPath) { 166 | $finders = $this->_getFinder($assocPath); 167 | if ($finders === ['all']) { 168 | $contain[] = $assocPath; 169 | } else { 170 | $contain[$assocPath] = function ($query) use ($finders) { 171 | foreach ($finders as $finder) { 172 | $query->find($finder); 173 | } 174 | 175 | return $query; 176 | }; 177 | } 178 | } 179 | 180 | return $contain; 181 | } 182 | 183 | /** 184 | * Modify entity 185 | * 186 | * @param \Cake\Datasource\EntityInterface $entity Entity 187 | * @param \Cake\ORM\Table|\Cake\ORM\Association $object Table or association instance. 188 | * @return void 189 | */ 190 | protected function _modifyEntity(EntityInterface $entity, Table|Association $object): void 191 | { 192 | // belongs to many is tricky 193 | if ($object instanceof BelongsToMany && !$this->getConfig('preserveJoinData')) { 194 | unset($entity->_joinData); 195 | } elseif (!$object instanceof BelongsToMany) { 196 | // unset primary key 197 | unset($entity->{$object->getPrimaryKey()}); 198 | 199 | // unset foreign key 200 | if ($object instanceof Association) { 201 | unset($entity->{$object->getPrimaryKey()}); 202 | } 203 | } 204 | 205 | // set translations as new 206 | if (!empty($entity->_translations)) { 207 | foreach ($entity->_translations as $translation) { 208 | $translation->setNew(true); 209 | } 210 | } 211 | 212 | // set as new 213 | $entity->setNew(true); 214 | } 215 | 216 | /** 217 | * Drill down the related properties based on containments and modify each entity. 218 | * 219 | * @param \Cake\Datasource\EntityInterface $entity Entity 220 | * @param \Cake\ORM\Table|\Cake\ORM\Association $object Table or association instance. 221 | * @param array $parts Related properties chain. 222 | * @return void 223 | */ 224 | protected function _drillDownAssoc(EntityInterface $entity, Table|Association $object, array $parts): void 225 | { 226 | $assocName = array_shift($parts); 227 | $prop = $object->{$assocName}->getProperty(); 228 | $associated = $entity->{$prop}; 229 | 230 | if (!$associated || $object->{$assocName} instanceof BelongsTo) { 231 | return; 232 | } 233 | 234 | if ($associated instanceof EntityInterface) { 235 | $associated = [$associated]; 236 | } 237 | 238 | /** @var array<\Cake\Datasource\EntityInterface> $associated */ 239 | foreach ($associated as $e) { 240 | if ($parts) { 241 | $this->_drillDownAssoc($e, $object->{$assocName}, $parts); 242 | } 243 | 244 | if (!$e->isNew()) { 245 | $this->_modifyEntity($e, $object->{$assocName}); 246 | } 247 | } 248 | } 249 | 250 | /** 251 | * Drill down the properties and modify the leaf property. 252 | * 253 | * @param string $action Action to perform. 254 | * @param \Cake\Datasource\EntityInterface $entity Entity 255 | * @param \Cake\Datasource\EntityInterface $original Entity 256 | * @param array $parts Related properties chain. 257 | * @param mixed|null $value Value to set or use for modification. 258 | * @return void 259 | */ 260 | protected function _drillDownEntity( 261 | string $action, 262 | EntityInterface $entity, 263 | EntityInterface $original, 264 | array $parts, 265 | mixed $value = null, 266 | ): void { 267 | $prop = array_shift($parts); 268 | if (!$parts) { 269 | $this->_doAction($action, $entity, $original, $prop, $value); 270 | 271 | return; 272 | } 273 | 274 | if ($entity->{$prop} instanceof EntityInterface) { 275 | $this->_drillDownEntity($action, $entity->{$prop}, $original, $parts, $value); 276 | 277 | return; 278 | } 279 | 280 | if (is_iterable($entity->{$prop})) { 281 | foreach ($entity->{$prop} as $e) { 282 | $this->_drillDownEntity($action, $e, $original, $parts, $value); 283 | } 284 | } 285 | } 286 | 287 | /** 288 | * Perform specified action. 289 | * 290 | * @param string $action Action to perform. 291 | * @param \Cake\Datasource\EntityInterface $entity Entity 292 | * @param \Cake\Datasource\EntityInterface $original Entity 293 | * @param string $prop Property name. 294 | * @param mixed|null $value Value to set or use for modification. 295 | * @return void 296 | */ 297 | protected function _doAction( 298 | string $action, 299 | EntityInterface $entity, 300 | EntityInterface $original, 301 | string $prop, 302 | mixed $value = null 303 | ): void { 304 | switch ($action) { 305 | case 'remove': 306 | $entity->unset($prop); 307 | 308 | if (!empty($entity->_translations)) { 309 | foreach ($entity->_translations as &$translation) { 310 | $translation->unset($prop); 311 | } 312 | } 313 | break; 314 | 315 | case 'set': 316 | if (!is_string($value) && is_callable($value)) { 317 | $value = $value($entity, $original); 318 | } 319 | $entity->set($prop, $value); 320 | 321 | if (!empty($entity->_translations)) { 322 | foreach ($entity->_translations as &$translation) { 323 | $translation->set($prop, $value); 324 | } 325 | } 326 | break; 327 | 328 | case 'prepend': 329 | $entity->set($prop, $value . $entity->get($prop)); 330 | 331 | if (!empty($entity->_translations)) { 332 | foreach ($entity->_translations as &$translation) { 333 | if (!is_null($translation->get($prop))) { 334 | $translation->set($prop, $value . $translation->get($prop)); 335 | } 336 | } 337 | } 338 | break; 339 | 340 | case 'append': 341 | $entity->set($prop, $entity->get($prop) . $value); 342 | 343 | if (!empty($entity->_translations)) { 344 | foreach ($entity->_translations as &$translation) { 345 | if (!is_null($translation->get($prop))) { 346 | $translation->set($prop, $translation->get($prop) . $value); 347 | } 348 | } 349 | } 350 | break; 351 | } 352 | } 353 | } 354 | --------------------------------------------------------------------------------