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