Passed
Pull Request — master (#240)
by Wilmer
12:51
created

Transaction::rollBack()   B

Complexity

Conditions 7
Paths 7

Size

Total Lines 39
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 7.5375

Importance

Changes 2
Bugs 0 Features 1
Metric Value
cc 7
eloc 22
nc 7
nop 0
dl 0
loc 39
ccs 14
cts 18
cp 0.7778
crap 7.5375
rs 8.6346
c 2
b 0
f 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Db\Transaction;
6
7
use Psr\Log\LogLevel;
8
use Throwable;
9
use Yiisoft\Db\Connection\Connection;
10
use Yiisoft\Db\Exception\Exception;
11
use Yiisoft\Db\Exception\InvalidConfigException;
12
use Yiisoft\Db\Exception\NotSupportedException;
13
14
/**
15
 * Transaction represents a DB transaction.
16
 *
17
 * It is usually created by calling {@see Connection::beginTransaction()}.
18
 *
19
 * The following code is a typical example of using transactions (note that some DBMS may not support transactions):
20
 *
21
 * ```php
22
 * $transaction = $connection->beginTransaction();
23
 * try {
24
 *     $connection->createCommand($sql1)->execute();
25
 *     $connection->createCommand($sql2)->execute();
26
 *     //.... other SQL executions
27
 *     $transaction->commit();
28
 * } catch (\Throwable $e) {
29
 *     $transaction->rollBack();
30
 *     throw $e;
31
 * }
32
 * ```
33
 *
34
 * @property bool $isActive Whether this transaction is active. Only an active transaction can {@see commit()} or
35
 * {@see rollBack()}. This property is read-only.
36
 * @property string $isolationLevel The transaction isolation level to use for this transaction. This can be one of
37
 * {@see READ_UNCOMMITTED}, {@see READ_COMMITTED}, {@see REPEATABLE_READ} and {@see SERIALIZABLE} but also a string
38
 * containing DBMS specific syntax to be used after `SET TRANSACTION ISOLATION LEVEL`. This property is write-only.
39
 * @property int $level The current nesting level of the transaction. This property is read-only.
40
 */
41
class Transaction
42
{
43
    /**
44
     * A constant representing the transaction isolation level `READ UNCOMMITTED`.
45
     *
46
     * {@see http://en.wikipedia.org/wiki/Isolation_%28database_systems%29#Isolation_levels}
47
     */
48
    public const READ_UNCOMMITTED = 'READ UNCOMMITTED';
49
50
    /**
51
     * A constant representing the transaction isolation level `READ COMMITTED`.
52
     *
53
     * {@see http://en.wikipedia.org/wiki/Isolation_%28database_systems%29#Isolation_levels}
54
     */
55
    public const READ_COMMITTED = 'READ COMMITTED';
56
57
    /**
58
     * A constant representing the transaction isolation level `REPEATABLE READ`.
59
     *
60
     * {@see http://en.wikipedia.org/wiki/Isolation_%28database_systems%29#Isolation_levels}
61
     */
62
    public const REPEATABLE_READ = 'REPEATABLE READ';
63
64
    /**
65
     * A constant representing the transaction isolation level `SERIALIZABLE`.
66
     *
67
     * {@see http://en.wikipedia.org/wiki/Isolation_%28database_systems%29#Isolation_levels}
68
     */
69
    public const SERIALIZABLE = 'SERIALIZABLE';
70
71
    private int $level = 0;
72
    private Connection $db;
73
74 40
    public function __construct(Connection $db)
75
    {
76 40
        $this->db = $db;
77 40
    }
78
79
    /**
80
     * Returns a value indicating whether this transaction is active.
81
     *
82
     * @return bool whether this transaction is active. Only an active transaction can {@see commit()} or
83
     * {@see rollBack()}.
84
     */
85 40
    public function isActive(): bool
86
    {
87 40
        return $this->level > 0 && $this->db && $this->db->isActive();
88
    }
89
90
    /**
91
     * Begins a transaction.
92
     *
93
     * @param string|null $isolationLevel The {@see isolation level}[] to use for this transaction.
94
     * This can be one of {@see READ_UNCOMMITTED}, {@see READ_COMMITTED}, {@see REPEATABLE_READ} and {@see SERIALIZABLE}
95
     * but also a string containing DBMS specific syntax to be used after `SET TRANSACTION ISOLATION LEVEL`.
96
     *
97
     * If not specified (`null`) the isolation level will not be set explicitly and the DBMS default will be used.
98
     *
99
     * > Note: This setting does not work for PostgreSQL, where setting the isolation level before the transaction has
100
     * no effect. You have to call {@see setIsolationLevel()} in this case after the transaction has started.
101
     *
102
     * > Note: Some DBMS allow setting of the isolation level only for the whole connection so subsequent transactions
103
     * may get the same isolation level even if you did not specify any. When using this feature you may need to set the
104
     * isolation level for all transactions explicitly to avoid conflicting settings.
105
     * At the time of this writing affected DBMS are MSSQL and SQLite.
106
     *
107
     * [isolation level]: http://en.wikipedia.org/wiki/Isolation_%28database_systems%29#Isolation_levels
108
     *
109
     * @throws InvalidConfigException if {@see db} is `null`
110
     * @throws NotSupportedException if the DBMS does not support nested transactions
111
     * @throws Exception|Throwable if DB connection fails
112
     */
113 40
    public function begin(?string $isolationLevel = null): void
114
    {
115 40
        if ($this->db === null) {
116
            throw new InvalidConfigException('Transaction::db must be set.');
117 40
        }
118
119
        $this->db->open();
120
121 40
        if ($this->level === 0) {
122
            if ($isolationLevel !== null) {
123 40
                $this->db->getSchema()->setTransactionIsolationLevel($isolationLevel);
124 40
            }
125 9
126
            if ($this->db->getLogger() !== null) {
127
                $this->db->getLogger()->log(
128 40
                    LogLevel::DEBUG,
129 40
                    'Begin transaction' . ($isolationLevel ? ' with isolation level ' . $isolationLevel : '')
130 40
                    . ' ' . __METHOD__
131 40
                );
132
            }
133
134 40
            $this->db->getPDO()->beginTransaction();
135 40
            $this->level = 1;
136
137 40
            return;
138
        }
139
140 10
        $schema = $this->db->getSchema();
141
142 10
        if ($schema->supportsSavepoint()) {
143 5
            if ($this->db->getLogger() !== null) {
144 5
                $this->db->getLogger()->log(
145 5
                    LogLevel::DEBUG,
146
                    'Set savepoint ' . $this->level . ' ' . __METHOD__
147
                );
148 5
            }
149
150 5
            $schema->createSavepoint('LEVEL' . $this->level);
151 5
        } else {
152 5
            if ($this->db->getLogger() !== null) {
153
                $this->db->getLogger()->log(
154
                    LogLevel::DEBUG,
155 5
                    'Transaction not started: nested transaction not supported ' . __METHOD__
156
                );
157
            }
158 5
159 5
            throw new NotSupportedException('Transaction not started: nested transaction not supported.');
160
        }
161
162
        $this->level++;
163
    }
164
165
    /**
166 24
     * Commits a transaction.
167
     *
168 24
     * @throws Exception|Throwable if the transaction is not active
169
     */
170 24
    public function commit(): void
171
    {
172
        if (!$this->isActive()) {
173
            throw new Exception('Failed to commit transaction: transaction was inactive.');
174 24
        }
175 24
176 24
        $this->level--;
177 24
        if ($this->level === 0) {
178 24
            if ($this->db->getLogger() !== null) {
179
                $this->db->getLogger()->log(
180
                    LogLevel::DEBUG,
181 24
                    'Commit transaction ' . __METHOD__
182
                );
183 24
            }
184
185
            $this->db->getPDO()->commit();
186
187
            return;
188
        }
189
190
        $schema = $this->db->getSchema();
191
        if ($schema->supportsSavepoint()) {
192
            if ($this->db->getLogger() !== null) {
193
                $this->db->getLogger()->log(
194
                    LogLevel::DEBUG,
195
                    'Release savepoint ' . $this->level . ' ' . __METHOD__
196
                );
197
            }
198
            $schema->releaseSavepoint('LEVEL' . $this->level);
199
        } else {
200
            if ($this->db->getLogger() !== null) {
201
                $this->db->getLogger()->log(
202
                    LogLevel::INFO,
203
                    'Transaction not committed: nested transaction not supported ' . __METHOD__
204 21
                );
205
            }
206 21
        }
207
    }
208 21
209
    /**
210
     * Rolls back a transaction.
211
     */
212
    public function rollBack(): void
213
    {
214
        if (!$this->isActive()) {
215
            /**
216 21
             * do nothing if transaction is not active: this could be the transaction is committed but the event handler
217 21
             * to "commitTransaction" throw an exception
218 16
             */
219 16
            return;
220 16
        }
221
222
        $this->level--;
223 16
        if ($this->level === 0) {
224
            if ($this->db->getLogger() !== null) {
225 16
                $this->db->getLogger()->log(
226
                    LogLevel::INFO,
227
                    'Roll back transaction ' . __METHOD__
228 5
                );
229 5
            }
230 5
231 5
            $this->db->getPDO()->rollBack();
232 5
233
            return;
234
        }
235 5
236
        $schema = $this->db->getSchema();
237
        if ($schema->supportsSavepoint()) {
238
            if ($this->db->getLogger() !== null) {
239
                $this->db->getLogger()->log(
240
                    LogLevel::DEBUG,
241
                    'Roll back to savepoint ' . $this->level . ' ' . __METHOD__
242 5
                );
243
            }
244
245
            $schema->rollBackSavepoint('LEVEL' . $this->level);
246
        } else {
247
            if ($this->db->getLogger() !== null) {
248
                $this->db->getLogger()->log(
249
                    LogLevel::INFO,
250
                    'Transaction not rolled back: nested transaction not supported ' . __METHOD__
251
                );
252
            }
253
        }
254
    }
255
256
    /**
257
     * Sets the transaction isolation level for this transaction.
258
     *
259 1
     * This method can be used to set the isolation level while the transaction is already active.
260
     * However this is not supported by all DBMS so you might rather specify the isolation level directly when calling
261 1
     * {@see begin()}.
262
     *
263 1
     * @param string $level The transaction isolation level to use for this transaction.
264
     * This can be one of {@see READ_UNCOMMITTED}, {@see READ_COMMITTED}, {@see REPEATABLE_READ} and {@see SERIALIZABLE}
265
     * but also a string containing DBMS specific syntax to be used after `SET TRANSACTION ISOLATION LEVEL`.
266
     *
267 1
     * @throws Exception|Throwable if the transaction is not active.
268 1
     *
269 1
     * {@see http://en.wikipedia.org/wiki/Isolation_%28database_systems%29#Isolation_levels}
270
     */
271
    public function setIsolationLevel(string $level): void
272 1
    {
273 1
        if (!$this->isActive()) {
274
            throw new Exception('Failed to set isolation level: transaction was inactive.');
275
        }
276
277
        if ($this->db->getLogger() !== null) {
278 25
            $this->db->getLogger()->log(
279
                LogLevel::DEBUG,
280 25
                'Setting transaction isolation level to ' . $this->level . ' ' . __METHOD__
281
            );
282
        }
283
284
        $this->db->getSchema()->setTransactionIsolationLevel($level);
285
    }
286
287
    /**
288
     * @return int the nesting level of the transaction. 0 means the outermost level.
289
     */
290
    public function getLevel(): int
291
    {
292
        return $this->level;
293
    }
294
}
295