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

Relationship2::getDirtyRelated()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 3
ccs 0
cts 2
cp 0
crap 2
rs 10
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 Relationship2
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\Relationship2::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\Relationship2 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
                    $getEntityParams = [
212
                        'alias' => $alias,
213
                        'fields' => $referencedFields,
214
                        'modelClass' => $referencedModel,
215
                        'readFields' => $fields,
216
                        'type' => $type,
217
                        'whiteList' => $whiteList,
218
                        'dataColumnMap' => $dataColumnMap,
219
                    ];
220
                    if (empty($relationData) && !in_array($type, [Relation::HAS_MANY_THROUGH, Relation::HAS_MANY])) {
221
                        $assign = $this->getEntityFromData($relationData, $getEntityParams);
222
                    }
223
                    else {
224
                        foreach ($relationData as $traversedKey => $traversedData) {
225
                            // Array of things
226
                            if (is_int($traversedKey)) {
227
                                $entity = null;
228
                                
229
                                // Using bool as behaviour to delete missing relationship or keep them
230
                                // @TODO find a better way
231
                                // if [alias => [true, ...]
232
                                if ($traversedData === 'false') {
233
                                    $traversedData = false;
234
                                }
235
                                if ($traversedData === 'true') {
236
                                    $traversedData = true;
237
                                }
238
                                
239
                                if (is_bool($traversedData)) {
240
                                    $this->keepMissingRelated[$alias] = $traversedData;
241
                                    continue;
242
                                }
243
                                
244
                                // if [alias => [1, 2, 3, ...]]
245
                                if (is_int($traversedData) || is_string($traversedData)) {
246
                                    $traversedData = [$referencedFields[0] => $traversedData];
247
                                }
248
                                
249
                                // if [alias => AliasModel]
250
                                if ($traversedData instanceof ModelInterface) {
251
                                    if ($traversedData instanceof $referencedModel) {
252
                                        $entity = $traversedData;
253
                                    }
254
                                    else {
255
                                        throw new Exception('Instance of `' . get_class($traversedData) . '` received on model `' . $modelClass . '` in alias `' . $alias . ', expected instance of `' . $referencedModel . '`', 400);
256
                                    }
257
                                }
258
                                
259
                                // if [alias => [[id => 1], [id => 2], [id => 3], ....]]
260
                                elseif (is_iterable($traversedData)) {
261
                                    $entity = $this->getEntityFromData((array)$traversedData, $getEntityParams);
262
                                }
263
                                
264
                                if ($entity) {
265
                                    $assign [] = $entity;
266
                                }
267
                            }
268
                            
269
                            // if [alias => [id => 1]]
270
                            else {
271
                                $assign = $this->getEntityFromData((array)$relationData, $getEntityParams);
272
                                break;
273
                            }
274
                        }
275
                    }
276
                }
277
                
278
                // we got something to assign
279
                $keepMissingRelationship = $this->keepMissingRelated[$alias] ?? null;
280
                if (!empty($assign) || $keepMissingRelationship === false) {
281
                    $assign = is_array($assign) ? array_values(array_filter($assign)) : $assign;
282
                    $this->{$alias} = $assign;
283
                    
284
                    // fix to force recursive parent save from children entities within _preSaveRelatedRecords method
285
                    if ($this->{$alias} && $this->{$alias} instanceof ModelInterface) {
286
                        $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...
287
                    }
288
                    
289
                    $this->dirtyRelated[mb_strtolower($alias)] = $this->{$alias} ?? false;
290
                    if (empty($assign)) {
291
                        $this->dirtyRelated[mb_strtolower($alias)] = [];
292
                    }
293
                }
294
            }
295
        }
296
        
297
        return $this;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this returns the type Zemit\Mvc\Model\Relationship2 which is incompatible with the type-hinted return Phalcon\Mvc\ModelInterface.
Loading history...
298
    }
299
    
300
    /**
301
     * Saves related records that must be stored prior to save the master record
302
     * Refactored based on the native cphalcon version, so we can support :
303
     * - combined keys on relationship definition
304
     * - relationship context within the model messages based on the alias definition
305
     * @throws Exception
306
     */
307
    protected function preSaveRelatedRecords(AdapterInterface $connection, $related): bool
308
    {
309
        $nesting = false;
310
        
311
        /**
312
         * Start an implicit transaction
313
         */
314
        $connection->begin($nesting);
315
        $className = get_class($this);
316
        
317
        $modelsManager = $this->getModelsManager();
318
        
319
        /**
320
         * @var string $alias alias
321
         * @var ModelInterface $record
322
         */
323
        if ($related && is_iterable($related)) {
324
            foreach ($related as $alias => $record) {
325
                
326
                // Try to get a relation with the same name
327
                $relation = $modelsManager->getRelationByAlias($className, $alias);
328
                
329
                if ($relation) {
330
                    $type = $relation->getType();
331
                    
332
                    // Only belongsTo are stored before save the master record
333
                    if ($type === Relation::BELONGS_TO) {
334
                        
335
                        // We only support model interface for the belongs-to relation
336
                        if (!($record instanceof ModelInterface)) {
337
                            $connection->rollback($nesting);
338
                            throw new Exception('Instance of `' . get_class($record) . '` received on model `' . $className . '` in alias `' . $alias .
339
                                ', expected instance of `' . ModelInterface::class . '` as part of the belongs-to relation', 400);
340
                        }
341
                        
342
                        // Get relationFields and referencedFields as array
343
                        $relationFields = $relation->getFields();
344
                        $relationFields = is_array($relationFields) ? $relationFields : [$relationFields];
345
                        
346
                        $referencedFields = $relation->getReferencedFields();
347
                        $referencedFields = is_array($referencedFields) ? $referencedFields : [$referencedFields];
348
                        
349
                        // Set the relationship context
350
                        // @todo review this
351
                        $currentRelationshipContext = $this->getRelationshipContext();
352
                        $relationshipPrefix = !empty($currentRelationshipContext)? $currentRelationshipContext . '.' : '';
353
                        $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

353
                        $record->/** @scrutinizer ignore-call */ 
354
                                 setRelationshipContext($relationshipPrefix . $alias);
Loading history...
354
                        
355
                        /**
356
                         * If dynamic update is enabled, saving the record must not take any action
357
                         * Only save if the model is dirty to prevent circular relations causing an infinite loop
358
                         */
359
                        if ($record->getDirtyState() !== PhalconModel::DIRTY_STATE_PERSISTENT && !$record->save()) {
360
                            $this->appendMessagesFromRecord($record, $alias);
361
                            $this->appendMessage(new Message('Unable to save related record', $alias, 'Bad Request', 400));
0 ignored issues
show
Bug introduced by
The method appendMessage() does not exist on Zemit\Mvc\Model\Relationship2. 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

361
                            $this->/** @scrutinizer ignore-call */ 
362
                                   appendMessage(new Message('Unable to save related record', $alias, '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...
362
                            $connection->rollback($nesting);
363
                            return false;
364
                        }
365
                        
366
                        // Read the attributes from the referenced model and assign it to the current model
367
                        foreach ($referencedFields as $key => $referencedField) {
368
                            $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

368
                            /** @scrutinizer ignore-call */ 
369
                            $this->{$relationFields[$key]} = $record->readAttribute($referencedField);
Loading history...
369
                        }
370
                    }
371
                }
372
            }
373
        }
374
        
375
        
376
        return true;
377
    }
378
    
379
    /**
380
     * NOTE: we need this, this behaviour only happens:
381
     * - in many to many nodes
382
     * Fix uniqueness on combined keys in node entities, and possibly more...
383
     * @link https://forum.phalconphp.com/discussion/2190/many-to-many-expected-behaviour
384
     * @link http://stackoverflow.com/questions/23374858/update-a-records-n-n-relationships
385
     * @link https://github.com/phalcon/cphalcon/issues/2871
386
     */
387
    protected function postSaveRelatedRecords(AdapterInterface $connection, $related = null): bool
388
    {
389
        $nesting = false;
390
        
391
        if ($related && is_iterable($related)) {
392
            foreach ($related as $lowerCaseAlias => $assign) {
393
                
394
                $modelsManager = $this->getModelsManager();
395
                $modelsMetaData = $this->getModelsMetaData();
396
                
397
                $relation = $modelsManager->getRelationByAlias(get_class($this), $lowerCaseAlias);
398
                
399
                // Append error if relation is not defined
400
                if (!($relation instanceof RelationInterface)) {
401
                    $this->appendMessage(new Message(
402
                        'There are no defined relations for the model `' . get_class($this) . '` using alias `' . $lowerCaseAlias . '`',
403
                        $lowerCaseAlias,
404
                        404
405
                    ));
406
                    continue;
407
                }
408
                
409
                // Discard belongsTo relations
410
                if ($relation->getType() === Relation::BELONGS_TO) {
411
                    continue;
412
                }
413
                
414
                if (!is_array($assign) && !is_object($assign)) {
415
                    $this->appendMessage(new Message(
416
                        'Only objects/arrays can be stored as part of has-many/has-one/has-one-through/has-many-to-many relations',
417
                        $lowerCaseAlias,
418
                        400
419
                    ));
420
                    continue;
421
                }
422
                
423
                $relationFields = $relation->getFields();
424
                $relationFields = is_array($relationFields) ? $relationFields : [$relationFields];
425
                
426
                // Custom logic for many-to-many relationships
427
                if ($relation->getType() === Relation::HAS_MANY_THROUGH) {
428
                    
429
                    $intermediateModelClass = $relation->getIntermediateModel();
430
                    $intermediateModel = $modelsManager->load($intermediateModelClass);
431
                    
432
                    $intermediateFields = $relation->getIntermediateFields();
433
                    $intermediateFields = is_array($intermediateFields) ? $intermediateFields : [$intermediateFields];
434
                    
435
                    $intermediateReferencedFields = $relation->getIntermediateReferencedFields();
436
                    $intermediateReferencedFields = is_array($intermediateReferencedFields) ? $intermediateReferencedFields : [$intermediateReferencedFields];
437
                    
438
                    $referencedFields = $relation->getReferencedFields();
439
                    $referencedFields = is_array($referencedFields) ? $referencedFields : [$referencedFields];
440
                    
441
                    $intermediatePrimaryKeyAttributes = $modelsMetaData->getPrimaryKeyAttributes($intermediateModel);
442
                    $intermediateBindTypes = $modelsMetaData->getBindTypes($intermediateModel);
443
                    
444
                    // get current model bindings
445
                    $relationBind = [];
446
                    foreach ($relationFields as $relationField) {
447
                        $relationBind [] = $this->readAttribute($relationField) ?? null;
448
                    }
449
                    
450
                    $nodeIdListToKeep = [];
451
                    foreach ($assign as $key => $entity) {
452
                        
453
                        // get referenced model bindings
454
                        $referencedBind = [];
455
                        foreach ($referencedFields as $referencedField) {
456
                            $referencedBind [] = $entity->readAttribute($referencedField) ?? null;
457
                        }
458
                        
459
                        $nodeEntity = $intermediateModel::findFirst([
460
                            'conditions' => implode_mb_sprintf(array_merge($intermediateFields, $intermediateReferencedFields), ' and ', '[' . $intermediateModelClass . '].[%s] = ?%s'),
461
                            'bind' => [...$relationBind, ...$referencedBind],
462
                            'bindTypes' => array_fill(0, count($intermediateFields) + count($intermediateReferencedFields), Column::BIND_PARAM_STR),
463
                        ]);
464
                        
465
                        if ($nodeEntity) {
466
                            $buildPrimaryKey = [];
467
                            foreach ($intermediatePrimaryKeyAttributes as $intermediatePrimaryKey => $intermediatePrimaryKeyAttribute) {
468
                                $buildPrimaryKey [] = $nodeEntity->readAttribute($intermediatePrimaryKeyAttribute);
469
                            }
470
                            $nodeIdListToKeep [] = implode('.', $buildPrimaryKey);
471
                            
472
                            // Restoring node entities if previously soft deleted
473
                            if (method_exists($nodeEntity, 'restore') && method_exists($nodeEntity, 'isDeleted')) {
474
                                if ($nodeEntity->isDeleted() && !$nodeEntity->restore()) {
475
                                    $this->appendMessagesFromRecord($nodeEntity, $lowerCaseAlias);
476
                                    $this->appendMessage(new Message('Unable to restore node model `'.$intermediateModelClass.'` entity', $lowerCaseAlias . '.' . $key, 'Bad Request', 400));
477
                                    $connection->rollback($nesting);
478
                                    return false;
479
                                }
480
                            }
481
                            
482
                            // save edge record
483
                            if (!$entity->save()) {
484
                                $this->appendMessagesFromRecord($entity, $lowerCaseAlias);
485
                                $this->appendMessage(new Message('Unable to save entity', $lowerCaseAlias . '.' . $key, 'Bad Request', 400));
486
                                $connection->rollback($nesting);
487
                                return false;
488
                            }
489
                            
490
                            // remove it
491
                            unset($assign[$key]);
492
                            unset($related[$lowerCaseAlias][$key]);
493
                        }
494
                    }
495
                    
496
                    if (!($this->keepMissingRelated[$lowerCaseAlias] ?? true)) {
497
                        
498
                        // handle if we empty the related
499
                        if (empty($nodeIdListToKeep)) {
500
                            $nodeIdListToKeep = [0];
501
                        }
502
                        else {
503
                            $nodeIdListToKeep = array_values(array_filter(array_unique($nodeIdListToKeep)));
504
                        }
505
                        
506
                        $idBindType = count($intermediatePrimaryKeyAttributes) === 1 ? $intermediateBindTypes[$intermediatePrimaryKeyAttributes[0]] : Column::BIND_PARAM_STR;
507
                        
508
                        /** @var ModelInterface|string $intermediateModelClass */
509
                        $nodeEntityToDeleteList = $intermediateModelClass::find([
510
                            'conditions' => implode_mb_sprintf(array_merge($intermediateFields), ' and ', '[' . $intermediateModelClass . '].[%s] = ?%s')
0 ignored issues
show
Bug introduced by
Are you sure $intermediateModelClass 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

510
                            'conditions' => implode_mb_sprintf(array_merge($intermediateFields), ' and ', '[' . /** @scrutinizer ignore-type */ $intermediateModelClass . '].[%s] = ?%s')
Loading history...
511
                                . ' and concat(' . implode_mb_sprintf($intermediatePrimaryKeyAttributes, ', \'.\', ', '[' . $intermediateModelClass . '].[%s]') . ') not in ({id:array})',
512
                            'bind' => [...$relationBind, 'id' => $nodeIdListToKeep],
513
                            'bindTypes' => [...array_fill(0, count($intermediateFields), Column::BIND_PARAM_STR), 'id' => $idBindType],
514
                        ]);
515
                        
516
                        // delete missing related
517
                        if (!$nodeEntityToDeleteList->delete()) {
518
                            $this->appendMessagesFromResultset($nodeEntityToDeleteList, $lowerCaseAlias);
519
                            $this->appendMessage(new Message('Unable to delete node model `' . $intermediateModelClass . '` entities', $lowerCaseAlias, 'Bad Request', 400));
520
                            $connection->rollback($nesting);
521
                            return false;
522
                        }
523
                    }
524
                }
525
                
526
                // Create an implicit array for has-many/has-one records
527
                $relatedRecords = $assign instanceof ModelInterface ? [$assign] : $assign;
528
                foreach ($relationFields as $relationField) {
529
                    if (!property_exists($this, $relationField)) {
530
                        $connection->rollback($nesting);
531
                        throw new Exception("The column '" . $relationField . "' needs to be present in the model.");
532
                    }
533
                }
534
                
535
                if ($relation->isThrough()) {
536
                    if (!$this->saveRelatedThrough($relatedRecords, $lowerCaseAlias, $relation)) {
0 ignored issues
show
Bug introduced by
It seems like $relatedRecords can also be of type object; however, parameter $relatedRecords of Zemit\Mvc\Model\Relation...2::saveRelatedThrough() does only seem to accept iterable, 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

536
                    if (!$this->saveRelatedThrough(/** @scrutinizer ignore-type */ $relatedRecords, $lowerCaseAlias, $relation)) {
Loading history...
537
                        $connection->rollback($nesting);
538
                        return false;
539
                    }
540
                }
541
                elseif (!$this->saveRelatedRecords($relatedRecords, $lowerCaseAlias, $relation)) {
0 ignored issues
show
Bug introduced by
It seems like $relatedRecords can also be of type object; however, parameter $relatedRecords of Zemit\Mvc\Model\Relation...2::saveRelatedRecords() does only seem to accept iterable, 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

541
                elseif (!$this->saveRelatedRecords(/** @scrutinizer ignore-type */ $relatedRecords, $lowerCaseAlias, $relation)) {
Loading history...
542
                    $connection->rollback($nesting);
543
                    return false;
544
                }
545
            }
546
        }
547
        
548
        // Commit the implicit transaction
549
        return $connection->commit($nesting);
550
    }
551
    
552
    public function saveRelatedRecords(iterable $relatedRecords, string $alias, RelationInterface $relation): bool
553
    {
554
        $referencedFields = $relation->getReferencedFields();
555
        $referencedFields = is_array($referencedFields) ? $referencedFields : [$referencedFields];
556
        
557
        $relationFields = $relation->getFields();
558
        $relationFields = is_array($relationFields) ? $relationFields : [$relationFields];
559
        
560
        foreach ($relatedRecords as $recordAfterIndex => $recordAfter) {
561
            foreach ($relationFields as $key => $column) {
562
                $recordAfter->writeAttribute($referencedFields[$key], $this->readAttribute($column));
563
            }
564
            
565
            if (!$recordAfter->save()) {
566
                $this->appendMessagesFromRecord($recordAfter, $alias, $recordAfterIndex);
567
                $this->appendMessage(new Message('Unable to save related record', $alias . '.' . $recordAfterIndex, 'Bad Request', 400));
568
                return false;
569
            }
570
        }
571
        return true;
572
    }
573
    
574
    public function saveRelatedThrough(iterable $relatedRecords, string $alias, RelationInterface $relation): bool
575
    {
576
        $referencedFields = $relation->getReferencedFields();
577
        $referencedFields = is_array($referencedFields) ? $referencedFields : [$referencedFields];
578
        
579
        $relationFields = $relation->getFields();
580
        $relationFields = is_array($relationFields) ? $relationFields : [$relationFields];
581
        
582
        $intermediateModelClass = $relation->getIntermediateModel();
583
        
584
        $intermediateFields = $relation->getIntermediateFields();
585
        $intermediateFields = is_array($intermediateFields) ? $intermediateFields : [$intermediateFields];
586
        
587
        $intermediateReferencedFields = $relation->getIntermediateReferencedFields();
588
        $intermediateReferencedFields = is_array($intermediateReferencedFields) ? $intermediateReferencedFields : [$intermediateReferencedFields];
589
        
590
        foreach ($relatedRecords as $recordAfterIndex => $recordAfter) {
591
            
592
            $intermediateModel = $this->getModelsManager()->load($intermediateModelClass);
593
            
594
            if (!$recordAfter->save()) {
595
                $this->appendMessagesFromRecord($recordAfter, $alias, $recordAfterIndex);
596
                $this->appendMessage(new Message('Unable to save related record', $alias . '.' . $recordAfterIndex, 'Bad Request', 400));
597
                return false;
598
            }
599
            
600
            /**
601
             *  Has-one-through relations can only use one intermediate model.
602
             *  If it already exists, it can be updated with the new referenced key.
603
             */
604
            if ($relation->getType() === Relation::HAS_ONE_THROUGH) {
605
                
606
                $bind = [];
607
                foreach ($relationFields as $column) {
608
                    $bind[] = $this->readAttribute($column);
609
                }
610
                
611
                $existingIntermediateModel = $intermediateModel::findFirst([
612
                    'conditions' => implode_mb_sprintf($intermediateFields, ' and ', '[' . $intermediateModelClass . '].[%s] = ?%s'),
613
                    'bind' => $bind,
614
                    'bindTypes' => array_fill(0, count($bind), Column::BIND_PARAM_STR),
615
                ]);
616
                if ($existingIntermediateModel) {
617
                    $intermediateModel = $existingIntermediateModel;
618
                }
619
            }
620
            
621
            foreach ($relationFields as $key => $column) {
622
                
623
                // Write value in the intermediate model
624
                $intermediateModel->writeAttribute($intermediateFields[$key], $this->readAttribute($column));
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

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

689
                $primaryKeys = $modelsMetaData->getPrimaryKeyAttributes(/** @scrutinizer ignore-type */ $this);
Loading history...
690
                
691
                // Force primary keys for single to many
692
                foreach ($primaryKeys as $primaryKey) {
693
                    
694
                    if (!in_array($primaryKey, $fields, true)) {
695
                        $dataKeys [$primaryKey] = $data[$primaryKey] ?? null;
696
                        $fields [] = $primaryKey;
697
                    }
698
                }
699
            }
700
            
701
            /** @var ModelInterface|string $modelClass */
702
            $className = is_string($modelClass) ? $modelClass : get_class($modelClass);
703
            $entity = $modelClass::findFirst([
704
                'conditions' => implode_mb_sprintf($fields, ' and ', '[' . $className . '].[%s] = ?%s'),
705
                'bind' => array_values($dataKeys),
706
                'bindTypes' => array_fill(0, count($dataKeys), Column::BIND_PARAM_STR),
707
            ]);
708
        }
709
        
710
        if (!$entity) {
711
            $entity = new $modelClass();
712
        }
713
        
714
        // assign new values
715
        // can be null to bypass, empty array for nothing or filled array
716
        $assignWhiteList = isset($whiteList[$modelClass]) || isset($whiteList[$alias]);
717
        $assignColumnMap = isset($dataColumnMap[$modelClass]) || isset($dataColumnMap[$alias]);
718
        $assignWhiteList = $assignWhiteList ? array_merge_recursive($whiteList[$modelClass] ?? [], $whiteList[$alias] ?? []) : null;
719
        $assignColumnMap = $assignColumnMap ? array_merge_recursive($dataColumnMap[$modelClass] ?? [], $dataColumnMap[$alias] ?? []) : null;
720
        $entity->assign($data, $assignWhiteList, $assignColumnMap);
721
//        $entity->setDirtyState(self::DIRTY_STATE_TRANSIENT);
722
        
723
        return $entity;
724
    }
725
    
726
    /**
727
     * Append a message to this model from another record,
728
     * also prepend a context to the previous context
729
     */
730
    public function appendMessagesFromRecord(ModelInterface $record, ?string $context = null, ?int $index = 0): void
731
    {
732
        foreach ($record->getMessages() as $message) {
733
            
734
            $message->setMetaData([
735
                'index' => $index,
736
                '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

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

796
        $columnMap = $this->getModelsMetaData()->getColumnMap(/** @scrutinizer ignore-type */ $this);
Loading history...
797
        
798
        foreach ($this->getDirtyRelated() as $attribute => $related) {
799
            
800
            // Map column if defined
801
            if ($columnMap && isset($columnMap[$attribute])) {
802
                $attributeField = $columnMap[$attribute];
803
            }
804
            else {
805
                $attributeField = $attribute;
806
            }
807
            
808
            // Skip or set the related columns
809
            if ($relationFields) {
810
                if (!key_exists($attributeField, $relationFields) && !in_array($attributeField, $relationFields)) {
811
                    continue;
812
                }
813
            }
814
            $relatedColumns = $relationFields[$attributeField] ?? null;
815
            
816
            // Run toArray on related records
817
            if ($related instanceof ModelInterface) {
818
                $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

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