Passed
Push — master ( f4eeb8...0d22ee )
by Sergei
11:47 queued 01:39
created

Transaction::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 3
rs 10
ccs 2
cts 2
cp 1
crap 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\AwareTrait\LoggerAwareTrait;
10
use Yiisoft\Db\Connection\ConnectionInterface;
11
use Yiisoft\Db\Exception\Exception;
12
use Yiisoft\Db\Exception\InvalidConfigException;
13
use Yiisoft\Db\Exception\NotSupportedException;
14
15
/**
16
 * Transaction represents a DB transaction.
17
 *
18
 * It is usually created by calling {@see Connection::beginTransaction()}.
19
 *
20
 * The following code is a typical example of using transactions (note that some DBMS may not support transactions):
21
 *
22
 * ```php
23
 * $transaction = $connection->beginTransaction();
24
 * try {
25
 *     $connection->createCommand($sql1)->execute();
26
 *     $connection->createCommand($sql2)->execute();
27
 *     //.... other SQL executions
28
 *     $transaction->commit();
29
 * } catch (\Throwable $e) {
30
 *     $transaction->rollBack();
31
 *     throw $e;
32
 * }
33
 * ```
34
 *
35
 * @property bool $isActive Whether this transaction is active. Only an active transaction can {@see commit()} or
36
 * {@see rollBack()}. This property is read-only.
37
 * @property string $isolationLevel The transaction isolation level to use for this transaction. This can be one of
38
 * {@see READ_UNCOMMITTED}, {@see READ_COMMITTED}, {@see REPEATABLE_READ} and {@see SERIALIZABLE} but also a string
39
 * containing DBMS specific syntax to be used after `SET TRANSACTION ISOLATION LEVEL`. This property is write-only.
40
 * @property int $level The current nesting level of the transaction. This property is read-only.
41
 */
42
class Transaction
43
{
44
    use LoggerAwareTrait;
45
46
    /**
47
     * A constant representing the transaction isolation level `READ UNCOMMITTED`.
48
     *
49
     * {@see http://en.wikipedia.org/wiki/Isolation_%28database_systems%29#Isolation_levels}
50
     */
51
    public const READ_UNCOMMITTED = 'READ UNCOMMITTED';
52
53
    /**
54
     * A constant representing the transaction isolation level `READ COMMITTED`.
55
     *
56
     * {@see http://en.wikipedia.org/wiki/Isolation_%28database_systems%29#Isolation_levels}
57
     */
58
    public const READ_COMMITTED = 'READ COMMITTED';
59
60
    /**
61
     * A constant representing the transaction isolation level `REPEATABLE READ`.
62
     *
63
     * {@see http://en.wikipedia.org/wiki/Isolation_%28database_systems%29#Isolation_levels}
64
     */
65
    public const REPEATABLE_READ = 'REPEATABLE READ';
66
67
    /**
68
     * A constant representing the transaction isolation level `SERIALIZABLE`.
69
     *
70
     * {@see http://en.wikipedia.org/wiki/Isolation_%28database_systems%29#Isolation_levels}
71
     */
72
    public const SERIALIZABLE = 'SERIALIZABLE';
73
74
    private int $level = 0;
75
    private ConnectionInterface $db;
76
77 40
    public function __construct(ConnectionInterface $db)
78
    {
79 40
        $this->db = $db;
80
    }
81
82
    /**
83
     * Returns a value indicating whether this transaction is active.
84
     *
85
     * @return bool whether this transaction is active. Only an active transaction can {@see commit()} or
86
     * {@see rollBack()}.
87
     */
88 40
    public function isActive(): bool
89
    {
90 40
        return $this->level > 0 && $this->db && $this->db->isActive();
0 ignored issues
show
Bug introduced by
The method isActive() does not exist on Yiisoft\Db\Connection\ConnectionInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to Yiisoft\Db\Connection\ConnectionInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

90
        return $this->level > 0 && $this->db && $this->db->/** @scrutinizer ignore-call */ isActive();
Loading history...
91
    }
92
93
    /**
94
     * Begins a transaction.
95
     *
96
     * @param string|null $isolationLevel The {@see isolation level}[] to use for this transaction.
97
     * This can be one of {@see READ_UNCOMMITTED}, {@see READ_COMMITTED}, {@see REPEATABLE_READ} and {@see SERIALIZABLE}
98
     * but also a string containing DBMS specific syntax to be used after `SET TRANSACTION ISOLATION LEVEL`.
99
     *
100
     * If not specified (`null`) the isolation level will not be set explicitly and the DBMS default will be used.
101
     *
102
     * > Note: This setting does not work for PostgreSQL, where setting the isolation level before the transaction has
103
     * no effect. You have to call {@see setIsolationLevel()} in this case after the transaction has started.
104
     *
105
     * > Note: Some DBMS allow setting of the isolation level only for the whole connection so subsequent transactions
106
     * may get the same isolation level even if you did not specify any. When using this feature you may need to set the
107
     * isolation level for all transactions explicitly to avoid conflicting settings.
108
     * At the time of this writing affected DBMS are MSSQL and SQLite.
109
     *
110
     * [isolation level]: http://en.wikipedia.org/wiki/Isolation_%28database_systems%29#Isolation_levels
111
     *
112
     * @throws InvalidConfigException if {@see db} is `null`
113
     * @throws NotSupportedException if the DBMS does not support nested transactions
114
     * @throws Exception|Throwable if DB connection fails
115
     */
116 40
    public function begin(?string $isolationLevel = null): void
117
    {
118 40
        if ($this->db === null) {
119
            throw new InvalidConfigException('Transaction::db must be set.');
120
        }
121
122 40
        $this->db->open();
0 ignored issues
show
Bug introduced by
The method open() does not exist on Yiisoft\Db\Connection\ConnectionInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to Yiisoft\Db\Connection\ConnectionInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

122
        $this->db->/** @scrutinizer ignore-call */ 
123
                   open();
Loading history...
123
124 40
        if ($this->level === 0) {
125 40
            if ($isolationLevel !== null) {
126 9
                $this->db->getSchema()->setTransactionIsolationLevel($isolationLevel);
127
            }
128
129 40
            if ($this->logger !== null) {
130 40
                $this->logger->log(
131
                    LogLevel::DEBUG,
132 40
                    'Begin transaction' . ($isolationLevel ? ' with isolation level ' . $isolationLevel : '')
133
                    . ' ' . __METHOD__
134
                );
135
            }
136
137 40
            $this->db->getPDO()->beginTransaction();
0 ignored issues
show
Bug introduced by
The method getPDO() does not exist on Yiisoft\Db\Connection\ConnectionInterface. Since it exists in all sub-types, consider adding an abstract or default implementation to Yiisoft\Db\Connection\ConnectionInterface. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

137
            $this->db->/** @scrutinizer ignore-call */ 
138
                       getPDO()->beginTransaction();
Loading history...
138 40
            $this->level = 1;
139
140 40
            return;
141
        }
142
143 10
        $schema = $this->db->getSchema();
144
145 10
        if ($schema->supportsSavepoint()) {
146 5
            if ($this->logger !== null) {
147 5
                $this->logger->log(LogLevel::DEBUG, 'Set savepoint ' . $this->level . ' ' . __METHOD__);
148
            }
149
150 5
            $schema->createSavepoint('LEVEL' . $this->level);
151
        } else {
152 5
            if ($this->logger !== null) {
153 5
                $this->logger->log(
154
                    LogLevel::DEBUG,
155 5
                    'Transaction not started: nested transaction not supported ' . __METHOD__
156
                );
157
            }
158
159 5
            throw new NotSupportedException('Transaction not started: nested transaction not supported.');
160
        }
161
162 5
        $this->level++;
163
    }
164
165
    /**
166
     * Commits a transaction.
167
     *
168
     * @throws Exception|Throwable if the transaction is not active
169
     */
170 24
    public function commit(): void
171
    {
172 24
        if (!$this->isActive()) {
173
            throw new Exception('Failed to commit transaction: transaction was inactive.');
174
        }
175
176 24
        $this->level--;
177 24
        if ($this->level === 0) {
178 24
            if ($this->logger !== null) {
179 24
                $this->logger->log(LogLevel::DEBUG, 'Commit transaction ' . __METHOD__);
180
            }
181
182 24
            $this->db->getPDO()->commit();
183
184 24
            return;
185
        }
186
187
        $schema = $this->db->getSchema();
188
189
        if ($schema->supportsSavepoint()) {
190
            if ($this->logger !== null) {
191
                $this->logger->log(LogLevel::DEBUG, 'Release savepoint ' . $this->level . ' ' . __METHOD__);
192
            }
193
194
            $schema->releaseSavepoint('LEVEL' . $this->level);
195
        } else {
196
            if ($this->logger !== null) {
197
                $this->logger->log(
198
                    LogLevel::INFO,
199
                    'Transaction not committed: nested transaction not supported ' . __METHOD__
200
                );
201
            }
202
        }
203
    }
204
205
    /**
206
     * Rolls back a transaction.
207
     */
208 21
    public function rollBack(): void
209
    {
210 21
        if (!$this->isActive()) {
211
            /**
212
             * do nothing if transaction is not active: this could be the transaction is committed but the event handler
213
             * to "commitTransaction" throw an exception
214
             */
215
            return;
216
        }
217
218 21
        $this->level--;
219 21
        if ($this->level === 0) {
220 16
            if ($this->logger !== null) {
221 16
                $this->logger->log(LogLevel::INFO, 'Roll back transaction ' . __METHOD__);
222
            }
223
224 16
            $this->db->getPDO()->rollBack();
225
226 16
            return;
227
        }
228
229 5
        $schema = $this->db->getSchema();
230 5
        if ($schema->supportsSavepoint()) {
231 5
            if ($this->logger !== null) {
232 5
                $this->logger->log(LogLevel::DEBUG, 'Roll back to savepoint ' . $this->level . ' ' . __METHOD__);
233
            }
234
235 5
            $schema->rollBackSavepoint('LEVEL' . $this->level);
236
        } else {
237
            if ($this->logger !== null) {
238
                $this->logger->log(
239
                    LogLevel::INFO,
240
                    'Transaction not rolled back: nested transaction not supported ' . __METHOD__
241
                );
242
            }
243
        }
244
    }
245
246
    /**
247
     * Sets the transaction isolation level for this transaction.
248
     *
249
     * This method can be used to set the isolation level while the transaction is already active.
250
     * However this is not supported by all DBMS so you might rather specify the isolation level directly when calling
251
     * {@see begin()}.
252
     *
253
     * @param string $level The transaction isolation level to use for this transaction.
254
     * This can be one of {@see READ_UNCOMMITTED}, {@see READ_COMMITTED}, {@see REPEATABLE_READ} and {@see SERIALIZABLE}
255
     * but also a string containing DBMS specific syntax to be used after `SET TRANSACTION ISOLATION LEVEL`.
256
     *
257
     * @throws Exception|Throwable if the transaction is not active.
258
     *
259
     * {@see http://en.wikipedia.org/wiki/Isolation_%28database_systems%29#Isolation_levels}
260
     */
261 1
    public function setIsolationLevel(string $level): void
262
    {
263 1
        if (!$this->isActive()) {
264
            throw new Exception('Failed to set isolation level: transaction was inactive.');
265
        }
266
267 1
        if ($this->logger !== null) {
268 1
            $this->logger->log(
269
                LogLevel::DEBUG,
270 1
                'Setting transaction isolation level to ' . $this->level . ' ' . __METHOD__
271
            );
272
        }
273
274 1
        $this->db->getSchema()->setTransactionIsolationLevel($level);
275
    }
276
277
    /**
278
     * @return int the nesting level of the transaction. 0 means the outermost level.
279
     */
280 25
    public function getLevel(): int
281
    {
282 25
        return $this->level;
283
    }
284
}
285