Test Failed
Push — master ( be437e...866f6c )
by Julien
07:25
created

Relationship::postSaveRelatedRecords()   F

Complexity

Conditions 42
Paths 3397

Size

Total Lines 217
Code Lines 122

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 69
CRAP Score 205.33

Importance

Changes 2
Bugs 1 Features 0
Metric Value
cc 42
eloc 122
c 2
b 1
f 0
nc 3397
nop 3
dl 0
loc 217
ccs 69
cts 126
cp 0.5476
crap 205.33
rs 0

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