Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.24% covered (success)
99.24%
130 / 131
92.86% covered (success)
92.86%
13 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
QueryBuilderBase
99.24% covered (success)
99.24%
130 / 131
92.86% covered (success)
92.86%
13 / 14
62
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __destruct
n/a
0 / 0
n/a
0 / 0
1
 __call
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 resetQuery
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 _select
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 _getCompile
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 _like
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 _having
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 _where
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 _whereString
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 _whereIn
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 _run
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 _appendQuery
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 _compileType
n/a
0 / 0
n/a
0 / 0
8
 _compile
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 _compileReturning
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
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
17namespace Query;
18
19use BadMethodCallException;
20
21use PDO;
22use PDOStatement;
23use Query\Drivers\DriverInterface;
24use function is_string;
25use 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 */
61class 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}