Test Failed
Push — master ( 894c40...e5d2d2 )
by Julien
11:34
created

Loader::execute()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
cc 2
eloc 3
c 0
b 0
f 0
nc 2
nop 0
dl 0
loc 7
ccs 0
cts 3
cp 0
crap 6
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
    private const E_INVALID_SUBJECT = 'Expected value of `subject` to be either a ModelInterface object, a Simple object or an array of ModelInterface objects';
33
    
34
    /**
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
            $from = iterator_to_array($from);
53
            if (isset($from[0])) {
54
                $className ??= get_class($from[0]);
55
            }
56
            else {
57
                $from = null;
58
            }
59
        }
60
        
61
        // Handle array
62
        elseif (is_array($from)) {
63
            $from = array_filter($from);
64
            if (isset($from[0])) {
65
                $className ??= get_class($from[0]);
66
            }
67
            foreach ($from as $el) {
68
                if ($el instanceof ModelInterface) {
69
                    // elements must be all the same model class
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 \InvalidArgumentException(self::E_INVALID_SUBJECT);
98
        }
99
        
100
        $this->subject = $from;
101
        $this->className = $className;
102
        $this->eagerLoads = ($from === null || empty($arguments)) ? [] : self::parseArguments($arguments);
103
    }
104
    
105
    /**
106
     * Set Options
107
     */
108
    public function setOptions(array $options = []): self
109
    {
110
        $this->options = $options;
111
        return $this;
112
    }
113
    
114
    /**
115
     * Create and get from a mixed $subject
116
     *
117
     * @param ModelInterface|ModelInterface[]|Simple $subject
118
     * @param mixed ...$arguments
119
     * @return mixed
120
     * @throws \InvalidArgumentException
121
     */
122
    public static function from($subject, array ...$arguments)
123
    {
124
        if ($subject instanceof ModelInterface) {
125
            return self::fromModel($subject, ...$arguments);
126
        }
127
        elseif ($subject instanceof Simple) {
128
            return self::fromResultset($subject, ...$arguments);
129
        }
130
        elseif (is_array($subject)) {
0 ignored issues
show
introduced by
The condition is_array($subject) is always true.
Loading history...
131
            return self::fromArray($subject, ...$arguments);
132
        }
133
        
134
        throw new \InvalidArgumentException(self::E_INVALID_SUBJECT);
135
    }
136
    
137
    /**
138
     * Create and get from a Model
139
     *
140
     * @param ModelInterface $subject
141
     * @param mixed ...$arguments
142
     * @return ModelInterface
143
     */
144
    public static function fromModel(ModelInterface $subject, ...$arguments): ModelInterface
145
    {
146
        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...
147
    }
148
    
149
    /**
150
     * Create and get from a Model without soft deleted records
151
     *
152
     * @param ModelInterface $subject
153
     * @param mixed ...$arguments
154
     * @return ModelInterface
155
     */
156
    public static function fromModelWithoutSoftDelete(ModelInterface $subject, ...$arguments): ModelInterface
157
    {
158
        $options = ['softDelete' => 'softDelete'];
159
        $obj = new self($subject, ...$arguments);
160
        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...
161
    }
162
    
163
    /**
164
     * Create and get from an array
165
     *
166
     * @param ModelInterface[] $subject
167
     * @param mixed ...$arguments
168
     * @return array
169
     */
170
    public static function fromArray(array $subject, ...$arguments): array
171
    {
172
        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...
173
    }
174
    
175
    /**
176
     * Create and get from an array without soft deleted records
177
     *
178
     * @param ModelInterface[] $subject
179
     * @param mixed ...$arguments
180
     * @return array
181
     */
182
    public static function fromArrayWithoutSoftDelete(array $subject, ...$arguments): array
183
    {
184
        $options = ['softDelete' => 'softDelete'];
185
        $obj = new self($subject, ...$arguments);
186
        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...
187
    }
188
    
189
    /**
190
     * Create and get from a Resultset
191
     *
192
     * @param Simple $subject
193
     * @param mixed ...$arguments
194
     * @return ?array
195
     */
196
    public static function fromResultset(Simple $subject, ...$arguments): ?array
197
    {
198
        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...
199
    }
200
    
201
    /**
202
     * @return null|ModelInterface[]|ModelInterface
203
     */
204
    public function get()
205
    {
206
        return $this->singleModel
207
            ? $this->subject[0] ?? null
208
            : $this->subject ?? [];
209
    }
210
    
211
    /**
212
     * @return null|ModelInterface[]
213
     */
214
    public function getSubject()
215
    {
216
        return $this->subject;
217
    }
218
    
219
    /**
220
     * Parses the arguments that will be resolved to Relation instances
221
     *
222
     * @param array $arguments
223
     * @return array
224
     * @throws \InvalidArgumentException
225
     */
226
    private static function parseArguments(array $arguments)
227
    {
228
        if (empty($arguments)) {
229
            throw new \InvalidArgumentException('Arguments can not be empty');
230
        }
231
        
232
        $relations = [];
233
        if (count($arguments) === 1 && isset($arguments[0]) && is_array($arguments[0])) {
234
            foreach ($arguments[0] as $relationAlias => $queryConstraints) {
235
                if (is_string($relationAlias)) {
236
                    $relations[$relationAlias] = is_callable($queryConstraints) ? $queryConstraints : null;
237
                }
238
                elseif (is_string($queryConstraints)) {
239
                    $relations[$queryConstraints] = null;
240
                }
241
            }
242
        }
243
        else {
244
            foreach ($arguments as $relationAlias) {
245
                if (is_string($relationAlias)) {
246
                    $relations[$relationAlias] = null;
247
                }
248
            }
249
        }
250
        
251
        return $relations;
252
    }
253
    
254
    public function addEagerLoad(string $relationAlias, ?callable $constraints = null): self
255
    {
256
        $this->eagerLoads[$relationAlias] = $constraints;
257
        return $this;
258
    }
259
    
260
    /**
261
     * Resolves the relations
262
     *
263
     * @return EagerLoad[]
264
     * @throws \RuntimeException
265
     */
266
    private function buildTree()
267
    {
268
        uksort($this->eagerLoads, 'strcmp');
269
        
270
        $di = \Phalcon\DI::getDefault();
271
        $mM = $di['modelsManager'];
272
        
273
        $eagerLoads = $resolvedRelations = [];
274
        
275
        foreach ($this->eagerLoads as $relationAliases => $queryConstraints) {
276
            $nestingLevel = 0;
277
            $relationAliases = explode('.', $relationAliases);
278
            $nestingLevels = count($relationAliases);
279
            
280
            do {
281
                do {
282
                    $alias = $relationAliases[$nestingLevel];
283
                    $name = implode('.', array_slice($relationAliases, 0, $nestingLevel + 1));
284
                }
285
                while (isset($eagerLoads[$name]) && ++$nestingLevel);
286
                
287
                if ($nestingLevel === 0) {
288
                    $parentClassName = $this->className;
289
                }
290
                else {
291
                    $parentName = implode('.', array_slice($relationAliases, 0, $nestingLevel));
292
                    $parentClassName = $resolvedRelations[$parentName]->getReferencedModel();
293
                    
294
                    if ($parentClassName[0] === '\\') {
295
                        $parentClassName = ltrim($parentClassName, '\\');
296
                    }
297
                }
298
                
299
                if (!isset($resolvedRelations[$name])) {
300
                    $mM->load($parentClassName);
301
                    $relation = $mM->getRelationByAlias($parentClassName, $alias);
302
                    
303
                    if (!$relation instanceof Relation) {
304
                        throw new \RuntimeException(sprintf(
305
                            'There is no defined relation for the model `%s` using alias `%s`',
306
                            $parentClassName,
307
                            $alias
308
                        ));
309
                    }
310
                    
311
                    $resolvedRelations[$name] = $relation;
312
                }
313
                else {
314
                    $relation = $resolvedRelations[$name];
315
                }
316
                
317
                $relType = $relation->getType();
318
                
319
                if ($relType !== Relation::BELONGS_TO &&
320
                    $relType !== Relation::HAS_ONE &&
321
                    $relType !== Relation::HAS_MANY &&
322
                    $relType !== Relation::HAS_MANY_THROUGH
323
                ) {
324
                    
325
                    throw new \RuntimeException(sprintf('Unknown relation type `%s`', $relType));
326
                }
327
                
328
                if (is_array($relation->getFields()) ||
329
                    is_array($relation->getReferencedFields())
330
                ) {
331
                    
332
                    throw new \RuntimeException('Relations with composite keys are not supported');
333
                }
334
                
335
                $parent = $nestingLevel > 0 && isset($parentName)? $eagerLoads[$parentName] : $this;
336
                $constraints = $nestingLevel + 1 === $nestingLevels ? $queryConstraints : null;
337
                
338
                $eagerLoads[$name] = new EagerLoad($relation, $constraints, $parent);
339
            }
340
            while (++$nestingLevel < $nestingLevels);
341
        }
342
        
343
        return $eagerLoads;
344
    }
345
    
346
    /**
347
     * @return $this
348
     */
349
    public function execute(): self
350
    {
351
        foreach ($this->buildTree() as $eagerLoad) {
352
            $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

352
            $eagerLoad->/** @scrutinizer ignore-call */ 
353
                        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...
353
        }
354
        
355
        return $this;
356
    }
357
    
358
    /**
359
     * Loader::execute() alias
360
     *
361
     * @return $this
362
     */
363
    public function load(): self
364
    {
365
        return $this->execute();
366
    }
367
}
368