├── .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 | [](https://github.com/riesenia/cakephp-duplicatable/actions/workflows/ci.yml)
4 | [](https://codecov.io/github/riesenia/cakephp-duplicatable)
5 | [](https://packagist.org/packages/riesenia/cakephp-duplicatable)
6 | [](https://packagist.org/packages/riesenia/cakephp-duplicatable)
7 | [](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 |
--------------------------------------------------------------------------------