Connection::replaceParameters()   B
last analyzed

Complexity

Conditions 7
Paths 1

Size

Total Lines 22
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 14
c 1
b 0
f 0
dl 0
loc 22
rs 8.8333
cc 7
nc 1
nop 2
1
<?php
2
3
/**
4
 * Platine Database
5
 *
6
 * Platine Database is the abstraction layer using PDO with support of query and schema builder
7
 *
8
 * This content is released under the MIT License (MIT)
9
 *
10
 * Copyright (c) 2020 Platine Database
11
 *
12
 * Permission is hereby granted, free of charge, to any person obtaining a copy
13
 * of this software and associated documentation files (the "Software"), to deal
14
 * in the Software without restriction, including without limitation the rights
15
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
 * copies of the Software, and to permit persons to whom the Software is
17
 * furnished to do so, subject to the following conditions:
18
 *
19
 * The above copyright notice and this permission notice shall be included in all
20
 * copies or substantial portions of the Software.
21
 *
22
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
 * SOFTWARE.
29
 */
30
31
/**
32
 *  @file Connection.php
33
 *
34
 *  The Database Connection class
35
 *
36
 *  @package    Platine\Database
37
 *  @author Platine Developers Team
38
 *  @copyright  Copyright (c) 2020
39
 *  @license    http://opensource.org/licenses/MIT  MIT License
40
 *  @link   https://www.platine-php.com
41
 *  @version 1.0.0
42
 *  @filesource
43
 */
44
declare(strict_types=1);
45
46
namespace Platine\Database;
47
48
use InvalidArgumentException;
49
use PDO;
50
use PDOException;
51
use PDOStatement;
52
use Platine\Database\Driver\Driver;
53
use Platine\Database\Exception\ConnectionException;
54
use Platine\Database\Exception\QueryException;
55
use Platine\Database\Exception\QueryPrepareException;
56
use Platine\Database\Exception\TransactionException;
57
use Platine\Logger\Logger;
58
use Platine\Logger\LoggerInterface;
59
60
/**
61
 * @class Connection
62
 * @package Platine\Database
63
 */
64
class Connection
65
{
66
    /**
67
     * The PDO instance
68
     * @var PDO
69
     */
70
    protected PDO $pdo;
71
72
    /**
73
     * The PDO data source name
74
     * @var string
75
     */
76
    protected string $dsn = '';
77
78
    /**
79
     * The list of execution query logs
80
     * @var array<int, array<string, mixed>>
81
     */
82
    protected array $logs = [];
83
84
    /**
85
     * The driver to use
86
     * @var Driver
87
     */
88
    protected Driver $driver;
89
90
    /**
91
     * The Schema instance to use
92
     * @var Schema
93
     */
94
    protected Schema $schema;
95
96
    /**
97
     * The connection configuration
98
     * @var Configuration
99
     */
100
    protected Configuration $config;
101
102
    /**
103
     * The connection parameters
104
     * @var array<int|string, mixed>
105
     */
106
    protected array $params = [];
107
108
    /**
109
     * The logger interface test
110
     * @var LoggerInterface
111
     */
112
    protected LoggerInterface $logger;
113
114
    /**
115
     * The last execution SQL queries
116
     * @var array<int, string>
117
     */
118
    protected array $sql = [];
119
120
    /**
121
     * The last execution query parameters values
122
     * @var array<int, array<mixed>>
123
     */
124
    protected array $values = [];
125
126
    /**
127
     * Emulate instead of execute the query
128
     * @var bool
129
     */
130
    protected bool $emulate = false;
131
132
    /**
133
     * Connection constructor.
134
     * @param Configuration $config
135
     * @param LoggerInterface|null $logger
136
     * @throws ConnectionException
137
     */
138
    public function __construct(
139
        Configuration $config,
140
        ?LoggerInterface $logger = null
141
    ) {
142
        $this->config = $config;
143
144
        $this->logger = $logger ?? new Logger();
145
        if ($this->logger instanceof Logger) {
146
            $channel = sprintf('db:%s', $config->getName());
147
            $this->logger->setChannel($channel);
148
        }
149
150
        $this->createDriver();
151
152
        $this->schema = new Schema($this);
153
154
        $this->connect();
155
    }
156
157
    /**
158
     * Create database driver
159
     * @return $this
160
     */
161
    public function createDriver(): self
162
    {
163
        /** @var class-string<Driver> $driverClass */
164
        $driverClass = $this->config->getDriverClassName();
165
166
        $this->driver = new $driverClass($this);
167
168
        return $this;
169
    }
170
171
    /**
172
     * Start the database transaction
173
     * @param string $name
174
     * @return bool
175
     */
176
    public function startTransaction(string $name = 'default'): bool
177
    {
178
        $this->logger->info('Start transaction [{name}]', ['name' => $name]);
179
        if ($this->pdo->inTransaction()) {
180
            $this->logger->warning(
181
                'Can not start transaction [{name}], there is an active transaction',
182
                ['name' => $name]
183
            );
184
            return false;
185
        }
186
187
        return $this->pdo->beginTransaction();
188
    }
189
190
    /**
191
     * Commit database transaction
192
     * @param string $name
193
     * @return bool
194
     */
195
    public function commit(string $name = 'default'): bool
196
    {
197
        $this->logger->info('Commit transaction [{name}]', ['name' => $name]);
198
199
        if ($this->pdo->inTransaction() === false) {
200
            $this->logger->warning(
201
                'Can not commit transaction [{name}], there is no active transaction',
202
                ['name' => $name]
203
            );
204
            return false;
205
        }
206
207
        return $this->pdo->commit();
208
    }
209
210
    /**
211
     * Rollback the database transaction
212
     * @param string $name
213
     * @return bool
214
     */
215
    public function rollback(string $name = 'default'): bool
216
    {
217
        $this->logger->info('Rollback transaction [{name}]', ['name' => $name]);
218
        if ($this->pdo->inTransaction() === false) {
219
            $this->logger->warning(
220
                'Can not rollback transaction [{name}], there is no active transaction',
221
                ['name' => $name]
222
            );
223
            return false;
224
        }
225
226
        return $this->pdo->rollBack();
227
    }
228
229
    /**
230
     * Connect to the database
231
     * @return void
232
     */
233
    public function connect(): void
234
    {
235
        $this->setConnectionParams();
236
237
        if ($this->config->isPersistent()) {
238
            $this->persistent(true);
239
        }
240
241
        $attr = $this->params;
242
243
        if (count($attr) === 0) {
244
            throw new InvalidArgumentException('Invalid database options supplied');
245
        }
246
247
        $driver = $attr['driver'];
248
        unset($attr['driver']);
249
250
        $params = [];
251
        foreach ($attr as $key => $value) {
252
            $params[] = is_int($key) ? $value : $key . '=' . $value;
253
        }
254
255
        $dsn = $driver . ':' . implode(';', $params);
256
        if (in_array($driver, ['mysql', 'pgsql', 'sqlsrv'])) {
257
            $charset = $this->config->getCharset();
258
            $this->config->addCommand('SET NAMES "' . $charset . '"' . (
259
                    $this->config->getDriverName() === 'mysql'
260
                            ? ' COLLATE "' . $this->config->getCollation() . '"'
261
                            : ''
262
                    ));
263
        }
264
265
        $this->dsn = $dsn;
266
267
        $this->createPDO();
268
    }
269
270
    /**
271
     *
272
     * @param LoggerInterface $logger
273
     * @return $this
274
     */
275
    public function setLogger(LoggerInterface $logger): self
276
    {
277
        $this->logger = $logger;
278
279
        return $this;
280
    }
281
282
    /**
283
     * Return the query execution logs
284
     * @return array<int, array<string, mixed>>
285
     */
286
    public function getLogs(): array
287
    {
288
        return $this->logs;
289
    }
290
291
    /**
292
     * Return the current connection parameters
293
     * @return array<int|string, mixed>
294
     */
295
    public function getParams(): array
296
    {
297
        return $this->params;
298
    }
299
300
    /**
301
     * Return the current connection configuration
302
     * @return Configuration
303
     */
304
    public function getConfig(): Configuration
305
    {
306
        return $this->config;
307
    }
308
309
    /**
310
     * Return the current driver instance
311
     * @return Driver
312
     */
313
    public function getDriver(): Driver
314
    {
315
        return $this->driver;
316
    }
317
318
    /**
319
     * Return the current Schema instance
320
     * @return Schema
321
     */
322
    public function getSchema(): Schema
323
    {
324
        return $this->schema;
325
    }
326
327
    /**
328
     * Set connection to be persistent
329
     * @param bool $value
330
     * @return $this
331
     */
332
    public function persistent(bool $value = true): self
333
    {
334
        $this->config->setOption(PDO::ATTR_PERSISTENT, $value);
335
336
        return $this;
337
    }
338
339
    /**
340
     * @return string
341
     */
342
    public function getDsn(): string
343
    {
344
        return $this->dsn;
345
    }
346
347
    /**
348
     * Return the instance of the PDO
349
     * @return PDO
350
     */
351
    public function getPDO(): PDO
352
    {
353
        return $this->pdo;
354
    }
355
356
    /**
357
     * Execute the SQL query and return the result
358
     * @param string $sql
359
     * @param array<int, mixed> $params the query parameters
360
     * @return ResultSet
361
     * @throws QueryException
362
     */
363
    public function query(string $sql, array $params = []): ResultSet
364
    {
365
        $prepared = $this->prepare($sql, $params);
366
        $this->execute($prepared);
367
368
        return new ResultSet($prepared['statement']);
369
    }
370
371
    /**
372
     * Direct execute the SQL query
373
     * @param string $sql
374
     * @param array<int, mixed> $params the query parameters
375
     * @return bool
376
     * @throws QueryException
377
     */
378
    public function exec(string $sql, array $params = []): bool
379
    {
380
        return $this->execute($this->prepare($sql, $params));
381
    }
382
383
    /**
384
     *  Execute the SQL query and return the number
385
     * of affected rows
386
     * @param string $sql
387
     * @param array<int, mixed> $params the query parameters
388
     * @return int
389
     * @throws QueryException
390
     */
391
    public function count(string $sql, array $params = []): int
392
    {
393
        $prepared = $this->prepare($sql, $params);
394
        $this->execute($prepared);
395
396
        $result = $prepared['statement']->rowCount();
397
        $prepared['statement']->closeCursor();
398
399
        return $result;
400
    }
401
402
    /**
403
     *  Execute the SQL query and return the first column result
404
     * @param string $sql
405
     * @param array<int, mixed> $params the query parameters
406
     * @return mixed
407
     * @throws QueryException
408
     */
409
    public function column(string $sql, array $params = []): mixed
410
    {
411
        $prepared = $this->prepare($sql, $params);
412
        $this->execute($prepared);
413
414
        $result = $prepared['statement']->fetchColumn();
415
        $prepared['statement']->closeCursor();
416
417
        return $result;
418
    }
419
420
    /**
421
     * @param callable $callback
422
     * @param mixed $that
423
     *
424
     * @return mixed
425
     *
426
     * @throws ConnectionException
427
     */
428
    public function transaction(
429
        callable $callback,
430
        mixed $that = null
431
    ): mixed {
432
        if ($that === null) {
433
            $that = $this;
434
        }
435
436
        if ($this->pdo->inTransaction()) {
437
            return $callback($that);
438
        }
439
440
        try {
441
            $this->pdo->beginTransaction();
442
            $result = $callback($that);
443
            $this->pdo->commit();
444
        } catch (PDOException $exception) {
445
            $this->pdo->rollBack();
446
            $this->logger->error('Database transaction error. Error message: {error}', [
447
                'error' => $exception->getMessage()
448
            ]);
449
            throw new TransactionException(
450
                $exception->getMessage(),
451
                (int) $exception->getCode(),
452
                $exception->getPrevious()
453
            );
454
        }
455
456
        return $result;
457
    }
458
459
    /**
460
     * {@inheritdoc}
461
     */
462
    public function __sleep(): array
463
    {
464
        return [
465
            'dsn',
466
            'driver',
467
            'schema',
468
            'config',
469
            'params',
470
            'logger',
471
        ];
472
    }
473
474
    /**
475
     * {@inheritdoc}
476
     */
477
    public function __wakeup(): void
478
    {
479
        $this->createPDO();
480
    }
481
482
    /**
483
     * Return the last SQL queries
484
     * @return array<string>
485
     */
486
    public function getSql(): array
487
    {
488
        return $this->sql;
489
    }
490
491
    /**
492
     * Return the last query parameters values
493
     * @return array<int, array<mixed>>
494
     */
495
    public function getValues(): array
496
    {
497
        return $this->values;
498
    }
499
500
    /**
501
     * Reset the last query SQL and parameters
502
     * @return $this
503
     */
504
    public function resetSqlValues(): self
505
    {
506
        $this->sql    = [];
507
        $this->values = [];
508
509
        return $this;
510
    }
511
512
    /**
513
     * Return the emulation status
514
     * @return bool
515
     */
516
    public function isEmulate(): bool
517
    {
518
        return $this->emulate;
519
    }
520
521
    /**
522
     * Set the emulation status
523
     * @param bool $emulate
524
     * @return $this
525
     */
526
    public function setEmulate(bool $emulate): self
527
    {
528
        $this->emulate = $emulate;
529
        return $this;
530
    }
531
532
533
    /**
534
     * Create PDO connection
535
     * @return void
536
     * @throws ConnectionException
537
     */
538
    protected function createPDO(): void
539
    {
540
        try {
541
            $this->pdo = new PDO(
542
                $this->dsn,
543
                $this->config->getUsername(),
544
                $this->config->getPassword(),
545
                $this->config->getOptions()
546
            );
547
548
            foreach ($this->config->getCommands() as $command) {
549
                $this->pdo->exec($command);
550
            }
551
        } catch (PDOException $exception) {
552
            $this->logger->emergency('Can not connect to database. Error message: {error}', [
553
                'error' => $exception->getMessage()
554
            ]);
555
556
            throw new ConnectionException(
557
                'Can not connect to database',
558
                (int) $exception->getCode(),
559
                $exception->getPrevious()
560
            );
561
        }
562
    }
563
564
     /**
565
     * Change the query parameters placeholder with the value
566
     * @param string $query
567
     * @param array<int, mixed> $params
568
     * @return string
569
     */
570
    protected function replaceParameters(string $query, array $params): string
571
    {
572
        $driver = $this->driver;
573
574
        return (string) preg_replace_callback(
575
            '/\?/',
576
            function () use ($driver, &$params) {
577
                $param = array_shift($params);
578
579
                $value = is_object($param) ? get_class($param) : $param;
580
                if (is_int($value) || is_float($value)) {
581
                    return $value;
582
                }
583
                if ($value === null) {
584
                    return 'NULL';
585
                }
586
                if (is_bool($value)) {
587
                    return $value ? 'TRUE' : 'FALSE';
588
                }
589
                return $driver->quote($value);
590
            },
591
            $query
592
        );
593
    }
594
595
    /**
596
     * Prepare the query
597
     * @param string $query
598
     * @param array<mixed> $params
599
     * @return array<string, mixed>
600
     * @throws QueryException
601
     */
602
    protected function prepare(string $query, array $params): array
603
    {
604
        $this->sql[] = $query;
605
        $this->values[] = $params;
606
        if ($this->emulate) {
607
            return [
608
                'statement' => new ConnectionStatement(),
609
                'query' => $query,
610
                'params' => $params
611
            ];
612
        }
613
614
        try {
615
            $statement = $this->pdo->prepare($query);
616
        } catch (PDOException $exception) {
617
            $this->logger->error('Error when prepare query [{query}]. Error message: {error}', [
618
                'error' => $exception->getMessage(),
619
                'query' => $query
620
            ]);
621
            throw new QueryPrepareException(
622
                $exception->getMessage() . ' [' . $query . ']',
623
                (int) $exception->getCode(),
624
                $exception->getPrevious()
625
            );
626
        }
627
628
        return [
629
            'statement' => $statement,
630
            'query' => $query,
631
            'params' => $params
632
        ];
633
    }
634
635
    /**
636
     * Execute the prepared query
637
     * @param array<string, mixed> $prepared
638
     * @return bool the status of the execution
639
     * @throws QueryException
640
     */
641
    protected function execute(array $prepared): bool
642
    {
643
        if ($this->emulate) {
644
            return true;
645
        }
646
647
        $sql = $this->replaceParameters($prepared['query'], $prepared['params']);
648
        $sqlLog = [
649
            'query' => $prepared['query'],
650
            'parameters' => implode(', ', $prepared['params']),
651
        ];
652
653
        try {
654
            if ($prepared['params']) {
655
                $this->bindValues($prepared['statement'], $prepared['params']);
656
            }
657
            $start = microtime(true);
658
            $result = $prepared['statement']->execute();
659
            $executionTime = microtime(true) - $start;
660
            $sqlLog['time'] = number_format($executionTime, 6);
661
662
            $this->logs[] = $sqlLog;
663
664
            $this->logger->info(
665
                'Execute Query: [{query}], parameters: [{parameters}], time: [{time}]',
666
                $sqlLog
667
            );
668
669
            $slowQueryTime = $this->config->getSlowQueryTime();
670
671
            if ($executionTime >= $slowQueryTime) {
672
                $this->logger->warning(
673
                    'Query: [{query}], parameters: [{parameters}] cost too much time: [{time}]',
674
                    $sqlLog
675
                );
676
            }
677
        } catch (PDOException $exception) {
678
            $this->logger->error('Error when execute query [{sql}]. Error message: {error}', [
679
                'error' => $exception->getMessage(),
680
                'sql' => $sql
681
            ]);
682
            throw new QueryException(
683
                $exception->getMessage() . ' [' . $sql . ']',
684
                (int) $exception->getCode(),
685
                $exception->getPrevious()
686
            );
687
        }
688
689
        return $result;
690
    }
691
692
    /**
693
     * Bind the parameters values
694
     * @param PDOStatement $statement
695
     * @param array<int, mixed> $values
696
     *
697
     * @return void
698
     */
699
    protected function bindValues(PDOStatement $statement, array $values): void
700
    {
701
        foreach ($values as $key => $value) {
702
            $param = PDO::PARAM_STR;
703
            if (is_null($value)) {
704
                $param = PDO::PARAM_NULL;
705
            } elseif (is_int($value) || is_float($value)) {
706
                $param = PDO::PARAM_INT;
707
            } elseif (is_bool($value)) {
708
                $param = PDO::PARAM_BOOL;
709
            }
710
711
            $statement->bindValue($key + 1, $value, $param);
712
        }
713
    }
714
715
    /**
716
     * Set the PDO connection parameters to use
717
     * @return void
718
     */
719
    protected function setConnectionParams(): void
720
    {
721
        $port = $this->config->getPort();
722
        $database = $this->config->getDatabase();
723
        $hostname = $this->config->getHostname();
724
        $attr = [];
725
726
        $driverName = $this->config->getDriverName();
727
        switch ($driverName) {
728
            case 'mysql':
729
            case 'pgsql':
730
                $attr = [
731
                    'driver' => $driverName,
732
                    'dbname' => $database,
733
                    'host' => $hostname,
734
                ];
735
736
                if ($port > 0) {
737
                    $attr['port'] = $port;
738
                }
739
740
                if ($driverName === 'mysql') {
741
                    //Make MySQL using standard quoted identifier
742
                    $this->config->addCommand('SET SQL_MODE=ANSI_QUOTES');
743
                    $this->config->addCommand('SET CHARACTER SET "' . $this->config->getCharset() . '"');
744
745
                    $socket = $this->config->getSocket();
746
                    if (!empty($socket)) {
747
                        $attr['unix_socket'] = $socket;
748
                    }
749
                }
750
                break;
751
            case 'sqlsrv':
752
                //Keep MSSQL QUOTED_IDENTIFIER is ON for standard quoting
753
                $this->config->addCommand('SET QUOTED_IDENTIFIER ON');
754
755
                //Make ANSI_NULLS is ON for NULL value
756
                $this->config->addCommand('SET ANSI_NULLS ON');
757
758
                $attr = [
759
                    'driver' => 'sqlsrv',
760
                    'Server' => $hostname
761
                        . ($port > 0 ? ':' . $port : ''),
762
                    'Database' => $database
763
                ];
764
765
                $appName = $this->config->getAppname();
766
                if (!empty($appName)) {
767
                    $attr['APP'] = $appName;
768
                }
769
770
                $attributes = [
771
                    'ApplicationIntent',
772
                    'AttachDBFileName',
773
                    'Authentication',
774
                    'ColumnEncryption',
775
                    'ConnectionPooling',
776
                    'Encrypt',
777
                    'Failover_Partner',
778
                    'KeyStoreAuthentication',
779
                    'KeyStorePrincipalId',
780
                    'KeyStoreSecret',
781
                    'LoginTimeout',
782
                    'MultipleActiveResultSets',
783
                    'MultiSubnetFailover',
784
                    'Scrollable',
785
                    'TraceFile',
786
                    'TraceOn',
787
                    'TransactionIsolation',
788
                    'TransparentNetworkIPResolution',
789
                    'TrustServerCertificate',
790
                    'WSID',
791
                ];
792
793
                foreach ($attributes as $attribute) {
794
                    $str = preg_replace(
795
                        ['/([a-z\d])([A-Z])/', '/([^_])([A-Z][a-z])/'],
796
                        '$1_$2',
797
                        $attribute
798
                    );
799
800
                    if (is_string($str)) {
801
                        $keyname = strtolower($str);
802
803
                        if ($this->config->hasAttribute($keyname)) {
804
                            $attr[$attribute] = $this->config->getAttribute($keyname);
805
                        }
806
                    }
807
                }
808
                break;
809
            case 'oci':
810
            case 'oracle':
811
                $attr = [
812
                    'driver' => 'oci',
813
                    'dbname' => '//' . $hostname
814
                    . ($port > 0 ? ':' . $port : ':1521') . '/' . $database
815
                ];
816
817
                $attr['charset'] = $this->config->getCharset();
818
                break;
819
            case 'sqlite':
820
                $attr = [
821
                    'driver' => 'sqlite',
822
                    $database
823
                ];
824
                break;
825
        }
826
827
        $this->params = $attr;
828
    }
829
}
830