Passed
Pull Request — 2.x (#405)
by Aleksei
17:51
created

QueryBuilder::resolve()   B

Complexity

Conditions 7
Paths 8

Size

Total Lines 38
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 7

Importance

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