Passed
Push — master ( a059a9...cdaab4 )
by Anton
01:40
created

QueryBuilder::with()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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