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

Relationship::getEntityFromData()   C

Complexity

Conditions 13
Paths 19

Size

Total Lines 81
Code Lines 37

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 26
CRAP Score 19.2573

Importance

Changes 0
Metric Value
cc 13
eloc 37
c 0
b 0
f 0
nc 19
nop 2
dl 0
loc 81
ccs 26
cts 39
cp 0.6667
crap 19.2573
rs 6.6166

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