Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.31% covered (success)
99.31%
144 / 145
97.14% covered (success)
97.14%
34 / 35
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractDriver
99.31% covered (success)
99.31%
144 / 145
97.14% covered (success)
97.14%
34 / 35
62
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 _loadSubClasses
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 __call
n/a
0 / 0
n/a
0 / 0
4
 getLastQuery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLastQuery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSql
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUtil
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setTablePrefix
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prepareQuery
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 prepareExecute
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 affectedRows
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prefixTable
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 quoteTable
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 quoteIdent
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 getSchemas
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTables
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getDbs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getViews
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getSequences
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFunctions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getProcedures
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTriggers
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSystemTables
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getColumns
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getIndexes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTypes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 driverQuery
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 numRows
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 insertBatch
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 updateBatch
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
6
 truncate
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 returning
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 _quote
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 _prefix
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
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\Drivers;
18
19use InvalidArgumentException;
20use PDO;
21use PDOStatement;
22
23use function call_user_func_array;
24use function dbFilter;
25use function is_object;
26use function is_string;
27
28/**
29 * Base Database class
30 *
31 * Extends PDO to simplify cross-database issues
32 */
33abstract class AbstractDriver extends PDO implements DriverInterface
34{
35    /**
36     * Reference to the last executed query
37     */
38    protected PDOStatement $statement;
39
40    /**
41     * Start character to escape identifiers
42     */
43    protected string $escapeCharOpen = '"';
44
45    /**
46     * End character to escape identifiers
47     */
48    protected string $escapeCharClose = '"';
49
50    /**
51     * Reference to sql class
52     */
53    protected SQLInterface $driverSQL;
54
55    /**
56     * Reference to util class
57     */
58    protected AbstractUtil $util;
59
60    /**
61     * Last query executed
62     */
63    protected string $lastQuery = '';
64
65    /**
66     * Prefix to apply to table names
67     */
68    protected string $tablePrefix = '';
69
70    /**
71     * Whether the driver supports 'TRUNCATE'
72     */
73    protected bool $hasTruncate = TRUE;
74
75    /**
76     * PDO constructor wrapper
77     */
78    public function __construct(string $dsn, ?string $username=NULL, ?string $password=NULL, array $driverOptions=[])
79    {
80        // Set PDO to display errors as exceptions, and apply driver options
81        $driverOptions[PDO::ATTR_ERRMODE] = PDO::ERRMODE_EXCEPTION;
82        parent::__construct($dsn, $username, $password, $driverOptions);
83
84        $this->_loadSubClasses();
85    }
86
87    /**
88     * Loads the subclasses for the driver
89     */
90    protected function _loadSubClasses(): void
91    {
92        // Load the sql and util class for the driver
93        $thisClass = $this::class;
94        $nsArray = explode('\\', $thisClass);
95        array_pop($nsArray);
96        $driver = array_pop($nsArray);
97        $sqlClass = __NAMESPACE__ . "\\{$driver}\\SQL";
98        $utilClass = __NAMESPACE__ . "\\{$driver}\\Util";
99
100        $this->driverSQL = new $sqlClass();
101        $this->util = new $utilClass($this);
102    }
103
104    /**
105     * Allow invoke to work on table object
106     *
107     * @codeCoverageIgnore
108     * @return mixed
109     */
110    public function __call(string $name, array $args = [])
111    {
112        if (
113            isset($this->$name)
114            && is_object($this->$name)
115            && method_exists($this->$name, '__invoke')
116        ) {
117            return call_user_func_array([$this->$name, '__invoke'], $args);
118        }
119
120        return NULL;
121    }
122
123    // --------------------------------------------------------------------------
124    // ! Accessors / Mutators
125    // --------------------------------------------------------------------------
126    /**
127     * Get the last sql query executed
128     */
129    public function getLastQuery(): string
130    {
131        return $this->lastQuery;
132    }
133
134    /**
135     * Set the last query sql
136     */
137    public function setLastQuery(string $queryString): void
138    {
139        $this->lastQuery = $queryString;
140    }
141
142    /**
143     * Get the SQL class for the current driver
144     */
145    public function getSql(): SQLInterface
146    {
147        return $this->driverSQL;
148    }
149
150    /**
151     * Get the Util class for the current driver
152     */
153    public function getUtil(): AbstractUtil
154    {
155        return $this->util;
156    }
157
158    /**
159     * Set the common table name prefix
160     */
161    public function setTablePrefix(string $prefix): void
162    {
163        $this->tablePrefix = $prefix;
164    }
165
166    // --------------------------------------------------------------------------
167    // ! Concrete functions that can be overridden in child classes
168    // --------------------------------------------------------------------------
169    /**
170     * Simplifies prepared statements for database queries
171     */
172    public function prepareQuery(string $sql, array $data): PDOStatement
173    {
174        // Prepare the sql, save the statement for easy access later
175        $this->statement = $this->prepare($sql);
176
177        // Bind the parameters
178        foreach ($data as $k => $value)
179        {
180            // Parameters are 1-based, the data is 0-based
181            // So, if the key is numeric, add 1
182            if (is_numeric($k))
183            {
184                $k++;
185            }
186            $this->statement->bindValue($k, $value);
187        }
188
189        return $this->statement;
190    }
191
192    /**
193     * Create and execute a prepared statement with the provided parameters
194     *
195     * @throws InvalidArgumentException
196     */
197    public function prepareExecute(string $sql, array $params): PDOStatement
198    {
199        $this->statement = $this->prepareQuery($sql, $params);
200        $this->statement->execute();
201
202        return $this->statement;
203    }
204
205    /**
206     * Returns number of rows affected by an INSERT, UPDATE, DELETE type query
207     */
208    public function affectedRows(): int
209    {
210        // Return number of rows affected
211        return $this->statement->rowCount();
212    }
213
214    /**
215     * Prefixes a table if it is not already prefixed
216     */
217    public function prefixTable(string $table): string
218    {
219        // Add the prefix to the table name
220        // before quoting it
221        if ( ! empty($this->tablePrefix))
222        {
223            // Split identifier by period, will split into:
224            // database.schema.table OR
225            // schema.table OR
226            // database.table OR
227            // table
228            $identifiers = explode('.', $table);
229            $segments = count($identifiers);
230
231            // Quote the last item, and add the database prefix
232            $identifiers[$segments - 1] = $this->_prefix(end($identifiers));
233
234            // Rejoin
235            $table = implode('.', $identifiers);
236        }
237
238        return $table;
239    }
240
241    /**
242     * Quote database table name, and set prefix
243     */
244    public function quoteTable(string $table): string
245    {
246        $table = $this->prefixTable($table);
247
248        // Finally, quote the table
249        return $this->quoteIdent($table);
250    }
251
252    /**
253     * Surrounds the string with the databases identifier escape characters
254     */
255    public function quoteIdent(string|array $identifier): string|array
256    {
257        if (is_array($identifier))
258        {
259            return array_map([$this, __METHOD__], $identifier);
260        }
261
262        // Make all the string-handling methods happy
263        // $identifier = (string)$identifier;
264
265        // Handle comma-separated identifiers
266        if (str_contains($identifier, ','))
267        {
268            $parts = array_map('mb_trim', explode(',', $identifier));
269            $parts = array_map([$this, __METHOD__], $parts);
270            $identifier = implode(',', $parts);
271        }
272
273        // Split each identifier by the period
274        $hiers = explode('.', $identifier);
275        $hiers = array_map('mb_trim', $hiers);
276
277        // Re-compile the string
278        $raw = implode('.', array_map([$this, '_quote'], $hiers));
279
280        // Fix functions
281        $funcs = [];
282        preg_match_all("#{$this->escapeCharOpen}([a-zA-Z0-9_]+(\((.*?)\))){$this->escapeCharClose}#iu", $raw, $funcs, PREG_SET_ORDER);
283
284        foreach ($funcs as $f)
285        {
286            // Unquote the function
287            // Quote the inside identifiers
288            $raw = str_replace([$f[0], $f[3]], [$f[1], $this->quoteIdent($f[3])], $raw);
289        }
290
291        return $raw;
292    }
293
294    /**
295     * Return schemas for databases that list them
296     */
297    public function getSchemas(): ?array
298    {
299        // Most DBMSs conflate schemas and databases
300        return $this->getDbs();
301    }
302
303    /**
304     * Return list of tables for the current database
305     */
306    public function getTables(): ?array
307    {
308        $tables = $this->driverQuery('tableList');
309        natsort($tables);
310
311        return $tables;
312    }
313
314    /**
315     * Return list of dbs for the current connection, if possible
316     */
317    public function getDbs(): ?array
318    {
319        return $this->driverQuery('dbList');
320    }
321
322    /**
323     * Return list of views for the current database
324     */
325    public function getViews(): ?array
326    {
327        $views = $this->driverQuery('viewList');
328        sort($views);
329
330        return $views;
331    }
332
333    /**
334     * Return list of sequences for the current database, if they exist
335     */
336    public function getSequences(): ?array
337    {
338        return $this->driverQuery('sequenceList');
339    }
340
341    /**
342     * Return list of functions for the current database
343     *
344     * @deprecated Will be removed in next version
345     */
346    public function getFunctions(): ?array
347    {
348        return $this->driverQuery('functionList', FALSE);
349    }
350
351    /**
352     * Return list of stored procedures for the current database
353     *
354     * @deprecated Will be removed in next version
355     */
356    public function getProcedures(): ?array
357    {
358        return $this->driverQuery('procedureList', FALSE);
359    }
360
361    /**
362     * Return list of triggers for the current database
363     *
364     * @deprecated Will be removed in next version
365     */
366    public function getTriggers(): ?array
367    {
368        return $this->driverQuery('triggerList', FALSE);
369    }
370
371    /**
372     * Retrieves an array of non-user-created tables for
373     * the connection/database
374     */
375    public function getSystemTables(): ?array
376    {
377        return $this->driverQuery('systemTableList');
378    }
379
380    /**
381     * Retrieve column information for the current database table
382     */
383    public function getColumns(string $table): ?array
384    {
385        return $this->driverQuery($this->getSql()->columnList($this->prefixTable($table)), FALSE);
386    }
387
388    /**
389     * Retrieve foreign keys for the table
390     */
391    public function getFks(string $table): ?array
392    {
393        return $this->driverQuery($this->getSql()->fkList($table), FALSE);
394    }
395
396    /**
397     * Retrieve indexes for the table
398     */
399    public function getIndexes(string $table): ?array
400    {
401        return $this->driverQuery($this->getSql()->indexList($this->prefixTable($table)), FALSE);
402    }
403
404    /**
405     * Retrieve list of data types for the database
406     */
407    public function getTypes(): ?array
408    {
409        return $this->driverQuery('typeList', FALSE);
410    }
411
412    /**
413     * Get the version of the database engine
414     */
415    public function getVersion(): string
416    {
417        return $this->getAttribute(PDO::ATTR_SERVER_VERSION);
418    }
419
420    /**
421     * Method to simplify retrieving db results for meta-data queries
422     */
423    public function driverQuery(string|array $query, bool $filteredIndex=TRUE): ?array
424    {
425        // Call the appropriate method, if it exists
426        if (is_string($query) && method_exists($this->driverSQL, $query))
427        {
428            $query = $this->getSql()->$query();
429        }
430
431        // Return if the values are returned instead of a query,
432        // or if the query doesn't apply to the driver
433        if ( ! is_string($query))
434        {
435            return $query;
436        }
437
438        // Run the query!
439        $res = $this->query($query);
440
441        $flag = $filteredIndex ? PDO::FETCH_NUM : PDO::FETCH_ASSOC;
442        $all = $res->fetchAll($flag);
443
444        return $filteredIndex ? dbFilter($all, 0) : $all;
445    }
446
447    /**
448     * Return the number of rows returned for a SELECT query
449     *
450     * @see http://us3.php.net/manual/en/pdostatement.rowcount.php#87110
451     */
452    public function numRows(): ?int
453    {
454        $regex = '/^SELECT\s+(?:ALL\s+|DISTINCT\s+)?(?:.*?)\s+FROM\s+(.*)$/i';
455        $output = [];
456
457        if (preg_match($regex, $this->lastQuery, $output) > 0)
458        {
459            $stmt = $this->query("SELECT COUNT(*) FROM {$output[1]}");
460
461            return (int) $stmt->fetchColumn();
462        }
463
464        return NULL;
465    }
466
467    /**
468     * Create sql for batch insert
469     */
470    public function insertBatch(string $table, array $data=[]): array
471    {
472        $data = (array) $data;
473        $firstRow = (array) current($data);
474
475        // Values for insertion
476        $vals = [];
477
478        foreach ($data as $group)
479        {
480            $vals = [...$vals, ...array_values($group)];
481        }
482
483        $table = $this->quoteTable($table);
484        $fields = array_keys($firstRow);
485
486        $sql = "INSERT INTO {$table} ("
487            . implode(',', $this->quoteIdent($fields))
488            . ') VALUES ';
489
490        // Create the placeholder groups
491        $params = array_fill(0, count($fields), '?');
492        $paramString = '(' . implode(',', $params) . ')';
493        $paramList = array_fill(0, count($data), $paramString);
494
495        // Append the placeholder groups to the query
496        $sql .= implode(',', $paramList);
497
498        return [$sql, $vals];
499    }
500
501    /**
502     * Creates a batch update, and executes it.
503     * Returns the number of affected rows
504     *
505     * @param string $table The table to update
506     * @param array $data an array of update values
507     * @param string $where The where key
508     */
509    public function updateBatch(string $table, array $data, string $where): array
510    {
511        $affectedRows = 0;
512        $insertData = [];
513        $fieldLines = [];
514
515        $sql = 'UPDATE ' . $this->quoteTable($table) . ' SET ';
516
517        // Get the keys of the current set of data, except the one used to
518        // set the update condition
519        $fields = array_unique(
520            array_reduce($data, static function ($previous, $current) use (&$affectedRows, $where): array {
521                $affectedRows++;
522                $keys = array_diff(array_keys($current), [$where]);
523
524                if ($previous === NULL)
525                {
526                    return $keys;
527                }
528
529                return array_merge($previous, $keys);
530            })
531        );
532
533        // Create the CASE blocks for each data set
534        foreach ($fields as $field)
535        {
536            $line =  $this->quoteIdent($field) . " = CASE\n";
537
538            $cases = [];
539
540            foreach ($data as $case)
541            {
542                if (array_key_exists($field, $case))
543                {
544                    $insertData[] = $case[$where];
545                    $insertData[] = $case[$field];
546                    $cases[] = 'WHEN ' . $this->quoteIdent($where) . ' =? '
547                        . 'THEN ? ';
548                }
549            }
550
551            $line .= implode("\n", $cases) . "\n";
552            $line .= 'ELSE ' . $this->quoteIdent($field) . ' END';
553
554            $fieldLines[] = $line;
555        }
556
557        $sql .= implode(",\n", $fieldLines) . "\n";
558
559        $whereValues = array_column($data, $where);
560
561        foreach ($whereValues as $value)
562        {
563            $insertData[] = $value;
564        }
565
566        // Create the placeholders for the WHERE IN clause
567        $placeholders = array_fill(0, count($whereValues), '?');
568
569        $sql .= 'WHERE ' . $this->quoteIdent($where) . ' IN ';
570        $sql .= '(' . implode(',', $placeholders) . ')';
571
572        return [$sql, $insertData, $affectedRows];
573    }
574
575    /**
576     * Empty the passed table
577     */
578    public function truncate(string $table): PDOStatement
579    {
580        $sql = $this->hasTruncate
581            ? 'TRUNCATE TABLE '
582            : 'DELETE FROM ';
583
584        $sql .= $this->quoteTable($table);
585
586        $this->statement = $this->query($sql);
587
588        return $this->statement;
589    }
590
591    /**
592     * Generate the returning clause for the current database
593     */
594    public function returning(string $query, string $select): string
595    {
596        return "{$query} RETURNING {$select}";
597    }
598
599    /**
600     * Helper method for quote_ident
601     */
602    public function _quote(mixed $str): mixed
603    {
604        // Check that the current value is a string,
605        // and is not already quoted before quoting
606        // that value, otherwise, return the original value
607        return (
608            is_string($str)
609            && ( ! str_starts_with($str, $this->escapeCharOpen))
610            && strrpos($str, $this->escapeCharClose) !== 0
611        )
612            ? "{$this->escapeCharOpen}{$str}{$this->escapeCharClose}"
613            : $str;
614    }
615
616    /**
617     * Sets the table prefix on the passed string
618     */
619    protected function _prefix(string $str): string
620    {
621        // Don't prefix an already prefixed table
622        if (str_contains($str, $this->tablePrefix))
623        {
624            return $str;
625        }
626
627        return $this->tablePrefix . $str;
628    }
629}