Passed
Push — master ( a97ceb...1d5991 )
by Julien
07:28
created

Relationship::postSaveRelatedThroughAfter()   C

Complexity

Conditions 13
Paths 225

Size

Total Lines 76
Code Lines 41

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 28
CRAP Score 20.1716

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 13
eloc 41
c 2
b 0
f 0
nc 225
nop 3
dl 0
loc 76
ccs 28
cts 43
cp 0.6512
crap 20.1716
rs 5.4708

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
/**
4
 * This file is part of the Zemit Framework.
5
 *
6
 * (c) Zemit Team <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE.txt
9
 * file that was distributed with this source code.
10
 */
11
12
namespace Zemit\Mvc\Model\Traits;
13
14
use Exception;
15
use Phalcon\Db\Adapter\AdapterInterface;
16
use Phalcon\Db\Column;
17
use Phalcon\Messages\Message;
18
use Phalcon\Mvc\EntityInterface;
19
use Phalcon\Mvc\Model;
20
use Phalcon\Mvc\Model\Row;
21
use Phalcon\Mvc\Model\Relation;
22
use Phalcon\Mvc\Model\RelationInterface;
23
use Phalcon\Mvc\Model\ResultsetInterface;
24
use Phalcon\Mvc\ModelInterface;
25
use Phalcon\Support\Collection\CollectionInterface;
26
use Zemit\Mvc\Model\Interfaces\RelationshipInterface;
27
use Zemit\Mvc\Model\Interfaces\SoftDeleteInterface;
28
use Zemit\Mvc\Model\Traits\Abstracts\AbstractEntity;
29
use Zemit\Mvc\Model\Traits\Abstracts\AbstractMetaData;
30
use Zemit\Mvc\Model\Traits\Abstracts\AbstractModelsManager;
31
32
/**
33
 * Allow to automagically save relationship
34
 */
35
trait Relationship
36
{
37
    use AbstractEntity;
38
    use AbstractMetaData;
39
    use AbstractModelsManager;
40
    
41
    abstract public function appendMessage(\Phalcon\Messages\MessageInterface $message): ModelInterface;
42
    
43
    private array $keepMissingRelated = [];
44
    
45
    private string $relationshipContext = '';
46
    
47
    protected $dirtyRelated = [];
48
    
49
    /**
50
     * Set the missing related configuration list
51
     */
52
    public function setKeepMissingRelated(array $keepMissingRelated): void
53
    {
54
        $this->keepMissingRelated = $keepMissingRelated;
55
    }
56
    
57
    /**
58
     * Return the missing related configuration list
59
     */
60
    public function getKeepMissingRelated(): array
61
    {
62
        return $this->keepMissingRelated;
63
    }
64
    
65
    /**
66
     * Return the keepMissing configuration for a specific relationship alias
67
     */
68
    public function getKeepMissingRelatedAlias(string $alias): bool
69
    {
70
        return (bool)$this->keepMissingRelated[$alias];
71
    }
72
    
73
    /**
74
     * Set the keepMissing configuration for a specific relationship alias
75
     */
76 2
    public function setKeepMissingRelatedAlias(string $alias, bool $keepMissing): void
77
    {
78 2
        $this->keepMissingRelated[$alias] = $keepMissing;
79
    }
80
    
81
    /**
82
     * Get the current relationship context
83
     */
84
    public function getRelationshipContext(): string
85
    {
86
        return $this->relationshipContext;
87
    }
88
    
89
    /**
90
     * Set the current relationship context
91
     */
92
    public function setRelationshipContext(string $context): void
93
    {
94
        $this->relationshipContext = $context;
95
    }
96
    
97
    /**
98
     * Return the dirtyRelated entities
99
     */
100 4
    public function getDirtyRelated(): array
101
    {
102 4
        return $this->dirtyRelated;
103
    }
104
    
105
    /**
106
     * Set the dirtyRelated entities
107
     */
108
    public function setDirtyRelated(array $dirtyRelated): void
109
    {
110
        $this->dirtyRelated = $dirtyRelated;
111
    }
112
    
113
    /**
114
     * Return the dirtyRelated entities
115
     */
116
    public function getDirtyRelatedAlias(string $alias): mixed
117
    {
118
        return $this->dirtyRelated[$alias];
119
    }
120
    
121
    /**
122
     * Return the dirtyRelated entities
123
     */
124
    public function setDirtyRelatedAlias(string $alias, mixed $value): void
125
    {
126
        $this->dirtyRelated[$alias] = $value;
127
    }
128
    
129
    /**
130
     * Check whether the current entity has dirty related or not
131
     */
132
    public function hasDirtyRelated(): bool
133
    {
134
        return (bool)count($this->dirtyRelated);
135
    }
136
    
137
    /**
138
     * Check whether the current entity has dirty related or not
139
     */
140
    public function hasDirtyRelatedAlias(string $alias): bool
141
    {
142
        return isset($this->dirtyRelated[$alias]);
143
    }
144
    
145
    /**
146
     * {@inheritDoc}
147
     * @throws Exception
148
     */
149 2
    public function assign(array $data, $whiteList = null, $dataColumnMap = null): ModelInterface
150
    {
151 2
        $this->assignRelated($data, $whiteList, $dataColumnMap);
152 2
        return parent::assign($data, $whiteList, $dataColumnMap);
153
    }
154
    
155
    /**
156
     * Assign related
157
     *
158
     * Single
159
     * [alias => new Alias()] // create new alias
160
     *
161
     * Many
162
     * [alias => [new Alias()]] // create new alias
163
     * [alias => [1, 2, 3, 4]] // append / merge 1, 2, 3, 4
164
     * [alias => [false, 1, 2, 4]]; // delete 3
165
     *
166
     * @param array $data
167
     * @param array|null $whiteList
168
     * @param array|null $dataColumnMap
169
     *
170
     * @return ModelInterface
171
     * @throws Exception
172
     */
173 2
    public function assignRelated(array $data, ?array $whiteList = null, ?array $dataColumnMap = null): ModelInterface
174
    {
175 2
        assert($this instanceof Model);
176
        
177
        // no data, nothing to do
178 2
        if (empty($data)) {
179
            return $this;
180
        }
181
        
182
        // Get the current model class name
183 2
        $modelClass = get_class($this);
184
        
185 2
        $modelsManager = $this->getModelsManager();
186
        
187 2
        foreach ($data as $alias => $relationData) {
188
            
189 2
            $relation = $modelsManager->getRelationByAlias($modelClass, $alias);
190
            
191
            // alias is not whitelisted
192 2
            if (!is_null($whiteList) && (!isset($whiteList[$alias]) && !in_array($alias, $whiteList))) {
193
                continue;
194
            }
195
            
196
            // @todo add a recursive whiteList check & columnMap support
197 2
            if ($relation) {
198 2
                $type = $relation->getType();
199
                
200 2
                $fields = $relation->getFields();
201 2
                $fields = is_array($fields) ? $fields : [$fields];
202
                
203 2
                $referencedFields = $relation->getReferencedFields();
204 2
                $referencedFields = is_array($referencedFields) ? $referencedFields : [$referencedFields];
205
                
206 2
                $referencedModel = $relation->getReferencedModel();
207 2
                $assign = null;
208
                
209 2
                if (is_int($relationData) || is_string($relationData)) {
210
                    $relationData = [$referencedFields[0] => $relationData];
211
                }
212
                
213 2
                if ($relationData instanceof ModelInterface) {
214
                    if ($relationData instanceof $referencedModel) {
215
                        $assign = $relationData;
216
                    }
217
                    else {
218
                        throw new Exception('Instance of `' . get_class($relationData) . '` received on model `' . $modelClass . '` in alias `' . $alias . ', expected instance of `' . $referencedModel . '`', 400);
219
                    }
220
                }
221
                
222
                // array | traversable | resultset
223 2
                elseif (is_array($relationData) || $relationData instanceof \Traversable) {
224 2
                    $assign = [];
225
                    
226 2
                    $getEntityParams = [
227 2
                        'alias' => $alias,
228 2
                        'fields' => $referencedFields,
229 2
                        'modelClass' => $referencedModel,
230 2
                        'readFields' => $fields,
231 2
                        'type' => $type,
232 2
                        'whiteList' => $whiteList,
233 2
                        'dataColumnMap' => $dataColumnMap,
234 2
                    ];
235
                    
236 2
                    if (empty($relationData) && !in_array($type, [Relation::HAS_MANY_THROUGH, Relation::HAS_MANY])) {
237
                        $assign = $this->getEntityFromData($relationData, $getEntityParams);
238
                    }
239
                    else {
240 2
                        foreach ($relationData as $traversedKey => $traversedData) {
241
                            // Array of things
242 2
                            if (is_int($traversedKey)) {
243 2
                                $entity = null;
244
                                
245
                                // Using bool as behaviour to delete missing relationship or keep them
246
                                // @TODO find a better way
247
                                // if [alias => [true, ...]
248 2
                                if ($traversedData === 'false') {
249
                                    $traversedData = false;
250
                                }
251 2
                                if ($traversedData === 'true') {
252
                                    $traversedData = true;
253
                                }
254
                                
255 2
                                if (is_bool($traversedData)) {
256 2
                                    $this->setKeepMissingRelatedAlias($alias, $traversedData);
257 2
                                    continue;
258
                                }
259
                                
260
                                // if [alias => [1, 2, 3, ...]]
261 2
                                if (is_int($traversedData) || is_string($traversedData)) {
262 1
                                    $traversedData = [$referencedFields[0] => $traversedData];
263
                                }
264
                                
265
                                // if [alias => AliasModel]
266 2
                                if ($traversedData instanceof ModelInterface) {
267 1
                                    if ($traversedData instanceof $referencedModel) {
268 1
                                        $entity = $traversedData;
269
                                    }
270
                                    else {
271
                                        throw new Exception('Instance of `' . get_class($traversedData) . '` received on model `' . $modelClass . '` in alias `' . $alias . ', expected instance of `' . $referencedModel . '`', 400);
272
                                    }
273
                                }
274
                                
275
                                // if [alias => [[id => 1], [id => 2], [id => 3], ....]]
276 2
                                elseif (is_array($traversedData) || $traversedData instanceof \Traversable) {
277 2
                                    $entity = $this->getEntityFromData((array)$traversedData, $getEntityParams);
278
                                }
279
                                
280 2
                                if ($entity) {
281 2
                                    $assign [] = $entity;
282
                                }
283
                            }
284
                            
285
                            // if [alias => [id => 1]]
286
                            else {
287
                                $assign = $this->getEntityFromData((array)$relationData, $getEntityParams);
288
                                break;
289
                            }
290
                        }
291
                    }
292
                }
293
                
294
                // we got something to assign
295 2
                $keepMissingRelationship = $this->keepMissingRelated[$alias] ?? null;
296 2
                if (!empty($assign) || $keepMissingRelationship === false) {
297 2
                    $this->{$alias} = $assign;
298
                    
299
                    // fix to force recursive parent save from children entities within _preSaveRelatedRecords method
300 2
                    if ($this->{$alias} && $this->{$alias} instanceof ModelInterface) {
301
                        $this->{$alias}->setDirtyState(Model::DIRTY_STATE_TRANSIENT);
302
                    }
303
                    
304 2
                    $this->dirtyRelated[mb_strtolower($alias)] = $this->{$alias} ?? false;
305 2
                    if (empty($assign)) {
306
                        $this->dirtyRelated[mb_strtolower($alias)] = [];
307
                    }
308
                }
309
            }
310
        }
311
        
312 2
        return $this;
313
    }
314
    
315
    /**
316
     * Saves related records that must be stored prior to save the master record
317
     * Refactored based on the native cphalcon version, so we can support :
318
     * - combined keys on relationship definition
319
     * - relationship context within the model messages based on the alias definition
320
     * @throws Exception
321
     */
322 2
    protected function preSaveRelatedRecords(AdapterInterface $connection, $related, CollectionInterface $visited): bool
323
    {
324 2
        $nesting = false;
325
        
326 2
        $connection->begin($nesting);
327 2
        $className = get_class($this);
328
        
329 2
        $modelsManager = $this->getModelsManager();
330
        
331 2
        foreach ($related as $alias => $record) {
332 2
            $relation = $modelsManager->getRelationByAlias($className, $alias);
333
            
334 2
            if ($relation) {
335 2
                $type = $relation->getType();
336
                
337
                // Only belongsTo are stored before save the master record
338 2
                if ($type === Relation::BELONGS_TO) {
339
                    
340
                    // Belongs-to relation: We only support model interface
341
                    if (!($record instanceof ModelInterface)) {
342
                        $connection->rollback($nesting);
343
                        throw new Exception(
344
                            'Instance of `' . get_class($record) . '` received on model `' . $className . '` in alias `' . $alias .
345
                            ', expected instance of `' . ModelInterface::class . '` as part of the belongs-to relation',
346
                            400
347
                        );
348
                    }
349
                    
350
                    $relationFields = $relation->getFields();
351
                    $relationFields = is_array($relationFields) ? $relationFields : [$relationFields];
352
                    
353
                    $referencedFields = $relation->getReferencedFields();
354
                    $referencedFields = is_array($referencedFields) ? $referencedFields : [$referencedFields];
355
                    
356
                    // Set the relationship context
357
                    if ($record instanceof RelationshipInterface) {
358
                        $currentRelationshipContext = $this->getRelationshipContext();
359
                        $relationshipPrefix = !empty($currentRelationshipContext)? $currentRelationshipContext . '.' : '';
360
                        $record->setRelationshipContext($relationshipPrefix . $alias);
361
                    }
362
                    
363
                    /**
364
                     * If dynamic update is enabled, saving the record must not take any action
365
                     * Only save if the model is dirty to prevent circular relations causing an infinite loop
366
                     */
367
                    assert($record instanceof Model);
368
                    if ($record->getDirtyState() !== Model::DIRTY_STATE_PERSISTENT && !$record->doSave($visited)) {
369
                        $this->appendMessagesFromRecord($record, $alias);
370
                        $connection->rollback($nesting);
371
                        return false;
372
                    }
373
                    
374
                    // assign referenced value to the current model
375
                    foreach ($referencedFields as $key => $referencedField) {
376
                        $this->{$relationFields[$key]} = $record->readAttribute($referencedField);
377
                    }
378
                }
379
            }
380
        }
381
        
382 2
        return true;
383
    }
384
    
385
    /**
386
     * NOTE: we need this, this behaviour only happens:
387
     * - in many to many nodes
388
     * Fix uniqueness on combined keys in node entities, and possibly more...
389
     * @link https://forum.phalconphp.com/discussion/2190/many-to-many-expected-behaviour
390
     * @link http://stackoverflow.com/questions/23374858/update-a-records-n-n-relationships
391
     * @link https://github.com/phalcon/cphalcon/issues/2871
392
     * @throws Exception
393
     */
394 2
    protected function postSaveRelatedRecords(AdapterInterface $connection, $related, CollectionInterface $visited): bool
395
    {
396 2
        assert($this instanceof ModelInterface);
397 2
        $nesting = false;
398
        
399 2
        if ($related) {
400 2
            foreach ($related as $lowerCaseAlias => $assign) {
401
                
402 2
                $modelsManager = $this->getModelsManager();
403 2
                $relation = $modelsManager->getRelationByAlias(get_class($this), $lowerCaseAlias);
404
                
405 2
                if (!$relation) {
406
                    if (is_array($assign)) {
407
                        $connection->rollback($nesting);
408
                        throw new Exception("There are no defined relations for the model '" . get_class($this) . "' using alias '" . $lowerCaseAlias . "'");
409
                    }
410
                }
411 2
                assert($relation instanceof RelationInterface);
412
                
413
                /**
414
                 * Discard belongsTo relations
415
                 */
416 2
                if ($relation->getType() === Relation::BELONGS_TO) {
417
                    continue;
418
                }
419
                
420 2
                if (!is_array($assign) && !is_object($assign)) {
421
                    $connection->rollback($nesting);
422
                    throw new Exception('Only objects/arrays can be stored as part of has-many/has-one/has-one-through/has-many-to-many relations');
423
                }
424
                
425
                /**
426
                 * Custom logic for single-to-many relationships
427
                 */
428 2
                if ($relation->getType() === Relation::HAS_MANY) {
429
                    
430
                    // auto-delete missing related if keepMissingRelated is false
431 1
                    if (!($this->keepMissingRelated[$lowerCaseAlias] ?? true)) {
432 1
                        $originFields = $relation->getFields();
433 1
                        $originFields = is_array($originFields) ? $originFields : [$originFields];
434
                        
435 1
                        $referencedFields = $relation->getReferencedFields();
436 1
                        $referencedFields = is_array($referencedFields) ? $referencedFields : [$referencedFields];
437
                        
438 1
                        $referencedModelClass = $relation->getReferencedModel();
439 1
                        $referencedModel = $modelsManager->load($referencedModelClass);
440
                        
441 1
                        $referencedPrimaryKeyAttributes = $referencedModel->getModelsMetaData()->getPrimaryKeyAttributes($referencedModel);
442 1
                        $referencedBindTypes = $referencedModel->getModelsMetaData()->getBindTypes($referencedModel);
443
                        
444 1
                        $originBind = [];
445 1
                        foreach ($originFields as $originField) {
446 1
                            $originBind [] = $this->readAttribute($originField);
447
                        }
448
                    
449 1
                        $idBindType = count($referencedPrimaryKeyAttributes) === 1 ? $referencedBindTypes[$referencedPrimaryKeyAttributes[0]] : Column::BIND_PARAM_STR;
450
                        
451 1
                        $idListToKeep = [0];
452 1
                        foreach ($assign as $entity) {
453 1
                            $buildPrimaryKey = [];
454 1
                            foreach ($referencedPrimaryKeyAttributes as $referencedPrimaryKey => $referencedPrimaryKeyAttribute) {
455 1
                                $buildPrimaryKey [] = $entity->readAttribute($referencedPrimaryKeyAttribute);
456
                            }
457 1
                            $idListToKeep [] = implode('.', $buildPrimaryKey);
458
                        }
459
                        
460
                        // fetch missing related entities
461 1
                        $referencedEntityToDeleteResultset = $referencedModel::find([
462 1
                            'conditions' => implode_sprintf(array_merge($referencedFields), ' and ', '[' . $referencedModelClass . '].[%s] = ?%s') .
463 1
                            ' and concat(' . implode_sprintf($referencedPrimaryKeyAttributes, ', \'.\', ', '[' . $referencedModelClass . '].[%s]') . ') not in ({id:array})',
464 1
                            'bind' => [...$originBind, 'id' => $idListToKeep],
465 1
                            'bindTypes' => [...array_fill(0, count($referencedFields), Column::BIND_PARAM_STR), 'id' => $idBindType],
466 1
                        ]);
467
                        
468
                        // delete missing related entities
469 1
                        if (!$referencedEntityToDeleteResultset->delete()) {
470
                            $this->appendMessagesFromResultset($referencedEntityToDeleteResultset, $lowerCaseAlias);
471
                            $this->appendMessage(new Message('Unable to delete node entity `' . $referencedModelClass . '`', $lowerCaseAlias, 'Bad Request', 400));
472
                            $connection->rollback($nesting);
473
                            return false;
474
                        }
475
                    }
476
                }
477
                
478
                /**
479
                 * Custom logic for many-to-many relationships
480
                 */
481 1
                elseif ($relation->getType() === Relation::HAS_MANY_THROUGH) {
482 1
                    $originFields = $relation->getFields();
483 1
                    $originFields = is_array($originFields) ? $originFields : [$originFields];
484
                    
485 1
                    $intermediateModelClass = $relation->getIntermediateModel();
486 1
                    $intermediateModel = $modelsManager->load($intermediateModelClass);
487
                    
488 1
                    $intermediateFields = $relation->getIntermediateFields();
489 1
                    $intermediateFields = is_array($intermediateFields) ? $intermediateFields : [$intermediateFields];
490
                    
491 1
                    $intermediateReferencedFields = $relation->getIntermediateReferencedFields();
492 1
                    $intermediateReferencedFields = is_array($intermediateReferencedFields) ? $intermediateReferencedFields : [$intermediateReferencedFields];
493
                    
494 1
                    $referencedFields = $relation->getReferencedFields();
495 1
                    $referencedFields = is_array($referencedFields) ? $referencedFields : [$referencedFields];
496
                    
497 1
                    $intermediatePrimaryKeyAttributes = $intermediateModel->getModelsMetaData()->getPrimaryKeyAttributes($intermediateModel);
498 1
                    $intermediateBindTypes = $intermediateModel->getModelsMetaData()->getBindTypes($intermediateModel);
499
                    
500
                    // get current model bindings
501 1
                    $originBind = [];
502 1
                    foreach ($originFields as $originField) {
503 1
                        $originBind [] = $this->readAttribute($originField);
504
//                        $originBind [] = $this->{'get' . ucfirst($originField)} ?? $this->$originField ?? null;
505
                    }
506
                    
507 1
                    $nodeIdListToKeep = [];
508 1
                    foreach ($assign as $key => $entity) {
509
                        // get referenced model bindings
510 1
                        $referencedBind = [];
511 1
                        foreach ($referencedFields as $referencedField) {
512 1
                            assert($entity instanceof EntityInterface);
513 1
                            $referencedBind [] = $entity->readAttribute($referencedField);
514
                        }
515
                        
516 1
                        $nodeEntity = $intermediateModel::findFirst([
517 1
                            'conditions' => implode_sprintf(array_merge($intermediateFields, $intermediateReferencedFields), ' and ', '[' . $intermediateModelClass . '].[%s] = ?%s'),
518 1
                            'bind' => [...$originBind, ...$referencedBind],
519 1
                            'bindTypes' => array_fill(0, count($intermediateFields) + count($intermediateReferencedFields), Column::BIND_PARAM_STR),
520 1
                        ]);
521
                        
522 1
                        if ($nodeEntity) {
523 1
                            $buildPrimaryKey = [];
524 1
                            foreach ($intermediatePrimaryKeyAttributes as $intermediatePrimaryKey => $intermediatePrimaryKeyAttribute) {
525 1
                                $buildPrimaryKey [] = $nodeEntity->readAttribute($intermediatePrimaryKeyAttribute);
526
                            }
527 1
                            $nodeIdListToKeep [] = implode('.', $buildPrimaryKey);
528
                            
529
                            // Restoring node entities if previously soft deleted
530 1
                            if ($nodeEntity instanceof SoftDeleteInterface && $nodeEntity->isDeleted() && !$nodeEntity->restore()) {
531
                                assert($nodeEntity instanceof ModelInterface);
532
                                $this->appendMessagesFromRecord($nodeEntity, $lowerCaseAlias, $key);
533
                                $this->appendMessage(new Message('Unable to restored previously deleted related node `' . $intermediateModelClass . '`', $lowerCaseAlias, 'Bad Request', 400));
534
                                $connection->rollback($nesting);
535
                                return false;
536
                            }
537
                            
538
                            // save edge record
539 1
                            assert($entity instanceof Model);
540 1
                            if (!$entity->doSave($visited)) {
541
                                $this->appendMessagesFromRecord($entity, $lowerCaseAlias, $key);
542
                                $this->appendMessage(new Message('Unable to save related entity `' . $intermediateModelClass . '`', $lowerCaseAlias, 'Bad Request', 400));
543
                                $connection->rollback($nesting);
544
                                return false;
545
                            }
546
                            
547
                            // remove it
548 1
                            unset($related[$lowerCaseAlias][$key]);
549
                            
550
                            // @todo see if we have to remove from object too
551 1
                            if (is_array($assign)) {
552 1
                                unset($assign[$key]);
553
                            }
554
555
//                            // add to assign
556
//                            $nodeAssign [] = $nodeEntity;
557
                        }
558
                    }
559
                    
560 1
                    if (!($this->keepMissingRelated[$lowerCaseAlias] ?? true)) {
561 1
                        $idBindType = count($intermediatePrimaryKeyAttributes) === 1 ? $intermediateBindTypes[$intermediatePrimaryKeyAttributes[0]] : Column::BIND_PARAM_STR;
562 1
                        $nodeIdListToKeep = empty($nodeIdListToKeep)? [0] : array_keys(array_flip($nodeIdListToKeep));
563 1
                        $nodeEntityToDeleteResultset = $intermediateModel::find([
564 1
                            'conditions' => implode_sprintf(array_merge($intermediateFields), ' and ', '[' . $intermediateModelClass . '].[%s] = ?%s')
565 1
                                . ' and concat(' . implode_sprintf($intermediatePrimaryKeyAttributes, ', \'.\', ', '[' . $intermediateModelClass . '].[%s]') . ') not in ({id:array})',
566 1
                            'bind' => [...$originBind, 'id' => $nodeIdListToKeep],
567 1
                            'bindTypes' => [...array_fill(0, count($intermediateFields), Column::BIND_PARAM_STR), 'id' => $idBindType],
568 1
                        ]);
569
                        
570
                        // delete missing related
571 1
                        if (!$nodeEntityToDeleteResultset->delete()) {
572
                            $this->appendMessagesFromResultset($nodeEntityToDeleteResultset, $lowerCaseAlias);
573
                            $this->appendMessage(new Message('Unable to delete node entity `' . $intermediateModelClass . '`', $lowerCaseAlias, 'Bad Request', 400));
574
                            $connection->rollback($nesting);
575
                            return false;
576
                        }
577
                    }
578
                }
579
                
580 2
                $relationFields = $relation->getFields();
581 2
                $relationFields = is_array($relationFields) ? $relationFields : [$relationFields];
582
                
583 2
                foreach ($relationFields as $relationField) {
584 2
                    if (!property_exists($this, $relationField)) {
585
                        $connection->rollback($nesting);
586
                        throw new Exception("The column '" . $relationField . "' needs to be present in the model");
587
                    }
588
                }
589
                
590 2
                $relatedRecords = $assign instanceof ModelInterface ? [$assign] : $assign;
591
                
592 2
                if ($this->postSaveRelatedThroughAfter($relation, $relatedRecords, $visited) === false) {
593
                    $this->appendMessage(new Message('Unable to save related through after', $lowerCaseAlias, 'Bad Request', 400));
594
                    $connection->rollback($nesting);
595
                    return false;
596
                }
597
                
598 2
                if ($this->postSaveRelatedRecordsAfter($relation, $relatedRecords, $visited) === false) {
599
                    $this->appendMessage(new Message('Unable to save related records after', $lowerCaseAlias, 'Bad Request', 400));
600
                    $connection->rollback($nesting);
601
                    return false;
602
                }
603
            }
604
        }
605
        
606
        /**
607
         * Commit the implicit transaction
608
         */
609 2
        $connection->commit($nesting);
610 2
        return true;
611
    }
612
    
613 2
    public function postSaveRelatedRecordsAfter(RelationInterface $relation, $relatedRecords, CollectionInterface $visited): ?bool
614
    {
615 2
        if ($relation->isThrough()) {
616 1
            return null;
617
        }
618
        
619 1
        $lowerCaseAlias = $relation->getOption('alias');
620
        
621 1
        $relationFields = $relation->getFields();
622 1
        $relationFields = is_array($relationFields) ? $relationFields : [$relationFields];
623
        
624 1
        $referencedFields = $relation->getReferencedFields();
625 1
        $referencedFields = is_array($referencedFields) ? $referencedFields : [$referencedFields];
626
        
627 1
        foreach ($relatedRecords as $recordAfter) {
628
//            $test = $recordAfter->toArray();
629
//            $recordAfter->assign($relationFields);
630 1
            foreach ($relationFields as $key => $relationField) {
631 1
                $recordAfter->writeAttribute($referencedFields[$key], $this->readAttribute($relationField));
632
            }
633
            
634
            try {
635
                // Save the record and get messages
636 1
                if (!$recordAfter->doSave($visited)) {
637
                    $this->appendMessagesFromRecord($recordAfter, $lowerCaseAlias);
638 1
                    return false;
639
                }
640
            } catch (\Exception $e) {
641
//                dd($test, $recordAfter->toArray());
642
                $this->appendMessages([
643
                    new Message($e->getMessage() . ' - ' . $e->getCode(), $lowerCaseAlias, 'Exception', (int)$e->getCode()),
644
                ]);
645
                return false;
646
            }
647
            
0 ignored issues
show
Coding Style introduced by
Blank line found at end of control structure
Loading history...
648
        }
649
        
650 1
        return true;
651
    }
652
    
653 2
    public function postSaveRelatedThroughAfter(RelationInterface $relation, $relatedRecords, CollectionInterface $visited): ?bool
654
    {
655 2
        assert($this instanceof RelationshipInterface);
656 2
        assert($this instanceof EntityInterface);
657 2
        assert($this instanceof ModelInterface);
658
        
659 2
        if (!$relation->isThrough()) {
660 1
            return null;
661
        }
662
        
663 1
        $modelsManager = $this->getModelsManager();
664 1
        $lowerCaseAlias = $relation->getOption('alias');
665
        
666 1
        $relationFields = $relation->getFields();
667 1
        $relationFields = is_array($relationFields) ? $relationFields : [$relationFields];
668
        
669 1
        $referencedFields = $relation->getReferencedFields();
670 1
        $referencedFields = is_array($referencedFields) ? $referencedFields : [$referencedFields];
671
        
672 1
        $intermediateModelClass = $relation->getIntermediateModel();
673
        
674 1
        $intermediateFields = $relation->getIntermediateFields();
675 1
        $intermediateFields = is_array($intermediateFields) ? $intermediateFields : [$intermediateFields];
676
        
677 1
        $intermediateReferencedFields = $relation->getIntermediateReferencedFields();
678 1
        $intermediateReferencedFields = is_array($intermediateReferencedFields) ? $intermediateReferencedFields : [$intermediateReferencedFields];
679
        
680 1
        foreach ($relatedRecords as $relatedAfterKey => $recordAfter) {
681 1
            assert($recordAfter instanceof Model);
682
            
683
            // Save the record and get messages
684 1
            if (!$recordAfter->doSave($visited)) {
685
                $this->appendMessagesFromRecord($recordAfter, $lowerCaseAlias, $relatedAfterKey);
686
                return false;
687
            }
688
            
689
            // Create a new instance of the intermediate model
690 1
            $intermediateModel = $modelsManager->load($intermediateModelClass);
691
            
692
            /**
693
             *  Has-one-through relations can only use one intermediate model.
694
             *  If it already exists, it can be updated with the new referenced key.
695
             */
696 1
            if ($relation->getType() === Relation::HAS_ONE_THROUGH) {
697
                $bind = [];
698
                foreach ($relationFields as $relationField) {
699
                    $bind[] = $this->readAttribute($relationField);
700
                }
701
                
702
                $existingIntermediateModel = $intermediateModel::findFirst([
703
                    'conditions' => implode_sprintf($intermediateFields, ' and ', '[' . $intermediateModelClass . '].[%s] = ?%s'),
704
                    'bind' => $bind,
705
                    'bindTypes' => array_fill(0, count($bind), Column::BIND_PARAM_STR),
706
                ]);
707
                
708
                if ($existingIntermediateModel) {
709
                    $intermediateModel = $existingIntermediateModel;
710
                }
711
            }
712
            
713
            // Set intermediate model columns values
714 1
            foreach ($relationFields as $relationFieldKey => $relationField) {
715 1
                $intermediateModel->writeAttribute($intermediateFields[$relationFieldKey], $this->readAttribute($relationField));
716 1
                $intermediateValue = $recordAfter->readAttribute($referencedFields[$relationFieldKey]);
717 1
                $intermediateModel->writeAttribute($intermediateReferencedFields[$relationFieldKey], $intermediateValue);
718
            }
719
            
720
            // Save the record and get messages
721 1
            if (!$intermediateModel->doSave($visited)) {
722
                $this->appendMessagesFromRecord($intermediateModel, $lowerCaseAlias);
723
                $this->appendMessage(new Message('Unable to save intermediate model `' . $intermediateModelClass . '`', $lowerCaseAlias, 'Bad Request', 400));
724
                return false;
725
            }
726
        }
727
        
728 1
        return true;
729
    }
730
    
731
    /**
732
     * Find the first record by its primary key attributes.
733
     *
734
     * @param array $data The data containing the primary key values.
735
     * @param string|null $modelClass The class name of the model to search for. If not provided, the current model class will be used.
736
     * 
737
     * @return ModelInterface|Model\Row|null The found record entity.
738
     */
739 2
    public function findFirstByPrimaryKeys(array $data, ?string $modelClass): ModelInterface|Row|null
740
    {
741 2
        assert($this instanceof ModelInterface);
742
        
743 2
        $modelClass ??= self::class;
744
        
745 2
        $modelsManager = $this->getModelsManager();
746 2
        $modelsMetaData = $this->getModelsMetaData();
747
        
748 2
        $relatedModel = $modelsManager->load($modelClass);
749 2
        $relatedPrimaryKeys = $modelsMetaData->getPrimaryKeyAttributes($relatedModel);
750 2
        $relatedPrimaryValues = array_intersect_key($data, array_flip($relatedPrimaryKeys));
751
        
752 2
        if (count($relatedPrimaryKeys) === count($relatedPrimaryValues)) {
753 2
            return $relatedModel::findFirst([
754 2
                'conditions' => implode_sprintf($relatedPrimaryKeys, ' and ', '[' . $relatedModel::class . '].[%s] = ?%s'),
755 2
                'bind' => array_values($relatedPrimaryValues),
756 2
                'bindTypes' => array_fill(0, count($relatedPrimaryValues), Column::BIND_PARAM_STR),
757 2
            ]);
758
        }
759
        
760 2
        return null;
761
    }
762
    
763
    /**
764
     * Get the entity object from the given data.
765
     * It will try to find the existing record and then assign the new data.
766
     * - Will first try using the primary key of the related record
767
     * - Then will try using the defined relationship fields using the relationship alias
768
     *
769
     * @param array $data The data array.
770
     * @param array $configuration The configuration options.
771
     *                                - alias: The alias name.
772
     *                                - fields: The fields array.
773
     *                                - modelClass: The model class.
774
     *                                - readFields: The read fields array.
775
     *                                - type: The relationship type.
776
     *                                - whiteList: The whitelist array.
777
     *                                - dataColumnMap: The data column map array.
778
     *
779
     * @return ModelInterface|Model\Row|null The entity object or null if not found.
780
     */
781 2
    public function getEntityFromData(array $data, array $configuration = []): ModelInterface|Row|null
782
    {
783 2
        assert($this instanceof ModelInterface);
784 2
        assert($this instanceof EntityInterface);
785
        
786 2
        $alias = $configuration['alias'] ?? null;
787 2
        $fields = $configuration['fields'] ?? [];
788 2
        $modelClass = $configuration['modelClass'] ?? null;
789 2
        $readFields = $configuration['readFields'] ?? null;
790 2
        $type = $configuration['type'] ?? null;
791 2
        $whiteList = $configuration['whiteList'] ?? null;
792 2
        $dataColumnMap = $configuration['dataColumnMap'] ?? null;
793
        
794 2
        if (!is_array($fields)) {
795
            throw new \Exception('Parameter `fields` must be an array');
796
        }
797
        
798 2
        if (!isset($modelClass)) {
799
            throw new \Exception('Parameter `modelClass` is mandatory');
800
        }
801
        
802
        // using primary key first
803 2
        $entity = $this->findFirstByPrimaryKeys($data, $modelClass);
804
        
805
        // not found, using the relationship fields instead
806 2
        if (!$entity) {
807 2
            if ($type === Relation::HAS_ONE || $type === Relation::BELONGS_TO) {
808
                
809
                // Set value to compare
810
                if (!empty($readFields)) {
811
                    
812
                    foreach ($readFields as $key => $field) {
813
                        
814
                        if (empty($data[$fields[$key]])) {
815
                            
816
                            // @todo maybe remove this if
817
                            $value = $this->readAttribute($field);
818
                            if (!empty($value)) {
819
                                
820
                                // @todo maybe remove this if
821
                                $data [$fields[$key]] = $value;
822
                            }
823
                        }
824
                    }
825
                }
826
            }
827
            
828
            // array_keys_exists (if $referencedFields keys exists)
829 2
            $dataKeys = array_intersect_key($data, array_flip($fields));
830
            
831
            // all keys were found
832 2
            if (count($dataKeys) === count($fields)) {
833
                
834
                if ($type === Relation::HAS_MANY) {
835
                    
836
                    $modelsMetaData = $this->getModelsMetaData();
837
                    $primaryKeys = $modelsMetaData->getPrimaryKeyAttributes($this);
838
                    
839
                    // Force primary keys for single to many
840
                    foreach ($primaryKeys as $primaryKey) {
841
                        
842
                        if (!in_array($primaryKey, $fields, true)) {
843
                            $dataKeys [$primaryKey] = $data[$primaryKey] ?? null;
844
                            $fields [] = $primaryKey;
845
                        }
846
                    }
847
                }
848
                
849
                $modelsManager = $this->getModelsManager();
850
                $relatedModel = $modelsManager->load($modelClass);
851
                
852
                $entity = $relatedModel::findFirst([
853
                    'conditions' => implode_sprintf($fields, ' and ', '[' . $relatedModel::class . '].[%s] = ?%s'),
854
                    'bind' => array_values($dataKeys),
855
                    'bindTypes' => array_fill(0, count($dataKeys), Column::BIND_PARAM_STR),
856
                ]);
857
            }
858
        }
859
        
860
        // not found, we will create a new related entity
861 2
        if (!$entity) {
862 2
            $entity = new $modelClass();
863
        }
864
        
865 2
        assert($entity instanceof ModelInterface);
866
        
867
        // assign new values
868
        // can be null to bypass, empty array for nothing or filled array
869 2
        $whiteListAlias = isset($whiteList, $alias)? $whiteList[$alias] ?? [] : null;
870 2
        $dataColumnMapAlias = isset($dataColumnMap, $alias)? $dataColumnMap[$alias] ?? [] : null;
871 2
        $entity->assign($data, $whiteListAlias, $dataColumnMapAlias);
872
//        $entity->setDirtyState(self::DIRTY_STATE_TRANSIENT);
873
        
874 2
        return $entity;
875
    }
876
    
877
    public function appendMessages(array $messages = [], ?string $context = null, ?int $index = null): void
878
    {
879
        assert($this instanceof ModelInterface);
880
        foreach ($messages as $message) {
881
            assert($message instanceof Message);
882
            
883
            $message->setMetaData([
884
                'index' => $this->rebuildMessageIndex($message, $index),
885
                'context' => $this->rebuildMessageContext($message, $context),
886
            ]);
887
            
888
            $this->appendMessage($message);
889
        }
890
    }
891
    
892
    /**
893
     * Appends messages from a record to the current messages container.
894
     *
895
     * @param ModelInterface|null $record The record from which to append the messages.
896
     * @param string|null $context The context in which the messages should be added. Defaults to null.
897
     * @param int|null $index The index at which the messages should be added. Defaults to 0.
898
     * 
899
     * @return void
900
     */
901
    public function appendMessagesFromRecord(?ModelInterface $record = null, string $context = null, ?int $index = null): void
902
    {
903
        if (isset($record)) {
904
            $this->appendMessages($record->getMessages(), $context, $index);
905
        }
906
    }
907
    
908
    /**
909
     * Append messages from a resultset to the current message container.
910
     *
911
     * @param ResultsetInterface|null $resultset The resultset containing the messages to be appended. If not provided, no messages will be appended.
912
     * @param string|null $context The context to assign to the appended messages. If not provided, the default context will be used.
913
     * @param int|null $index The index at which the messages should be inserted in the messages array. If not provided, the messages will be appended at the end.
914
     */
915
    public function appendMessagesFromResultset(?ResultsetInterface $resultset = null, ?string $context = null, ?int $index = null): void
916
    {
917
        if (isset($resultset)) {
918
            $this->appendMessages($resultset->getMessages(), $context, $index);
919
        }
920
    }
921
    
922
    /**
923
     * Appends messages from a record list to the current message container.
924
     *
925
     * @param iterable|null $recordList The list of records to append messages from.
926
     * @param string|null $context The context to associate with the messages.
927
     * @param int|null $index The index to use for the messages.
928
     * 
929
     * @return void
930
     */
931
    public function appendMessagesFromRecordList(?iterable $recordList = null, ?string $context = null, ?int $index = null): void
932
    {
933
        if (isset($recordList)) {
934
            foreach ($recordList as $key => $record) {
935
                $this->appendMessagesFromRecord($record, $context . '[' . $index . ']', $key);
936
            }
937
        }
938
    }
939
    
940
    /**
941
     * Rebuilds the message context.
942
     *
943
     * This method appends the given context to the previous context stored in the message metadata.
944
     * If there is no previous context, only the given context is returned.
945
     *
946
     * @param Message $message The message object whose context needs to be rebuilt.
947
     * @param string|null $context The context to be appended.
948
     *
949
     * @return string The rebuilt context
950
     */
951
    public function rebuildMessageContext(Message $message, ?string $context = null): string
952
    {
953
        $metaData = $message->getMetaData();
954
        $previousContext = $metaData['context'] ?? '';
955
        return $context . (empty($previousContext) ? '' : '.' . $previousContext);
956
    }
957
    
958
    /**
959
     * Rebuilds the message index.
960
     *
961
     * This method constructs the new message index based on the provided $index argument
962
     * and the previous index stored in the message's metadata. It returns the new index
963
     * as a string.
964
     *
965
     * @param Message $message The message object for which the index is being rebuilt.
966
     * @param int|null $index The new index to be assigned to the message. Can be null.
967
     * @return string The new index as a string
968
     */
969
    public function rebuildMessageIndex(Message $message, ?int $index = null): string
970
    {
971
        $metaData = $message->getMetaData();
972
        $previousIndex = $metaData['index'] ?? '';
973
        return $index . (empty($previousIndex) ? '' : '.' . $previousIndex);
974
    }
975
    
976
    /**
977
     * Retrieves the related records as an array.
978
     *
979
     * If $columns is provided, only the specified columns will be included in the array.
980
     * If $useGetter is set to true, it will use the getter methods of the related records.
981
     *
982
     * @param array|null $columns (optional) The columns to include in the array for each related record
983
     * @param bool $useGetter (optional) Whether to use getter methods of the related records (default: true)
984
     * 
985
     * @return array The related records as an array
986
     */
987 4
    public function relatedToArray(?array $columns = null, bool $useGetter = true): array
988
    {
989 4
        $ret = [];
990
        
991 4
        assert($this instanceof ModelInterface);
992 4
        $columnMap = $this->getModelsMetaData()->getColumnMap($this);
993
        
994 4
        foreach ($this->getDirtyRelated() as $attribute => $related) {
995
            
996
            // Map column if defined
997 2
            if ($columnMap && isset($columnMap[$attribute])) {
998
                $attributeField = $columnMap[$attribute];
999
            }
1000
            else {
1001 2
                $attributeField = $attribute;
1002
            }
1003
            
1004
            // Skip or set the related columns
1005 2
            if ($columns) {
1006
                if (!key_exists($attributeField, $columns) && !in_array($attributeField, $columns)) {
1007
                    continue;
1008
                }
1009
            }
1010 2
            $relatedColumns = $columns[$attributeField] ?? null;
1011
            
1012
            // Run toArray on related records
1013 2
            if ($related instanceof ModelInterface && method_exists($related, 'toArray')) {
1014
                $ret[$attributeField] = $related->toArray($relatedColumns, $useGetter);
1015
            }
1016 2
            elseif (is_iterable($related)) {
1017 2
                $ret[$attributeField] = [];
1018 2
                foreach ($related as $entity) {
1019 2
                    if ($entity instanceof ModelInterface && method_exists($entity, 'toArray')) {
1020 2
                        $ret[$attributeField][] = $entity->toArray($relatedColumns, $useGetter);
1021
                    }
1022
                    elseif (is_array($entity)) {
1023
                        $ret[$attributeField][] = $entity;
1024
                    }
1025
                }
1026
            }
1027
            else {
1028
                $ret[$attributeField] = null;
1029
            }
1030
        }
1031
        
1032 4
        return $ret;
1033
    }
1034
    
1035
    /**
1036
     * Overriding default phalcon getRelated in order to fix an important issue
1037
     * where the related record is being stored into the "related" property and then
1038
     * passed from the collectRelatedToSave and is mistakenly saved without the user consent
1039
     *
1040
     * @param string $alias
1041
     * @param $arguments
1042
     * @return false|int|Model\Resultset\Simple
1043
     * @throws Exception
1044
     */
1045 1
    public function getRelated(string $alias, $arguments = null)
1046
    {
1047 1
        $className = get_class($this);
1048 1
        $manager = $this->getModelsManager();
1049 1
        $lowerAlias = strtolower($alias);
1050
        
1051 1
        $relation = $manager->getRelationByAlias($className, $lowerAlias);
1052 1
        if (!$relation) {
1053
            throw new Exception(
1054
                "There is no defined relations for the model '"
1055
                . $className . "' using alias '" . $alias . "'"
1056
            );
1057
        }
1058
1059 1
        assert($relation instanceof RelationInterface);
1060 1
        assert($this instanceof ModelInterface);
1061
        
1062 1
        return $manager->getRelationRecords($relation, $this, $arguments);
1063
    }
1064
    
1065
    /**
1066
     * {@inheritDoc}
1067
     */
1068 4
    public function toArray($columns = null, $useGetter = true): array
1069
    {
1070 4
        return array_merge(parent::toArray($columns, $useGetter), $this->relatedToArray($columns, $useGetter));
1071
    }
1072
}
1073