Test Failed
Push — master ( 7bbbf4...34ff55 )
by Julien
04:57
created

Relationship::setKeepMissingRelated()   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 1
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 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
                    $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\Relationship 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
                        $record->setRelationshipContext($this->getRelationshipContext() . '.' . $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

350
                        $record->/** @scrutinizer ignore-call */ 
351
                                 setRelationshipContext($this->getRelationshipContext() . '.' . $alias);
Loading history...
351
                        
352
                        /**
353
                         * If dynamic update is enabled, saving the record must not take any action
354
                         * Only save if the model is dirty to prevent circular relations causing an infinite loop
355
                         */
356
                        if ($record->getDirtyState() !== PhalconModel::DIRTY_STATE_PERSISTENT && !$record->save()) {
357
                            $this->appendMessagesFromRecord($record, $alias);
358
                            $connection->rollback($nesting);
359
                            return false;
360
                        }
361
                        
362
                        // Read the attributes from the referenced model and assign it to the current model
363
                        foreach ($referencedFields as $key => $referencedField) {
364
                            $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

364
                            /** @scrutinizer ignore-call */ 
365
                            $this->{$relationFields[$key]} = $record->readAttribute($referencedField);
Loading history...
365
                        }
366
                    }
367
                }
368
            }
369
        }
370
        
371
        
372
        return true;
373
    }
374
    
375
    /**
376
     * NOTE: we need this, this behaviour only happens:
377
     * - in many to many nodes
378
     * Fix uniqueness on combined keys in node entities, and possibly more...
379
     * @link https://forum.phalconphp.com/discussion/2190/many-to-many-expected-behaviour
380
     * @link http://stackoverflow.com/questions/23374858/update-a-records-n-n-relationships
381
     * @link https://github.com/phalcon/cphalcon/issues/2871
382
     */
383
    protected function postSaveRelatedRecords(AdapterInterface $connection, $related = null): bool
384
    {
385
        $nesting = false;
386
        
387
        if ($related && is_iterable($related)) {
388
            foreach ($related as $lowerCaseAlias => $assign) {
389
                
390
                $modelsManager = $this->getModelsManager();
391
                $modelsMetaData = $this->getModelsMetaData();
392
                
393
                $relation = $modelsManager->getRelationByAlias(get_class($this), $lowerCaseAlias);
394
                
395
                // Append error if relation is not defined
396
                if (!($relation instanceof RelationInterface)) {
397
                    $this->appendMessage(new Message(
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

397
                    $this->/** @scrutinizer ignore-call */ 
398
                           appendMessage(new Message(

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...
398
                        'There are no defined relations for the model `' . get_class($this) . '` using alias `' . $lowerCaseAlias . '`',
399
                        $lowerCaseAlias,
400
                        404
401
                    ));
402
                    continue;
403
                }
404
                
405
                // Discard belongsTo relations
406
                if ($relation->getType() === Relation::BELONGS_TO) {
407
                    continue;
408
                }
409
                
410
                if (!is_array($assign) && !is_object($assign)) {
411
                    $this->appendMessage(new Message(
412
                        'Only objects/arrays can be stored as part of has-many/has-one/has-one-through/has-many-to-many relations',
413
                        $lowerCaseAlias,
414
                        400
415
                    ));
416
                    continue;
417
                }
418
                
419
                $relationFields = $relation->getFields();
420
                $relationFields = is_array($relationFields) ? $relationFields : [$relationFields];
421
                
422
                // Custom logic for many-to-many relationships
423
                if ($relation->getType() === Relation::HAS_MANY_THROUGH) {
424
                    
425
                    $intermediateModelClass = $relation->getIntermediateModel();
426
                    $intermediateModel = $modelsManager->load($intermediateModelClass);
427
                    
428
                    $intermediateFields = $relation->getIntermediateFields();
429
                    $intermediateFields = is_array($intermediateFields) ? $intermediateFields : [$intermediateFields];
430
                    
431
                    $intermediateReferencedFields = $relation->getIntermediateReferencedFields();
432
                    $intermediateReferencedFields = is_array($intermediateReferencedFields) ? $intermediateReferencedFields : [$intermediateReferencedFields];
433
                    
434
                    $referencedFields = $relation->getReferencedFields();
435
                    $referencedFields = is_array($referencedFields) ? $referencedFields : [$referencedFields];
436
                    
437
                    $intermediatePrimaryKeyAttributes = $modelsMetaData->getPrimaryKeyAttributes($intermediateModel);
438
                    $intermediateBindTypes = $modelsMetaData->getBindTypes($intermediateModel);
439
                    
440
                    // get current model bindings
441
                    $relationBind = [];
442
                    foreach ($relationFields as $relationField) {
443
                        $relationBind [] = $this->readAttribute($relationField) ?? null;
444
                    }
445
                    
446
                    $nodeIdListToKeep = [];
447
                    foreach ($assign as $key => $entity) {
448
                        
449
                        // get referenced model bindings
450
                        $referencedBind = [];
451
                        foreach ($referencedFields as $referencedField) {
452
                            $referencedBind [] = $entity->readAttribute($referencedField) ?? null;
453
                        }
454
                        
455
                        $nodeEntity = $intermediateModel::findFirst([
456
                            'conditions' => implode_mb_sprintf(array_merge($intermediateFields, $intermediateReferencedFields), ' and ', '[' . $intermediateModelClass . '].[%s] = ?%s'),
457
                            'bind' => [...$relationBind, ...$referencedBind],
458
                            'bindTypes' => array_fill(0, count($intermediateFields) + count($intermediateReferencedFields), Column::BIND_PARAM_STR),
459
                        ]);
460
                        
461
                        if ($nodeEntity) {
462
                            $buildPrimaryKey = [];
463
                            foreach ($intermediatePrimaryKeyAttributes as $intermediatePrimaryKey => $intermediatePrimaryKeyAttribute) {
464
                                $buildPrimaryKey [] = $nodeEntity->readAttribute($intermediatePrimaryKeyAttribute);
465
                            }
466
                            $nodeIdListToKeep [] = implode('.', $buildPrimaryKey);
467
                            
468
                            // Restoring node entities if previously soft deleted
469
                            if (method_exists($nodeEntity, 'restore') && method_exists($nodeEntity, 'isDeleted')) {
470
                                if ($nodeEntity->isDeleted() && !$nodeEntity->restore()) {
471
                                    $this->appendMessagesFromRecord($nodeEntity, $lowerCaseAlias);
472
                                    $connection->rollback($nesting);
473
                                    return false;
474
                                }
475
                            }
476
                            
477
                            // save edge record
478
                            if (!$entity->save()) {
479
                                $this->appendMessagesFromRecord($entity, $lowerCaseAlias);
480
                                $connection->rollback($nesting);
481
                                return false;
482
                            }
483
                            
484
                            // remove it
485
                            unset($assign[$key]);
486
                            unset($related[$lowerCaseAlias][$key]);
487
                        }
488
                    }
489
                    
490
                    if (!($this->keepMissingRelated[$lowerCaseAlias] ?? true)) {
491
                        
492
                        // handle if we empty the related
493
                        if (empty($nodeIdListToKeep)) {
494
                            $nodeIdListToKeep = [0];
495
                        }
496
                        else {
497
                            $nodeIdListToKeep = array_values(array_filter(array_unique($nodeIdListToKeep)));
498
                        }
499
                        
500
                        $idBindType = count($intermediatePrimaryKeyAttributes) === 1 ? $intermediateBindTypes[$intermediatePrimaryKeyAttributes[0]] : Column::BIND_PARAM_STR;
501
                        
502
                        /** @var ModelInterface|string $intermediateModelClass */
503
                        $nodeEntityToDeleteList = $intermediateModelClass::find([
504
                            '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

504
                            'conditions' => implode_mb_sprintf(array_merge($intermediateFields), ' and ', '[' . /** @scrutinizer ignore-type */ $intermediateModelClass . '].[%s] = ?%s')
Loading history...
505
                                . ' and concat(' . implode_mb_sprintf($intermediatePrimaryKeyAttributes, ', \'.\', ', '[' . $intermediateModelClass . '].[%s]') . ') not in ({id:array})',
506
                            'bind' => [...$relationBind, 'id' => $nodeIdListToKeep],
507
                            'bindTypes' => [...array_fill(0, count($intermediateFields), Column::BIND_PARAM_STR), 'id' => $idBindType],
508
                        ]);
509
                        
510
                        // delete missing related
511
                        if (!$nodeEntityToDeleteList->delete()) {
512
                            $this->appendMessagesFromResultset($nodeEntityToDeleteList, $lowerCaseAlias);
513
                            $connection->rollback($nesting);
514
                            return false;
515
                        }
516
                    }
517
                }
518
                
519
                // Create an implicit array for has-many/has-one records
520
                $relatedRecords = $assign instanceof ModelInterface ? [$assign] : $assign;
521
                foreach ($relationFields as $relationField) {
522
                    if (!property_exists($this, $relationField)) {
523
                        $connection->rollback($nesting);
524
                        throw new Exception("The column '" . $relationField . "' needs to be present in the model.");
525
                    }
526
                }
527
                
528
                if ($relation->isThrough()) {
529
                    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\Relationship::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

529
                    if (!$this->saveRelatedThrough(/** @scrutinizer ignore-type */ $relatedRecords, $lowerCaseAlias, $relation)) {
Loading history...
530
                        $connection->rollback($nesting);
531
                        return false;
532
                    }
533
                }
534
                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\Relationship::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

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

615
                $intermediateModel->/** @scrutinizer ignore-call */ 
616
                                    writeAttribute($intermediateFields[$key], $this->readAttribute($column));
Loading history...
616
                
617
                // Get the value from the referenced model
618
                $intermediateValue = $recordAfter->readAttribute($referencedFields[$key]);
619
                
620
                // Write the intermediate value in the intermediate model
621
                $intermediateModel->writeAttribute($intermediateReferencedFields[$key], $intermediateValue);
622
            }
623
            
624
            // Save the record and get messages
625
            if (!$intermediateModel->save()) {
626
                $this->appendMessagesFromRecord($intermediateModel, $alias);
627
                return false;
628
            }
629
        }
630
        
631
        return true;
632
    }
633
    
634
    /**
635
     * Get an entity from data
636
     */
637
    public function getEntityFromData(array $data, array $configuration = []): ModelInterface
638
    {
639
        $alias = $configuration['alias'] ?? null;
640
        $fields = $configuration['fields'] ?? null;
641
        $modelClass = $configuration['modelClass'] ?? null;
642
        $readFields = $configuration['readFields'] ?? null;
643
        $type = $configuration['type'] ?? null;
644
        $whiteList = $configuration['whiteList'] ?? null;
645
        $dataColumnMap = $configuration['dataColumnMap'] ?? null;
646
        
647
        $entity = false;
648
        
649
        if ($type === Relation::HAS_ONE || $type === Relation::BELONGS_TO) {
650
            
651
            // Set value to compare
652
            if (!empty($readFields)) {
653
                
654
                foreach ($readFields as $key => $field) {
655
                    
656
                    if (empty($data[$fields[$key]])) {
657
                        
658
                        // @todo maybe remove this if
659
                        $value = $this->readAttribute($field);
660
                        if (!empty($value)) {
661
                            
662
                            // @todo maybe remove this if
663
                            $data [$fields[$key]] = $value;
664
                        }
665
                    }
666
                }
667
            }
668
        }
669
        
670
        // array_keys_exists (if $referencedFields keys exists)
671
        $dataKeys = array_intersect_key($data, array_flip($fields));
672
        
673
        // all keys were found
674
        if (count($dataKeys) === count($fields)) {
675
            
676
            if ($type === Relation::HAS_MANY) {
677
                
678
                $modelsMetaData = $this->getModelsMetaData();
679
                $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

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

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

786
        $columnMap = $this->getModelsMetaData()->getColumnMap(/** @scrutinizer ignore-type */ $this);
Loading history...
787
        
788
        foreach ($this->getDirtyRelated() as $attribute => $related) {
789
            
790
            // Map column if defined
791
            if ($columnMap && isset($columnMap[$attribute])) {
792
                $attributeField = $columnMap[$attribute];
793
            }
794
            else {
795
                $attributeField = $attribute;
796
            }
797
            
798
            // Skip or set the related columns
799
            if ($relationFields) {
800
                if (!key_exists($attributeField, $relationFields) && !in_array($attributeField, $relationFields)) {
801
                    continue;
802
                }
803
            }
804
            $relatedColumns = $relationFields[$attributeField] ?? null;
805
            
806
            // Run toArray on related records
807
            if ($related instanceof ModelInterface) {
808
                $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

808
                /** @scrutinizer ignore-call */ 
809
                $ret[$attributeField] = $related->toArray($relatedColumns);
Loading history...
809
            }
810
            elseif (is_iterable($related)) {
811
                $ret[$attributeField] = [];
812
                foreach ($related as $entity) {
813
                    if ($entity instanceof ModelInterface) {
814
                        $ret[$attributeField][] = $entity->toArray($relatedColumns);
815
                    }
816
                    elseif (is_array($entity)) {
817
                        $ret[$attributeField][] = $entity;
818
                    }
819
                }
820
            }
821
            else {
822
                $ret[$attributeField] = null;
823
            }
824
        }
825
        
826
        return $ret;
827
    }
828
    
829
    /**
830
     * {@inheritDoc}
831
     */
832
    public function toArray($relationFields = null): array
833
    {
834
        return array_merge(parent::toArray($relationFields), $this->relatedToArray($relationFields));
835
    }
836
}
837