Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
99.24% |
130 / 131 |
|
92.86% |
13 / 14 |
CRAP | |
0.00% |
0 / 1 |
QueryBuilderBase | |
99.24% |
130 / 131 |
|
92.86% |
13 / 14 |
62 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
__destruct | n/a |
0 / 0 |
n/a |
0 / 0 |
1 | |||||
__call | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
resetQuery | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
_select | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
_getCompile | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
_like | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
4 | |||
_having | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
_where | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
_whereString | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
6 | |||
_whereIn | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
_run | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
5 | |||
_appendQuery | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
4 | |||
_compileType | n/a |
0 / 0 |
n/a |
0 / 0 |
8 | |||||
_compile | |
95.24% |
20 / 21 |
|
0.00% |
0 / 1 |
6 | |||
_compileReturning | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
10 |
1 | <?php declare(strict_types=1); |
2 | /** |
3 | * Query |
4 | * |
5 | * SQL Query Builder / Database Abstraction Layer |
6 | * |
7 | * PHP version 8.1 |
8 | * |
9 | * @package Query |
10 | * @author Timothy J. Warren <tim@timshome.page> |
11 | * @copyright 2012 - 2023 Timothy J. Warren |
12 | * @license http://www.opensource.org/licenses/mit-license.html MIT License |
13 | * @link https://git.timshomepage.net/aviat/Query |
14 | * @version 4.0.0 |
15 | */ |
16 | |
17 | namespace Query; |
18 | |
19 | use BadMethodCallException; |
20 | |
21 | use PDO; |
22 | use PDOStatement; |
23 | use Query\Drivers\DriverInterface; |
24 | use function is_string; |
25 | use function regexInArray; |
26 | |
27 | /** |
28 | * @method affectedRows(): int |
29 | * @method beginTransaction(): bool |
30 | * @method commit(): bool |
31 | * @method errorCode(): string |
32 | * @method errorInfo(): array |
33 | * @method exec(string $statement): int |
34 | * @method getAttribute(int $attribute) |
35 | * @method getColumns(string $table): array | null |
36 | * @method getDbs(): array | null |
37 | * @method getFks(string $table): array | null |
38 | * @method getIndexes(string $table): array | null |
39 | * @method getLastQuery(): string |
40 | * @method getSchemas(): array | null |
41 | * @method getSequences(): array | null |
42 | * @method getSystemTables(): array | null |
43 | * @method getTables(): array |
44 | * @method getTypes(): array | null |
45 | * @method getUtil(): \Query\Drivers\AbstractUtil |
46 | * @method getVersion(): string |
47 | * @method getViews(): array | null |
48 | * @method inTransaction(): bool |
49 | * @method lastInsertId(string $name = NULL): string |
50 | * @method numRows(): int | null |
51 | * @method prepare(string $statement, array $driver_options = []): PDOStatement |
52 | * @method prepareExecute(string $sql, array $params): PDOStatement |
53 | * @method prepareQuery(string $sql, array $data): PDOStatement |
54 | * @method query(string $statement): PDOStatement |
55 | * @method quote(string $string, int $parameter_type = PDO::PARAM_STR): string |
56 | * @method rollback(): bool |
57 | * @method setAttribute(int $attribute, $value): bool |
58 | * @method setTablePrefix(string $prefix): void |
59 | * @method truncate(string $table): PDOStatement |
60 | */ |
61 | class QueryBuilderBase |
62 | { |
63 | /** |
64 | * Convenience property for connection management |
65 | */ |
66 | public string $connName = ''; |
67 | |
68 | /** |
69 | * List of queries executed |
70 | */ |
71 | public array $queries = [ |
72 | 'total_time' => 0, |
73 | ]; |
74 | |
75 | /** |
76 | * Whether to do only an explain on the query |
77 | */ |
78 | protected bool $explain = FALSE; |
79 | |
80 | /** |
81 | * Whether to return data from a modification query |
82 | */ |
83 | protected bool $returning = FALSE; |
84 | |
85 | /** |
86 | * Query Builder state |
87 | */ |
88 | protected State $state; |
89 | |
90 | // -------------------------------------------------------------------------- |
91 | // ! Methods |
92 | // -------------------------------------------------------------------------- |
93 | /** |
94 | * Constructor |
95 | */ |
96 | public function __construct(protected ?DriverInterface $driver, protected QueryParser $parser) |
97 | { |
98 | // Create new State object |
99 | $this->state = new State(); |
100 | } |
101 | |
102 | /** |
103 | * Destructor |
104 | * @codeCoverageIgnore |
105 | */ |
106 | public function __destruct() |
107 | { |
108 | $this->driver = NULL; |
109 | } |
110 | |
111 | /** |
112 | * Calls a function further down the inheritance chain. |
113 | * 'Implements' methods on the driver object |
114 | * |
115 | * @throws BadMethodCallException |
116 | * @return mixed |
117 | */ |
118 | public function __call(string $name, array $params) |
119 | { |
120 | if (method_exists($this->driver, $name)) |
121 | { |
122 | return $this->driver->$name(...$params); |
123 | } |
124 | |
125 | throw new BadMethodCallException('Method does not exist'); |
126 | } |
127 | |
128 | /** |
129 | * Clear out the class variables, so the next query can be run |
130 | */ |
131 | public function resetQuery(): void |
132 | { |
133 | $this->state = new State(); |
134 | $this->explain = FALSE; |
135 | $this->returning = FALSE; |
136 | } |
137 | |
138 | /** |
139 | * Method to simplify select_ methods |
140 | */ |
141 | protected function _select(string $field, bool|string $as = FALSE): string |
142 | { |
143 | // Escape the identifiers |
144 | $field = $this->driver->quoteIdent($field); |
145 | |
146 | if ( ! is_string($as)) |
147 | { |
148 | // @codeCoverageIgnoreStart |
149 | return $field; |
150 | // @codeCoverageIgnoreEnd |
151 | } |
152 | |
153 | $as = $this->driver->quoteIdent($as); |
154 | |
155 | return "({$field}) AS {$as} "; |
156 | } |
157 | |
158 | /** |
159 | * Helper function for returning sql strings |
160 | */ |
161 | protected function _getCompile(QueryType $type, string $table, bool $reset): string |
162 | { |
163 | $sql = $this->_compile($type, $table); |
164 | |
165 | // Reset the query builder for the next query |
166 | if ($reset) |
167 | { |
168 | $this->resetQuery(); |
169 | } |
170 | |
171 | return $sql; |
172 | } |
173 | |
174 | /** |
175 | * Simplify 'like' methods |
176 | */ |
177 | protected function _like(string $field, mixed $val, LikeType $pos, string $like = 'LIKE', string $conj = 'AND'): self |
178 | { |
179 | $field = $this->driver->quoteIdent($field); |
180 | |
181 | // Add the like string into the order map |
182 | $like = $field . " {$like} ?"; |
183 | |
184 | if ($pos === LikeType::BEFORE) |
185 | { |
186 | $val = "%{$val}"; |
187 | } |
188 | elseif ($pos === LikeType::AFTER) |
189 | { |
190 | $val = "{$val}%"; |
191 | } |
192 | else |
193 | { |
194 | $val = "%{$val}%"; |
195 | } |
196 | |
197 | $conj = empty($this->state->getQueryMap()) ? ' WHERE ' : " {$conj} "; |
198 | $this->state->appendMap($conj, $like, MapType::LIKE); |
199 | |
200 | // Add to the values array |
201 | $this->state->appendWhereValues($val); |
202 | |
203 | return $this; |
204 | } |
205 | |
206 | /** |
207 | * Simplify building having clauses |
208 | */ |
209 | protected function _having(mixed $key, mixed $values = [], string $conj = 'AND'): self |
210 | { |
211 | $where = $this->_where($key, $values); |
212 | |
213 | // Create key/value placeholders |
214 | foreach ($where as $f => $val) |
215 | { |
216 | // Split each key by spaces, in case there |
217 | // is an operator such as >, <, !=, etc. |
218 | $fArray = explode(' ', trim($f)); |
219 | |
220 | $item = $this->driver->quoteIdent($fArray[0]); |
221 | |
222 | // Simple key value, or an operator |
223 | $item .= (count($fArray) === 1) ? '=?' : " {$fArray[1]} ?"; |
224 | |
225 | // Put in the having map |
226 | $this->state->appendHavingMap([ |
227 | 'conjunction' => empty($this->state->getHavingMap()) |
228 | ? ' HAVING ' |
229 | : " {$conj} ", |
230 | 'string' => $item, |
231 | ]); |
232 | } |
233 | |
234 | return $this; |
235 | } |
236 | |
237 | /** |
238 | * Do all the redundant stuff for where/having type methods |
239 | */ |
240 | protected function _where(mixed $key, mixed $val = []): array |
241 | { |
242 | $where = []; |
243 | $pairs = []; |
244 | |
245 | if (is_scalar($key)) |
246 | { |
247 | $pairs[$key] = $val; |
248 | } |
249 | else |
250 | { |
251 | $pairs = $key; |
252 | } |
253 | |
254 | foreach ($pairs as $k => $v) |
255 | { |
256 | $where[$k] = $v; |
257 | $this->state->appendWhereValues($v); |
258 | } |
259 | |
260 | return $where; |
261 | } |
262 | |
263 | /** |
264 | * Simplify generating where string |
265 | */ |
266 | protected function _whereString(mixed $key, mixed $values = [], string $defaultConj = 'AND'): self |
267 | { |
268 | // Create key/value placeholders |
269 | foreach ($this->_where($key, $values) as $f => $val) |
270 | { |
271 | $queryMap = $this->state->getQueryMap(); |
272 | |
273 | // Split each key by spaces, in case there |
274 | // is an operator such as >, <, !=, etc. |
275 | $fArray = explode(' ', trim($f)); |
276 | |
277 | $item = $this->driver->quoteIdent($fArray[0]); |
278 | |
279 | // Simple key value, or an operator |
280 | $item .= (count($fArray) === 1) ? '=?' : " {$fArray[1]} ?"; |
281 | $lastItem = end($queryMap); |
282 | |
283 | // Determine the correct conjunction |
284 | $conjunctionList = array_column($queryMap, 'conjunction'); |
285 | if (empty($queryMap) || ( ! regexInArray($conjunctionList, "/^ ?\n?WHERE/i"))) |
286 | { |
287 | $conj = "\nWHERE "; |
288 | } |
289 | elseif ($lastItem['type'] === MapType::GROUP_START) |
290 | { |
291 | $conj = ''; |
292 | } |
293 | else |
294 | { |
295 | $conj = " {$defaultConj} "; |
296 | } |
297 | |
298 | $this->state->appendMap($conj, $item, MapType::WHERE); |
299 | } |
300 | |
301 | return $this; |
302 | } |
303 | |
304 | /** |
305 | * Simplify where_in methods |
306 | * |
307 | * @param string $in - The (not) in fragment |
308 | * @param string $conj - The where in conjunction |
309 | */ |
310 | protected function _whereIn(mixed $key, mixed $val = [], string $in = 'IN', string $conj = 'AND'): self |
311 | { |
312 | $key = $this->driver->quoteIdent($key); |
313 | $params = array_fill(0, is_countable($val) ? count($val) : 0, '?'); |
314 | $this->state->appendWhereValues($val); |
315 | |
316 | $conjunction = empty($this->state->getQueryMap()) ? ' WHERE ' : " {$conj} "; |
317 | $str = $key . " {$in} (" . implode(',', $params) . ') '; |
318 | |
319 | $this->state->appendMap($conjunction, $str, MapType::WHERE_IN); |
320 | |
321 | return $this; |
322 | } |
323 | |
324 | /** |
325 | * Executes the compiled query |
326 | */ |
327 | protected function _run(QueryType $type, string $table, ?string $sql = NULL, ?array $vals = NULL, bool $reset = TRUE): PDOStatement |
328 | { |
329 | if ($sql === NULL) |
330 | { |
331 | $sql = $this->_compile($type, $table); |
332 | } |
333 | |
334 | if ($vals === NULL) |
335 | { |
336 | $vals = array_merge($this->state->getValues(), $this->state->getWhereValues()); |
337 | } |
338 | |
339 | $startTime = microtime(TRUE); |
340 | |
341 | $res = empty($vals) |
342 | ? $this->driver->query($sql) |
343 | : $this->driver->prepareExecute($sql, $vals); |
344 | |
345 | $endTime = microtime(TRUE); |
346 | $totalTime = number_format($endTime - $startTime, 5); |
347 | |
348 | // Add this query to the list of executed queries |
349 | $this->_appendQuery($vals, $sql, (int) $totalTime); |
350 | |
351 | // Reset class state for next query |
352 | if ($reset) |
353 | { |
354 | $this->resetQuery(); |
355 | } |
356 | |
357 | return $res; |
358 | } |
359 | |
360 | /** |
361 | * Convert the prepared statement into readable sql |
362 | */ |
363 | protected function _appendQuery(array $values, string $sql, int $totalTime): void |
364 | { |
365 | $evals = is_array($values) ? $values : []; |
366 | $esql = str_replace('?', '%s', $sql); |
367 | |
368 | // Quote string values |
369 | foreach ($evals as &$v) |
370 | { |
371 | $v = (is_numeric($v)) |
372 | ? $v |
373 | : htmlentities($this->driver->quote($v), ENT_NOQUOTES, 'utf-8'); |
374 | } |
375 | unset($v); |
376 | |
377 | // Add the query onto the array of values to pass |
378 | // as arguments to sprintf |
379 | array_unshift($evals, $esql); |
380 | |
381 | // Add the interpreted query to the list of executed queries |
382 | $this->queries[] = [ |
383 | 'time' => $totalTime, |
384 | 'sql' => sprintf(...$evals), |
385 | ]; |
386 | |
387 | $this->queries['total_time'] += $totalTime; |
388 | |
389 | // Set the last query to get rowcounts properly |
390 | $this->driver->setLastQuery($sql); |
391 | } |
392 | |
393 | /** |
394 | * Sub-method for generating sql strings |
395 | * |
396 | * @codeCoverageIgnore |
397 | */ |
398 | protected function _compileType(QueryType $type = QueryType::SELECT, string $table = ''): string |
399 | { |
400 | $setArrayKeys = $this->state->getSetArrayKeys(); |
401 | |
402 | switch ($type) |
403 | { |
404 | case QueryType::INSERT: |
405 | $paramCount = is_countable($setArrayKeys) ? count($setArrayKeys) : 0; |
406 | $params = array_fill(0, $paramCount, '?'); |
407 | $sql = "INSERT INTO {$table} (" |
408 | . implode(',', $setArrayKeys) |
409 | . ")\nVALUES (" . implode(',', $params) . ')'; |
410 | break; |
411 | |
412 | case QueryType::UPDATE: |
413 | $setString = $this->state->getSetString(); |
414 | $sql = "UPDATE {$table}\nSET {$setString}"; |
415 | break; |
416 | |
417 | case QueryType::DELETE: |
418 | $sql = "DELETE FROM {$table}"; |
419 | break; |
420 | |
421 | case QueryType::SELECT: |
422 | default: |
423 | $fromString = $this->state->getFromString(); |
424 | $selectString = $this->state->getSelectString(); |
425 | |
426 | $sql = "SELECT * \nFROM {$fromString}"; |
427 | |
428 | // Set the select string |
429 | if ( ! empty($selectString)) |
430 | { |
431 | // Replace the star with the selected fields |
432 | $sql = str_replace('*', $selectString, $sql); |
433 | } |
434 | break; |
435 | } |
436 | |
437 | return $sql; |
438 | } |
439 | |
440 | /** |
441 | * String together the sql statements for sending to the db |
442 | */ |
443 | protected function _compile(QueryType $type, string $table = ''): string |
444 | { |
445 | // Get the base clause for the query |
446 | $sql = $this->_compileType($type, $this->driver->quoteTable($table)); |
447 | |
448 | $clauses = [ |
449 | 'queryMap', |
450 | 'groupString', |
451 | 'orderString', |
452 | 'havingMap', |
453 | ]; |
454 | |
455 | // Set each type of subclause |
456 | foreach ($clauses as $clause) |
457 | { |
458 | $func = 'get' . ucfirst($clause); |
459 | $param = $this->state->$func(); |
460 | if (is_array($param)) |
461 | { |
462 | foreach ($param as $q) |
463 | { |
464 | $sql .= $q['conjunction'] . $q['string']; |
465 | } |
466 | } |
467 | else |
468 | { |
469 | $sql .= $param; |
470 | } |
471 | } |
472 | |
473 | // Set the limit via the class variables |
474 | $limit = $this->state->getLimit(); |
475 | if (is_numeric($limit)) |
476 | { |
477 | $sql = $this->driver->getSql()->limit($sql, $limit, $this->state->getOffset()); |
478 | } |
479 | |
480 | // Set the returning clause, if applicable |
481 | $sql = $this->_compileReturning($sql, $type); |
482 | |
483 | // See if the query plan, rather than the |
484 | // query data should be returned |
485 | if ($this->explain === TRUE) |
486 | { |
487 | $sql = $this->driver->getSql()->explain($sql); |
488 | } |
489 | |
490 | return $sql; |
491 | } |
492 | |
493 | /** |
494 | * Generate returning clause of query |
495 | */ |
496 | protected function _compileReturning(string $sql, QueryType $type): string |
497 | { |
498 | if ($this->returning === FALSE) |
499 | { |
500 | return $sql; |
501 | } |
502 | |
503 | $rawSelect = $this->state->getSelectString(); |
504 | $selectString = ($rawSelect === '') ? '*' : $rawSelect; |
505 | $returningSQL = $this->driver->returning($sql, $selectString); |
506 | |
507 | if ($returningSQL === $sql) |
508 | { |
509 | // If the driver doesn't support the returning clause, it returns the original query. |
510 | // Fake the same result with a transaction and a select query |
511 | if ( ! $this->inTransaction()) |
512 | { |
513 | $this->beginTransaction(); |
514 | } |
515 | |
516 | // Generate the appropriate select query for the returning clause fallback |
517 | // @TODO figure out how to do a proper fallback |
518 | switch ($type) |
519 | { |
520 | case QueryType::INSERT: |
521 | |
522 | case QueryType::UPDATE: |
523 | |
524 | case QueryType::INSERT_BATCH: |
525 | case QueryType::UPDATE_BATCH: |
526 | |
527 | default: |
528 | // On Delete queries, what would we return? |
529 | break; |
530 | } |
531 | } |
532 | |
533 | return $returningSQL; |
534 | } |
535 | } |