Passed
Push — master ( a3fbf4...44236a )
by MusikAnimal
05:22
created

Repository::setQueryTimeout()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
nop 1
dl 0
loc 7
ccs 0
cts 0
cp 0
crap 6
rs 9.4285
c 0
b 0
f 0
1
<?php
2
/**
3
 * This file contains only the Repository class.
4
 */
5
6
namespace Xtools;
7
8
use Doctrine\DBAL\Connection;
9
use Doctrine\DBAL\Exception\DriverException;
10
use Doctrine\DBAL\Query\QueryBuilder;
11
use Mediawiki\Api\MediawikiApi;
12
use Psr\Cache\CacheItemPoolInterface;
13
use Psr\Log\LoggerInterface;
14
use Psr\Log\NullLogger;
15
use Symfony\Component\DependencyInjection\Container;
16
use Symfony\Component\Stopwatch\Stopwatch;
17
use Symfony\Component\HttpKernel\Exception\HttpException;
18
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
19
use GuzzleHttp\Promise\Promise;
20
use DateInterval;
21
22
/**
23
 * A repository is responsible for retrieving data from wherever it lives (databases, APIs,
24
 * filesystems, etc.)
25
 */
26
abstract class Repository
27
{
28
29
    /** @var Container The application's DI container. */
30
    protected $container;
31
32
    /** @var Connection The database connection to the meta database. */
33
    private $metaConnection;
34
35
    /** @var Connection The database connection to the projects' databases. */
36
    private $projectsConnection;
37
38
    /** @var Connection The database connection to other tools' databases.  */
39
    private $toolsConnection;
40
41
    /** @var GuzzleHttp\Client $apiConnection Connection to XTools API. */
0 ignored issues
show
Bug introduced by
The type Xtools\GuzzleHttp\Client was not found. Did you mean GuzzleHttp\Client? If so, make sure to prefix the type with \.
Loading history...
42
    private $apiConnection;
43
44
    /** @var CacheItemPoolInterface The cache. */
45
    protected $cache;
46
47
    /** @var LoggerInterface The log. */
48
    protected $log;
49
50
    /** @var Stopwatch The stopwatch for time profiling. */
51
    protected $stopwatch;
52
53
    /**
54
     * Create a new Repository with nothing but a null-logger.
55
     */
56 101
    public function __construct()
57
    {
58 101
        $this->log = new NullLogger();
59 101
    }
60
61
    /**
62
     * Set the DI container.
63
     * @param Container $container
64
     */
65 16
    public function setContainer(Container $container)
66
    {
67 16
        $this->container = $container;
68 16
        $this->cache = $container->get('cache.app');
69 16
        $this->log = $container->get('logger');
70 16
        $this->stopwatch = $container->get('debug.stopwatch');
71 16
    }
72
73
    /**
74
     * Get the NullLogger instance.
75
     * @return NullLogger
76
     */
77 1
    public function getLog()
78
    {
79 1
        return $this->log;
80
    }
81
82
    /**
83
     * Is XTools connecting to MMF Labs?
84
     * @return boolean
85
     * @codeCoverageIgnore
86
     */
87
    public function isLabs()
88
    {
89
        return (bool)$this->container->getParameter('app.is_labs');
90
    }
91
92
    /**
93
     * Get various metadata about the current tool being used, which will
94
     * be used in logging for diagnosting any issues.
95
     * @return array
96
     *
97
     * There is no request stack in the tests.
98
     * @codeCoverageIgnore
99
     */
100
    protected function getCurrentRequestMetadata()
101
    {
102
        $request = $this->container->get('request_stack')->getCurrentRequest();
103
104
        if (null === $request) {
105
            return;
106
        }
107
108
        $requestTime = microtime(true) - $request->server->get('REQUEST_TIME_FLOAT');
109
110
        return [
111
            'requestTime' => round($requestTime, 2),
112
            'path' => $request->getPathInfo(),
113
        ];
114
    }
115
116
    /***************
117
     * CONNECTIONS *
118
     ***************/
119
120
    /**
121
     * Get the database connection for the 'meta' database.
122
     * @return Connection
123
     * @codeCoverageIgnore
124
     */
125
    protected function getMetaConnection()
126
    {
127
        if (!$this->metaConnection instanceof Connection) {
0 ignored issues
show
introduced by
The condition ! $this->metaConnection ...octrine\DBAL\Connection can never be true.
Loading history...
128
            $this->metaConnection = $this->container
129
                ->get('doctrine')
130
                ->getManager('meta')
131
                ->getConnection();
132
        }
133
        return $this->metaConnection;
134
    }
135
136
    /**
137
     * Get the database connection for the 'projects' database.
138
     * @return Connection
139
     * @codeCoverageIgnore
140
     */
141
    protected function getProjectsConnection()
142
    {
143
        if (!$this->projectsConnection instanceof Connection) {
0 ignored issues
show
introduced by
The condition ! $this->projectsConnect...octrine\DBAL\Connection can never be true.
Loading history...
144
            $this->projectsConnection = $this->container
145
                ->get('doctrine')
146
                ->getManager('replicas')
147
                ->getConnection();
148
        }
149
        return $this->projectsConnection;
150
    }
151
152
    /**
153
     * Get the database connection for the 'tools' database
154
     * (the one that other tools store data in).
155
     * @return Connection
156
     * @codeCoverageIgnore
157
     */
158
    protected function getToolsConnection()
159
    {
160
        if (!$this->toolsConnection instanceof Connection) {
0 ignored issues
show
introduced by
The condition ! $this->toolsConnection...octrine\DBAL\Connection can never be true.
Loading history...
161
            $this->toolsConnection = $this->container
162
                ->get('doctrine')
163
                ->getManager('toolsdb')
164
                ->getConnection();
165
        }
166
        return $this->toolsConnection;
167
    }
168
169
    /**
170
     * Get the API object for the given project.
171
     *
172
     * @param Project $project
173
     * @return MediawikiApi
174
     */
175 2
    public function getMediawikiApi(Project $project)
176
    {
177 2
        $apiPath = $this->container->getParameter('api_path');
178 2
        if ($apiPath) {
179 2
            $api = MediawikiApi::newFromApiEndpoint($project->getUrl().$apiPath);
180
        } else {
181
            $api = MediawikiApi::newFromPage($project->getUrl());
182
        }
183 2
        return $api;
184
    }
185
186
    /*****************
187
     * QUERY HELPERS *
188
     *****************/
189
190
    /**
191
     * Make a request to the XTools API, optionally doing so asynchronously via Guzzle.
192
     * @param string $endpoint Relative path to endpoint with relevant query parameters.
193
     * @param bool $async Set to true to asynchronously query and return a promise.
194
     * @return GuzzleHttp\Psr7\Response|GuzzleHttp\Promise\Promise
0 ignored issues
show
Bug introduced by
The type Xtools\GuzzleHttp\Promise\Promise was not found. Did you mean GuzzleHttp\Promise\Promise? If so, make sure to prefix the type with \.
Loading history...
Bug introduced by
The type Xtools\GuzzleHttp\Psr7\Response was not found. Did you mean GuzzleHttp\Psr7\Response? If so, make sure to prefix the type with \.
Loading history...
195
     */
196
    public function queryXToolsApi($endpoint, $async = false)
197
    {
198
        if (!$this->apiConnection) {
199
            $this->apiConnection = $this->container->get('guzzle.client.xtools');
200
        }
201
202
        $key = $this->container->getParameter('secret');
203
204
        // Remove trailing slash if present.
205
        $basePath = trim($this->container->getParameter('app.base_path'), '/');
206
207
        $endpoint = "$basePath/api/$endpoint/$key";
208
209
        if ($async) {
210
            return $this->apiConnection->getAsync($endpoint);
211
        } else {
212
            return $this->apiConnection->get($endpoint);
213
        }
214
    }
215
216
    /**
217
     * Normalize and quote a table name for use in SQL.
218
     *
219
     * @param string $databaseName
220
     * @param string $tableName
221
     * @param string|null $tableExtension Optional table extension, which will only get used if we're on labs.
222
     * @return string Fully-qualified and quoted table name.
223
     */
224 1
    public function getTableName($databaseName, $tableName, $tableExtension = null)
225
    {
226 1
        $mapped = false;
227
228
        // This is a workaround for a one-to-many mapping
229
        // as required by Labs. We combine $tableName with
230
        // $tableExtension in order to generate the new table name
231 1
        if ($this->isLabs() && $tableExtension !== null) {
232
            $mapped = true;
233
            $tableName = $tableName.'_'.$tableExtension;
234 1
        } elseif ($this->container->hasParameter("app.table.$tableName")) {
235
            // Use the table specified in the table mapping configuration, if present.
236
            $mapped = true;
237
            $tableName = $this->container->getParameter("app.table.$tableName");
238
        }
239
240
        // For 'revision' and 'logging' tables (actually views) on Labs, use the indexed versions
241
        // (that have some rows hidden, e.g. for revdeleted users).
242
        // This is a safeguard in case table mapping isn't properly set up.
243 1
        $isLoggingOrRevision = in_array($tableName, ['revision', 'logging', 'archive']);
244 1
        if (!$mapped && $isLoggingOrRevision && $this->isLabs()) {
245
            $tableName = $tableName."_userindex";
246
        }
247
248
        // Figure out database name.
249
        // Use class variable for the database name if not set via function parameter.
250 1
        if ($this->isLabs() && substr($databaseName, -2) != '_p') {
251
            // Append '_p' if this is labs.
252
            $databaseName .= '_p';
253
        }
254
255 1
        return "`$databaseName`.`$tableName`";
256
    }
257
258
    /**
259
     * Get a unique cache key for the given list of arguments. Assuming each argument of
260
     * your function should be accounted for, you can pass in them all with func_get_args:
261
     *   $this->getCacheKey(func_get_args(), 'unique key for function');
262
     * Arugments that are a model should implement their own getCacheKey() that returns
263
     * a unique identifier for an instance of that model. See User::getCacheKey() for example.
264
     * @param array|mixed $args Array of arguments or a single argument.
265
     * @param string $key Unique key for this function. If omitted the function name itself
266
     *   is used, which is determined using `debug_backtrace`.
267
     * @return string
268
     */
269 8
    public function getCacheKey($args, $key = null)
270
    {
271 8
        if ($key === null) {
272 1
            $key = debug_backtrace()[1]['function'];
273
        }
274
275 8
        if (!is_array($args)) {
276 8
            $args = [$args];
277
        }
278
279
        // Start with base key.
280 8
        $cacheKey = $key;
281
282
        // Loop through and determine what values to use based on type of object.
283 8
        foreach ($args as $arg) {
284
            // Zero is an acceptable value.
285 8
            if ($arg === '' || $arg === null) {
286 1
                continue;
287
            }
288
289 8
            $cacheKey .= $this->getCacheKeyFromArg($arg);
290
        }
291
292 8
        return $cacheKey;
293
    }
294
295
    /**
296
     * Get a cache-friendly string given an argument.
297
     * @param  mixed $arg
298
     * @return string
299
     */
300 8
    private function getCacheKeyFromArg($arg)
301
    {
302 8
        if (method_exists($arg, 'getCacheKey')) {
303 1
            return '.'.$arg->getCacheKey();
304 8
        } elseif (is_array($arg)) {
305
            // Assumed to be an array of objects that can be parsed into a string.
306 1
            return '.'.join('', $arg);
307
        } else {
308
            // Assumed to be a string, number or boolean.
309 8
            return '.'.md5($arg);
310
        }
311
    }
312
313
    /**
314
     * Set the cache with given options.
315
     * @param string $cacheKey
316
     * @param mixed  $value
317
     * @param string $duration Valid DateInterval string.
318
     * @return mixed The given $value.
319
     */
320 1
    public function setCache($cacheKey, $value, $duration = 'PT10M')
321
    {
322 1
        $cacheItem = $this->cache
323 1
            ->getItem($cacheKey)
324 1
            ->set($value)
325 1
            ->expiresAfter(new DateInterval($duration));
326 1
        $this->cache->save($cacheItem);
327 1
        return $value;
328
    }
329
330
    /********************************
331
     * DATABASE INTERACTION HELPERS *
332
     ********************************/
333
334
    /**
335
     * Creates WHERE conditions with date range to be put in query.
336
     *
337
     * @param false|int $start
338
     * @param false|int $end
339
     * @param string $tableAlias Alias of table FOLLOWED BY DOT.
340
     * @param string $field
341
     * @return string
342
     */
343 1
    public function getDateConditions($start, $end, $tableAlias = '', $field = 'rev_timestamp')
344
    {
345 1
        $datesConditions = '';
346 1
        if (false !== $start) {
347
            // Convert to YYYYMMDDHHMMSS. *who in the world thought of having time in BLOB of this format ;-;*
348 1
            $start = date('Ymd', $start).'000000';
349 1
            $datesConditions .= " AND {$tableAlias}{$field} > '$start'";
350
        }
351 1
        if (false !== $end) {
352 1
            $end = date('Ymd', $end).'235959';
353 1
            $datesConditions .= " AND {$tableAlias}{$field} < '$end'";
354
        }
355
356 1
        return $datesConditions;
357
    }
358
359
    /**
360
     * Execute a query using the projects connection, handling certain Exceptions.
361
     * @param string $sql
362
     * @param array $params Parameters to bound to the prepared query.
363
     * @param int|null $timeout Maximum statement time in seconds. null will use the
364
     *   default specified by the app.query_timeout config parameter.
365
     * @return \Doctrine\DBAL\Driver\Statement
366
     * @codeCoverageIgnore
367
     */
368
    public function executeProjectsQuery($sql, $params = [], $timeout = null)
369
    {
370
        try {
371
            $this->setQueryTimeout($timeout);
372
            return $this->getProjectsConnection()->executeQuery($sql, $params);
373
        } catch (DriverException $e) {
374
            return $this->handleDriverError($e, $timeout);
375
        }
376
    }
377
378
    /**
379
     * Execute a query using the projects connection, handling certain Exceptions.
380
     * @param QueryBuilder $qb
381
     * @param int $timeout Maximum statement time in seconds. null will use the
382
     *   default specified by the app.query_timeout config parameter.
383
     * @return \Doctrine\DBAL\Driver\Statement
384
     * @codeCoverageIgnore
385
     */
386
    public function executeQueryBuilder(QueryBuilder $qb, $timeout = null)
387
    {
388
        try {
389
            $this->setQueryTimeout($timeout);
390
            return $qb->execute();
391
        } catch (DriverException $e) {
392
            return $this->handleDriverError($e, $timeout);
393
        }
394
    }
395
396
    /**
397
     * Special handling of some DriverExceptions, otherwise original Exception is thrown.
398
     * @param DriverException $e
399
     * @param int $timeout Timeout value, if applicable. This is passed to the i18n message.
400
     * @throws ServiceUnavailableHttpException
401
     * @throws DriverException
402
     * @codeCoverageIgnore
403
     */
404
    private function handleDriverError(DriverException $e, $timeout)
405
    {
406
        // If no value was passed for the $timeout, it must be the default.
407
        if ($timeout === null) {
0 ignored issues
show
introduced by
The condition $timeout === null can never be true.
Loading history...
408
            $timeout = $this->container->getParameter('app.query_timeout');
409
        }
410
411
        if ($e->getErrorCode() === 1226) {
412
            $this->logErrorData('MAX CONNECTIONS');
413
            throw new ServiceUnavailableHttpException(30, 'error-service-overload', null, 503);
414
        } elseif ($e->getErrorCode() === 1969) {
415
            $this->logErrorData('QUERY TIMEOUT');
416
            throw new HttpException(504, 'error-query-timeout', null, [], $timeout);
417
        } else {
418
            throw $e;
419
        }
420
    }
421
422
    /**
423
     * Log error containing the given error code, along with the request path and request time.
424
     * @param string $error
425
     */
426
    private function logErrorData($error)
427
    {
428
        $metadata = $this->getCurrentRequestMetadata();
429
        $this->getLog()->error(
430
            '>>> '.$metadata['path'].' ('.$error.' after '.$metadata['requestTime'].')'
431
        );
432
    }
433
434
    /**
435
     * Set the maximum statement time on the MySQL engine.
436
     * @param int|null $timeout In seconds. null will use the default
437
     *   specified by the app.query_timeout config parameter.
438
     * @codeCoverageIgnore
439
     */
440
    public function setQueryTimeout($timeout = null)
441
    {
442
        if ($timeout === null) {
443
            $timeout = $this->container->getParameter('app.query_timeout');
444
        }
445
        $sql = "SET max_statement_time = $timeout";
446
        $this->getProjectsConnection()->executeQuery($sql);
447
    }
448
}
449