Test Setup Failed
Pull Request — master (#10)
by
unknown
01:32
created

src/Concerns/ManagesTransactions.php (1 issue)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

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
     * @return mixed
25
     *
26
     * @throws \Exception|\Throwable
27
     */
28
    public function transaction(Closure $callback, $options = [], $attempts = 1)
29
    {
30
        $this->beginTransaction();
31
32
        return tap($callback($this), function () use ($options, $attempts) {
33
            $this->commit($options, $attempts);
34
        });
35
    }
36
37
    /**
38
     * Start a new database transaction.
39
     *
40
     * @return void
41
     *
42
     * @throws \Exception
43
     */
44
    public function beginTransaction()
45
    {
46
        $this->transactions++;
47
48
        $this->transactionCommands[$this->transactions] = [];
49
50
        $this->fireConnectionEvent('beganTransaction');
51
    }
52
53
    /**
54
     * Add a command to the transaction. Parameters must include:
55
     * collections['write'][]: collections that are written to
56
     * collections['read'][]: collections that are read from
57
     * command: the db command to execute.
58
     *
59
     * @param \Illuminate\Support\Fluent $command
60
     */
61
    public function addTransactionCommand(IlluminateFluent $command)
62
    {
63
        $this->transactionCommands[$this->transactions][] = $command;
64
    }
65
66
    /**
67
     * Add a query command to the transaction.
68
     *
69
     * @param $query
70
     * @param $bindings
71
     * @param array|null $collections
72
     * @return IlluminateFluent
73
     */
74
    public function addQueryToTransaction($query, $bindings = [], $collections = null)
75
    {
76
        //If transaction collections aren't provided we will try to extract them from the query.
77
        if (empty($collections)) {
78
            $collections = $this->extractTransactionCollections($query, $bindings, $collections);
79
        }
80
81
//        $query = addslashes($query);
82
        $jsCommand = 'db._query(aql`'.$query.'`';
83
        if (! empty($bindings)) {
84
            $bindings = json_encode($bindings);
85
            $jsCommand .= ', '.$bindings;
86
        }
87
        $jsCommand .= ');';
88
        $command = new IlluminateFluent([
89
            'name' => 'aqlQuery',
90
            'command' => $jsCommand,
91
            'collections' => $collections,
92
        ]);
93
94
        $this->addTransactionCommand($command);
95
96
        return $command;
97
    }
98
99
    /**
100
     * Transaction like a list of read collections to prevent possible read deadlocks.
101
     * Transactions require a list of write collections to prepare write locks.
102
     *
103
     * @param $query
104
     * @param $bindings
105
     * @param $collections
106
     * @return mixed
107
     */
108
    public function extractTransactionCollections($query, $bindings, $collections)
109
    {
110
        //Extract write collections
111
        $collections = $this->extractReadCollections($query, $bindings, $collections);
112
        $collections = $this->extractWriteCollections($query, $bindings, $collections);
113
114
        return $collections;
115
    }
116
117
    /**
118
     * Extract collections that are read from in a query. Not required but can prevent deadlocks.
119
     *
120
     * @param $query
121
     * @param $bindings
122
     * @param $collections
123
     * @return mixed
124
     */
125
    public function extractReadCollections($query, $bindings, $collections)
126
    {
127
        $extractedCollections = [];
128
        //WITH statement at the start of the query
129
        preg_match_all('/^(?:\s+?)WITH(?:\s+?)([\S\s]*?)(?:\s+?)FOR/mis', $query, $rawWithCollections);
130
        foreach ($rawWithCollections[1] as $key => $value) {
131
            $splits = preg_split("/\s*,\s*/", $value);
132
            $extractedCollections = array_merge($extractedCollections, $splits);
133
        }
134
135
        //FOR statements
136
        preg_match_all('/FOR(?:\s+?)(?:\w+)(?:\s+?)(?:IN|INTO)(?:\s+?)(?!OUTBOUND|INBOUND|ANY)(@?@?\w+(?!\.))/mis', $query, $rawForCollections);
137
        $extractedCollections = array_merge($extractedCollections, $rawForCollections[1]);
138
139
        //Document functions which require a document as their first argument
140
        preg_match_all('/(?:DOCUMENT\(|ATTRIBUTES\(|HAS\(|KEEP\(|LENGTH\(|MATCHES\(|PARSE_IDENTIFIER\(|UNSET\(|UNSET_RECURSIVE\(|VALUES\(|OUTBOUND|INBOUND|ANY)(?:\s+?)(?!\{)(?:\"|\'|\`)(@?@?\w+)\/(?:\w+)(?:\"|\'|\`)/mis', $query, $rawDocCollections);
141
        $extractedCollections = array_merge($extractedCollections, $rawDocCollections[1]);
142
143
        $extractedCollections = array_map('trim', $extractedCollections);
144
145
        $extractedCollections = $this->getCollectionByBinding($extractedCollections, $bindings);
146
147
        if (isset($collections['read'])) {
148
            $collections['read'] = array_merge($collections['read'], $extractedCollections);
149
        } else {
150
            $collections['read'] = $extractedCollections;
151
        }
152
153
        $collections['read'] = array_unique($collections['read']);
154
155
        return $collections;
156
    }
157
158
    /**
159
     * Extract collections that are written to in a query.
160
     *
161
     * @param $query
162
     * @param $bindings
163
     * @param $collections
164
     * @return mixed
165
     */
166
    public function extractWriteCollections($query, $bindings, $collections)
167
    {
168
        preg_match_all('/(?:\s+?)(?:INSERT|REPLACE|UPDATE|REMOVE)(?:\s+?)(?:{(?:.*?)}|@?@?\w+?)(?:\s+?)(?:IN|INTO)(?:\s+?)(@?@?\w+)/mis', $query, $extractedCollections);
169
        $extractedCollections = array_map('trim', $extractedCollections[1]);
170
171
        $extractedCollections = $this->getCollectionByBinding($extractedCollections, $bindings);
172
173
        if (isset($collections['write'])) {
174
            $collections['write'] = array_merge($collections['write'], $extractedCollections);
175
        } else {
176
            $collections['write'] = $extractedCollections;
177
        }
178
179
        $collections['read'] = array_unique($collections['read']);
180
181
        return $collections;
182
    }
183
184
    /**
185
     * Get the collection names that are bound in a query.
186
     *
187
     * @param $collections
188
     * @param $bindings
189
     * @return mixed
190
     */
191
    public function getCollectionByBinding($collections, $bindings)
192
    {
193
        foreach ($collections as $key => $collection) {
194
            if (strpos($collection, '@@') === 0 && isset($bindings[$collection])) {
195
                $collections[$key] = $bindings[$collection];
196
            }
197
        }
198
199
        return $collections;
200
    }
201
202
    /**
203
     * Commit the current transaction.
204
     *
205
     * @param array $options
206
     * @param int $attempts
207
     * @return mixed
208
     * @throws Exception
209
     */
210
    public function commit($options = [], $attempts = 1)
211
    {
212
        if (! $this->transactions > 0) {
213
            throw new \Exception('Transaction committed before starting one.');
214
        }
215
        if (! isset($this->transactionCommands[$this->transactions]) || empty($this->transactionCommands[$this->transactions])) {
216
            throw new \Exception('Cannot commit an empty transaction.');
217
        }
218
219
        $options['collections'] = $this->compileTransactionCollections();
220
221
        $options['action'] = $this->compileTransactionAction();
222
223
        $results = $this->executeTransaction($options, $attempts);
224
225
        $this->fireConnectionEvent('committed');
226
227
        return $results;
228
    }
229
230
    public function executeTransaction($options, $attempts = 1)
231
    {
232
        $results = null;
233
234
        $this->arangoTransaction = new ArangoTransaction($this->arangoConnection, $options);
235
236
        for ($currentAttempt = 1; $currentAttempt <= $attempts; $currentAttempt++) {
237
            try {
238
                $results = $this->arangoTransaction->execute();
239
240
                $this->transactions--;
241
            } catch (Exception $e) {
242
                $this->fireConnectionEvent('rollingBack');
243
244
                $results = $this->handleTransactionException($e, $currentAttempt, $attempts);
245
            }
246
        }
247
248
        return $results;
249
    }
250
251
    /**
252
     * Handle an exception encountered when running a transacted statement.
253
     *
254
     * @param Exception $e
255
     * @param integer $currentAttempt
256
     * @param integer $attempts
257
     * @return mixed
258
     */
259
    protected function handleTransactionException($e, $currentAttempt, $attempts)
260
    {
261
        $retry = false;
262
        // If the failure was due to a lost connection we can just try again.
263
        if ($this->causedByLostConnection($e)) {
264
            $this->reconnect();
265
            $retry = true;
266
        }
267
268
        // Retry if the failure was caused by a deadlock or ArangoDB suggests we try so.
269
        // We can check if we have exceeded the maximum attempt count for this and if
270
        // we haven't we will return and try this transaction again.
271
        if ($this->causedByDeadlock($e) &&
272
            $currentAttempt < $attempts) {
273
            $retry = true;
274
        }
275
276
        if ($retry) {
277
            return $this->arangoTransaction->execute();
278
        }
279
280
        throw $e;
281
    }
282
283
    /**
284
     * compile an array of unique collections that are used to read from and/or write to.
285
     *
286
     * @return array
287
     */
288
    public function compileTransactionCollections()
289
    {
290
        $result['write'] = [];
0 ignored issues
show
Coding Style Comprehensibility introduced by
$result was never initialized. Although not strictly required by PHP, it is generally a good practice to add $result = array(); before regardless.

Adding an explicit array definition is generally preferable to implicit array definition as it guarantees a stable state of the code.

Let’s take a look at an example:

foreach ($collection as $item) {
    $myArray['foo'] = $item->getFoo();

    if ($item->hasBar()) {
        $myArray['bar'] = $item->getBar();
    }

    // do something with $myArray
}

As you can see in this example, the array $myArray is initialized the first time when the foreach loop is entered. You can also see that the value of the bar key is only written conditionally; thus, its value might result from a previous iteration.

This might or might not be intended. To make your intention clear, your code more readible and to avoid accidental bugs, we recommend to add an explicit initialization $myArray = array() either outside or inside the foreach loop.

Loading history...
291
        $result['read'] = [];
292
293
        $commands = $this->transactionCommands[$this->transactions];
294
295
        foreach ($commands as $command) {
296
            if (isset($command->collections['write'])) {
297
                $write = $command->collections['write'];
298
                if (is_string($write)) {
299
                    $write = (array) $write;
300
                }
301
                $result['write'] = array_merge($result['write'], $write);
302
            }
303
            if (isset($command->collections['read'])) {
304
                $read = $command->collections['read'];
305
                if (is_string($read)) {
306
                    $read = (array) $read;
307
                }
308
                $result['read'] = array_merge($result['write'], $read);
309
            }
310
        }
311
312
        $result['read'] = array_merge($result['read'], $result['write']);
313
314
        $result['write'] = array_filter(array_unique($result['write']));
315
        if (empty($result['write'])) {
316
            unset($result['write']);
317
        }
318
319
        $result['read'] = array_filter(array_unique($result['read']));
320
        if (empty($result['read'])) {
321
            unset($result['read']);
322
        }
323
324
        $result = array_filter($result);
325
326
        return $result;
327
    }
328
329
    public function compileTransactionAction()
330
    {
331
        $commands = collect($this->transactionCommands[$this->transactions]);
332
333
        $action = "function () { var db = require('@arangodb').db; ";
334
        $action .= $commands->implode('command', ' ');
335
        $action .= ' }';
336
337
        return $action;
338
    }
339
340
    /**
341
     * Handle an exception from a rollback.
342
     *
343
     * @param \Exception  $e
344
     *
345
     * @throws \Exception
346
     */
347
    protected function handleRollBackException($e)
348
    {
349
        if ($this->causedByLostConnection($e)) {
350
            $this->transactions = 0;
351
        }
352
353
        throw $e;
354
    }
355
356
    /**
357
     * Get the number of active transactions.
358
     *
359
     * @return int
360
     */
361
    public function transactionLevel()
362
    {
363
        return $this->transactions;
364
    }
365
366
    public function getTransactionCommands()
367
    {
368
        return $this->transactionCommands;
369
    }
370
371
    //Override unused trait transaction functions with dummy methods
372
373
    /**
374
     * Dummy.
375
     *
376
     * @param $e
377
     */
378
    public function handleBeginTransactionException($e)
379
    {
380
        //
381
    }
382
383
    /**
384
     * Dummy override: Rollback the active database transaction.
385
     *
386
     * @param  int|null  $toLevel
387
     * @return void
388
     *
389
     * @throws \Exception
390
     */
391
    public function rollBack($toLevel = null)
392
    {
393
        //
394
    }
395
396
    /**
397
     * Dummy override: ArangoDB rolls back the entire transaction on a failure.
398
     *
399
     * @param  int  $toLevel
400
     * @return void
401
     */
402
    protected function performRollBack($toLevel)
403
    {
404
        //
405
    }
406
407
    /**
408
     * Create a save point within the database.
409
     * Not supported by ArangoDB(?).
410
     *
411
     * @return void
412
     */
413
    protected function createSavepoint()
414
    {
415
        //
416
    }
417
}
418