Relationship::postSaveRelatedRecordsAfter()   B
last analyzed

Complexity

Conditions 8
Paths 37

Size

Total Lines 37
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 9

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 8
eloc 19
nc 37
nop 3
dl 0
loc 37
ccs 15
cts 20
cp 0.75
crap 9
rs 8.4444
c 1
b 0
f 0
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