Issues (2510)

app/DB.php (1 issue)

Labels
Severity
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2025 webtrees development team
6
 * This program is free software: you can redistribute it and/or modify
7
 * it under the terms of the GNU General Public License as published by
8
 * the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 * You should have received a copy of the GNU General Public License
15
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
 */
17
18
declare(strict_types=1);
19
20
namespace Fisharebest\Webtrees;
21
22
use Closure;
23
use Doctrine\DBAL\Configuration;
24
use Doctrine\DBAL\Connection;
25
use Doctrine\DBAL\DriverManager;
26
use Doctrine\DBAL\Query\Expression\ExpressionBuilder;
27
use Doctrine\DBAL\Query\QueryBuilder;
28
use Doctrine\DBAL\Schema\Column;
29
use Doctrine\DBAL\Schema\DefaultExpression;
30
use Doctrine\DBAL\Schema\ForeignKeyConstraint;
31
use Doctrine\DBAL\Schema\ForeignKeyConstraint\ReferentialAction;
32
use Doctrine\DBAL\Schema\Index;
33
use Doctrine\DBAL\Schema\Index\IndexType;
34
use Doctrine\DBAL\Schema\PrimaryKeyConstraint;
35
use Doctrine\DBAL\Types\AsciiStringType;
36
use Doctrine\DBAL\Types\DateTimeImmutableType;
37
use Doctrine\DBAL\Types\FloatType;
38
use Doctrine\DBAL\Types\IntegerType;
39
use Doctrine\DBAL\Types\StringType;
40
use Doctrine\DBAL\Types\TextType;
41
use Illuminate\Database\Capsule\Manager;
42
use Illuminate\Database\Query\Builder;
43
use Illuminate\Database\Query\Expression;
44
use PDO;
45
use PDOException;
46
use RuntimeException;
47
use SensitiveParameter;
48
49
use function str_starts_with;
50
51
final class DB extends Manager
52
{
53
    // Supported drivers
54
    public const string MYSQL      = 'mysql';
0 ignored issues
show
A parse error occurred: Syntax error, unexpected T_STRING, expecting '=' on line 54 at column 24
Loading history...
55
    public const string POSTGRES   = 'pgsql';
56
    public const string SQLITE     = 'sqlite';
57
    public const string SQL_SERVER = 'sqlsrv';
58
59
    private const array COLLATION_ASCII = [
60
        self::MYSQL      => 'ascii_bin',
61
        self::POSTGRES   => 'C',
62
        self::SQLITE     => 'BINARY',
63
        self::SQL_SERVER => 'Latin1_General_Bin',
64
    ];
65
66
    // MySQL 5.x uses utf8mb4_unicode_ci (Unicode 4.0) for utf8mb4
67
    // MySQL 5.7 uses utf8mb4_unicode_520_ci (Unicode 5.2) for utf8mb4
68
    // MySQL 8.x uses utf8mb4_0900_ai_ci (Unicode 9.0) for utf8mb4
69
    // MySQL 9.x uses utf8mb4_uca1400_ai_ci (Unicode 14.0) for utf8mb4
70
    // Just specify the character set and let MySQL choose the latest collation
71
    private const array CHARSET_UTF8 = [
72
        self::MYSQL      => 'utf8mb4',
73
        self::POSTGRES   => null,
74
        self::SQLITE     => null,
75
        self::SQL_SERVER => null,
76
    ];
77
78
    private const array COLLATION_UTF8 = [
79
        self::MYSQL      => null,
80
        self::POSTGRES   => 'und-x-icu',
81
        self::SQLITE     => 'NOCASE',
82
        self::SQL_SERVER => 'utf8_CI_AI',
83
    ];
84
85
    private const array TABLE_OPTIONS = [
86
        self::MYSQL      => ['charset' => 'utf8mb4'],
87
        self::POSTGRES   => [],
88
        self::SQLITE     => [],
89
        self::SQL_SERVER => [],
90
    ];
91
92
    private const array REGEX_OPERATOR = [
93
        self::MYSQL      => 'REGEXP',
94
        self::POSTGRES   => '~',
95
        self::SQLITE     => 'REGEXP',
96
        self::SQL_SERVER => 'REGEXP',
97
    ];
98
99
    private const array GROUP_CONCAT_FUNCTION = [
100
        self::MYSQL      => 'GROUP_CONCAT(%s)',
101
        self::POSTGRES   => "STRING_AGG(%s, ',')",
102
        self::SQLITE     => 'GROUP_CONCAT(%s)',
103
        self::SQL_SERVER => "STRING_AGG(%s, ',')",
104
    ];
105
106
    private const array DRIVER_INITIALIZATION = [
107
        self::MYSQL      => "SET NAMES utf8mb4, sql_mode := 'ANSI,STRICT_ALL_TABLES', TIME_ZONE := '+00:00', SQL_BIG_SELECTS := 1, GROUP_CONCAT_MAX_LEN := 1048576",
108
        self::POSTGRES   => "CREATE COLLATION IF NOT EXISTS webtrees_ci_ai (provider=icu, locale='und', deterministic=false)",
109
        self::SQLITE     => 'PRAGMA foreign_keys = ON',
110
        self::SQL_SERVER => 'SET language us_english', // For timestamp columns
111
    ];
112
113
    private static Connection $dbal_connection;
114
115
    public static function connect(
116
        #[SensitiveParameter]
117
        string $driver,
118
        #[SensitiveParameter]
119
        string $host,
120
        #[SensitiveParameter]
121
        string $port,
122
        #[SensitiveParameter]
123
        string $database,
124
        #[SensitiveParameter]
125
        string $username,
126
        #[SensitiveParameter]
127
        string $password,
128
        #[SensitiveParameter]
129
        string $prefix,
130
        #[SensitiveParameter]
131
        string $key,
132
        #[SensitiveParameter]
133
        string $certificate,
134
        #[SensitiveParameter]
135
        string $ca,
136
        #[SensitiveParameter]
137
        bool $verify_certificate,
138
    ): void {
139
        $options = [
140
            // Some drivers do this and some don't. Make them consistent.
141
            PDO::ATTR_STRINGIFY_FETCHES => true,
142
        ];
143
144
        // MySQL/MariaDB support encrypted connections
145
        if ($driver === self::MYSQL && $key !== '' && $certificate !== '' && $ca !== '') {
146
            $options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = $verify_certificate;
147
            $options[PDO::MYSQL_ATTR_SSL_KEY]                = Webtrees::ROOT_DIR . 'data/' . $key;
148
            $options[PDO::MYSQL_ATTR_SSL_CERT]               = Webtrees::ROOT_DIR . 'data/' . $certificate;
149
            $options[PDO::MYSQL_ATTR_SSL_CA]                 = Webtrees::ROOT_DIR . 'data/' . $ca;
150
        }
151
152
        if ($driver === self::SQLITE && $database !== ':memory:') {
153
            $database = Webtrees::ROOT_DIR . 'data/' . $database . '.sqlite';
154
        }
155
156
        // doctrine/dbal
157
158
        $parameters = match ($driver) {
159
            self::MYSQL => [
160
                'driver'   => 'pdo_mysql',
161
                'dbname'   => $database,
162
                'user'     => $username,
163
                'password' => $password,
164
                'port'     => $port,
165
                'charset'  => 'utf8mb4',
166
            ],
167
            self::POSTGRES => [
168
                'driver'   => 'pdo_pgsql',
169
                'dbname'   => $database,
170
                'user'     => $username,
171
                'password' => $password,
172
                'port'     => $port,
173
                'charset'  => 'utf8',
174
            ],
175
            self::SQLITE => [
176
                'driver'   => 'pdo_sqlite',
177
                'path'     => $database,
178
            ],
179
            self::SQL_SERVER => [
180
                'driver'   => 'pdo_sqlsrv',
181
                'dbname'   => $database,
182
                'user'     => $username,
183
                'password' => $password,
184
                'port'     => $port,
185
            ],
186
        };
187
188
        $configuration = new Configuration();
189
        $configuration->setSchemaAssetsFilter(schemaAssetsFilter: self::schemaAssetsFilter(...));
190
191
        self::$dbal_connection = DriverManager::getConnection(params: $parameters, config: $configuration);
192
193
        // illuminate/database
194
195
        $capsule = new self();
196
        $capsule->addConnection([
197
            'driver'                   => $driver,
198
            'host'                     => $host,
199
            'port'                     => $port,
200
            'database'                 => $database,
201
            'username'                 => $username,
202
            'password'                 => $password,
203
            'prefix'                   => $prefix,
204
            'prefix_indexes'           => true,
205
            'options'                  => $options,
206
            'trust_server_certificate' => true, // For SQL-Server - #5246
207
        ]);
208
        $capsule->setAsGlobal();
209
210
        // Eager-load the connection to prevent database credentials appearing in error logs.
211
        try {
212
            self::pdo();
213
        } catch (PDOException $exception) {
214
            throw new RuntimeException($exception->getMessage());
215
        }
216
217
        $sql = self::DRIVER_INITIALIZATION[$driver];
218
219
        if ($sql !== '') {
220
            self::exec($sql);
221
        }
222
    }
223
224
    private static function schemaAssetsFilter(string $asset): bool
225
    {
226
        return str_starts_with(haystack: $asset, needle: parent::connection()->getTablePrefix());
227
    }
228
229
    public static function driverName(): string
230
    {
231
        return self::pdo()->getAttribute(PDO::ATTR_DRIVER_NAME);
232
    }
233
234
    public static function exec(string $sql): int|false
235
    {
236
        return self::pdo()->exec($sql);
237
    }
238
239
    public static function lastInsertId(): int
240
    {
241
        $return = self::pdo()->lastInsertId();
242
243
        if ($return === false) {
244
            throw new RuntimeException('Unable to retrieve last insert ID');
245
        }
246
247
        // All IDs are integers in our schema.
248
        return (int) $return;
249
    }
250
251
    private static function pdo(): PDO
252
    {
253
        return parent::connection()->getPdo();
254
    }
255
256
    /**
257
     * @param non-empty-string $identifier
258
     *
259
     * @return non-empty-string
260
     */
261
    public static function prefix(string $identifier): string
262
    {
263
        return parent::connection()->getTablePrefix() . $identifier;
264
    }
265
266
    /**
267
     * SQL-Server needs to be told that we are going to insert into an identity column.
268
     *
269
     * @param non-empty-string $table
270
     * @param Closure(): void  $callback
271
     */
272
    public static function identityInsert(string $table, Closure $callback): void
273
    {
274
        if (self::driverName() === self::SQL_SERVER) {
275
            self::exec(sql: 'SET IDENTITY_INSERT [' . self::prefix(identifier: $table) . '] ON');
276
        }
277
278
        $callback();
279
280
        if (self::driverName() === self::SQL_SERVER) {
281
            self::exec(sql: 'SET IDENTITY_INSERT [' . self::prefix(identifier: $table) . '] OFF');
282
        }
283
    }
284
285
    public static function rollBack(): void
286
    {
287
        parent::connection()->rollBack();
288
    }
289
290
    /**
291
     * @internal
292
     *
293
     * @param list<string> $expressions
294
     */
295
    public static function concat(array $expressions): string
296
    {
297
        if (self::driverName() === self::SQL_SERVER) {
298
            return 'CONCAT(' . implode(', ', $expressions) . ')';
299
        }
300
301
        // ANSI standard.  MySQL uses this with ANSI mode
302
        return '(' . implode(' || ', $expressions) . ')';
303
    }
304
305
    /**
306
     * @internal
307
     */
308
    public static function iLike(): string
309
    {
310
        if (self::driverName() === self::POSTGRES) {
311
            return 'ILIKE';
312
        }
313
314
        return 'LIKE';
315
    }
316
317
    /**
318
     * @internal
319
     */
320
    public static function groupConcat(string $column): string
321
    {
322
        return sprintf(self::GROUP_CONCAT_FUNCTION[self::driverName()], $column);
323
    }
324
325
    /**
326
     * @return Expression<string>
327
     */
328
    public static function binaryColumn(string $column, string|null $alias = null): Expression
329
    {
330
        if (self::driverName() === self::MYSQL) {
331
            $sql = 'CAST(' . $column . ' AS binary)';
332
        } else {
333
            $sql = $column;
334
        }
335
336
        if ($alias !== null) {
337
            $sql .= ' AS ' . $alias;
338
        }
339
340
        return new Expression($sql);
341
    }
342
343
    public static function regexOperator(): string
344
    {
345
        return self::REGEX_OPERATOR[self::driverName()];
346
    }
347
348
    /**
349
     * @return array<string,string>
350
     */
351
    public static function tableOptions(): array
352
    {
353
        return self::TABLE_OPTIONS[self::driverName()];
354
    }
355
356
    /**
357
     * PHPSTAN can't detect the magic methods in the parent class.
358
     */
359
    public static function query(): Builder
360
    {
361
        return parent::connection()->query();
362
    }
363
364
    public static function getDBALConnection(): Connection
365
    {
366
        return self::$dbal_connection;
367
    }
368
369
    public static function select(string ...$expressions): QueryBuilder
370
    {
371
        return self::$dbal_connection
372
            ->createQueryBuilder()
373
            ->select(...$expressions);
374
    }
375
376
    /**
377
     * @param non-empty-string                                     $table
378
     * @param array<array-key,array<string,int|float|string|null>> $rows
379
     */
380
    public static function insert(string $table, array $rows): void
381
    {
382
        foreach ($rows as $row) {
383
            self::getDBALConnection()->insert(table: self::prefix($table), data: $row);
384
        }
385
    }
386
387
    public static function delete(string ...$expressions): QueryBuilder
388
    {
389
        return self::$dbal_connection
390
            ->createQueryBuilder()
391
            ->delete(...$expressions);
392
    }
393
394
    public static function expression(): ExpressionBuilder
395
    {
396
        return self::$dbal_connection->createExpressionBuilder();
397
    }
398
399
    /**
400
     * @param non-empty-string $name
401
     */
402
    public static function char(string $name, int $length, bool $nullable = false, string|null $default = null): Column
403
    {
404
        return Column::editor()
405
            ->setUnquotedName(name: $name)
406
            ->setType(type: new AsciiStringType())
407
            ->setLength(length: $length)
408
            ->setFixed(fixed: true)
409
            ->setNotNull(notNull: !$nullable)
410
            ->setDefaultValue(defaultValue: $default)
411
            ->setCollation(collation: self::COLLATION_ASCII[self::driverName()])
412
            ->create();
413
    }
414
415
    /**
416
     * @param non-empty-string $name
417
     */
418
    public static function varchar(string $name, int $length, bool $nullable = false, string|null $default = null): Column
419
    {
420
        return Column::editor()
421
            ->setUnquotedName(name: $name)
422
            ->setType(type: new AsciiStringType())
423
            ->setLength(length: $length)
424
            ->setFixed(fixed: false)
425
            ->setNotNull(notNull: !$nullable)
426
            ->setDefaultValue(defaultValue: $default)
427
            ->setCollation(collation: self::COLLATION_ASCII[self::driverName()])
428
            ->create();
429
    }
430
431
    /**
432
     * @param non-empty-string $name
433
     */
434
    public static function nchar(string $name, int $length, bool $nullable = false, string|null $default = null): Column
435
    {
436
        return Column::editor()
437
            ->setUnquotedName(name: $name)
438
            ->setType(type: new StringType())
439
            ->setLength(length: $length)
440
            ->setFixed(fixed: true)
441
            ->setNotNull(notNull: !$nullable)
442
            ->setDefaultValue(defaultValue: $default)
443
            ->setCharset(charset: self::CHARSET_UTF8[self::driverName()])
444
            ->setCollation(collation: self::COLLATION_UTF8[self::driverName()])
445
            ->create();
446
    }
447
448
    /**
449
     * @param non-empty-string $name
450
     */
451
    public static function nvarchar(string $name, int $length, bool $nullable = false, string|null $default = null): Column
452
    {
453
        return Column::editor()
454
            ->setUnquotedName(name: $name)
455
            ->setType(type: new StringType())
456
            ->setLength(length: $length)
457
            ->setFixed(fixed: false)
458
            ->setNotNull(notNull: !$nullable)
459
            ->setDefaultValue(defaultValue: $default)
460
            ->setCharset(charset: self::CHARSET_UTF8[self::driverName()])
461
            ->setCollation(collation: self::COLLATION_UTF8[self::driverName()])
462
            ->create();
463
    }
464
465
    /**
466
     * @param non-empty-string $name
467
     */
468
    public static function integer(string $name, bool $autoincrement = false, bool $nullable = false, int|null $default = null): Column
469
    {
470
        return Column::editor()
471
            ->setUnquotedName(name: $name)
472
            ->setType(type: new IntegerType())
473
            ->setAutoincrement($autoincrement)
474
            ->setNotNull(notNull: !$nullable)
475
            ->setDefaultValue(defaultValue: $default)
476
            ->create();
477
    }
478
479
    /**
480
     * @param non-empty-string $name
481
     */
482
    public static function float(string $name, bool $nullable = false): Column
483
    {
484
        return Column::editor()
485
            ->setUnquotedName(name: $name)
486
            ->setType(type: new FloatType())
487
            ->setNotNull(notNull: !$nullable)
488
            ->create();
489
    }
490
491
    /**
492
     * @param non-empty-string $name
493
     */
494
    public static function text(string $name): Column
495
    {
496
        return Column::editor()
497
            ->setUnquotedName(name: $name)
498
            ->setType(type: new TextType())
499
            ->setCharset(charset: self::CHARSET_UTF8[self::driverName()])
500
            ->setCollation(collation: self::COLLATION_UTF8[self::driverName()])
501
            ->create();
502
    }
503
504
    /**
505
     * @param non-empty-string $name
506
     */
507
    public static function timestamp(string $name, int $precision = 0, DefaultExpression|null $default = null): Column
508
    {
509
        return Column::editor()
510
            ->setUnquotedName(name: $name)
511
            ->setType(type: new DateTimeImmutableType())
512
            ->setPrecision($precision)
513
            ->setDefaultValue(defaultValue: $default)
514
            ->create();
515
    }
516
517
    /**
518
     * @param non-empty-list<non-empty-string> $columns
519
     */
520
    public static function primaryKey(array $columns): PrimaryKeyConstraint
521
    {
522
        return PrimaryKeyConstraint::editor()
523
            ->setUnquotedColumnNames(...$columns)
524
            ->create();
525
    }
526
527
    /**
528
     * @param non-empty-string                 $name
529
     * @param non-empty-list<non-empty-string> $columns
530
     */
531
    public static function index(string $name, array $columns): Index
532
    {
533
        return Index::editor()
534
            ->setType(IndexType::REGULAR)
535
            ->setUnquotedName(self::prefix($name))
536
            ->setUnquotedColumnNames(...$columns)
537
            ->create();
538
    }
539
540
    /**
541
     * @param non-empty-string                 $name
542
     * @param non-empty-list<non-empty-string> $columns
543
     */
544
    public static function uniqueIndex(string $name, array $columns): Index
545
    {
546
        return Index::editor()
547
            ->setType(IndexType::UNIQUE)
548
            ->setUnquotedName(self::prefix($name))
549
            ->setUnquotedColumnNames(...$columns)
550
            ->create();
551
    }
552
553
    /**
554
     * @param non-empty-string                  $name
555
     * @param non-empty-array<non-empty-string> $local_columns
556
     * @param non-empty-string                  $foreign_table
557
     * @param non-empty-array<non-empty-string> $foreign_columns
558
     */
559
    public static function foreignKey(
560
        string $name,
561
        array $local_columns,
562
        string $foreign_table,
563
        array|null $foreign_columns = null,
564
        ReferentialAction $on_delete = ReferentialAction::NO_ACTION,
565
        ReferentialAction $on_update = ReferentialAction::NO_ACTION,
566
    ): ForeignKeyConstraint {
567
        $foreign_columns ??= $local_columns;
568
569
        return ForeignKeyConstraint::editor()
570
            ->setUnquotedName(self::prefix($name))
571
            ->setUnquotedReferencingColumnNames(...$local_columns)
572
            ->setUnquotedReferencedTableName(self::prefix($foreign_table))
573
            ->setUnquotedReferencedColumnNames(...$foreign_columns)
574
            ->setOnDeleteAction($on_delete)
575
            ->setOnUpdateAction($on_update)
576
            ->create();
577
    }
578
}
579