Passed
Push — 2.x ( 655975...5d1907 )
by Aleksei
30:56 queued 10:57
created

QueryBuilder   B

Complexity

Total Complexity 43

Size/Duplication

Total Lines 244
Duplicated Lines 0 %

Test Coverage

Coverage 92.77%

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 85
c 3
b 0
f 0
dl 0
loc 244
ccs 77
cts 83
cp 0.9277
rs 8.96
wmc 43

14 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 5 1
A getQuery() 0 3 1
B resolve() 0 38 7
A getLoader() 0 3 1
B findLoader() 0 14 7
A withForward() 0 8 1
A proxyArgs() 0 21 5
A withQuery() 0 6 1
A wrap() 0 9 3
A isJoin() 0 3 1
A with() 0 5 1
A walkRecursive() 0 21 6
A __call() 0 12 3
A targetFunc() 0 17 5

How to fix   Complexity   

Complex Class

Complex classes like QueryBuilder often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use QueryBuilder, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Cycle\ORM\Select;
6
7
use Closure;
8
use Cycle\ORM\Exception\BuilderException;
9
use JetBrains\PhpStorm\ExpectedValues;
10
use Cycle\Database\Driver\Compiler;
11
use Cycle\Database\Query\SelectQuery;
12
13
/**
14
 * Mocks SelectQuery and automatically resolves identifiers for the loaded relations.
15
 *
16
 * @method QueryBuilder distinct()
17
 * @method QueryBuilder where(...$args);
18
 * @method QueryBuilder andWhere(...$args);
19
 * @method QueryBuilder orWhere(...$args);
20
 * @method QueryBuilder having(...$args);
21
 * @method QueryBuilder andHaving(...$args);
22
 * @method QueryBuilder orHaving(...$args);
23
 * @method QueryBuilder orderBy($expression, $direction = 'ASC');
24
 * @method QueryBuilder limit(int $limit)
25
 * @method QueryBuilder offset(int $offset)
26
 * @method QueryBuilder forUpdate()
27
 * @method QueryBuilder whereJson(string $path, mixed $value)
28
 * @method QueryBuilder orWhereJson(string $path, mixed $value)
29
 * @method QueryBuilder whereJsonContains(string $path, mixed $value, bool $encode = true, bool $validate = true)
30
 * @method QueryBuilder orWhereJsonContains(string $path, mixed $value, bool $encode = true, bool $validate = true)
31
 * @method QueryBuilder whereJsonDoesntContain(string $path, mixed $value, bool $encode = true, bool $validate = true)
32
 * @method QueryBuilder orWhereJsonDoesntContain(string $path, mixed $value, bool $encode = true, bool $validate = true)
33
 * @method QueryBuilder whereJsonContainsKey(string $path)
34
 * @method QueryBuilder orWhereJsonContainsKey(string $path)
35 6834
 * @method QueryBuilder whereJsonDoesntContainKey(string $path)
36
 * @method QueryBuilder orWhereJsonDoesntContainKey(string $path)
37
 * @method QueryBuilder whereJsonLength(string $path, int $length, string $operator = '=')
38
 * @method QueryBuilder orWhereJsonLength(string $path, int $length, string $operator = '=')
39
 * @method int avg($identifier) Perform aggregation (AVG) based on column or expression value.
40
 * @method int min($identifier) Perform aggregation (MIN) based on column or expression value.
41
 * @method int max($identifier) Perform aggregation (MAX) based on column or expression value.
42
 * @method int sum($identifier) Perform aggregation (SUM) based on column or expression value.
43
 */
44
final class QueryBuilder
45
{
46
    private ?string $forward = null;
47 6044
48
    public function __construct(
49 6044
        private SelectQuery $query,
50 6044
        /** @internal */
51 6028
        private AbstractLoader $loader
52
    ) {
53
    }
54 72
55
    /**
56
     * Forward call to underlying target.
57
     *
58
     * @return mixed|SelectQuery
59
     */
60 744
    public function __call(string $func, array $args)
61
    {
62 744
        $result = \call_user_func_array(
63
            $this->targetFunc($func),
64
            $this->isJoin($func) ? $args : $this->proxyArgs($args)
65
        );
66
67
        if ($result === $this->query) {
68
            return $this;
69
        }
70
71
        return $result;
72
    }
73
74
    /**
75
     * Get currently associated query. Immutable.
76 1016
     */
77
    public function getQuery(): ?SelectQuery
78
    {
79
        return clone $this->query;
80 1016
    }
81 1016
82
    /**
83 1016
     * Access to underlying loader. Immutable.
84
     */
85
    public function getLoader(): AbstractLoader
86 520
    {
87
        return clone $this->loader;
88 520
    }
89 520
90
    /**
91 520
     * Select query method prefix for all "where" queries. Can route "where" to "onWhere".
92
     */
93
    public function withForward(
94
        #[ExpectedValues(values: ['where', 'onWhere'])]
95
        string $forward = null
96
    ): self {
97
        $builder = clone $this;
98
        $builder->forward = $forward;
99
100
        return $builder;
101
    }
102
103
    public function withQuery(SelectQuery $query): self
104 5298
    {
105
        $builder = clone $this;
106 5298
        $builder->query = $query;
107 24
108
        return $builder;
109
    }
110 5298
111
    /**
112 2514
     * Resolve given object.field identifier into proper table alias and column name.
113
     * Attention, calling this method would not affect loaded relations, you must call with/load directly!
114 2514
     *
115 2514
     * Use this method for complex relation queries in combination with Expression()
116
     *
117
     * @param bool $autoload If set to true (default) target relation will be automatically loaded.
118
     *
119 4176
     * @throws BuilderException
120
     */
121 4176
    public function resolve(string $identifier, bool $autoload = true): string
122 4176
    {
123 4160
        if ($identifier === '*') {
124
            return '*';
125 4160
        }
126 4160
127
        if (!\str_contains($identifier, '.')) {
128
            $current = $this->loader;
129
130 48
            do {
131
                $column = $current->fieldAlias($identifier);
132
133
                // Find an inheritance parent that has this field
134
                if ($column === null) {
135
                    $parent = $current->getParentLoader();
136 8
                    if ($parent !== null) {
137
                        $current = $parent;
138 8
                        continue;
139
                    }
140 8
                }
141
142
                return \sprintf('%s.%s', $current->getAlias(), $column ?? $identifier);
143
            } while (true);
144
        }
145
146
        $split = \strrpos($identifier, '.');
147
148 4176
        $loader = $this->findLoader(\substr($identifier, 0, $split), $autoload);
149
        if ($loader !== null) {
150 4176
            $identifier = \substr($identifier, $split + 1);
151
            return \sprintf(
152 48
                '%s.%s',
153
                $loader->getAlias(),
154
                $loader->fieldAlias($identifier) ?? $identifier,
155 4160
            );
156 3824
        }
157
158
        return $identifier;
159 376
    }
160
161 376
    /**
162
     * Join relation without loading it's data.
163
     */
164
    public function with(string $relation, array $options = []): self
165
    {
166
        $this->loader->loadRelation($relation, $options, true, false);
167 6044
168
        return $this;
169 6044
    }
170 1016
171 1016
    /**
172 680
     * Find loader associated with given entity/relation alias.
173 680
     *
174 640
     * @param bool $autoload When set to true relation will be automatically loaded.
175 8
     */
176 8
    private function findLoader(string $name, bool $autoload = true): ?LoaderInterface
177 640
    {
178
        if (strpos($name, '(')) {
179
            // expressions are not allowed
180
            return null;
181
        }
182
183 6044
        if ($name == '' || $name == '@' || $name == $this->loader->getTarget() || $name == $this->loader->getAlias()) {
184
            return $this->loader;
185
        }
186
187
        $loader = $autoload ? $this->loader : clone $this->loader;
188
189
        return $loader->loadRelation($name, [], true);
190 6044
    }
191
192 6044
    /**
193 2612
     * Replace target where call with another compatible method (for example join or having).
194
     */
195
    private function targetFunc(string $call): callable
196 5896
    {
197 4304
        if ($this->forward != null) {
0 ignored issues
show
Bug introduced by
It seems like you are loosely comparing $this->forward of type null|string against null; this is ambiguous if the string can be empty. Consider using a strict comparison !== instead.
Loading history...
198
            switch (strtolower($call)) {
199
                case 'where':
200 5896
                    $call = $this->forward;
201 4016
                    break;
202
                case 'orwhere':
203
                    $call = 'or' . ucfirst($this->forward);
204 5896
                    break;
205 472
                case 'andwhere':
206 472
                    $call = 'and' . ucfirst($this->forward);
207
                    break;
208
            }
209
        }
210 5896
211
        return [$this->query, $call];
212
    }
213
214
    /**
215
     * Automatically modify all identifiers to mount table prefix. Provide ability to automatically resolve
216
     * relations.
217
     */
218 2306
    private function proxyArgs(array $args): array
219
    {
220 2306
        if (!isset($args[0])) {
221 2306
            return $args;
222
        }
223
224 2306
        if (\is_string($args[0])) {
225
            $args[0] = $this->resolve($args[0]);
226
        }
227
228
        if (\is_array($args[0])) {
229
            $args[0] = $this->walkRecursive($args[0], [$this, 'wrap']);
230
        }
231
232
        if ($args[0] instanceof Closure) {
233
            $args[0] = function ($q) use ($args): void {
234 4016
                $args[0]($this->withQuery($q));
235
            };
236 4016
        }
237 4016
238 2306
        return $args;
239 304
    }
240
241 16
    /**
242 16
     * Automatically resolve identifier value or wrap the expression.
243
     *
244 304
     * @param mixed $value
245 16
     */
246
    private function wrap(int|string &$identifier, &$value): void
247
    {
248
        if (!\is_numeric($identifier)) {
249 2306
            $identifier = $this->resolve($identifier);
250 2306
        }
251
252
        if ($value instanceof Closure) {
253 4016
            $value = function ($q) use ($value): void {
254
                $value($this->withQuery($q));
255
            };
256
        }
257
    }
258
259
    /**
260
     * Walk through method arguments using given function.
261
     */
262
    private function walkRecursive(array $input, callable $func, bool $complex = false): array
263
    {
264
        $result = [];
265
        foreach ($input as $k => $v) {
266
            if (\is_array($v)) {
267
                if (!\is_numeric($k) && \in_array(\strtoupper($k), [Compiler::TOKEN_AND, Compiler::TOKEN_OR], true)) {
268
                    // complex expression like @OR and @AND
269
                    $result[$k] = $this->walkRecursive($v, $func, true);
270
                    continue;
271
                }
272
273
                if ($complex) {
274
                    $v = $this->walkRecursive($v, $func);
275
                }
276
            }
277
278
            $func(...[&$k, &$v]);
279
            $result[$k] = $v;
280
        }
281
282
        return $result;
283
    }
284
285
    private function isJoin(string $method): bool
286
    {
287
        return \in_array($method, ['join', 'innerJoin', 'rightJoin', 'leftJoin', 'fullJoin'], true);
288
    }
289
}
290