Completed
Push — master ( 4be035...c450d5 )
by MusikAnimal
06:35
created

Repository::getMediawikiApi()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2.0185

Importance

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