Relationship::getEntityFromData()   C
last analyzed

Complexity

Conditions 17
Paths 82

Size

Total Lines 94
Code Lines 44

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 24
CRAP Score 48.6227

Importance

Changes 0
Metric Value
cc 17
eloc 44
c 0
b 0
f 0
nc 82
nop 2
dl 0
loc 94
ccs 24
cts 46
cp 0.5217
crap 48.6227
rs 5.2166

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