Passed
Pull Request — 4 (#8448)
by Sam
09:04
created

PDOConnector::getOrPrepareStatement()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 18
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 8
nc 3
nop 1
dl 0
loc 18
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\ORM\Connect;
4
5
use SilverStripe\Core\Config\Config;
6
use PDO;
7
use PDOStatement;
8
use InvalidArgumentException;
9
10
/**
11
 * PDO driver database connector
12
 */
13
class PDOConnector extends DBConnector implements TransactionManager
14
{
15
16
    /**
17
     * Should ATTR_EMULATE_PREPARES flag be used to emulate prepared statements?
18
     *
19
     * @config
20
     * @var boolean
21
     */
22
    private static $emulate_prepare = false;
0 ignored issues
show
introduced by
The private property $emulate_prepare is not used, and could be removed.
Loading history...
23
24
    /**
25
     * Should we return everything as a string in order to allow transaction savepoints?
26
     * This preserves the behaviour of <= 4.3, including some bugs.
27
     *
28
     * @config
29
     * @var boolean
30
     */
31
    private static $legacy_types = false;
0 ignored issues
show
introduced by
The private property $legacy_types is not used, and could be removed.
Loading history...
32
33
    /**
34
     * Default strong SSL cipher to be used
35
     *
36
     * @config
37
     * @var string
38
     */
39
    private static $ssl_cipher_default = 'DHE-RSA-AES256-SHA';
0 ignored issues
show
introduced by
The private property $ssl_cipher_default is not used, and could be removed.
Loading history...
40
41
    /**
42
     * The PDO connection instance
43
     *
44
     * @var PDO
45
     */
46
    protected $pdoConnection = null;
47
48
    /**
49
     * Name of the currently selected database
50
     *
51
     * @var string
52
     */
53
    protected $databaseName = null;
54
55
    /**
56
     * If available, the row count of the last executed statement
57
     *
58
     * @var int|null
59
     */
60
    protected $rowCount = null;
61
62
    /**
63
     * Error generated by the errorInfo() method of the last PDOStatement
64
     *
65
     * @var array|null
66
     */
67
    protected $lastStatementError = null;
68
69
    /**
70
     * List of prepared statements, cached by SQL string
71
     *
72
     * @var array
73
     */
74
    protected $cachedStatements = array();
75
76
    /**
77
     * Driver
78
     * @var string
79
     */
80
    protected $driver = null;
81
82
    /*
83
     * Is a transaction currently active?
84
     * @var bool
85
     */
86
    protected $inTransaction = false;
87
88
    /**
89
     * Flush all prepared statements
90
     */
91
    public function flushStatements()
92
    {
93
        $this->cachedStatements = array();
94
    }
95
96
    /**
97
     * Retrieve a prepared statement for a given SQL string, or return an already prepared version if
98
     * one exists for the given query
99
     *
100
     * @param string $sql
101
     * @return PDOStatement
102
     */
103
    public function getOrPrepareStatement($sql)
104
    {
105
        // Return cached statements
106
        if (isset($this->cachedStatements[$sql])) {
107
            return $this->cachedStatements[$sql];
108
        }
109
110
        // Generate new statement
111
        $statement = $this->pdoConnection->prepare(
112
            $sql,
113
            array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY)
114
        );
115
116
        // Only cache select statements
117
        if (preg_match('/^(\s*)select\b/i', $sql)) {
118
            $this->cachedStatements[$sql] = $statement;
119
        }
120
        return $statement;
121
    }
122
123
    /**
124
     * Is PDO running in emulated mode
125
     *
126
     * @return boolean
127
     */
128
    public static function is_emulate_prepare()
129
    {
130
        return self::config()->get('emulate_prepare');
131
    }
132
133
    public function connect($parameters, $selectDB = false)
134
    {
135
        $this->flushStatements();
136
137
        // Note that we don't select the database here until explicitly
138
        // requested via selectDatabase
139
        $this->driver = $parameters['driver'];
140
141
        // Build DSN string
142
        $dsn = array();
143
144
        // Typically this is false, but some drivers will request this
145
        if ($selectDB) {
146
            // Specify complete file path immediately following driver (SQLLite3)
147
            if (!empty($parameters['filepath'])) {
148
                $dsn[] = $parameters['filepath'];
149
            } elseif (!empty($parameters['database'])) {
150
                // Some databases require a selected database at connection (SQLite3, Azure)
151
                if ($parameters['driver'] === 'sqlsrv') {
152
                    $dsn[] = "Database={$parameters['database']}";
153
                } else {
154
                    $dsn[] = "dbname={$parameters['database']}";
155
                }
156
            }
157
        }
158
159
        // Syntax for sql server is slightly different
160
        if ($parameters['driver'] === 'sqlsrv') {
161
            $server = $parameters['server'];
162
            if (!empty($parameters['port'])) {
163
                $server .= ",{$parameters['port']}";
164
            }
165
            $dsn[] = "Server=$server";
166
        } elseif ($parameters['driver'] === 'dblib') {
167
            $server = $parameters['server'];
168
            if (!empty($parameters['port'])) {
169
                $server .= ":{$parameters['port']}";
170
            }
171
            $dsn[] = "host={$server}";
172
        } else {
173
            if (!empty($parameters['server'])) {
174
                // Use Server instead of host for sqlsrv
175
                $dsn[] = "host={$parameters['server']}";
176
            }
177
178
            if (!empty($parameters['port'])) {
179
                $dsn[] = "port={$parameters['port']}";
180
            }
181
        }
182
183
        // Connection charset and collation
184
        $connCharset = Config::inst()->get(MySQLDatabase::class, 'connection_charset');
185
        $connCollation = Config::inst()->get(MySQLDatabase::class, 'connection_collation');
186
187
        // Set charset if given and not null. Can explicitly set to empty string to omit
188
        if (!in_array($parameters['driver'], ['sqlsrv', 'pgsql'])) {
189
            $charset = isset($parameters['charset'])
190
                    ? $parameters['charset']
191
                    : $connCharset;
192
            if (!empty($charset)) {
193
                $dsn[] = "charset=$charset";
194
            }
195
        }
196
197
        // Connection commands to be run on every re-connection
198
        if (!isset($charset)) {
199
            $charset = $connCharset;
200
        }
201
202
        $options = [];
203
        if ($parameters['driver'] === 'mysql') {
204
            $options[PDO::MYSQL_ATTR_INIT_COMMAND] = 'SET NAMES ' . $charset . ' COLLATE ' . $connCollation;
205
        }
206
207
        // Set SSL options if they are defined
208
        if (array_key_exists('ssl_key', $parameters) &&
209
            array_key_exists('ssl_cert', $parameters)
210
        ) {
211
            $options[PDO::MYSQL_ATTR_SSL_KEY] = $parameters['ssl_key'];
212
            $options[PDO::MYSQL_ATTR_SSL_CERT] = $parameters['ssl_cert'];
213
            if (array_key_exists('ssl_ca', $parameters)) {
214
                $options[PDO::MYSQL_ATTR_SSL_CA] = $parameters['ssl_ca'];
215
            }
216
            // use default cipher if not provided
217
            $options[PDO::MYSQL_ATTR_SSL_CIPHER] = array_key_exists('ssl_cipher', $parameters) ? $parameters['ssl_cipher'] : self::config()->get('ssl_cipher_default');
218
        }
219
220
        if (static::config()->get('legacy_types')) {
221
            $options[PDO::ATTR_STRINGIFY_FETCHES] = true;
222
            $options[PDO::ATTR_EMULATE_PREPARES] = true;
223
        } else {
224
            // Set emulate prepares (unless null / default)
225
            $isEmulatePrepares = self::is_emulate_prepare();
226
            if (isset($isEmulatePrepares)) {
227
                $options[PDO::ATTR_EMULATE_PREPARES] = (bool)$isEmulatePrepares;
228
            }
229
230
            // Disable stringified fetches
231
            $options[PDO::ATTR_STRINGIFY_FETCHES] = false;
232
        }
233
234
        // May throw a PDOException if fails
235
        $this->pdoConnection = new PDO(
236
            $this->driver . ':' . implode(';', $dsn),
237
            empty($parameters['username']) ? '' : $parameters['username'],
238
            empty($parameters['password']) ? '' : $parameters['password'],
239
            $options
240
        );
241
242
        // Show selected DB if requested
243
        if ($this->pdoConnection && $selectDB && !empty($parameters['database'])) {
244
            $this->databaseName = $parameters['database'];
245
        }
246
    }
247
248
249
    /**
250
     * Return the driver for this connector
251
     * E.g. 'mysql', 'sqlsrv', 'pgsql'
252
     *
253
     * @return string
254
     */
255
    public function getDriver()
256
    {
257
        return $this->driver;
258
    }
259
260
    public function getVersion()
261
    {
262
        return $this->pdoConnection->getAttribute(PDO::ATTR_SERVER_VERSION);
263
    }
264
265
    public function escapeString($value)
266
    {
267
        $value = $this->quoteString($value);
268
269
        // Since the PDO library quotes the value, we should remove this to maintain
270
        // consistency with MySQLDatabase::escapeString
271
        if (preg_match('/^\'(?<value>.*)\'$/', $value, $matches)) {
272
            $value = $matches['value'];
273
        }
274
        return $value;
275
    }
276
277
    public function quoteString($value)
278
    {
279
        return $this->pdoConnection->quote($value);
280
    }
281
282
    /**
283
     * Invoked before any query is executed
284
     *
285
     * @param string $sql
286
     */
287
    protected function beforeQuery($sql)
288
    {
289
        // Reset state
290
        $this->rowCount = 0;
291
        $this->lastStatementError = null;
292
293
        // Flush if necessary
294
        if ($this->isQueryDDL($sql)) {
295
            $this->flushStatements();
296
        }
297
    }
298
299
    /**
300
     * Executes a query that doesn't return a resultset
301
     *
302
     * @param string $sql The SQL query to execute
303
     * @param integer $errorLevel For errors to this query, raise PHP errors
304
     * using this error level.
305
     * @return int
306
     */
307
    public function exec($sql, $errorLevel = E_USER_ERROR)
308
    {
309
        $this->beforeQuery($sql);
310
311
        // Directly exec this query
312
        $result = $this->pdoConnection->exec($sql);
313
314
        // Check for errors
315
        if ($result !== false) {
316
            return $this->rowCount = $result;
317
        }
318
319
        $this->databaseError($this->getLastError(), $errorLevel, $sql);
320
        return null;
321
    }
322
323
    public function query($sql, $errorLevel = E_USER_ERROR)
324
    {
325
        $this->beforeQuery($sql);
326
327
        // Directly query against connection
328
        $statement = $this->pdoConnection->query($sql);
329
330
        // Generate results
331
        return $this->prepareResults($statement, $errorLevel, $sql);
0 ignored issues
show
Bug introduced by
It seems like $statement can also be of type boolean; however, parameter $statement of SilverStripe\ORM\Connect...ector::prepareResults() does only seem to accept PDOStatement, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

331
        return $this->prepareResults(/** @scrutinizer ignore-type */ $statement, $errorLevel, $sql);
Loading history...
332
    }
333
334
    /**
335
     * Determines the PDO::PARAM_* type for a given PHP type string
336
     * @param string $phpType Type of object in PHP
337
     * @return integer PDO Parameter constant value
338
     */
339
    public function getPDOParamType($phpType)
340
    {
341
        switch ($phpType) {
342
            case 'boolean':
343
                return PDO::PARAM_BOOL;
344
            case 'NULL':
345
                return PDO::PARAM_NULL;
346
            case 'integer':
347
                return PDO::PARAM_INT;
348
            case 'object': // Allowed if the object or resource has a __toString method
349
            case 'resource':
350
            case 'float': // Not actually returnable from get_type
351
            case 'double':
352
            case 'string':
353
                return PDO::PARAM_STR;
354
            case 'blob':
355
                return PDO::PARAM_LOB;
356
            case 'array':
357
            case 'unknown type':
358
            default:
359
                throw new InvalidArgumentException("Cannot bind parameter as it is an unsupported type ($phpType)");
360
        }
361
    }
362
363
    /**
364
     * Bind all parameters to a PDOStatement
365
     *
366
     * @param PDOStatement $statement
367
     * @param array $parameters
368
     */
369
    public function bindParameters(PDOStatement $statement, $parameters)
370
    {
371
        // Bind all parameters
372
        $parameterCount = count($parameters);
373
        for ($index = 0; $index < $parameterCount; $index++) {
374
            $value = $parameters[$index];
375
            $phpType = gettype($value);
376
377
            // Allow overriding of parameter type using an associative array
378
            if ($phpType === 'array') {
379
                $phpType = $value['type'];
380
                $value = $value['value'];
381
            }
382
383
            // Check type of parameter
384
            $type = $this->getPDOParamType($phpType);
385
            if ($type === PDO::PARAM_STR) {
386
                $value = (string) $value;
387
            }
388
389
            // Bind this value
390
            $statement->bindValue($index+1, $value, $type);
391
        }
392
    }
393
394
    public function preparedQuery($sql, $parameters, $errorLevel = E_USER_ERROR)
395
    {
396
        $this->beforeQuery($sql);
397
398
        // Prepare statement
399
        $statement = $this->getOrPrepareStatement($sql);
400
401
        // Bind and invoke statement safely
402
        if ($statement) {
0 ignored issues
show
introduced by
$statement is of type PDOStatement, thus it always evaluated to true.
Loading history...
403
            $this->bindParameters($statement, $parameters);
404
            $statement->execute($parameters);
405
        }
406
407
        // Generate results
408
        return $this->prepareResults($statement, $errorLevel, $sql);
409
    }
410
411
    /**
412
     * Given a PDOStatement that has just been executed, generate results
413
     * and report any errors
414
     *
415
     * @param PDOStatement $statement
416
     * @param int $errorLevel
417
     * @param string $sql
418
     * @param array $parameters
419
     * @return PDOQuery
420
     */
421
    protected function prepareResults($statement, $errorLevel, $sql, $parameters = array())
422
    {
423
424
        // Record row-count and errors of last statement
425
        if ($this->hasError($statement)) {
426
            $this->lastStatementError = $statement->errorInfo();
427
        } elseif ($statement) {
0 ignored issues
show
introduced by
$statement is of type PDOStatement, thus it always evaluated to true.
Loading history...
428
            // Count and return results
429
            $this->rowCount = $statement->rowCount();
430
            return new PDOQuery($statement, $this);
431
        }
432
433
        // Ensure statement is closed
434
        if ($statement) {
0 ignored issues
show
introduced by
$statement is of type PDOStatement, thus it always evaluated to true.
Loading history...
435
            $statement->closeCursor();
436
        }
437
438
        // Report any errors
439
        if ($parameters) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $parameters of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
440
            $parameters = $this->parameterValues($parameters);
441
        }
442
        $this->databaseError($this->getLastError(), $errorLevel, $sql, $parameters);
443
        return null;
444
    }
445
446
    /**
447
     * Determine if a resource has an attached error
448
     *
449
     * @param PDOStatement|PDO $resource the resource to check
450
     * @return boolean Flag indicating true if the resource has an error
451
     */
452
    protected function hasError($resource)
453
    {
454
        // No error if no resource
455
        if (empty($resource)) {
456
            return false;
457
        }
458
459
        // If the error code is empty the statement / connection has not been run yet
460
        $code = $resource->errorCode();
461
        if (empty($code)) {
462
            return false;
463
        }
464
465
        // Skip 'ok' and undefined 'warning' types.
466
        // @see http://docstore.mik.ua/orelly/java-ent/jenut/ch08_06.htm
467
        return $code !== '00000' && $code !== '01000';
468
    }
469
470
    public function getLastError()
471
    {
472
        $error = null;
473
        if ($this->lastStatementError) {
474
            $error = $this->lastStatementError;
475
        } elseif ($this->hasError($this->pdoConnection)) {
476
            $error = $this->pdoConnection->errorInfo();
477
        }
478
        if ($error) {
479
            return sprintf("%s-%s: %s", $error[0], $error[1], $error[2]);
480
        }
481
        return null;
482
    }
483
484
    public function getGeneratedID($table)
485
    {
486
        return $this->pdoConnection->lastInsertId();
487
    }
488
489
    public function affectedRows()
490
    {
491
        return $this->rowCount;
492
    }
493
494
    public function selectDatabase($name)
495
    {
496
        $this->exec("USE \"{$name}\"");
497
        $this->databaseName = $name;
498
        return true;
499
    }
500
501
    public function getSelectedDatabase()
502
    {
503
        return $this->databaseName;
504
    }
505
506
    public function unloadDatabase()
507
    {
508
        $this->databaseName = null;
509
    }
510
511
    public function isActive()
512
    {
513
        return $this->databaseName && $this->pdoConnection;
514
    }
515
516
    public function transactionStart($transactionMode = false, $sessionCharacteristics = false)
517
    {
518
        $this->inTransaction = true;
519
520
        if ($transactionMode) {
521
            $this->query("SET TRANSACTION $transactionMode");
522
        }
523
524
        if ($this->pdoConnection->beginTransaction()) {
525
            if ($sessionCharacteristics) {
526
                $this->query("SET SESSION CHARACTERISTICS AS TRANSACTION $sessionCharacteristics");
527
            }
528
            return true;
529
        }
530
        return false;
531
    }
532
533
    public function transactionEnd()
534
    {
535
        $this->inTransaction = false;
536
        return $this->pdoConnection->commit();
537
    }
538
539
    public function transactionRollback($savepoint = null)
540
    {
541
        if ($savepoint) {
542
            if ($this->supportsSavepoints()) {
543
                $this->exec("ROLLBACK TO SAVEPOINT $savepoint");
544
            } else {
545
                throw new DatabaseException("Savepoints not supported on this PDO connection");
546
            }
547
        }
548
549
        $this->inTransaction = false;
550
        return $this->pdoConnection->rollBack();
551
    }
552
553
    public function transactionDepth()
554
    {
555
        return (int)$this->inTransaction;
556
    }
557
558
    public function transactionSavepoint($savepoint = null)
559
    {
560
        if ($this->supportsSavepoints()) {
561
            $this->exec("SAVEPOINT $savepoint");
562
        } else {
563
            throw new DatabaseException("Savepoints not supported on this PDO connection");
564
        }
565
    }
566
567
    public function supportsSavepoints()
568
    {
569
        return static::config()->get('legacy_types');
570
    }
571
}
572