Passed
Push — 1.0.x ( 8353d5...3a2c37 )
by Julien
21:28
created

Relationship::assignRelated()   F

Complexity

Conditions 34
Paths 276

Size

Total Lines 140
Code Lines 70

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 54
CRAP Score 44.0908

Importance

Changes 0
Metric Value
cc 34
eloc 70
c 0
b 0
f 0
nc 276
nop 3
dl 0
loc 140
ccs 54
cts 68
cp 0.7941
crap 44.0908
rs 2.3833

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\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 2
    public function getDirtyRelated(): ?array
99
    {
100 2
        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
        $entity = false;
733
        
734 1
        if ($type === Relation::HAS_ONE || $type === Relation::BELONGS_TO) {
735
            
736
            // Set value to compare
737
            if (!empty($readFields)) {
738
                
739
                foreach ($readFields as $key => $field) {
740
                    
741
                    if (empty($data[$fields[$key]])) {
742
                        
743
                        // @todo maybe remove this if
744
                        $value = $this->readAttribute($field);
745
                        if (!empty($value)) {
746
                            
747
                            // @todo maybe remove this if
748
                            $data [$fields[$key]] = $value;
749
                        }
750
                    }
751
                }
752
            }
753
        }
754
        
755
        // array_keys_exists (if $referencedFields keys exists)
756 1
        $dataKeys = array_intersect_key($data, array_flip($fields));
757
        
758
        // all keys were found
759 1
        if (count($dataKeys) === count($fields)) {
760
            
761 1
            if ($type === Relation::HAS_MANY) {
762
                
763
                $modelsMetaData = $this->getModelsMetaData();
764
                $primaryKeys = $modelsMetaData->getPrimaryKeyAttributes($this);
765
                
766
                // Force primary keys for single to many
767
                foreach ($primaryKeys as $primaryKey) {
768
                    
769
                    if (!in_array($primaryKey, $fields, true)) {
770
                        $dataKeys [$primaryKey] = $data[$primaryKey] ?? null;
771
                        $fields [] = $primaryKey;
772
                    }
773
                }
774
            }
775
            
776
            /** @var ModelInterface|null $entity */
777 1
            $entity = call_user_func($modelClass . '::findFirst', [
778 1
                'conditions' => implode_sprintf($fields, ' and ', '[' . $modelClass . '].[%s] = ?%s'),
779 1
                'bind' => array_values($dataKeys),
780 1
                'bindTypes' => array_fill(0, count($dataKeys), Column::BIND_PARAM_STR),
781 1
            ]);
782
        }
783
        
784 1
        if (!$entity) {
785 1
            $entity = new $modelClass();
786
        }
787
        
788 1
        assert($entity instanceof ModelInterface);
789
        
790
        // assign new values
791
        // can be null to bypass, empty array for nothing or filled array
792 1
        $entity->assign($data, $whiteList[$alias] ?? null, $dataColumnMap[$alias] ?? null);
793
//        $entity->setDirtyState(self::DIRTY_STATE_TRANSIENT);
794
        
795 1
        return $entity;
796
    }
797
    
798
    public function appendMessages(array $messages = [], ?string $context = null, ?int $index = null): void
799
    {
800
        assert($this instanceof ModelInterface);
801
        foreach ($messages as $message) {
802
            assert($message instanceof Message);
803
            
804
            $message->setMetaData([
805
                'index' => $this->rebuildMessageIndex($message, $index),
806
                'context' => $this->rebuildMessageContext($message, $context),
807
            ]);
808
            
809
            $this->appendMessage($message);
810
        }
811
    }
812
    
813
    /**
814
     * Appends messages from a record to the current messages container.
815
     *
816
     * @param ModelInterface|null $record The record from which to append the messages.
817
     * @param string|null $context The context in which the messages should be added. Defaults to null.
818
     * @param int|null $index The index at which the messages should be added. Defaults to 0.
819
     * 
820
     * @return void
821
     */
822
    public function appendMessagesFromRecord(?ModelInterface $record = null, string $context = null, ?int $index = null): void
823
    {
824
        if (isset($record)) {
825
            $this->appendMessages($record->getMessages(), $context, $index);
826
        }
827
    }
828
    
829
    /**
830
     * Append messages from a resultset to the current message container.
831
     *
832
     * @param ResultsetInterface|null $resultset The resultset containing the messages to be appended. If not provided, no messages will be appended.
833
     * @param string|null $context The context to assign to the appended messages. If not provided, the default context will be used.
834
     * @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.
835
     */
836
    public function appendMessagesFromResultset(?ResultsetInterface $resultset = null, ?string $context = null, ?int $index = null): void
837
    {
838
        if (isset($resultset)) {
839
            $this->appendMessages($resultset->getMessages(), $context, $index);
840
        }
841
    }
842
    
843
    /**
844
     * Appends messages from a record list to the current message container.
845
     *
846
     * @param iterable|null $recordList The list of records to append messages from.
847
     * @param string|null $context The context to associate with the messages.
848
     * @param int|null $index The index to use for the messages.
849
     * 
850
     * @return void
851
     */
852
    public function appendMessagesFromRecordList(?iterable $recordList = null, ?string $context = null, ?int $index = null): void
853
    {
854
        if (isset($recordList)) {
855
            foreach ($recordList as $key => $record) {
856
                $this->appendMessagesFromRecord($record, $context . '[' . $index . ']', $key);
857
            }
858
        }
859
    }
860
    
861
    /**
862
     * Rebuilds the message context.
863
     *
864
     * This method appends the given context to the previous context stored in the message metadata.
865
     * If there is no previous context, only the given context is returned.
866
     *
867
     * @param Message $message The message object whose context needs to be rebuilt.
868
     * @param string|null $context The context to be appended.
869
     *
870
     * @return string The rebuilt context
871
     */
872
    public function rebuildMessageContext(Message $message, ?string $context = null): string
873
    {
874
        $metaData = $message->getMetaData();
875
        $previousContext = $metaData['context'] ?? '';
876
        return $context . (empty($previousContext) ? '' : '.' . $previousContext);
877
    }
878
    
879
    /**
880
     * Rebuilds the message index.
881
     *
882
     * This method constructs the new message index based on the provided $index argument
883
     * and the previous index stored in the message's metadata. It returns the new index
884
     * as a string.
885
     *
886
     * @param Message $message The message object for which the index is being rebuilt.
887
     * @param int|null $index The new index to be assigned to the message. Can be null.
888
     * @return string The new index as a string
889
     */
890
    public function rebuildMessageIndex(Message $message, ?int $index = null): string
891
    {
892
        $metaData = $message->getMetaData();
893
        $previousIndex = $metaData['index'] ?? '';
894
        return $index . (empty($previousIndex) ? '' : '.' . $previousIndex);
895
    }
896
    
897
    /**
898
     * Retrieves the related records as an array.
899
     *
900
     * If $columns is provided, only the specified columns will be included in the array.
901
     * If $useGetter is set to true, it will use the getter methods of the related records.
902
     *
903
     * @param array|null $columns (optional) The columns to include in the array for each related record
904
     * @param bool $useGetter (optional) Whether to use getter methods of the related records (default: true)
905
     * 
906
     * @return array The related records as an array
907
     */
908 2
    public function relatedToArray(?array $columns = null, bool $useGetter = true): array
909
    {
910 2
        $ret = [];
911
        
912 2
        assert($this instanceof ModelInterface);
913 2
        $columnMap = $this->getModelsMetaData()->getColumnMap($this);
914
        
915 2
        foreach ($this->getDirtyRelated() as $attribute => $related) {
916
            
917
            // Map column if defined
918 1
            if ($columnMap && isset($columnMap[$attribute])) {
919
                $attributeField = $columnMap[$attribute];
920
            }
921
            else {
922 1
                $attributeField = $attribute;
923
            }
924
            
925
            // Skip or set the related columns
926 1
            if ($columns) {
927
                if (!key_exists($attributeField, $columns) && !in_array($attributeField, $columns)) {
928
                    continue;
929
                }
930
            }
931 1
            $relatedColumns = $columns[$attributeField] ?? null;
932
            
933
            // Run toArray on related records
934 1
            if ($related instanceof ModelInterface && method_exists($related, 'toArray')) {
935
                $ret[$attributeField] = $related->toArray($relatedColumns, $useGetter);
936
            }
937 1
            elseif (is_iterable($related)) {
938 1
                $ret[$attributeField] = [];
939 1
                foreach ($related as $entity) {
940 1
                    if ($entity instanceof ModelInterface && method_exists($entity, 'toArray')) {
941 1
                        $ret[$attributeField][] = $entity->toArray($relatedColumns, $useGetter);
942
                    }
943
                    elseif (is_array($entity)) {
944
                        $ret[$attributeField][] = $entity;
945
                    }
946
                }
947
            }
948
            else {
949
                $ret[$attributeField] = null;
950
            }
951
        }
952
        
953 2
        return $ret;
954
    }
955
    
956
    /**
957
     * Overriding default phalcon getRelated in order to fix an important issue
958
     * where the related record is being stored into the "related" property and then
959
     * passed from the collectRelatedToSave and is mistakenly saved without the user consent
960
     *
961
     * @param string $alias
962
     * @param $arguments
963
     * @return false|int|Model\Resultset\Simple
964
     * @throws Exception
965
     */
966 1
    public function getRelated(string $alias, $arguments = null)
967
    {
968 1
        $className = get_class($this);
969 1
        $manager = $this->getModelsManager();
970 1
        $lowerAlias = strtolower($alias);
971
        
972 1
        $relation = $manager->getRelationByAlias($className, $lowerAlias);
973 1
        if (!$relation) {
974
            throw new Exception(
975
                "There is no defined relations for the model '"
976
                . $className . "' using alias '" . $alias . "'"
977
            );
978
        }
979
980 1
        assert($relation instanceof RelationInterface);
981 1
        assert($this instanceof ModelInterface);
982
        
983 1
        return $manager->getRelationRecords($relation, $this, $arguments);
984
    }
985
    
986
    /**
987
     * {@inheritDoc}
988
     */
989 2
    public function toArray($columns = null, $useGetter = true): array
990
    {
991 2
        return array_merge(parent::toArray($columns, $useGetter), $this->relatedToArray($columns, $useGetter));
992
    }
993
}
994