Test Failed
Push — master ( d55706...78ede5 )
by MusikAnimal
07:11
created

Repository   A

Complexity

Total Complexity 41

Size/Duplication

Total Lines 333
Duplicated Lines 0 %

Test Coverage

Coverage 90.32%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 100
dl 0
loc 333
ccs 56
cts 62
cp 0.9032
rs 9.1199
c 2
b 0
f 0
wmc 41

15 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 3 1
A setContainer() 0 5 1
A isLabs() 0 3 1
A getCacheKey() 0 25 6
A getDateConditions() 0 14 3
A getProjectsConnection() 0 9 2
A handleDriverError() 0 14 4
A executeProjectsQuery() 0 9 2
A executeQueryBuilder() 0 8 2
A executeApiRequest() 0 11 1
A getCacheKeyFromArg() 0 10 3
A getToolsConnection() 0 9 2
A getMetaConnection() 0 9 2
A setCache() 0 8 1
B getTableName() 0 32 10

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
declare(strict_types = 1);
7
8
namespace AppBundle\Repository;
9
10
use AppBundle\Model\Project;
11
use DateInterval;
12
use Doctrine\DBAL\Connection;
13
use Doctrine\DBAL\Driver\ResultStatement;
14
use Doctrine\DBAL\Exception\DriverException;
15
use Doctrine\DBAL\Query\QueryBuilder;
16
use GuzzleHttp\Client;
17
use Psr\Cache\CacheItemPoolInterface;
18
use Psr\Log\LoggerInterface;
19
use Psr\Log\NullLogger;
20
use Symfony\Component\DependencyInjection\ContainerInterface;
21
use Symfony\Component\HttpFoundation\Request;
22
use Symfony\Component\HttpKernel\Exception\HttpException;
23
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
24
25
/**
26
 * A repository is responsible for retrieving data from wherever it lives (databases, APIs, filesystems, etc.)
27
 */
28
abstract class Repository
29
{
30
    /** @var ContainerInterface The application's DI container. */
31
    protected $container;
32
33
    /** @var Connection The database connection to the meta database. */
34
    private $metaConnection;
35
36
    /** @var Connection The database connection to the projects' databases. */
37
    private $projectsConnection;
38
39
    /** @var Connection The database connection to other tools' databases.  */
40
    private $toolsConnection;
41
42
    /** @var CacheItemPoolInterface The cache. */
43
    protected $cache;
44
45
    /** @var LoggerInterface The logger. */
46
    protected $log;
47
48
    /**
49
     * Create a new Repository with nothing but a null-logger.
50
     */
51 115
    public function __construct()
52
    {
53 115
        $this->log = new NullLogger();
54 115
    }
55
56
    /**
57
     * Set the DI container.
58
     * @param ContainerInterface $container
59
     */
60 17
    public function setContainer(ContainerInterface $container): void
61
    {
62 17
        $this->container = $container;
63 17
        $this->cache = $container->get('cache.app');
64 17
        $this->log = $container->get('logger');
65 17
    }
66
67
    /**
68
     * Is XTools connecting to WMF Labs?
69
     * @return bool
70
     * @codeCoverageIgnore
71
     */
72
    public function isLabs(): bool
73
    {
74
        return (bool)$this->container->getParameter('app.is_labs');
75
    }
76
77
    /***************
78
     * CONNECTIONS *
79
     ***************/
80
81
    /**
82
     * Get the database connection for the 'meta' database.
83
     * @return Connection
84
     * @codeCoverageIgnore
85
     */
86
    protected function getMetaConnection(): Connection
87
    {
88
        if (!$this->metaConnection instanceof Connection) {
0 ignored issues
show
introduced by
$this->metaConnection is always a sub-type of Doctrine\DBAL\Connection.
Loading history...
89
            $this->metaConnection = $this->container
90
                ->get('doctrine')
91
                ->getManager('meta')
92
                ->getConnection();
93
        }
94
        return $this->metaConnection;
95
    }
96
97
    /**
98
     * Get the database connection for the 'projects' database.
99
     * @return Connection
100
     * @codeCoverageIgnore
101
     */
102
    protected function getProjectsConnection(): Connection
103
    {
104
        if (!$this->projectsConnection instanceof Connection) {
0 ignored issues
show
introduced by
$this->projectsConnection is always a sub-type of Doctrine\DBAL\Connection.
Loading history...
105
            $this->projectsConnection = $this->container
106
                ->get('doctrine')
107
                ->getManager('replicas')
108
                ->getConnection();
109
        }
110
        return $this->projectsConnection;
111
    }
112
113
    /**
114
     * Get the database connection for the 'tools' database (the one that other tools store data in).
115
     * @return Connection
116
     * @codeCoverageIgnore
117
     */
118
    protected function getToolsConnection(): Connection
119
    {
120
        if (!$this->toolsConnection instanceof Connection) {
0 ignored issues
show
introduced by
$this->toolsConnection is always a sub-type of Doctrine\DBAL\Connection.
Loading history...
121
            $this->toolsConnection = $this->container
122
                ->get('doctrine')
123
                ->getManager('toolsdb')
124
                ->getConnection();
125
        }
126
        return $this->toolsConnection;
127
    }
128
129
    /*****************
130
     * QUERY HELPERS *
131
     *****************/
132
133
    /**
134
     * Make a request to the MediaWiki API.
135
     * @param Project $project
136
     * @param array $params
137
     * @return array
138
     */
139
    public function executeApiRequest(Project $project, array $params): array
140
    {
141
        /** @var Client $client */
142
        $client = $this->container->get('eight_points_guzzle.client.xtools');
143
144
        return json_decode($client->request('GET', $project->getApiUrl(), [
145
            'query' => array_merge([
146
                'action' => 'query',
147
                'format' => 'json',
148
            ], $params),
149
        ])->getBody()->getContents(), true);
150
    }
151
152
    /**
153
     * Normalize and quote a table name for use in SQL.
154
     * @param string $databaseName
155
     * @param string $tableName
156
     * @param string|null $tableExtension Optional table extension, which will only get used if we're on labs.
157
     *   If null, table extensions are added as defined in table_map.yml. If a blank string, no extension is added.
158
     * @return string Fully-qualified and quoted table name.
159
     */
160
    public function getTableName(string $databaseName, string $tableName, ?string $tableExtension = null): string
161
    {
162
        $mapped = false;
163
164 2
        // This is a workaround for a one-to-many mapping
165
        // as required by Labs. We combine $tableName with
166
        // $tableExtension in order to generate the new table name
167 2
        if ($this->isLabs() && null !== $tableExtension) {
168
            $mapped = true;
169 2
            $tableName .=('' === $tableExtension ? '' : '_'.$tableExtension);
170 2
        } elseif ($this->container->hasParameter("app.table.$tableName")) {
171 2
            // Use the table specified in the table mapping configuration, if present.
172
            $mapped = true;
173 2
            $tableName = $this->container->getParameter("app.table.$tableName");
174 2
        }
175
176
        // For 'revision' and 'logging' tables (actually views) on Labs, use the indexed versions
177
        // (that have some rows hidden, e.g. for revdeleted users).
178
        // This is a safeguard in case table mapping isn't properly set up.
179
        $isLoggingOrRevision = in_array($tableName, ['revision', 'logging', 'archive']);
180
        if (!$mapped && $isLoggingOrRevision && $this->isLabs()) {
181
            $tableName .="_userindex";
182
        }
183
184
        // Figure out database name.
185 1
        // Use class variable for the database name if not set via function parameter.
186
        if ($this->isLabs() && '_p' != substr($databaseName, -2)) {
187 1
            // Append '_p' if this is labs.
188
            $databaseName .= '_p';
189
        }
190
191
        return "`$databaseName`.`$tableName`";
192 1
    }
193
194
    /**
195 1
     * Get a unique cache key for the given list of arguments. Assuming each argument of
196
     * your function should be accounted for, you can pass in them all with func_get_args:
197
     *   $this->getCacheKey(func_get_args(), 'unique key for function');
198
     * Arguments that are a model should implement their own getCacheKey() that returns
199
     * a unique identifier for an instance of that model. See User::getCacheKey() for example.
200
     * @param array|mixed $args Array of arguments or a single argument.
201
     * @param string $key Unique key for this function. If omitted the function name itself
202
     *   is used, which is determined using `debug_backtrace`.
203
     * @return string
204 1
     */
205 1
    public function getCacheKey($args, $key = null): string
206
    {
207
        if (null === $key) {
208
            $key = debug_backtrace()[1]['function'];
209
        }
210
211 1
        if (!is_array($args)) {
212
            $args = [$args];
213
        }
214
215
        // Start with base key.
216 1
        $cacheKey = $key;
217
218
        // Loop through and determine what values to use based on type of object.
219
        foreach ($args as $arg) {
220
            // Zero is an acceptable value.
221
            if ('' === $arg || null === $arg) {
222
                continue;
223
            }
224
225
            $cacheKey .= $this->getCacheKeyFromArg($arg);
226
        }
227
228
        // Remove reserved characters.
229
        return preg_replace('/[{}()\/\@\:"]/', '', $cacheKey);
230 8
    }
231
232 8
    /**
233 1
     * Get a cache-friendly string given an argument.
234
     * @param mixed $arg
235
     * @return string
236 8
     */
237 8
    private function getCacheKeyFromArg($arg): string
238
    {
239
        if (method_exists($arg, 'getCacheKey')) {
240
            return '.'.$arg->getCacheKey();
241 8
        } elseif (is_array($arg)) {
242
            // Assumed to be an array of objects that can be parsed into a string.
243
            return '.'.md5(implode('', $arg));
244 8
        } else {
245
            // Assumed to be a string, number or boolean.
246 8
            return '.'.md5((string)$arg);
247 1
        }
248
    }
249
250 8
    /**
251
     * Set the cache with given options.
252
     * @param string $cacheKey
253
     * @param mixed $value
254 8
     * @param string $duration Valid DateInterval string.
255
     * @return mixed The given $value.
256
     */
257
    public function setCache(string $cacheKey, $value, $duration = 'PT10M')
258
    {
259
        $cacheItem = $this->cache
260
            ->getItem($cacheKey)
261
            ->set($value)
262 8
            ->expiresAfter(new DateInterval($duration));
263
        $this->cache->save($cacheItem);
264 8
        return $value;
265 1
    }
266 8
267
    /********************************
268 1
     * DATABASE INTERACTION HELPERS *
269
     ********************************/
270
271 8
    /**
272
     * Creates WHERE conditions with date range to be put in query.
273
     * @param false|int $start
274
     * @param false|int $end
275
     * @param string $tableAlias Alias of table FOLLOWED BY DOT.
276
     * @param string $field
277
     * @return string
278
     */
279
    public function getDateConditions($start, $end, $tableAlias = '', $field = 'rev_timestamp'): string
280
    {
281
        $datesConditions = '';
282 1
        if (false != $start) {
0 ignored issues
show
Bug Best Practice introduced by
It seems like you are loosely comparing $start of type false|integer against false; this is ambiguous if the integer can be zero. Consider using a strict comparison !== instead.
Loading history...
283
            // Convert to YYYYMMDDHHMMSS. *who in the world thought of having time in BLOB of this format ;-;*
284 1
            $start = date('Ymd', $start).'000000';
285 1
            $datesConditions .= " AND {$tableAlias}{$field} >= '$start'";
286 1
        }
287 1
        if (false != $end) {
0 ignored issues
show
Bug Best Practice introduced by
It seems like you are loosely comparing $end of type false|integer against false; this is ambiguous if the integer can be zero. Consider using a strict comparison !== instead.
Loading history...
288 1
            $end = date('Ymd', $end).'235959';
289 1
            $datesConditions .= " AND {$tableAlias}{$field} <= '$end'";
290
        }
291
292
        return $datesConditions;
293
    }
294
295
    /**
296
     * Execute a query using the projects connection, handling certain Exceptions.
297
     * @param string $sql
298
     * @param array $params Parameters to bound to the prepared query.
299
     * @param int|null $timeout Maximum statement time in seconds. null will use the
300
     *   default specified by the app.query_timeout config parameter.
301
     * @return ResultStatement
302
     * @throws HttpException
303
     * @throws DriverException
304 1
     * @codeCoverageIgnore
305
     */
306 1
    public function executeProjectsQuery(string $sql, array $params = [], ?int $timeout = null): ResultStatement
307 1
    {
308
        try {
309 1
            $timeout = $timeout ?? $this->container->getParameter('app.query_timeout');
310 1
            $sql = "SET STATEMENT max_statement_time = $timeout FOR\n".$sql;
311
312 1
            return $this->getProjectsConnection()->executeQuery($sql, $params);
313 1
        } catch (DriverException $e) {
314 1
            $this->handleDriverError($e, $timeout);
315
        }
316
    }
317 1
318
    /**
319
     * Execute a query using the projects connection, handling certain Exceptions.
320
     * @param QueryBuilder $qb
321
     * @param int $timeout Maximum statement time in seconds. null will use the
322
     *   default specified by the app.query_timeout config parameter.
323
     * @return ResultStatement
324
     * @throws HttpException
325
     * @throws DriverException
326
     * @codeCoverageIgnore
327
     */
328
    public function executeQueryBuilder(QueryBuilder $qb, ?int $timeout = null): ResultStatement
329
    {
330
        try {
331
            $timeout = $timeout ?? $this->container->getParameter('app.query_timeout');
332
            $sql = "SET STATEMENT max_statement_time = $timeout FOR\n".$qb->getSQL();
333
            return $qb->getConnection()->executeQuery($sql, $qb->getParameters(), $qb->getParameterTypes());
334
        } catch (DriverException $e) {
335
            $this->handleDriverError($e, $timeout);
336
        }
337
    }
338
339
    /**
340
     * Special handling of some DriverExceptions, otherwise original Exception is thrown.
341
     * @param DriverException $e
342
     * @param int $timeout Timeout value, if applicable. This is passed to the i18n message.
343
     * @throws HttpException
344
     * @throws DriverException
345
     * @codeCoverageIgnore
346
     */
347
    private function handleDriverError(DriverException $e, int $timeout): void
348
    {
349
        // If no value was passed for the $timeout, it must be the default.
350
        if (null === $timeout) {
0 ignored issues
show
introduced by
The condition null === $timeout is always false.
Loading history...
351
            $timeout = $this->container->getParameter('app.query_timeout');
352
        }
353
354
        if (1226 === $e->getErrorCode()) {
355
            throw new ServiceUnavailableHttpException(30, 'error-service-overload', null, 503);
356
        } elseif (in_array($e->getErrorCode(), [1969, 2006, 2013])) {
357
            // FIXME: Attempt to reestablish connection on 2006 error (MySQL server has gone away).
358
            throw new HttpException(504, 'error-query-timeout', null, [], $timeout);
359
        } else {
360
            throw $e;
361
        }
362
    }
363
}
364