• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dg / dibi / 22282875037

22 Feb 2026 06:32PM UTC coverage: 76.552% (-0.5%) from 77.004%
22282875037

push

github

dg
phpstan

55 of 96 new or added lines in 23 files covered. (57.29%)

1 existing line in 1 file now uncovered.

1763 of 2303 relevant lines covered (76.55%)

0.77 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

78.1
/src/Dibi/Connection.php
1
<?php declare(strict_types=1);
1✔
2

3
/**
4
 * This file is part of the Dibi, smart database abstraction layer (https://dibi.nette.org)
5
 * Copyright (c) 2005 David Grudl (https://davidgrudl.com)
6
 */
7

8
namespace Dibi;
9

10
use JetBrains\PhpStorm\Language;
11
use Traversable;
12
use function array_key_exists, is_array;
13
use const PHP_SAPI;
14

15

16
/**
17
 * Dibi connection.
18
 *
19
 * @property-read int $affectedRows
20
 * @property-read int $insertId
21
 */
22
class Connection implements IConnection
23
{
24
        /** @var array<callable(Event): void>  Occurs after query is executed */
25
        public array $onEvent = [];
26

27
        /** @var array<string, mixed> */
28
        private array $config;
29

30
        /** @var array<string, ?string>  Type constant => format string */
31
        private array $formats;
32
        private ?Driver $driver = null;
33
        private ?Translator $translator = null;
34

35
        /** @var array<string, callable(object): Expression | null> */
36
        private array $translators = [];
37
        private bool $sortTranslators = false;
38
        private HashMap $substitutes;
39
        private int $transactionDepth = 0;
40

41

42
        /**
43
         * Connection options: (see driver-specific options too)
44
         *   - lazy (bool) => if true, connection will be established only when required
45
         *   - result (array) => result set options
46
         *       - normalize => normalizes result fields (default: true)
47
         *       - formatDateTime => date-time format
48
         *           empty for decoding as Dibi\DateTime (default)
49
         *           "..." formatted according to given format, see https://www.php.net/manual/en/datetime.format.php
50
         *           "native" for leaving value as is
51
         *       - formatTimeInterval => time-interval format
52
         *           empty for decoding as DateInterval (default)
53
         *           "..." formatted according to given format, see https://www.php.net/manual/en/dateinterval.format.php
54
         *           "native" for leaving value as is
55
         *       - formatJson => json format
56
         *           "array" for decoding json as an array (default)
57
         *           "object" for decoding json as \stdClass
58
         *           "native" for leaving value as is
59
         *   - profiler (array)
60
         *       - run (bool) => enable profiler?
61
         *       - file => file to log
62
         *       - errorsOnly (bool) => log only errors
63
         *   - substitutes (array) => map of driver specific substitutes (under development)
64
         *   - onConnect (array) => list of SQL queries to execute (by Connection::query()) after connection is established
65
         * @param  array<string, mixed>  $config
66
         * @throws Exception
67
         */
68
        public function __construct(array $config, ?string $name = null)
1✔
69
        {
70
                Helpers::alias($config, 'username', 'user');
1✔
71
                Helpers::alias($config, 'password', 'pass');
1✔
72
                Helpers::alias($config, 'host', 'hostname');
1✔
73
                Helpers::alias($config, 'result|formatDate', 'resultDate');
1✔
74
                Helpers::alias($config, 'result|formatDateTime', 'resultDateTime');
1✔
75
                $config['driver'] ??= 'mysqli';
1✔
76
                $config['name'] = $name;
1✔
77
                $this->config = $config;
1✔
78

79
                $this->formats = [
1✔
80
                        Type::Date => $this->config['result']['formatDate'],
1✔
81
                        Type::DateTime => $this->config['result']['formatDateTime'],
1✔
82
                        Type::JSON => $this->config['result']['formatJson'] ?? 'array',
1✔
83
                        Type::TimeInterval => $this->config['result']['formatTimeInterval'] ?? null,
1✔
84
                ];
85

86
                // profiler
87
                if (isset($config['profiler']['file']) && (!isset($config['profiler']['run']) || $config['profiler']['run'])) {
1✔
88
                        $filter = $config['profiler']['filter'] ?? Event::QUERY;
×
89
                        $errorsOnly = $config['profiler']['errorsOnly'] ?? false;
×
90
                        $this->onEvent[] = [new Loggers\FileLogger($config['profiler']['file'], $filter, $errorsOnly), 'logEvent'];
×
91
                }
92

93
                $this->substitutes = new HashMap(fn(string $expr) => ":$expr:");
1✔
94
                if (!empty($config['substitutes'])) {
1✔
95
                        foreach ($config['substitutes'] as $key => $value) {
×
96
                                $this->substitutes->$key = $value;
×
97
                        }
98
                }
99

100
                if (isset($config['onConnect']) && !is_array($config['onConnect'])) {
1✔
101
                        throw new \InvalidArgumentException("Configuration option 'onConnect' must be array.");
1✔
102
                }
103

104
                if (empty($config['lazy'])) {
1✔
105
                        $this->connect();
1✔
106
                }
107
        }
1✔
108

109

110
        /**
111
         * Automatically frees the resources allocated for this result set.
112
         */
113
        public function __destruct()
114
        {
115
                if ($this->driver && $this->driver->getResource()) {
1✔
116
                        $this->disconnect();
1✔
117
                }
118
        }
1✔
119

120

121
        /**
122
         * Connects to a database.
123
         */
124
        final public function connect(): void
125
        {
126
                if ($this->config['driver'] instanceof Driver) {
1✔
127
                        $this->driver = $this->config['driver'];
1✔
128
                        $this->translator = new Translator($this);
1✔
129
                        return;
1✔
130

131
                } elseif (is_subclass_of($this->config['driver'], Driver::class)) {
1✔
132
                        $class = $this->config['driver'];
×
133

134
                } else {
135
                        $class = preg_replace(['#\W#', '#sql#'], ['_', 'Sql'], ucfirst(strtolower($this->config['driver'])));
1✔
136
                        $class = "Dibi\\Drivers\\{$class}Driver";
1✔
137
                        if (!class_exists($class)) {
1✔
138
                                throw new Exception("Unable to create instance of Dibi driver '$class'.");
×
139
                        }
140
                }
141

142
                $event = $this->onEvent ? new Event($this, Event::CONNECT) : null;
1✔
143
                try {
144
                        $this->driver = new $class($this->config);
1✔
145
                        $this->translator = new Translator($this);
1✔
146

147
                        if ($event) {
1✔
148
                                $this->onEvent($event->done());
×
149
                        }
150

151
                        if (isset($this->config['onConnect'])) {
1✔
152
                                foreach ($this->config['onConnect'] as $sql) {
1✔
153
                                        $this->query($sql);
1✔
154
                                }
155
                        }
156
                } catch (DriverException $e) {
1✔
157
                        if ($event) {
1✔
158
                                $this->onEvent($event->done($e));
×
159
                        }
160

161
                        throw $e;
1✔
162
                }
163
        }
1✔
164

165

166
        /**
167
         * Disconnects from a database.
168
         */
169
        final public function disconnect(): void
170
        {
171
                if ($this->driver) {
1✔
172
                        $this->driver->disconnect();
1✔
173
                        $this->driver = $this->translator = null;
1✔
174
                }
175
        }
1✔
176

177

178
        /**
179
         * Returns true when connection was established.
180
         */
181
        final public function isConnected(): bool
182
        {
183
                return (bool) $this->driver;
1✔
184
        }
185

186

187
        /**
188
         * Returns configuration variable. If no $key is passed, returns the entire array.
189
         * @see self::__construct
190
         */
191
        final public function getConfig(?string $key = null, mixed $default = null): mixed
1✔
192
        {
193
                return $key === null
1✔
194
                        ? $this->config
×
195
                        : ($this->config[$key] ?? $default);
1✔
196
        }
197

198

199
        /**
200
         * Returns the driver and connects to a database in lazy mode.
201
         */
202
        final public function getDriver(): Driver
203
        {
204
                if (!$this->driver) {
1✔
205
                        $this->connect();
×
206
                }
207

208
                assert($this->driver !== null);
209
                return $this->driver;
1✔
210
        }
211

212

213
        /**
214
         * Generates (translates) and executes SQL query.
215
         * @throws Exception
216
         */
217
        final public function query(#[Language('GenericSQL')] mixed ...$args): Result
1✔
218
        {
219
                return $this->nativeQuery($this->translate(...$args));
1✔
220
        }
221

222

223
        /**
224
         * Generates SQL query.
225
         * @throws Exception
226
         */
227
        final public function translate(#[Language('GenericSQL')] mixed ...$args): string
1✔
228
        {
229
                if (!$this->driver) {
1✔
230
                        $this->connect();
1✔
231
                }
232

233
                assert($this->translator !== null);
234
                return (clone $this->translator)->translate($args);
1✔
235
        }
236

237

238
        /**
239
         * Generates and prints SQL query.
240
         */
241
        final public function test(#[Language('GenericSQL')] mixed ...$args): bool
242
        {
243
                try {
244
                        Helpers::dump($this->translate(...$args));
×
245
                        return true;
×
246

247
                } catch (Exception $e) {
×
248
                        if ($e->getSql()) {
×
249
                                Helpers::dump($e->getSql());
×
250
                        } else {
251
                                echo $e::class . ': ' . $e->getMessage() . (PHP_SAPI === 'cli' ? "\n" : '<br>');
×
252
                        }
253

254
                        return false;
×
255
                }
256
        }
257

258

259
        /**
260
         * Generates (translates) and returns SQL query as DataSource.
261
         * @throws Exception
262
         */
263
        final public function dataSource(#[Language('GenericSQL')] mixed ...$args): DataSource
1✔
264
        {
265
                return new DataSource($this->translate(...$args), $this);
1✔
266
        }
267

268

269
        /**
270
         * Executes the SQL query.
271
         * @throws Exception
272
         */
273
        final public function nativeQuery(#[Language('SQL')] string $sql): Result
1✔
274
        {
275
                if (!$this->driver) {
1✔
276
                        $this->connect();
×
277
                }
278

279
                assert($this->driver !== null);
280
                \dibi::$sql = $sql;
1✔
281
                $event = $this->onEvent ? new Event($this, Event::QUERY, $sql) : null;
1✔
282
                try {
283
                        $res = $this->driver->query($sql);
1✔
284

285
                } catch (DriverException $e) {
1✔
286
                        if ($event) {
1✔
287
                                $this->onEvent($event->done($e));
×
288
                        }
289

290
                        throw $e;
1✔
291
                }
292

293
                $res = $this->createResultSet($res ?? new Drivers\NoDataResult(max(0, $this->driver->getAffectedRows())));
1✔
294
                if ($event) {
1✔
295
                        $this->onEvent($event->done($res));
×
296
                }
297

298
                return $res;
1✔
299
        }
300

301

302
        /**
303
         * Gets the number of affected rows by the last INSERT, UPDATE or DELETE query.
304
         * @throws Exception
305
         */
306
        public function getAffectedRows(): int
307
        {
308
                if (!$this->driver) {
1✔
309
                        $this->connect();
×
310
                }
311

312
                assert($this->driver !== null);
313
                $rows = $this->driver->getAffectedRows();
1✔
314
                if ($rows === null || $rows < 0) {
1✔
315
                        throw new Exception('Cannot retrieve number of affected rows.');
×
316
                }
317

318
                return $rows;
1✔
319
        }
320

321

322
        /**
323
         * Retrieves the ID generated for an AUTO_INCREMENT column by the previous INSERT query.
324
         * @throws Exception
325
         */
326
        public function getInsertId(?string $sequence = null): int
1✔
327
        {
328
                if (!$this->driver) {
1✔
329
                        $this->connect();
×
330
                }
331

332
                assert($this->driver !== null);
333
                $id = $this->driver->getInsertId($sequence);
1✔
334
                if ($id === null) {
1✔
335
                        throw new Exception('Cannot retrieve last generated ID.');
×
336
                }
337

338
                return $id;
1✔
339
        }
340

341

342
        /**
343
         * Begins a transaction (if supported).
344
         */
345
        public function begin(?string $savepoint = null): void
1✔
346
        {
347
                if ($this->transactionDepth !== 0) {
1✔
348
                        throw new \LogicException(__METHOD__ . '() call is forbidden inside a transaction() callback');
1✔
349
                }
350

351
                if (!$this->driver) {
1✔
352
                        $this->connect();
×
353
                }
354

355
                assert($this->driver !== null);
356
                $event = $this->onEvent ? new Event($this, Event::BEGIN, $savepoint) : null;
1✔
357
                try {
358
                        $this->driver->begin($savepoint);
1✔
359
                        if ($event) {
1✔
360
                                $this->onEvent($event->done());
1✔
361
                        }
362
                } catch (DriverException $e) {
×
363
                        if ($event) {
×
364
                                $this->onEvent($event->done($e));
×
365
                        }
366

367
                        throw $e;
×
368
                }
369
        }
1✔
370

371

372
        /**
373
         * Commits statements in a transaction.
374
         */
375
        public function commit(?string $savepoint = null): void
1✔
376
        {
377
                if ($this->transactionDepth !== 0) {
1✔
378
                        throw new \LogicException(__METHOD__ . '() call is forbidden inside a transaction() callback');
1✔
379
                }
380

381
                if (!$this->driver) {
1✔
382
                        $this->connect();
×
383
                }
384

385
                assert($this->driver !== null);
386
                $event = $this->onEvent ? new Event($this, Event::COMMIT, $savepoint) : null;
1✔
387
                try {
388
                        $this->driver->commit($savepoint);
1✔
389
                        if ($event) {
1✔
390
                                $this->onEvent($event->done());
1✔
391
                        }
392
                } catch (DriverException $e) {
×
393
                        if ($event) {
×
394
                                $this->onEvent($event->done($e));
×
395
                        }
396

397
                        throw $e;
×
398
                }
399
        }
1✔
400

401

402
        /**
403
         * Rollback changes in a transaction.
404
         */
405
        public function rollback(?string $savepoint = null): void
1✔
406
        {
407
                if ($this->transactionDepth !== 0) {
1✔
408
                        throw new \LogicException(__METHOD__ . '() call is forbidden inside a transaction() callback');
1✔
409
                }
410

411
                if (!$this->driver) {
1✔
412
                        $this->connect();
×
413
                }
414

415
                assert($this->driver !== null);
416
                $event = $this->onEvent ? new Event($this, Event::ROLLBACK, $savepoint) : null;
1✔
417
                try {
418
                        $this->driver->rollback($savepoint);
1✔
419
                        if ($event) {
1✔
420
                                $this->onEvent($event->done());
1✔
421
                        }
422
                } catch (DriverException $e) {
×
423
                        if ($event) {
×
424
                                $this->onEvent($event->done($e));
×
425
                        }
426

427
                        throw $e;
×
428
                }
429
        }
1✔
430

431

432
        /**
433
         * @template T
434
         * @param  callable(self): T  $callback
435
         * @return T
436
         */
437
        public function transaction(callable $callback): mixed
1✔
438
        {
439
                if ($this->transactionDepth === 0) {
1✔
440
                        $this->begin();
1✔
441
                }
442

443
                $this->transactionDepth++;
1✔
444
                try {
445
                        $res = $callback($this);
1✔
446
                } catch (\Throwable $e) {
1✔
447
                        $this->transactionDepth--;
1✔
448
                        if ($this->transactionDepth === 0) {
1✔
449
                                $this->rollback();
1✔
450
                        }
451

452
                        throw $e;
1✔
453
                }
454

455
                $this->transactionDepth--;
1✔
456
                if ($this->transactionDepth === 0) {
1✔
457
                        $this->commit();
1✔
458
                }
459

460
                return $res;
1✔
461
        }
462

463

464
        /**
465
         * Result set factory.
466
         */
467
        public function createResultSet(ResultDriver $resultDriver): Result
1✔
468
        {
469
                return (new Result($resultDriver, $this->config['result']['normalize'] ?? true))
1✔
470
                        ->setFormats($this->formats);
1✔
471
        }
472

473

474
        /********************* fluent SQL builders ****************d*g**/
475

476

477
        public function command(): Fluent
478
        {
479
                return new Fluent($this);
1✔
480
        }
481

482

483
        public function select(mixed ...$args): Fluent
1✔
484
        {
485
                return $this->command()->select(...$args);
1✔
486
        }
487

488

489
        /**
490
         * @param  string|string[]  $table
491
         * @param  iterable<string, mixed>  $args
492
         */
493
        public function update($table, iterable $args): Fluent
1✔
494
        {
495
                return $this->command()->update('%n', $table)->set($args);
1✔
496
        }
497

498

499
        /** @param  iterable<string, mixed>  $args */
500
        public function insert(string $table, iterable $args): Fluent
1✔
501
        {
502
                if ($args instanceof Traversable) {
1✔
503
                        $args = iterator_to_array($args);
×
504
                }
505

506
                return $this->command()->insert()
1✔
507
                        ->into('%n', $table, '(%n)', array_keys($args))->values('%l', $args);
1✔
508
        }
509

510

511
        public function delete(string $table): Fluent
1✔
512
        {
513
                return $this->command()->delete()->from('%n', $table);
1✔
514
        }
515

516

517
        /********************* substitutions ****************d*g**/
518

519

520
        /**
521
         * Returns substitution hashmap.
522
         */
523
        public function getSubstitutes(): HashMap
524
        {
525
                return $this->substitutes;
1✔
526
        }
527

528

529
        /**
530
         * Provides substitution.
531
         */
532
        public function substitute(string $value): string
1✔
533
        {
534
                return str_contains($value, ':')
1✔
535
                        ? preg_replace_callback('#:([^:\s]*):#', fn(array $m) => $this->substitutes->{$m[1]}, $value)
1✔
536
                        : $value;
1✔
537
        }
538

539

540
        /********************* value objects translation ****************d*g**/
541

542

543
        /**
544
         * @param  callable(object): Expression  $translator
545
         */
546
        public function setObjectTranslator(callable $translator): void
1✔
547
        {
548
                if (!$translator instanceof \Closure) {
1✔
549
                        $translator = \Closure::fromCallable($translator);
×
550
                }
551

552
                $param = (new \ReflectionFunction($translator))->getParameters()[0] ?? null;
1✔
553
                $type = $param?->getType();
1✔
554
                $types = match (true) {
1✔
555
                        $type instanceof \ReflectionNamedType => [$type],
1✔
556
                        $type instanceof \ReflectionUnionType => $type->getTypes(),
1✔
557
                        default => throw new Exception('Object translator must have exactly one parameter with class typehint.'),
1✔
558
                };
559

560
                foreach ($types as $type) {
1✔
561
                        if (!$type instanceof \ReflectionNamedType) {
1✔
NEW
562
                                continue;
×
563
                        }
564

565
                        if ($type->isBuiltin() || $type->allowsNull()) {
1✔
566
                                throw new Exception("Object translator must have exactly one parameter with non-nullable class typehint, got '$type'.");
1✔
567
                        }
568
                        $this->translators[$type->getName()] = $translator;
1✔
569
                }
570
                $this->sortTranslators = true;
1✔
571
        }
1✔
572

573

574
        public function translateObject(object $object): ?Expression
1✔
575
        {
576
                if ($this->sortTranslators) {
1✔
577
                        $this->translators = array_filter($this->translators);
1✔
578
                        uksort($this->translators, fn($a, $b) => is_subclass_of($a, $b) ? -1 : 1);
1✔
579
                        $this->sortTranslators = false;
1✔
580
                }
581

582
                if (!array_key_exists($object::class, $this->translators)) {
1✔
583
                        $translator = null;
1✔
584
                        foreach ($this->translators as $class => $t) {
1✔
585
                                if ($object instanceof $class) {
1✔
586
                                        $translator = $t;
1✔
587
                                        break;
1✔
588
                                }
589
                        }
590
                        $this->translators[$object::class] = $translator;
1✔
591
                }
592

593
                $translator = $this->translators[$object::class];
1✔
594
                if ($translator === null) {
1✔
595
                        return null;
1✔
596
                }
597

598
                $result = $translator($object);
1✔
599
                if (!$result instanceof Expression) {
1✔
600
                        throw new Exception(sprintf(
1✔
601
                                "Object translator for class '%s' returned '%s' but %s expected.",
1✔
602
                                $object::class,
1✔
603
                                get_debug_type($result),
1✔
604
                                Expression::class,
1✔
605
                        ));
606
                }
607

608
                return $result;
1✔
609
        }
610

611

612
        /********************* shortcuts ****************d*g**/
613

614

615
        /**
616
         * Executes SQL query and fetch result - shortcut for query() & fetch().
617
         * @throws Exception
618
         */
619
        public function fetch(#[Language('GenericSQL')] mixed ...$args): ?Row
1✔
620
        {
621
                return $this->query($args)->fetch();
1✔
622
        }
623

624

625
        /**
626
         * Executes SQL query and fetch results - shortcut for query() & fetchAll().
627
         * @return list<Row|mixed[]>
628
         * @throws Exception
629
         */
630
        public function fetchAll(#[Language('GenericSQL')] mixed ...$args): array
631
        {
632
                return $this->query($args)->fetchAll();
×
633
        }
634

635

636
        /**
637
         * Executes SQL query and fetch first column - shortcut for query() & fetchSingle().
638
         * @throws Exception
639
         */
640
        public function fetchSingle(#[Language('GenericSQL')] mixed ...$args): mixed
1✔
641
        {
642
                return $this->query($args)->fetchSingle();
1✔
643
        }
644

645

646
        /**
647
         * Executes SQL query and fetch pairs - shortcut for query() & fetchPairs().
648
         * @return mixed[]
649
         * @throws Exception
650
         */
651
        public function fetchPairs(#[Language('GenericSQL')] mixed ...$args): array
652
        {
653
                return $this->query($args)->fetchPairs();
×
654
        }
655

656

657
        public static function literal(string $value): Literal
658
        {
659
                return new Literal($value);
×
660
        }
661

662

663
        public static function expression(mixed ...$args): Expression
664
        {
665
                return new Expression(...$args);
×
666
        }
667

668

669
        /********************* misc ****************d*g**/
670

671

672
        /**
673
         * Import SQL dump from file.
674
         * @param  ?(callable(int, ?float): void)  $onProgress
675
         * @return int  count of sql commands
676
         */
677
        public function loadFile(string $file, ?callable $onProgress = null): int
1✔
678
        {
679
                return Helpers::loadFromFile($this, $file, $onProgress);
1✔
680
        }
681

682

683
        /**
684
         * Gets a information about the current database.
685
         */
686
        public function getDatabaseInfo(): Reflection\Database
687
        {
688
                if (!$this->driver) {
1✔
689
                        $this->connect();
×
690
                }
691

692
                assert($this->driver !== null);
693
                return new Reflection\Database($this->driver->getReflector(), $this->config['database'] ?? null);
1✔
694
        }
695

696

697
        /**
698
         * Prevents unserialization.
699
         * @param  array<string, mixed>  $_
700
         */
701
        public function __unserialize(array $_): never
702
        {
703
                throw new NotSupportedException('You cannot serialize or unserialize ' . static::class . ' instances.');
×
704
        }
×
705

706

707
        /**
708
         * Prevents serialization.
709
         * @return array<string, mixed>
710
         */
711
        public function __serialize(): array
712
        {
713
                throw new NotSupportedException('You cannot serialize or unserialize ' . static::class . ' instances.');
×
714
        }
715

716

717
        protected function onEvent(Event $arg): void
718
        {
719
                foreach ($this->onEvent as $handler) {
×
720
                        $handler($arg);
×
721
                }
722
        }
723
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc