Test Failed
Push — master ( 537660...c4a471 )
by Julien
06:00
created

Relationship::postSaveRelatedRecordsAfter()   B

Complexity

Conditions 7
Paths 21

Size

Total Lines 27
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 56

Importance

Changes 0
Metric Value
cc 7
eloc 14
c 0
b 0
f 0
nc 21
nop 2
dl 0
loc 27
ccs 0
cts 15
cp 0
crap 56
rs 8.8333
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;
13
14
use Exception;
15
use Phalcon\Db\Adapter\AdapterInterface;
16
use Phalcon\Db\Column;
17
use Phalcon\Messages\Message;
18
use Phalcon\Mvc\Model as PhalconModel;
19
use Phalcon\Mvc\Model\Relation;
20
use Phalcon\Mvc\Model\RelationInterface;
21
use Phalcon\Mvc\Model\ResultsetInterface;
22
use Phalcon\Mvc\ModelInterface;
23
use Zemit\Mvc\Model\AbstractTrait\AbstractEntity;
24
use Zemit\Mvc\Model\AbstractTrait\AbstractMetaData;
25
use Zemit\Mvc\Model\AbstractTrait\AbstractModelsManager;
26
27
/**
28
 * Allow to automagically save relationship
29
 */
30
trait Relationship
31
{
32
    use AbstractEntity;
33
    use AbstractMetaData;
34
    use AbstractModelsManager;
35
    
36
    private array $keepMissingRelated = [];
37
    
38
    private string $relationshipContext = '';
39
    
40
    protected $dirtyRelated;
41
    
42
    /**
43
     * Set the missing related configuration list
44
     */
45
    public function setKeepMissingRelated(array $keepMissingRelated): void
46
    {
47
        $this->keepMissingRelated = $keepMissingRelated;
48
    }
49
    
50
    /**
51
     * Return the missing related configuration list
52
     */
53
    public function getKeepMissingRelated(): array
54
    {
55
        return $this->keepMissingRelated;
56
    }
57
    
58
    /**
59
     * Return the keepMissing configuration for a specific relationship alias
60
     */
61
    public function getKeepMissingRelatedAlias(string $alias): bool
62
    {
63
        return (bool)$this->keepMissingRelated[$alias];
64
    }
65
    
66
    /**
67
     * Set the keepMissing configuration for a specific relationship alias
68
     */
69
    public function setKeepMissingRelatedAlias(string $alias, bool $keepMissing): void
70
    {
71
        $this->keepMissingRelated[$alias] = $keepMissing;
72
    }
73
    
74
    /**
75
     * Get the current relationship context
76
     */
77
    public function getRelationshipContext(): string
78
    {
79
        return $this->relationshipContext;
80
    }
81
    
82
    /**
83
     * Set the current relationship context
84
     */
85
    public function setRelationshipContext(string $context): void
86
    {
87
        $this->relationshipContext = $context;
88
    }
89
    
90
    /**
91
     * Return the dirtyRelated entities
92
     */
93
    public function getDirtyRelated(): ?array
94
    {
95
        return $this->dirtyRelated;
96
    }
97
    
98
    /**
99
     * Set the dirtyRelated entities
100
     */
101
    public function setDirtyRelated(?array $dirtyRelated = null): void
102
    {
103
        $this->dirtyRelated = $dirtyRelated;
104
    }
105
    
106
    /**
107
     * Return the dirtyRelated entities
108
     */
109
    public function getDirtyRelatedAlias(string $alias)
110
    {
111
        return $this->dirtyRelated[$alias];
112
    }
113
    
114
    /**
115
     * Return the dirtyRelated entities
116
     */
117
    public function setDirtyRelatedAlias(string $alias, $value): void
118
    {
119
        $this->dirtyRelated[$alias] = $value;
120
    }
121
    
122
    /**
123
     * Check whether the current entity has dirty related or not
124
     */
125
    public function hasDirtyRelated(): bool
126
    {
127
        return (bool)count($this->dirtyRelated);
128
    }
129
    
130
    /**
131
     * Check whether the current entity has dirty related or not
132
     */
133
    public function hasDirtyRelatedAlias(string $alias): bool
134
    {
135
        return isset($this->dirtyRelated[$alias]);
136
    }
137
    
138
    /**
139
     * {@inheritDoc}}
140
     * @throws Exception
141
     */
142
    public function assign(array $data, $whiteList = null, $dataColumnMap = null): ModelInterface
143
    {
144
        $this->assignRelated(...func_get_args());
0 ignored issues
show
Bug introduced by
func_get_args() is expanded, but the parameter $data of Zemit\Mvc\Model\Relationship::assignRelated() does not expect variable arguments. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

144
        $this->assignRelated(/** @scrutinizer ignore-type */ ...func_get_args());
Loading history...
145
        return parent::assign(...func_get_args());
146
    }
147
    
148
    /**
149
     * Assign related
150
     *
151
     * Single
152
     * [alias => new Alias()] // create new alias
153
     *
154
     * Many
155
     * [alias => [new Alias()]] // create new alias
156
     * [alias => [1, 2, 3, 4]] // append / merge 1, 2, 3, 4
157
     * [alias => [false, 1, 2, 4]]; // delete 3
158
     *
159
     * @param array $data
160
     * @param null $whiteList
0 ignored issues
show
Documentation Bug introduced by
Are you sure the doc-type for parameter $whiteList is correct as it would always require null to be passed?
Loading history...
161
     * @param null $dataColumnMap
0 ignored issues
show
Documentation Bug introduced by
Are you sure the doc-type for parameter $dataColumnMap is correct as it would always require null to be passed?
Loading history...
162
     *
163
     * @return $this|ModelInterface
164
     * @throws Exception
165
     */
166
    public function assignRelated(array $data, $whiteList = null, $dataColumnMap = null): ModelInterface
167
    {
168
        // no data, nothing to do
169
        if (empty($data)) {
170
            return $this;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this returns the type Zemit\Mvc\Model\Relationship which is incompatible with the type-hinted return Phalcon\Mvc\ModelInterface.
Loading history...
171
        }
172
        
173
        // Get the current model class name
174
        $modelClass = get_class($this);
175
        
176
        $modelsManager = $this->getModelsManager();
177
        
178
        foreach ($data as $alias => $relationData) {
179
            
180
            $relation = $modelsManager->getRelationByAlias($modelClass, $alias);
181
            
182
            // @todo add a recursive whiteList check & columnMap support
183
            if ($relation) {
184
                $type = $relation->getType();
185
                
186
                $fields = $relation->getFields();
187
                $fields = is_array($fields) ? $fields : [$fields];
188
                
189
                $referencedFields = $relation->getReferencedFields();
190
                $referencedFields = is_array($referencedFields) ? $referencedFields : [$referencedFields];
191
                
192
                $referencedModel = $relation->getReferencedModel();
193
                $assign = null;
194
                
195
                if (is_int($relationData) || is_string($relationData)) {
196
                    $relationData = [$referencedFields[0] => $relationData];
197
                }
198
                
199
                if ($relationData instanceof ModelInterface) {
200
                    if ($relationData instanceof $referencedModel) {
201
                        $assign = $relationData;
202
                    }
203
                    else {
204
                        throw new Exception('Instance of `' . get_class($relationData) . '` received on model `' . $modelClass . '` in alias `' . $alias . ', expected instance of `' . $referencedModel . '`', 400);
205
                    }
206
                }
207
                
208
                // array | traversable | resultset
209
                elseif (is_array($relationData) || $relationData instanceof \Traversable) {
210
                    $assign = [];
211
                    
212
                    $getEntityParams = [
213
                        'alias' => $alias,
214
                        'fields' => $referencedFields,
215
                        'modelClass' => $referencedModel,
216
                        'readFields' => $fields,
217
                        'type' => $type,
218
                        'whiteList' => $whiteList,
219
                        'dataColumnMap' => $dataColumnMap,
220
                    ];
221
                    
222
                    if (empty($relationData) && !in_array($type, [Relation::HAS_MANY_THROUGH, Relation::HAS_MANY])) {
223
                        $assign = $this->getEntityFromData($relationData, $getEntityParams);
224
                    }
225
                    else {
226
                        foreach ($relationData as $traversedKey => $traversedData) {
227
                            // Array of things
228
                            if (is_int($traversedKey)) {
229
                                $entity = null;
230
                                
231
                                // Using bool as behaviour to delete missing relationship or keep them
232
                                // @TODO find a better way
233
                                // if [alias => [true, ...]
234
                                if ($traversedData === 'false') {
235
                                    $traversedData = false;
236
                                }
237
                                if ($traversedData === 'true') {
238
                                    $traversedData = true;
239
                                }
240
                                
241
                                if (is_bool($traversedData)) {
242
                                    $this->keepMissingRelated[$alias] = $traversedData;
243
                                    continue;
244
                                }
245
                                
246
                                // if [alias => [1, 2, 3, ...]]
247
                                if (is_int($traversedData) || is_string($traversedData)) {
248
                                    $traversedData = [$referencedFields[0] => $traversedData];
249
                                }
250
                                
251
                                // if [alias => AliasModel]
252
                                if ($traversedData instanceof ModelInterface) {
253
                                    if ($traversedData instanceof $referencedModel) {
254
                                        $entity = $traversedData;
255
                                    }
256
                                    else {
257
                                        throw new Exception('Instance of `' . get_class($traversedData) . '` received on model `' . $modelClass . '` in alias `' . $alias . ', expected instance of `' . $referencedModel . '`', 400);
258
                                    }
259
                                }
260
                                
261
                                // if [alias => [[id => 1], [id => 2], [id => 3], ....]]
262
                                else if (is_array($traversedData) || $traversedData instanceof \Traversable) {
263
                                    $entity = $this->getEntityFromData((array)$traversedData, $getEntityParams);
264
                                }
265
                                
266
                                if ($entity) {
267
                                    $assign [] = $entity;
268
                                }
269
                            }
270
                            
271
                            // if [alias => [id => 1]]
272
                            else {
273
                                $assign = $this->getEntityFromData((array)$relationData, $getEntityParams);
274
                                break;
275
                            }
276
                        }
277
                    }
278
                }
279
                
280
                // we got something to assign
281
                $keepMissingRelationship = $this->keepMissingRelated[$alias] ?? null;
282
                if (!empty($assign) || $keepMissingRelationship === false) {
283
                    
284
                    $assign = is_array($assign) ? array_values(array_filter($assign)) : $assign;
285
                    $this->{$alias} = $assign;
286
                    
287
                    // fix to force recursive parent save from children entities within _preSaveRelatedRecords method
288
                    if ($this->{$alias} && $this->{$alias} instanceof ModelInterface) {
289
                        $this->{$alias}->setDirtyState(self::DIRTY_STATE_TRANSIENT);
0 ignored issues
show
Bug introduced by
The constant Zemit\Mvc\Model\Relation...::DIRTY_STATE_TRANSIENT was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
290
                    }
291
                    
292
                    $this->dirtyRelated[mb_strtolower($alias)] = $this->{$alias} ?? false;
293
                    if (empty($assign)) {
294
                        $this->dirtyRelated[mb_strtolower($alias)] = [];
295
                    }
296
                }
297
            }
298
        }
299
        
300
        return $this;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this returns the type Zemit\Mvc\Model\Relationship which is incompatible with the type-hinted return Phalcon\Mvc\ModelInterface.
Loading history...
301
    }
302
    
303
    /**
304
     * Saves related records that must be stored prior to save the master record
305
     * Refactored based on the native cphalcon version, so we can support :
306
     * - combined keys on relationship definition
307
     * - relationship context within the model messages based on the alias definition
308
     * @throws Exception
309
     */
310
    protected function preSaveRelatedRecords(AdapterInterface $connection, $related): bool
311
    {
312
        $nesting = false;
313
        
314
        $connection->begin($nesting);
315
        $className = get_class($this);
316
        
317
        $modelsManager = $this->getModelsManager();
318
        
319
        foreach ($related as $alias => $record) {
320
            $relation = $modelsManager->getRelationByAlias($className, $alias);
321
            
322
            if ($relation) {
323
                $type = $relation->getType();
324
                
325
                // Only belongsTo are stored before save the master record
326
                if ($type === Relation::BELONGS_TO) {
327
                    
328
                    // Belongs-to relation: We only support model interface
329
                    if (!($record instanceof ModelInterface)) {
330
                        $connection->rollback($nesting);
331
                        throw new Exception(
332
                            'Instance of `' . get_class($record) . '` received on model `' . $className . '` in alias `' . $alias .
333
                            ', expected instance of `' . ModelInterface::class . '` as part of the belongs-to relation',
334
                            400
335
                        );
336
                    }
337
                    
338
                    $relationFields = $relation->getFields();
339
                    $relationFields = is_array($relationFields) ? $relationFields : [$relationFields];
340
                    
341
                    $referencedFields = $relation->getReferencedFields();
342
                    $referencedFields = is_array($referencedFields) ? $referencedFields : [$referencedFields];
343
                    
344
                    // Set the relationship context
345
                    // @todo review this
346
                    $currentRelationshipContext = $this->getRelationshipContext();
347
                    $relationshipPrefix = !empty($currentRelationshipContext)? $currentRelationshipContext . '.' : '';
348
                    $record->setRelationshipContext($relationshipPrefix . $alias);
0 ignored issues
show
Bug introduced by
The method setRelationshipContext() does not exist on Phalcon\Mvc\ModelInterface. It seems like you code against a sub-type of Phalcon\Mvc\ModelInterface such as Phalcon\Mvc\Model or Zemit\Models\Setting or Zemit\Models\Category or Zemit\Models\Audit or Zemit\Models\UserGroup or Zemit\Models\User or Zemit\Models\Field or Zemit\Models\Page or Zemit\Models\Log or Zemit\Models\File or Zemit\Models\Role or Zemit\Models\GroupRole or Zemit\Models\Template or Zemit\Models\AuditDetail or Zemit\Models\UserType or Zemit\Models\Post or Zemit\Models\PostCategory or Zemit\Models\Session or Zemit\Models\TranslateField or Zemit\Models\GroupType or Zemit\Models\Translate or Zemit\Models\Email or Zemit\Models\Data or Zemit\Models\Group or Zemit\Models\Lang or Zemit\Models\EmailFile or Zemit\Models\TranslateTable or Zemit\Models\SiteLang or Zemit\Models\UserRole or Zemit\Models\Flag or Zemit\Models\Menu or Zemit\Models\Site or Zemit\Models\Type or Zemit\Models\Channel or Zemit\Models\Meta. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

348
                    $record->/** @scrutinizer ignore-call */ 
349
                             setRelationshipContext($relationshipPrefix . $alias);
Loading history...
349
                    
350
                    /**
351
                     * If dynamic update is enabled, saving the record must not take any action
352
                     * Only save if the model is dirty to prevent circular relations causing an infinite loop
353
                     */
354
                    if ($record->getDirtyState() !== PhalconModel::DIRTY_STATE_PERSISTENT && !$record->save()) {
355
                        $this->appendMessagesFromRecord($record, $alias);
356
                        $connection->rollback($nesting);
357
                        return false;
358
                    }
359
                    
360
                    // assign referenced value to the current model
361
                    foreach ($referencedFields as $key => $referencedField) {
362
                        $this->{$relationFields[$key]} = $record->readAttribute($referencedField);
0 ignored issues
show
Bug introduced by
The method readAttribute() does not exist on Phalcon\Mvc\ModelInterface. It seems like you code against a sub-type of Phalcon\Mvc\ModelInterface such as Phalcon\Mvc\Model or Zemit\Models\Setting or Zemit\Models\Category or Zemit\Models\Audit or Zemit\Models\UserGroup or Zemit\Models\User or Zemit\Models\Field or Zemit\Models\Page or Zemit\Models\Log or Zemit\Models\File or Zemit\Models\Role or Zemit\Models\GroupRole or Zemit\Models\Template or Zemit\Models\AuditDetail or Zemit\Models\UserType or Zemit\Models\Post or Zemit\Models\PostCategory or Zemit\Models\Session or Zemit\Models\TranslateField or Zemit\Models\GroupType or Zemit\Models\Translate or Zemit\Models\Email or Zemit\Models\Data or Zemit\Models\Group or Zemit\Models\Lang or Zemit\Models\EmailFile or Zemit\Models\TranslateTable or Zemit\Models\SiteLang or Zemit\Models\UserRole or Zemit\Models\Flag or Zemit\Models\Menu or Zemit\Models\Site or Zemit\Models\Type or Zemit\Models\Channel or Zemit\Models\Meta. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

362
                        /** @scrutinizer ignore-call */ 
363
                        $this->{$relationFields[$key]} = $record->readAttribute($referencedField);
Loading history...
363
                    }
364
                }
365
            }
366
        }
367
        
368
        return true;
369
    }
370
    
371
    /**
372
     * NOTE: we need this, this behaviour only happens:
373
     * - in many to many nodes
374
     * Fix uniqueness on combined keys in node entities, and possibly more...
375
     * @link https://forum.phalconphp.com/discussion/2190/many-to-many-expected-behaviour
376
     * @link http://stackoverflow.com/questions/23374858/update-a-records-n-n-relationships
377
     * @link https://github.com/phalcon/cphalcon/issues/2871
378
     */
379
    protected function postSaveRelatedRecords(AdapterInterface $connection, $related = null): bool
380
    {
381
        $nesting = false;
382
        
383
        if ($related) {
384
            foreach ($related as $lowerCaseAlias => $assign) {
385
                
386
                $modelsManager = $this->getModelsManager();
387
                $relation = $modelsManager->getRelationByAlias(get_class($this), $lowerCaseAlias);
388
                
389
                if (!$relation) {
390
                    if (is_array($assign)) {
391
                        $connection->rollback($nesting);
392
                        throw new Exception("There are no defined relations for the model '" . get_class($this) . "' using alias '" . $lowerCaseAlias . "'");
393
                    }
394
                }
395
                
396
                /**
397
                 * Discard belongsTo relations
398
                 */
399
                if ($relation->getType() === Relation::BELONGS_TO) {
400
                    continue;
401
                }
402
                
403
                if (!is_array($assign) && !is_object($assign)) {
404
                    $connection->rollback($nesting);
405
                    throw new Exception('Only objects/arrays can be stored as part of has-many/has-one/has-one-through/has-many-to-many relations');
406
                }
407
                
408
                /**
409
                 * Custom logic for many-to-many relationships
410
                 */
411
                if ($relation->getType() === Relation::HAS_MANY_THROUGH) {
412
                    $originFields = $relation->getFields();
413
                    $originFields = is_array($originFields) ? $originFields : [$originFields];
414
                    
415
                    $intermediateModelClass = $relation->getIntermediateModel();
416
                    $intermediateModel = $modelsManager->load($intermediateModelClass);
417
                    
418
                    $intermediateFields = $relation->getIntermediateFields();
419
                    $intermediateFields = is_array($intermediateFields) ? $intermediateFields : [$intermediateFields];
420
                    
421
                    $intermediateReferencedFields = $relation->getIntermediateReferencedFields();
422
                    $intermediateReferencedFields = is_array($intermediateReferencedFields) ? $intermediateReferencedFields : [$intermediateReferencedFields];
423
                    
424
                    $referencedFields = $relation->getReferencedFields();
425
                    $referencedFields = is_array($referencedFields) ? $referencedFields : [$referencedFields];
426
                    
427
                    $intermediatePrimaryKeyAttributes = $intermediateModel->getModelsMetaData()->getPrimaryKeyAttributes($intermediateModel);
428
                    $intermediateBindTypes = $intermediateModel->getModelsMetaData()->getBindTypes($intermediateModel);
429
                    
430
                    // get current model bindings
431
                    $originBind = [];
432
                    foreach ($originFields as $originField) {
433
                        $originBind [] = $this->readAttribute($originField);
434
//                        $originBind [] = $this->{'get' . ucfirst($originField)} ?? $this->$originField ?? null;
435
                    }
436
                    
437
                    $nodeIdListToKeep = [];
438
                    foreach ($assign as $key => $entity) {
439
                        assert($entity instanceof ModelInterface);
440
                        
441
                        // get referenced model bindings
442
                        $referencedBind = [];
443
                        foreach ($referencedFields as $referencedField) {
444
                            $referencedBind [] = $entity->readAttribute($referencedField);
445
                        }
446
                        
447
                        $nodeEntity = $intermediateModel::findFirst([
448
                            'conditions' => implode_sprintf(array_merge($intermediateFields, $intermediateReferencedFields), ' and ', '[' . $intermediateModelClass . '].[%s] = ?%s'),
449
                            'bind' => [...$originBind, ...$referencedBind],
450
                            'bindTypes' => array_fill(0, count($intermediateFields) + count($intermediateReferencedFields), Column::BIND_PARAM_STR),
451
                        ]);
452
                        
453
                        if ($nodeEntity) {
454
                            $buildPrimaryKey = [];
455
                            foreach ($intermediatePrimaryKeyAttributes as $intermediatePrimaryKey => $intermediatePrimaryKeyAttribute) {
456
                                $buildPrimaryKey [] = $nodeEntity->readAttribute($intermediatePrimaryKeyAttribute);
457
                            }
458
                            $nodeIdListToKeep [] = implode('.', $buildPrimaryKey);
459
                            
460
                            // Restoring node entities if previously soft deleted
461
                            if (method_exists($nodeEntity, 'restore') && method_exists($nodeEntity, 'isDeleted')) {
462
                                if ($nodeEntity->isDeleted() && !$nodeEntity->restore()) {
463
                                    $this->appendMessagesFromRecord($nodeEntity, $lowerCaseAlias, $key);
464
                                    $this->appendMessage(new Message('Unable to restored previously deleted related node `' . $intermediateModelClass . '`', $lowerCaseAlias, 'Bad Request', 400));
0 ignored issues
show
Bug introduced by
The method appendMessage() does not exist on Zemit\Mvc\Model\Relationship. Did you maybe mean appendMessagesFromResultset()? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

464
                                    $this->/** @scrutinizer ignore-call */ 
465
                                           appendMessage(new Message('Unable to restored previously deleted related node `' . $intermediateModelClass . '`', $lowerCaseAlias, 'Bad Request', 400));

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
465
                                    $connection->rollback($nesting);
466
                                    return false;
467
                                }
468
                            }
469
                            
470
                            // save edge record
471
                            if (!$entity->save()) {
472
                                $this->appendMessagesFromRecord($entity, $lowerCaseAlias, $key);
473
                                $this->appendMessage(new Message('Unable to save related entity `' . $intermediateModelClass . '`', $lowerCaseAlias, 'Bad Request', 400));
474
                                $connection->rollback($nesting);
475
                                return false;
476
                            }
477
                            
478
                            // remove it
479
                            unset($assign[$key]);
480
                            unset($related[$lowerCaseAlias][$key]);
481
482
//                            // add to assign
483
//                            $nodeAssign [] = $nodeEntity;
484
                        }
485
                    }
486
                    
487
                    if (!($this->keepMissingRelated[$lowerCaseAlias] ?? true)) {
488
                        $idBindType = count($intermediatePrimaryKeyAttributes) === 1 ? $intermediateBindTypes[$intermediatePrimaryKeyAttributes[0]] : Column::BIND_PARAM_STR;
489
                        $nodeIdListToKeep = empty($nodeIdListToKeep)? [0] : array_keys(array_flip($nodeIdListToKeep));
490
                        $nodeEntityToDeleteResultset = $intermediateModel::find([
491
                            'conditions' => implode_sprintf(array_merge($intermediateFields), ' and ', '[' . $intermediateModelClass . '].[%s] = ?%s')
492
                                . ' and concat(' . implode_sprintf($intermediatePrimaryKeyAttributes, ', \'.\', ', '[' . $intermediateModelClass . '].[%s]') . ') not in ({id:array})',
493
                            'bind' => [...$originBind, 'id' => $nodeIdListToKeep],
494
                            'bindTypes' => [...array_fill(0, count($intermediateFields), Column::BIND_PARAM_STR), 'id' => $idBindType],
495
                        ]);
496
                        
497
                        // delete missing related
498
                        if (!$nodeEntityToDeleteResultset->delete()) {
499
                            $this->appendMessagesFromResultset($nodeEntityToDeleteResultset, $lowerCaseAlias);
500
                            $this->appendMessage(new Message('Unable to delete node entity `' . $intermediateModelClass . '`', $lowerCaseAlias, 'Bad Request', 400));
501
                            $connection->rollback($nesting);
502
                            return false;
503
                        }
504
                    }
505
                }
506
                
507
                $relationFields = $relation->getFields();
508
                $relationFields = is_array($relationFields) ? $relationFields : [$relationFields];
509
                
510
                foreach ($relationFields as $relationField) {
511
                    if (!property_exists($this, $relationField)) {
512
                        $connection->rollback($nesting);
513
                        throw new Exception("The column '" . $relationField . "' needs to be present in the model");
514
                    }
515
                }
516
                
517
                $relatedRecords = $assign instanceof ModelInterface ? [$assign] : $assign;
518
                
519
                if ($this->postSaveRelatedThroughAfter($relation, $relatedRecords) === false) {
0 ignored issues
show
Bug introduced by
It seems like $relation can also be of type true; however, parameter $relation of Zemit\Mvc\Model\Relation...veRelatedThroughAfter() does only seem to accept Phalcon\Mvc\Model\RelationInterface, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

519
                if ($this->postSaveRelatedThroughAfter(/** @scrutinizer ignore-type */ $relation, $relatedRecords) === false) {
Loading history...
520
                    $this->appendMessage(new Message('Unable to save related through after', $lowerCaseAlias, 'Bad Request', 400));
521
                    $connection->rollback($nesting);
522
                    return false;
523
                }
524
                
525
                if ($this->postSaveRelatedRecordsAfter($relation, $relatedRecords) === false) {
0 ignored issues
show
Bug introduced by
It seems like $relation can also be of type true; however, parameter $relation of Zemit\Mvc\Model\Relation...veRelatedRecordsAfter() does only seem to accept Phalcon\Mvc\Model\RelationInterface, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

525
                if ($this->postSaveRelatedRecordsAfter(/** @scrutinizer ignore-type */ $relation, $relatedRecords) === false) {
Loading history...
526
                    $this->appendMessage(new Message('Unable to save related records after', $lowerCaseAlias, 'Bad Request', 400));
527
                    $connection->rollback($nesting);
528
                    return false;
529
                }
530
            }
531
        }
532
        
533
        /**
534
         * Commit the implicit transaction
535
         */
536
        $connection->commit($nesting);
537
        return true;
538
    }
539
    
540
    public function postSaveRelatedRecordsAfter(RelationInterface $relation, $relatedRecords): ?bool
541
    {
542
        if ($relation->isThrough()) {
543
            return null;
544
        }
545
        
546
        $lowerCaseAlias = $relation->getOption('alias');
547
        
548
        $relationFields = $relation->getFields();
549
        $relationFields = is_array($relationFields) ? $relationFields : [$relationFields];
550
        
551
        $referencedFields = $relation->getReferencedFields();
552
        $referencedFields = is_array($referencedFields) ? $referencedFields : [$referencedFields];
553
        
554
        foreach ($relatedRecords as $recordAfter) {
555
            foreach ($relationFields as $key => $relationField) {
556
                $recordAfter->writeAttribute($referencedFields[$key], $this->readAttribute($relationField));
557
            }
558
            
559
            // Save the record and get messages
560
            if (!$recordAfter->save()) {
561
                $this->appendMessagesFromRecord($recordAfter, $lowerCaseAlias);
562
                return false;
563
            }
564
        }
565
        
566
        return true;
567
    }
568
    
569
    public function postSaveRelatedThroughAfter(RelationInterface $relation, $relatedRecords): ?bool
570
    {
571
        if (!$relation->isThrough()) {
572
            return null;
573
        }
574
        
575
        $modelsManager = $this->getModelsManager();
576
        $lowerCaseAlias = $relation->getOption('alias');
577
        
578
        $relationFields = $relation->getFields();
579
        $relationFields = is_array($relationFields) ? $relationFields : [$relationFields];
580
        
581
        $referencedFields = $relation->getReferencedFields();
582
        $referencedFields = is_array($referencedFields) ? $referencedFields : [$referencedFields];
583
        
584
        $intermediateModelClass = $relation->getIntermediateModel();
585
        
586
        $intermediateFields = $relation->getIntermediateFields();
587
        $intermediateFields = is_array($intermediateFields) ? $intermediateFields : [$intermediateFields];
588
        
589
        $intermediateReferencedFields = $relation->getIntermediateReferencedFields();
590
        $intermediateReferencedFields = is_array($intermediateReferencedFields) ? $intermediateReferencedFields : [$intermediateReferencedFields];
591
        
592
        foreach ($relatedRecords as $relatedAfterKey => $recordAfter) {
593
            // Save the record and get messages
594
            if (!$recordAfter->save()) {
595
                $this->appendMessagesFromRecord($recordAfter, $lowerCaseAlias, $relatedAfterKey);
596
                return false;
597
            }
598
            
599
            // Create a new instance of the intermediate model
600
            $intermediateModel = $modelsManager->load($intermediateModelClass);
601
            
602
            /**
603
             *  Has-one-through relations can only use one intermediate model.
604
             *  If it already exists, it can be updated with the new referenced key.
605
             */
606
            if ($relation->getType() === Relation::HAS_ONE_THROUGH) {
607
                $bind = [];
608
                foreach ($relationFields as $relationField) {
609
                    $bind[] = $this->readAttribute($relationField);
610
                }
611
                
612
                $existingIntermediateModel = $intermediateModel::findFirst([
613
                    'conditions' => implode_sprintf($intermediateFields, ' and ', '[' . $intermediateModelClass . '].[%s] = ?%s'),
614
                    'bind' => $bind,
615
                    'bindTypes' => array_fill(0, count($bind), Column::BIND_PARAM_STR),
616
                ]);
617
                
618
                if ($existingIntermediateModel) {
619
                    $intermediateModel = $existingIntermediateModel;
620
                }
621
            }
622
            
623
            // Set intermediate model columns values
624
            foreach ($relationFields as $relationFieldKey => $relationField) {
625
                $intermediateModel->writeAttribute($intermediateFields[$relationFieldKey], $this->readAttribute($relationField));
0 ignored issues
show
Bug introduced by
The method writeAttribute() does not exist on Phalcon\Mvc\ModelInterface. It seems like you code against a sub-type of Phalcon\Mvc\ModelInterface such as Phalcon\Mvc\Model or Zemit\Models\Setting or Zemit\Models\Category or Zemit\Models\Audit or Zemit\Models\UserGroup or Zemit\Models\User or Zemit\Models\Field or Zemit\Models\Page or Zemit\Models\Log or Zemit\Models\File or Zemit\Models\Role or Zemit\Models\GroupRole or Zemit\Models\Template or Zemit\Models\AuditDetail or Zemit\Models\UserType or Zemit\Models\Post or Zemit\Models\PostCategory or Zemit\Models\Session or Zemit\Models\TranslateField or Zemit\Models\GroupType or Zemit\Models\Translate or Zemit\Models\Email or Zemit\Models\Data or Zemit\Models\Group or Zemit\Models\Lang or Zemit\Models\EmailFile or Zemit\Models\TranslateTable or Zemit\Models\SiteLang or Zemit\Models\UserRole or Zemit\Models\Flag or Zemit\Models\Menu or Zemit\Models\Site or Zemit\Models\Type or Zemit\Models\Channel or Zemit\Models\Meta. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

625
                $intermediateModel->/** @scrutinizer ignore-call */ 
626
                                    writeAttribute($intermediateFields[$relationFieldKey], $this->readAttribute($relationField));
Loading history...
626
                $intermediateValue = $recordAfter->readAttribute($referencedFields[$relationFieldKey]);
627
                $intermediateModel->writeAttribute($intermediateReferencedFields[$relationFieldKey], $intermediateValue);
628
            }
629
            
630
            // Save the record and get messages
631
            if (!$intermediateModel->save()) {
632
                $this->appendMessagesFromRecord($intermediateModel, $lowerCaseAlias);
633
                $this->appendMessage(new Message('Unable to save intermediate model `' . $intermediateModelClass . '`', $lowerCaseAlias, 'Bad Request', 400));
634
                return false;
635
            }
636
        }
637
        
638
        return true;
639
    }
640
    
641
    /**
642
     * Get an entity from data
643
     */
644
    public function getEntityFromData(array $data, array $configuration = []): ModelInterface
645
    {
646
        $alias = $configuration['alias'] ?? null;
647
        $fields = $configuration['fields'] ?? null;
648
        $modelClass = $configuration['modelClass'] ?? null;
649
        $readFields = $configuration['readFields'] ?? null;
650
        $type = $configuration['type'] ?? null;
651
        $whiteList = $configuration['whiteList'] ?? null;
652
        $dataColumnMap = $configuration['dataColumnMap'] ?? null;
653
        
654
        $entity = false;
655
        
656
        if ($type === Relation::HAS_ONE || $type === Relation::BELONGS_TO) {
657
            
658
            // Set value to compare
659
            if (!empty($readFields)) {
660
                
661
                foreach ($readFields as $key => $field) {
662
                    
663
                    if (empty($data[$fields[$key]])) {
664
                        
665
                        // @todo maybe remove this if
666
                        $value = $this->readAttribute($field);
667
                        if (!empty($value)) {
668
                            
669
                            // @todo maybe remove this if
670
                            $data [$fields[$key]] = $value;
671
                        }
672
                    }
673
                }
674
            }
675
        }
676
        
677
        // array_keys_exists (if $referencedFields keys exists)
678
        $dataKeys = array_intersect_key($data, array_flip($fields));
679
        
680
        // all keys were found
681
        if (count($dataKeys) === count($fields)) {
682
            
683
            if ($type === Relation::HAS_MANY) {
684
                
685
                $modelsMetaData = $this->getModelsMetaData();
686
                $primaryKeys = $modelsMetaData->getPrimaryKeyAttributes($this);
0 ignored issues
show
Bug introduced by
$this of type Zemit\Mvc\Model\Relationship is incompatible with the type Phalcon\Mvc\ModelInterface expected by parameter $model of Phalcon\Mvc\Model\MetaDa...tPrimaryKeyAttributes(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

686
                $primaryKeys = $modelsMetaData->getPrimaryKeyAttributes(/** @scrutinizer ignore-type */ $this);
Loading history...
687
                
688
                // Force primary keys for single to many
689
                foreach ($primaryKeys as $primaryKey) {
690
                    
691
                    if (!in_array($primaryKey, $fields, true)) {
692
                        $dataKeys [$primaryKey] = $data[$primaryKey] ?? null;
693
                        $fields [] = $primaryKey;
694
                    }
695
                }
696
            }
697
            
698
            /** @var ModelInterface|string $modelClass */
699
            $entity = $modelClass::findFirst([
700
                'conditions' => implode_sprintf($fields, ' and ', '[' . $modelClass . '].[%s] = ?%s'),
0 ignored issues
show
Bug introduced by
Are you sure $modelClass of type Phalcon\Mvc\ModelInterface|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

700
                'conditions' => implode_sprintf($fields, ' and ', '[' . /** @scrutinizer ignore-type */ $modelClass . '].[%s] = ?%s'),
Loading history...
701
                'bind' => array_values($dataKeys),
702
                'bindTypes' => array_fill(0, count($dataKeys), Column::BIND_PARAM_STR),
703
            ]);
704
        }
705
        
706
        if (!$entity) {
707
            $entity = new $modelClass();
708
        }
709
        
710
        // assign new values
711
        // can be null to bypass, empty array for nothing or filled array
712
        $assignWhiteList = isset($whiteList[$modelClass]) || isset($whiteList[$alias]);
713
        $assignColumnMap = isset($dataColumnMap[$modelClass]) || isset($dataColumnMap[$alias]);
714
        $assignWhiteList = $assignWhiteList ? array_merge_recursive($whiteList[$modelClass] ?? [], $whiteList[$alias] ?? []) : null;
715
        $assignColumnMap = $assignColumnMap ? array_merge_recursive($dataColumnMap[$modelClass] ?? [], $dataColumnMap[$alias] ?? []) : null;
716
        $entity->assign($data, $assignWhiteList, $assignColumnMap);
717
//        $entity->setDirtyState(self::DIRTY_STATE_TRANSIENT);
718
        
719
        return $entity;
720
    }
721
    
722
    public function appendMessages(array $messages = [], ?string $context = null, ?int $index = 0): void
723
    {
724
        foreach ($messages as $message) {
725
            assert($message instanceof Message);
726
            
727
            $message->setMetaData([
728
                'index' => $this->rebuildMessageIndex($message, $index),
729
                'context' => $this->rebuildMessageContext($message, $context),
0 ignored issues
show
Bug introduced by
It seems like $context can also be of type null; however, parameter $context of Zemit\Mvc\Model\Relation...rebuildMessageContext() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

729
                'context' => $this->rebuildMessageContext($message, /** @scrutinizer ignore-type */ $context),
Loading history...
730
            ]);
731
            
732
            $this->appendMessage($message);
733
        }
734
    }
735
    
736
    /**
737
     * Append a message to this model from another record,
738
     * also prepend a context to the previous context
739
     *
740
     * @param ResultsetInterface|ModelInterface $record
741
     * @param string|null $context
742
     */
743
    public function appendMessagesFromRecord($record, string $context = null, ?int $index = 0): void
744
    {
745
        if ($record) {
746
            $this->appendMessages($record->getMessages(), $context, $index);
747
        }
748
    }
749
    
750
    /**
751
     * Append a message to this model from another record,
752
     * also prepend a context to the previous context
753
     */
754
    public function appendMessagesFromResultset(?ResultsetInterface $resultset = null, ?string $context = null, ?int $index = 0): void
755
    {
756
        if ($resultset) {
757
            $this->appendMessages($resultset->getMessages(), $context, $index);
758
        }
759
    }
760
    
761
    /**
762
     * Append a message to this model from another record,
763
     * also prepend a context to the previous context
764
     */
765
    public function appendMessagesFromRecordList(?iterable $recordList = null, ?string $context = null, ?int $index = 0): void
766
    {
767
        if ($recordList) {
0 ignored issues
show
introduced by
$recordList is of type iterable|null, thus it always evaluated to false.
Loading history...
768
            foreach ($recordList as $key => $record) {
769
                $this->appendMessagesFromRecord($record, $context, $index . '.' . $key);
770
            }
771
        }
772
    }
773
    
774
    /**
775
     * Rebuilding context for meta data
776
     */
777
    public function rebuildMessageContext(Message $message, string $context): ?string
778
    {
779
        $metaData = $message->getMetaData();
780
        $previousContext = $metaData['context'] ?? '';
781
        return $context . (empty($previousContext) ? '' : '.' . $previousContext);
782
    }
783
    
784
    /**
785
     * Rebuilding context for meta data
786
     */
787
    public function rebuildMessageIndex(Message $message, ?int $index): ?string
788
    {
789
        $metaData = $message->getMetaData();
790
        $previousIndex = $metaData['index'] ?? '';
791
        return $index . (empty($previousIndex) ? '' : '.' . $previousIndex);
792
    }
793
    
794
    /**
795
     * Return the related instances as an array representation
796
     */
797
    public function relatedToArray(?array $relationFields = null): array
798
    {
799
        $ret = [];
800
        
801
        $columnMap = $this->getModelsMetaData()->getColumnMap($this);
0 ignored issues
show
Bug introduced by
$this of type Zemit\Mvc\Model\Relationship is incompatible with the type Phalcon\Mvc\ModelInterface expected by parameter $model of Phalcon\Mvc\Model\MetaDa...terface::getColumnMap(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

801
        $columnMap = $this->getModelsMetaData()->getColumnMap(/** @scrutinizer ignore-type */ $this);
Loading history...
802
        
803
        foreach ($this->getDirtyRelated() as $attribute => $related) {
804
            
805
            // Map column if defined
806
            if ($columnMap && isset($columnMap[$attribute])) {
807
                $attributeField = $columnMap[$attribute];
808
            }
809
            else {
810
                $attributeField = $attribute;
811
            }
812
            
813
            // Skip or set the related columns
814
            if ($relationFields) {
815
                if (!key_exists($attributeField, $relationFields) && !in_array($attributeField, $relationFields)) {
816
                    continue;
817
                }
818
            }
819
            $relatedColumns = $relationFields[$attributeField] ?? null;
820
            
821
            // Run toArray on related records
822
            if ($related instanceof ModelInterface) {
823
                $ret[$attributeField] = $related->toArray($relatedColumns);
0 ignored issues
show
Bug introduced by
The method toArray() does not exist on Phalcon\Mvc\ModelInterface. It seems like you code against a sub-type of Phalcon\Mvc\ModelInterface such as Phalcon\Mvc\Model or Zemit\Models\Setting or Zemit\Models\Category or Zemit\Models\Audit or Zemit\Models\UserGroup or Zemit\Models\User or Zemit\Models\Field or Zemit\Models\Page or Zemit\Models\Log or Zemit\Models\File or Zemit\Models\Role or Zemit\Models\GroupRole or Zemit\Models\Template or Zemit\Models\AuditDetail or Zemit\Models\UserType or Zemit\Models\Post or Zemit\Models\PostCategory or Zemit\Models\Session or Zemit\Models\TranslateField or Zemit\Models\GroupType or Zemit\Models\Translate or Zemit\Models\Email or Zemit\Models\Data or Zemit\Models\Group or Zemit\Models\Lang or Zemit\Models\EmailFile or Zemit\Models\TranslateTable or Zemit\Models\SiteLang or Zemit\Models\UserRole or Zemit\Models\Flag or Zemit\Models\Menu or Zemit\Models\Site or Zemit\Models\Type or Zemit\Models\Channel or Zemit\Models\Meta. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

823
                /** @scrutinizer ignore-call */ 
824
                $ret[$attributeField] = $related->toArray($relatedColumns);
Loading history...
824
            }
825
            elseif (is_iterable($related)) {
826
                $ret[$attributeField] = [];
827
                foreach ($related as $entity) {
828
                    if ($entity instanceof ModelInterface) {
829
                        $ret[$attributeField][] = $entity->toArray($relatedColumns);
830
                    }
831
                    elseif (is_array($entity)) {
832
                        $ret[$attributeField][] = $entity;
833
                    }
834
                }
835
            }
836
            else {
837
                $ret[$attributeField] = null;
838
            }
839
        }
840
        
841
        return $ret;
842
    }
843
    
844
    /**
845
     * {@inheritDoc}
846
     */
847
    public function toArray($relationFields = null): array
848
    {
849
        return array_merge(parent::toArray($relationFields), $this->relatedToArray($relationFields));
850
    }
851
}
852