Passed
Push — master ( 526c52...06ff74 )
by MusikAnimal
08:41 queued 03:03
created

Repository   B

Complexity

Total Complexity 46

Size/Duplication

Total Lines 384
Duplicated Lines 0 %

Test Coverage

Coverage 82.09%

Importance

Changes 0
Metric Value
wmc 46
dl 0
loc 384
ccs 55
cts 67
cp 0.8209
rs 8.3999
c 0
b 0
f 0

18 Methods

Rating   Name   Duplication   Size   Complexity  
A getCacheKeyFromArg() 0 10 3
A getDateConditions() 0 14 3
A getMetaConnection() 0 9 2
D getTableName() 0 32 9
A __construct() 0 3 1
A getToolsConnection() 0 9 2
A getProjectsConnection() 0 9 2
A executeProjectsQuery() 0 7 2
A getCurrentRequestMetadata() 0 13 2
A isLabs() 0 3 1
A getMediawikiApi() 0 9 2
A setQueryTimeout() 0 7 2
A setContainer() 0 6 1
A executeQueryBuilder() 0 7 2
A logErrorData() 0 5 1
A handleDriverError() 0 15 4
A setCache() 0 8 1
B getCacheKey() 0 25 6

How to fix   Complexity   

Complex Class

Complex classes like Repository often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Repository, and based on these observations, apply Extract Interface, too.

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