Passed
Push — master ( 91844f...3ae4be )
by Julien
07:39
created

Relationship   F

Complexity

Total Complexity 164

Size/Duplication

Total Lines 963
Duplicated Lines 0 %

Test Coverage

Coverage 53.97%

Importance

Changes 5
Bugs 1 Features 0
Metric Value
eloc 388
c 5
b 1
f 0
dl 0
loc 963
ccs 224
cts 415
cp 0.5397
rs 2
wmc 164

28 Methods

Rating   Name   Duplication   Size   Complexity  
C preSaveRelatedRecords() 0 61 12
B postSaveRelatedRecordsAfter() 0 28 7
C postSaveRelatedThroughAfter() 0 76 13
F postSaveRelatedRecords() 0 214 42
A setDirtyRelatedAlias() 0 3 1
A getDirtyRelatedAlias() 0 3 1
A setDirtyRelated() 0 3 1
A setRelationshipContext() 0 3 1
A getDirtyRelated() 0 3 1
A getKeepMissingRelatedAlias() 0 3 1
A getKeepMissingRelated() 0 3 1
A hasDirtyRelated() 0 3 1
A assign() 0 4 1
A setKeepMissingRelated() 0 3 1
A setKeepMissingRelatedAlias() 0 3 1
A hasDirtyRelatedAlias() 0 3 1
A getRelationshipContext() 0 3 1
F assignRelated() 0 140 34
A toArray() 0 3 1
A appendMessagesFromRecord() 0 4 2
A rebuildMessageIndex() 0 5 2
A appendMessagesFromResultset() 0 4 2
A getRelated() 0 18 2
C relatedToArray() 0 46 14
A appendMessagesFromRecordList() 0 5 3
A appendMessages() 0 12 2
C getEntityFromData() 0 81 13
A rebuildMessageContext() 0 5 2

How to fix   Complexity   

Complex Class

Complex classes like Relationship often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Relationship, and based on these observations, apply Extract Interface, too.

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