QueryBuilder   B
last analyzed

Complexity

Total Complexity 43

Size/Duplication

Total Lines 243
Duplicated Lines 0 %

Test Coverage

Coverage 92.77%

Importance

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

14 Methods

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