Passed
Push — master ( b14851...ac4b0d )
by MusikAnimal
06:01
created

Repository::executeApiRequest()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 7
nc 1
nop 2
dl 0
loc 11
rs 10
c 0
b 0
f 0
ccs 7
cts 7
cp 1
crap 1
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 112
    public function __construct()
52
    {
53 112
        $this->log = new NullLogger();
54 112
    }
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');
0 ignored issues
show
Documentation Bug introduced by
It seems like $container->get('logger') of type stdClass is incompatible with the declared type Psr\Log\LoggerInterface of property $log.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

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