Test Failed
Push — master ( a835f3...4c7cec )
by Julien
04:50
created

Loader::load()   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 0
Metric Value
cc 1
eloc 1
c 0
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\EagerLoading;
13
14
use Phalcon\Di;
15
use Phalcon\Mvc\ModelInterface;
16
use Phalcon\Mvc\Model\Relation;
17
use Phalcon\Mvc\Model\Resultset\Simple;
18
use Zemit\Exception;
19
20
final class Loader
21
{
22
    protected ?array $subject;
23
    
24
    protected string $className;
25
    
26
    protected array $eagerLoads;
27
    
28
    protected bool $singleModel;
29
    
30
    protected array $options = [];
31
    
32
    /**
33
     * @param ModelInterface|ModelInterface[]|Simple $from
34
     * @param ...$arguments
35
     * @throws \InvalidArgumentException
36
     */
37
    public function __construct($from, array ...$arguments)
38
    {
39
        $error = false;
40
        $className = null;
41
        $this->singleModel = false;
42
        
43
        // Handle Model Interface
44
        if ($from instanceof ModelInterface) {
45
            $className = get_class($from);
46
            $from = [$from];
47
            $this->singleModel = true;
48
        }
49
        
50
        // Handle Simple Resultset
51
        elseif ($from instanceof Simple) {
52
            $new = [];
53
            foreach ($from as $record) {
54
                $className ??= get_class($record);
0 ignored issues
show
Bug introduced by
It seems like $record can also be of type null; however, parameter $object of get_class() does only seem to accept object, 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

54
                $className ??= get_class(/** @scrutinizer ignore-type */ $record);
Loading history...
55
                $new[] = $record;
56
            }
57
            if (empty($from)) {
58
                $new = null;
59
            }
60
            $from = $new;
61
        }
62
        
63
        // Handle array
64
        elseif (is_array($from)) {
0 ignored issues
show
introduced by
The condition is_array($from) is always true.
Loading history...
65
            $from = array_filter($from);
66
            foreach ($from as $el) {
67
                if ($el instanceof ModelInterface) {
68
                    // elements must be all the same model class
69
                    $className ??= get_class($el);
70
                    if ($className !== get_class($el)) {
71
                        $error = true;
72
                        break;
73
                    }
74
                }
75
                else {
76
                    // element must be a ModelInterface
77
                    $error = true;
78
                    break;
79
                }
80
            }
81
            if (empty($from)) {
82
                $from = null;
83
            }
84
        }
85
        
86
        // Handle null or empty
87
        elseif (is_null($from) || is_bool($from)) {
88
            $from = null;
89
        }
90
        
91
        // error
92
        else {
93
            $error = true;
94
        }
95
        
96
        if ($error) {
97
            throw new Exception('Expected value of `subject` to be either a ModelInterface object, a Simple object or an array of ModelInterface objects');
98
        }
99
        
100
        $this->subject = $from;
101
        $this->className = $className;
102
        $this->eagerLoads = ($from === null || empty($arguments)) ? [] : self::parseArguments($arguments);
103
    }
104
    
105
    public function setOptions($options)
106
    {
107
        $this->options = $options;
108
        return $this;
109
    }
110
    
111
    /**
112
     * Create and get from a mixed $subject
113
     *
114
     * @param ModelInterface|ModelInterface[]|Simple $subject
115
     * @param mixed ...$arguments
116
     * @return mixed
117
     * @throws \InvalidArgumentException
118
     */
119
    public static function from($subject, array ...$arguments)
120
    {
121
        if ($subject instanceof ModelInterface) {
122
            return self::fromModel(...$arguments);
0 ignored issues
show
Bug introduced by
$arguments of type array is incompatible with the type Phalcon\Mvc\ModelInterface expected by parameter $subject of Zemit\Mvc\Model\EagerLoading\Loader::fromModel(). ( Ignorable by Annotation )

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

122
            return self::fromModel(/** @scrutinizer ignore-type */ ...$arguments);
Loading history...
123
        }
124
        elseif ($subject instanceof Simple) {
125
            return self::fromResultset(...$arguments);
0 ignored issues
show
Bug introduced by
$arguments of type array is incompatible with the type Phalcon\Mvc\Model\Resultset\Simple expected by parameter $subject of Zemit\Mvc\Model\EagerLoa...Loader::fromResultset(). ( Ignorable by Annotation )

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

125
            return self::fromResultset(/** @scrutinizer ignore-type */ ...$arguments);
Loading history...
126
        }
127
        elseif (is_array($subject)) {
0 ignored issues
show
introduced by
The condition is_array($subject) is always true.
Loading history...
128
            return self::fromArray(...$arguments);
129
        }
130
        
131
        throw new \InvalidArgumentException(self::E_INVALID_SUBJECT);
132
    }
133
    
134
    /**
135
     * Create and get from a Model
136
     *
137
     * @param ModelInterface $subject
138
     * @param mixed ...$arguments
139
     * @return ModelInterface
140
     */
141
    public static function fromModel(ModelInterface $subject, ...$arguments): ModelInterface
142
    {
143
        return (new self($subject, ...$arguments))->execute()->get();
0 ignored issues
show
Bug Best Practice introduced by
The expression return new self($subject...ents)->execute()->get() could return the type Phalcon\Mvc\ModelInterface[]|null which is incompatible with the type-hinted return Phalcon\Mvc\ModelInterface. Consider adding an additional type-check to rule them out.
Loading history...
144
    }
145
    
146
    /**
147
     * Create and get from a Model without soft deleted records
148
     *
149
     * @param ModelInterface $subject
150
     * @param mixed ...$arguments
151
     * @return ModelInterface
152
     */
153
    public static function fromModelWithoutSoftDelete(ModelInterface $subject, ...$arguments): ModelInterface
154
    {
155
        $options = ['softDelete' => 'softDelete'];
156
        $obj = new self($subject, ...$arguments);
157
        return $obj->setOptions($options)->execute()->get();
0 ignored issues
show
Bug Best Practice introduced by
The expression return $obj->setOptions(...ions)->execute()->get() could return the type Phalcon\Mvc\ModelInterface[]|null which is incompatible with the type-hinted return Phalcon\Mvc\ModelInterface. Consider adding an additional type-check to rule them out.
Loading history...
158
    }
159
    
160
    /**
161
     * Create and get from an array
162
     *
163
     * @param ModelInterface[] $subject
164
     * @param mixed ...$arguments
165
     * @return array
166
     */
167
    public static function fromArray(array $subject, ...$arguments): array
168
    {
169
        return (new self($subject, ...$arguments))->execute()->get();
0 ignored issues
show
Bug Best Practice introduced by
The expression return new self($subject...ents)->execute()->get() could return the type Phalcon\Mvc\ModelInterface|null which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
170
    }
171
    
172
    /**
173
     * Create and get from an array without soft deleted records
174
     *
175
     * @param ModelInterface[] $subject
176
     * @param mixed ...$arguments
177
     * @return array
178
     */
179
    public static function fromArrayWithoutSoftDelete(array $subject, ...$arguments): array
180
    {
181
        $options = ['softDelete' => 'softDelete'];
182
        $obj = new self($subject, ...$arguments);
183
        return $obj->setOptions($options)->execute()->get();
0 ignored issues
show
Bug Best Practice introduced by
The expression return $obj->setOptions(...ions)->execute()->get() could return the type Phalcon\Mvc\ModelInterface|null which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
184
    }
185
    
186
    /**
187
     * Create and get from a Resultset
188
     *
189
     * @param Simple $subject
190
     * @param mixed ...$arguments
191
     * @return ?array
192
     */
193
    public static function fromResultset(Simple $subject, ...$arguments): ?array
194
    {
195
        return (new self($subject, ...$arguments))->execute()->get();
0 ignored issues
show
Bug Best Practice introduced by
The expression return new self($subject...ents)->execute()->get() could return the type Phalcon\Mvc\ModelInterface which is incompatible with the type-hinted return array|null. Consider adding an additional type-check to rule them out.
Loading history...
196
    }
197
    
198
    /**
199
     * @return null|ModelInterface[]|ModelInterface
200
     */
201
    public function get()
202
    {
203
        return $this->singleModel
204
            ? $this->subject[0] ?? null
205
            : $this->subject ?? [];
206
    }
207
    
208
    /**
209
     * @return null|ModelInterface[]
210
     */
211
    public function getSubject()
212
    {
213
        return $this->subject;
214
    }
215
    
216
    /**
217
     * Parses the arguments that will be resolved to Relation instances
218
     *
219
     * @param array $arguments
220
     * @return array
221
     * @throws \InvalidArgumentException
222
     */
223
    private static function parseArguments(array $arguments)
224
    {
225
        if (empty($arguments)) {
226
            throw new \InvalidArgumentException('Arguments can not be empty');
227
        }
228
        
229
        $relations = [];
230
        if (count($arguments) === 1 && isset($arguments[0]) && is_array($arguments[0])) {
231
            foreach ($arguments[0] as $relationAlias => $queryConstraints) {
232
                if (is_string($relationAlias)) {
233
                    $relations[$relationAlias] = is_callable($queryConstraints) ? $queryConstraints : null;
234
                }
235
                elseif (is_string($queryConstraints)) {
236
                    $relations[$queryConstraints] = null;
237
                }
238
            }
239
        }
240
        else {
241
            foreach ($arguments as $relationAlias) {
242
                if (is_string($relationAlias)) {
243
                    $relations[$relationAlias] = null;
244
                }
245
            }
246
        }
247
        
248
        return $relations;
249
    }
250
    
251
    public function addEagerLoad(string $relationAlias, ?callable $constraints = null): self
252
    {
253
        $this->eagerLoads[$relationAlias] = $constraints;
254
        return $this;
255
    }
256
    
257
    /**
258
     * Resolves the relations
259
     *
260
     * @return EagerLoad[]
261
     * @throws \RuntimeException
262
     */
263
    private function buildTree()
264
    {
265
        uksort($this->eagerLoads, 'strcmp');
266
        
267
        $di = \Phalcon\DI::getDefault();
268
        $mM = $di['modelsManager'];
269
        
270
        $eagerLoads = $resolvedRelations = [];
271
        $cache = new Cache();
0 ignored issues
show
Bug introduced by
The type Zemit\Mvc\Model\EagerLoading\Cache was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
272
        
273
        foreach ($this->eagerLoads as $relationAliases => $queryConstraints) {
274
            $nestingLevel = 0;
275
            $relationAliases = explode('.', $relationAliases);
276
            $nestingLevels = count($relationAliases);
277
            
278
            do {
279
                do {
280
                    $alias = $relationAliases[$nestingLevel];
281
                    $name = implode('.', array_slice($relationAliases, 0, $nestingLevel + 1));
282
                }
283
                while (isset($eagerLoads[$name]) && ++$nestingLevel);
284
                
285
                if ($nestingLevel === 0) {
286
                    $parentClassName = $this->className;
287
                }
288
                else {
289
                    $parentName = implode('.', array_slice($relationAliases, 0, $nestingLevel));
290
                    $parentClassName = $resolvedRelations[$parentName]->getReferencedModel();
291
                    
292
                    if ($parentClassName[0] === '\\') {
293
                        $parentClassName = ltrim($parentClassName, '\\');
294
                    }
295
                }
296
                
297
                if (!isset($resolvedRelations[$name])) {
298
                    $mM->load($parentClassName);
299
                    $relation = $mM->getRelationByAlias($parentClassName, $alias);
300
                    
301
                    if (!$relation instanceof Relation) {
302
                        throw new \RuntimeException(sprintf(
303
                            'There is no defined relation for the model `%s` using alias `%s`',
304
                            $parentClassName,
305
                            $alias
306
                        ));
307
                    }
308
                    
309
                    $resolvedRelations[$name] = $relation;
310
                }
311
                else {
312
                    $relation = $resolvedRelations[$name];
313
                }
314
                
315
                $relType = $relation->getType();
316
                
317
                if ($relType !== Relation::BELONGS_TO &&
318
                    $relType !== Relation::HAS_ONE &&
319
                    $relType !== Relation::HAS_MANY &&
320
                    $relType !== Relation::HAS_MANY_THROUGH
321
                ) {
322
                    
323
                    throw new \RuntimeException(sprintf('Unknown relation type `%s`', $relType));
324
                }
325
                
326
                if (is_array($relation->getFields()) ||
327
                    is_array($relation->getReferencedFields())
328
                ) {
329
                    
330
                    throw new \RuntimeException('Relations with composite keys are not supported');
331
                }
332
                
333
                $parent = $nestingLevel > 0 ? $eagerLoads[$parentName] : $this;
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $parentName does not seem to be defined for all execution paths leading up to this point.
Loading history...
334
                $constraints = $nestingLevel + 1 === $nestingLevels ? $queryConstraints : null;
335
                
336
                $eagerLoads[$name] = new EagerLoad($relation, $constraints, $parent, $cache);
0 ignored issues
show
Unused Code introduced by
The call to Zemit\Mvc\Model\EagerLoa...agerLoad::__construct() has too many arguments starting with $cache. ( Ignorable by Annotation )

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

336
                $eagerLoads[$name] = /** @scrutinizer ignore-call */ new EagerLoad($relation, $constraints, $parent, $cache);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
337
            }
338
            while (++$nestingLevel < $nestingLevels);
339
        }
340
        
341
        return $eagerLoads;
342
    }
343
    
344
    /**
345
     * @return $this
346
     */
347
    public function execute(): self
348
    {
349
        foreach ($this->buildTree() as $eagerLoad) {
350
            $eagerLoad->load($this->options);
0 ignored issues
show
Unused Code introduced by
The call to Zemit\Mvc\Model\EagerLoading\EagerLoad::load() has too many arguments starting with $this->options. ( Ignorable by Annotation )

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

350
            $eagerLoad->/** @scrutinizer ignore-call */ 
351
                        load($this->options);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
351
        }
352
        
353
        return $this;
354
    }
355
    
356
    /**
357
     * Loader::execute() alias
358
     *
359
     * @return $this
360
     */
361
    public function load(): self
362
    {
363
        return $this->execute();
364
    }
365
}
366