Failed Conditions
Push — master ( fa3649...11c8ec )
by Bas
05:38
created

ManagesTransactions::performRollBack()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 0

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 0
c 1
b 0
f 0
dl 0
loc 2
rs 10
cc 1
nc 1
nop 1
1
<?php
2
3
namespace LaravelFreelancerNL\Aranguent\Concerns;
4
5
use ArangoDBClient\Transaction as ArangoTransaction;
6
use Closure;
7
use Exception;
8
use Illuminate\Support\Fluent as IlluminateFluent;
9
10
trait ManagesTransactions
11
{
12
    protected $transactions = 0;
13
14
    protected $transactionCommands = [];
15
16
    protected $arangoTransaction;
17
18
    /**
19
     * Execute a Closure within a transaction.
20
     *
21
     * @param \Closure $callback
22
     * @param array    $options
23
     * @param int      $attempts
24
     *
25
     * @throws \Exception|\Throwable
26
     *
27
     * @return mixed
28
     */
29
    public function transaction(Closure $callback, $options = [], $attempts = 1)
30
    {
31
        $this->beginTransaction();
32
33
        return tap($callback($this), function () use ($options, $attempts) {
34
            $this->commit($options, $attempts);
35
        });
36
    }
37
38
    /**
39
     * Start a new database transaction.
40
     *
41
     * @throws \Exception
42
     *
43
     * @return void
44
     */
45 4
    public function beginTransaction()
46
    {
47 4
        $this->transactions++;
48
49 4
        $this->transactionCommands[$this->transactions] = [];
50
51 4
        $this->fireConnectionEvent('beganTransaction');
0 ignored issues
show
Bug introduced by
It seems like fireConnectionEvent() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

51
        $this->/** @scrutinizer ignore-call */ 
52
               fireConnectionEvent('beganTransaction');
Loading history...
52 4
    }
53
54
    /**
55
     * Add a command to the transaction. Parameters must include:
56
     * collections['write'][]: collections that are written to
57
     * collections['read'][]: collections that are read from
58
     * command: the db command to execute.
59
     *
60
     * @param \Illuminate\Support\Fluent $command
61
     */
62 5
    public function addTransactionCommand(IlluminateFluent $command)
63
    {
64 5
        $this->transactionCommands[$this->transactions][] = $command;
65 5
    }
66
67
    /**
68
     * Add a query command to the transaction.
69
     *
70
     * @param $query
71
     * @param $bindings
72
     * @param array|null $collections
73
     *
74
     * @return IlluminateFluent
75
     */
76 2
    public function addQueryToTransaction($query, $bindings = [], $collections = null)
77
    {
78
        //If transaction collections aren't provided we will try to extract them from the query.
79 2
        if (empty($collections)) {
80 2
            $collections = $this->extractTransactionCollections($query, $bindings, $collections);
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $collections is correct as $this->extractTransactio...bindings, $collections) targeting LaravelFreelancerNL\Aran...ransactionCollections() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
81
        }
82
83
//        $query = addslashes($query);
84 2
        $jsCommand = 'db._query(aql`' . $query . '`';
85 2
        if (!empty($bindings)) {
86 2
            $bindings = json_encode($bindings);
87 2
            $jsCommand .= ', ' . $bindings;
88
        }
89 2
        $jsCommand .= ');';
90 2
        $command = new IlluminateFluent([
91 2
            'name'        => 'aqlQuery',
92 2
            'command'     => $jsCommand,
93 2
            'collections' => $collections,
94
        ]);
95
96 2
        $this->addTransactionCommand($command);
97
98 2
        return $command;
99
    }
100
101
    /**
102
     * Transaction like a list of read collections to prevent possible read deadlocks.
103
     * Transactions require a list of write collections to prepare write locks.
104
     *
105
     * @param $query
106
     * @param $bindings
107
     * @param $collections
108
     *
109
     * @return mixed
110
     */
111 2
    public function extractTransactionCollections($query, $bindings, $collections)
112
    {
113
        //Extract write collections
114 2
        $collections = $this->extractReadCollections($query, $bindings, $collections);
115 2
        $collections = $this->extractWriteCollections($query, $bindings, $collections);
116
117 2
        return $collections;
118
    }
119
120
    /**
121
     * Extract collections that are read from in a query. Not required but can prevent deadlocks.
122
     *
123
     * @param $query
124
     * @param $bindings
125
     * @param $collections
126
     *
127
     * @return mixed
128
     */
129 2
    public function extractReadCollections($query, $bindings, $collections)
130
    {
131 2
        $extractedCollections = [];
132 2
        $rawWithCollections = [];
133 2
        $rawForCollections = [];
134 2
        $rawDocCollections = [];
135
136
        //WITH statement at the start of the query
137 2
        preg_match_all('/^(?:\s+?)WITH(?:\s+?)([\S\s]*?)(?:\s+?)FOR/mis', $query, $rawWithCollections);
138 2
        foreach ($rawWithCollections[1] as $value) {
139
            $splits = preg_split("/\s*,\s*/", $value);
140
            $extractedCollections = array_merge($extractedCollections, $splits);
0 ignored issues
show
Bug introduced by
It seems like $splits can also be of type false; however, parameter $array2 of array_merge() does only seem to accept array|null, maybe add an additional type check? ( Ignorable by Annotation )

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

140
            $extractedCollections = array_merge($extractedCollections, /** @scrutinizer ignore-type */ $splits);
Loading history...
141
        }
142
143
        //FOR statements
144 2
        preg_match_all(
145 2
            '/FOR(?:\s+?)(?:\w+)(?:\s+?)(?:IN|INTO)(?:\s+?)(?!OUTBOUND|INBOUND|ANY)(@?@?\w+(?!\.))/mis',
146
            $query,
147
            $rawForCollections
148
        );
149 2
        $extractedCollections = array_merge($extractedCollections, $rawForCollections[1]);
150
151
        //Document functions which require a document as their first argument
152 2
        preg_match_all(
153 2
            '/(?:DOCUMENT\(|ATTRIBUTES\(|HAS\(|KEEP\(|LENGTH\(|MATCHES'
154
            . '\(|PARSE_IDENTIFIER\(|UNSET\(|UNSET_RECURSIVE\(|VALUES\(|OUTBOUND|INBOUND|ANY)'
155 2
            . '(?:\s+?)(?!\{)(?:\"|\'|\`)(@?@?\w+)\/(?:\w+)(?:\"|\'|\`)/mis',
156
            $query,
157
            $rawDocCollections
158
        );
159 2
        $extractedCollections = array_merge($extractedCollections, $rawDocCollections[1]);
160
161 2
        $extractedCollections = array_map('trim', $extractedCollections);
162
163 2
        $extractedCollections = $this->getCollectionByBinding($extractedCollections, $bindings);
164
165 2
        if (isset($collections['read'])) {
166
            $collections['read'] = array_merge($collections['read'], $extractedCollections);
167
        }
168 2
        if (! isset($collections['read'])) {
169 2
            $collections['read'] = $extractedCollections;
170
        }
171
172 2
        $collections['read'] = array_unique($collections['read']);
173
174 2
        return $collections;
175
    }
176
177
    /**
178
     * Extract collections that are written to in a query.
179
     *
180
     * @param $query
181
     * @param $bindings
182
     * @param $collections
183
     *
184
     * @return mixed
185
     */
186 2
    public function extractWriteCollections($query, $bindings, $collections)
187
    {
188 2
        preg_match_all(
189 2
            '/(?:\s+?)(?:INSERT|REPLACE|UPDATE|REMOVE)'
190 2
            . '(?:\s+?)(?:{(?:.*?)}|@?@?\w+?)(?:\s+?)(?:IN|INTO)(?:\s+?)(@?@?\w+)/mis',
191
            $query,
192
            $extractedCollections
193
        );
194 2
        $extractedCollections = array_map('trim', $extractedCollections[1]);
195
196 2
        $extractedCollections = $this->getCollectionByBinding($extractedCollections, $bindings);
197
198 2
        if (isset($collections['write'])) {
199
            $collections['write'] = array_merge($collections['write'], $extractedCollections);
200
        }
201 2
        if (! isset($collections['write'])) {
202 2
            $collections['write'] = $extractedCollections;
203
        }
204
205 2
        $collections['read'] = array_unique($collections['read']);
206
207 2
        return $collections;
208
    }
209
210
    /**
211
     * Get the collection names that are bound in a query.
212
     *
213
     * @param $collections
214
     * @param $bindings
215
     *
216
     * @return mixed
217
     */
218 2
    public function getCollectionByBinding($collections, $bindings)
219
    {
220 2
        foreach ($collections as $key => $collection) {
221 2
            if (strpos($collection, '@@') === 0 && isset($bindings[$collection])) {
222 2
                $collections[$key] = $bindings[$collection];
223
            }
224
        }
225
226 2
        return $collections;
227
    }
228
229
    /**
230
     * Commit the current transaction.
231
     *
232
     * @param array $options
233
     * @param int   $attempts
234
     *
235
     * @throws Exception
236
     *
237
     * @return mixed
238
     */
239 2
    public function commit($options = [], $attempts = 1)
240
    {
241 2
        if (!$this->transactions > 0) {
242 1
            throw new Exception('Transaction committed before starting one.');
243
        }
244
        if (
245 1
            !isset($this->transactionCommands[$this->transactions])
246 1
            || empty($this->transactionCommands[$this->transactions])
247
        ) {
248
            throw new Exception('Cannot commit an empty transaction.');
249
        }
250
251 1
        $options['collections'] = $this->compileTransactionCollections();
252
253 1
        $options['action'] = $this->compileTransactionAction();
254
255 1
        $results = $this->executeTransaction($options, $attempts);
256
257 1
        $this->fireConnectionEvent('committed');
258
259 1
        return $results;
260
    }
261
262 1
    public function executeTransaction($options, $attempts = 1)
263
    {
264 1
        $results = null;
265
266 1
        $this->arangoTransaction = new ArangoTransaction($this->arangoConnection, $options);
267
268 1
        for ($currentAttempt = 1; $currentAttempt <= $attempts; $currentAttempt++) {
269
            try {
270 1
                $results = $this->arangoTransaction->execute();
271
272 1
                $this->transactions--;
273
            } catch (Exception $e) {
274
                $this->fireConnectionEvent('rollingBack');
275
276
                $results = $this->handleTransactionException($e, $currentAttempt, $attempts);
277
            }
278
        }
279
280 1
        return $results;
281
    }
282
283
    /**
284
     * Handle an exception encountered when running a transacted statement.
285
     *
286
     * @param $e
287
     * @param $currentAttempt
288
     * @param $attempts
289
     *
290
     * @return mixed
291
     */
292
    protected function handleTransactionException($e, $currentAttempt, $attempts)
293
    {
294
        $retry = false;
295
        // If the failure was due to a lost connection we can just try again.
296
        if ($this->causedByLostConnection($e)) {
0 ignored issues
show
Bug introduced by
It seems like causedByLostConnection() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

296
        if ($this->/** @scrutinizer ignore-call */ causedByLostConnection($e)) {
Loading history...
297
            $this->reconnect();
0 ignored issues
show
Bug introduced by
It seems like reconnect() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

297
            $this->/** @scrutinizer ignore-call */ 
298
                   reconnect();
Loading history...
298
            $retry = true;
299
        }
300
301
        // Retry if the failure was caused by a deadlock or ArangoDB suggests we try so.
302
        // We can check if we have exceeded the maximum attempt count for this and if
303
        // we haven't we will return and try this transaction again.
304
        if (
305
            $this->causedByDeadlock($e) &&
0 ignored issues
show
Bug introduced by
It seems like causedByDeadlock() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

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

305
            $this->/** @scrutinizer ignore-call */ 
306
                   causedByDeadlock($e) &&
Loading history...
306
            $currentAttempt < $attempts
307
        ) {
308
            $retry = true;
309
        }
310
311
        if ($retry) {
312
            return $this->arangoTransaction->execute();
313
        }
314
315
        throw $e;
316
    }
317
318
    /**
319
     * compile an array of unique collections that are used to read from and/or write to.
320
     *
321
     * @return array
322
     */
323 1
    public function compileTransactionCollections()
324
    {
325 1
        $result['write'] = [];
0 ignored issues
show
Comprehensibility Best Practice introduced by
$result was never initialized. Although not strictly required by PHP, it is generally a good practice to add $result = array(); before regardless.
Loading history...
326 1
        $result['read'] = [];
327
328 1
        $commands = $this->transactionCommands[$this->transactions];
329
330 1
        foreach ($commands as $command) {
331 1
            if (isset($command->collections['write'])) {
332 1
                $write = $command->collections['write'];
333 1
                if (is_string($write)) {
334 1
                    $write = (array) $write;
335
                }
336 1
                $result['write'] = array_merge($result['write'], $write);
337
            }
338 1
            if (isset($command->collections['read'])) {
339 1
                $read = $command->collections['read'];
340 1
                if (is_string($read)) {
341 1
                    $read = (array) $read;
342
                }
343 1
                $result['read'] = array_merge($result['write'], $read);
344
            }
345
        }
346
347 1
        $result['read'] = array_merge($result['read'], $result['write']);
348
349 1
        $result['write'] = array_filter(array_unique($result['write']));
350 1
        if (empty($result['write'])) {
351
            unset($result['write']);
352
        }
353
354 1
        $result['read'] = array_filter(array_unique($result['read']));
355 1
        if (empty($result['read'])) {
356
            unset($result['read']);
357
        }
358
359 1
        $result = array_filter($result);
360
361 1
        return $result;
362
    }
363
364 2
    public function compileTransactionAction()
365
    {
366 2
        $commands = collect($this->transactionCommands[$this->transactions]);
367
368 2
        $action = "function () { var db = require('@arangodb').db; ";
369 2
        $action .= $commands->implode('command', ' ');
370 2
        $action .= ' }';
371
372 2
        return $action;
373
    }
374
375
    /**
376
     * Handle an exception from a rollback.
377
     *
378
     * @param \Exception $e
379
     *
380
     * @throws \Exception
381
     */
382
    protected function handleRollBackException($e)
383
    {
384
        if ($this->causedByLostConnection($e)) {
385
            $this->transactions = 0;
386
        }
387
388
        throw $e;
389
    }
390
391
    /**
392
     * Get the number of active transactions.
393
     *
394
     * @return int
395
     */
396 118
    public function transactionLevel()
397
    {
398 118
        return $this->transactions;
399
    }
400
401 2
    public function getTransactionCommands()
402
    {
403 2
        return $this->transactionCommands;
404
    }
405
406
    //Override unused trait transaction functions with dummy methods
407
408
//    /**
409
//     * Dummy.
410
//     *
411
//     * @param $e
412
//     */
413
//    public function handleBeginTransactionException($e)
414
//    {
415
//        //
416
//    }
417
418
//    /**
419
//     * Dummy override: Rollback the active database transaction.
420
//     *
421
//     * @param int|null $toLevel
422
//     *
423
//     * @throws \Exception
424
//     *
425
//     * @return void
426
//     */
427
//    public function rollBack($toLevel = null)
428
//    {
429
//        //
430
//    }
431
//
432
//    /**
433
//     * Dummy override: ArangoDB rolls back the entire transaction on a failure.
434
//     *
435
//     * @param int $toLevel
436
//     *
437
//     * @return void
438
//     */
439
//    protected function performRollBack($toLevel)
440
//    {
441
//        //
442
//    }
443
//
444
//    /**
445
//     * Create a save point within the database.
446
//     * Not supported by ArangoDB(?).
447
//     *
448
//     * @return void
449
//     */
450
//    protected function createSavepoint()
451
//    {
452
//        //
453
//    }
454
}
455