Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.55% covered (success)
96.55%
56 / 58
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Database
96.55% covered (success)
96.55%
56 / 58
90.00% covered (success)
90.00%
9 / 10
24
0.00% covered (danger)
0.00%
0 / 1
 connect
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 isolate
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 discard
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 request
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 begin
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 commit
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 rollback
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 reset
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 disconnect
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
2.50
 end
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Testing;
6
7use Celema\Quma\Database as QumaDatabase;
8use Override;
9use PDO;
10use PDOException;
11
12/**
13 * The database of an app under test. All instances with the same DSN share
14 * one connection per process, as opening one costs more than most tests
15 * take. While a test isolates it, the code under test works inside the
16 * test's transaction: its own transaction is a savepoint, and the end of a
17 * request rolls back only what the request left open.
18 *
19 * @internal
20 */
21final class Database extends QumaDatabase
22{
23    private const string SAVEPOINT = 'cosray_testing';
24    private const string REQUEST = 'cosray_testing_request';
25
26    /** @var array<string, PDO> */
27    private static array $connections = [];
28
29    private bool $isolated = false;
30
31    /** Whether the code under test opened its transaction. */
32    private bool $nested = false;
33
34    #[Override]
35    public function connect(): static
36    {
37        if ($this->pdo !== null) {
38            return $this;
39        }
40
41        $config = $this->conn->config;
42        $key = serialize([$config->dsn, $config->pdo->username, $config->pdo->effectiveOptions($config->driver)]);
43        $shared = self::$connections[$key] ?? null;
44
45        if ($shared === null) {
46            parent::connect();
47            self::$connections[$key] = $this->getConn();
48
49            return $this;
50        }
51
52        $this->pdo = $shared;
53        $this->markConnected();
54
55        return $this;
56    }
57
58    /** Opens the test's transaction, which holds everything until discard(). */
59    public function isolate(): void
60    {
61        parent::begin();
62        $this->isolated = true;
63        $this->nested = false;
64    }
65
66    /** Rolls back the test's transaction and all the work in it. */
67    public function discard(): void
68    {
69        $this->isolated = false;
70        $this->nested = false;
71
72        if ($this->pdo?->inTransaction()) {
73            $this->pdo->rollBack();
74        }
75    }
76
77    /**
78     * Runs a request in a savepoint. A statement that fails outside the code's
79     * own transaction aborts PostgreSQL's transaction; without a test it would
80     * fail alone, so the request's work is undone and the test can go on.
81     *
82     * @template T
83     *
84     * @param callable(): T $request
85     * @return T
86     */
87    public function request(callable $request): mixed
88    {
89        if (!$this->isolated) {
90            return $request();
91        }
92
93        $pdo = $this->getConn();
94        $pdo->exec('SAVEPOINT ' . self::REQUEST);
95
96        try {
97            return $request();
98        } finally {
99            try {
100                $pdo->exec('RELEASE SAVEPOINT ' . self::REQUEST);
101            } catch (PDOException) {
102                $pdo->exec('ROLLBACK TO SAVEPOINT ' . self::REQUEST);
103                $pdo->exec('RELEASE SAVEPOINT ' . self::REQUEST);
104            }
105        }
106    }
107
108    #[Override]
109    public function begin(): bool
110    {
111        if (!$this->isolated) {
112            return parent::begin();
113        }
114
115        if ($this->nested) {
116            throw new PDOException('There is already an active transaction');
117        }
118
119        $this->getConn()->exec('SAVEPOINT ' . self::SAVEPOINT);
120        $this->nested = true;
121
122        return true;
123    }
124
125    #[Override]
126    public function commit(): bool
127    {
128        if (!$this->isolated) {
129            return parent::commit();
130        }
131
132        $this->end('RELEASE SAVEPOINT ' . self::SAVEPOINT);
133
134        return true;
135    }
136
137    #[Override]
138    public function rollback(): bool
139    {
140        if (!$this->isolated) {
141            return parent::rollback();
142        }
143
144        $this->end('ROLLBACK TO SAVEPOINT ' . self::SAVEPOINT, 'RELEASE SAVEPOINT ' . self::SAVEPOINT);
145
146        return true;
147    }
148
149    #[Override]
150    public function reset(): bool
151    {
152        if (!$this->isolated) {
153            return parent::reset();
154        }
155
156        if ($this->nested) {
157            $this->rollback();
158        }
159
160        return true;
161    }
162
163    #[Override]
164    public function disconnect(): void
165    {
166        if ($this->isolated) {
167            $this->reset();
168
169            return;
170        }
171
172        parent::disconnect();
173    }
174
175    private function end(string ...$statements): void
176    {
177        if (!$this->nested) {
178            throw new PDOException('There is no active transaction');
179        }
180
181        $this->nested = false;
182        $pdo = $this->getConn();
183
184        foreach ($statements as $statement) {
185            $pdo->exec($statement);
186        }
187    }
188}