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

Relationship::getEntityFromData()   C

Complexity

Conditions 16
Paths 74

Size

Total Lines 87
Code Lines 41

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 29
CRAP Score 24.8367

Importance

Changes 0
Metric Value
cc 16
eloc 41
c 0
b 0
f 0
nc 74
nop 2
dl 0
loc 87
ccs 29
cts 43
cp 0.6744
crap 24.8367
rs 5.5666

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