Passed
Push — master ( 9b6f1b...510c7f )
by MusikAnimal
04:53
created

Repository::getDateConditions()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 14
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 8
nc 4
nop 4
dl 0
loc 14
ccs 9
cts 9
cp 1
crap 3
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\ServiceUnavailableHttpException;
18
use GuzzleHttp\Promise\Promise;
19
use DateInterval;
20
21
/**
22
 * A repository is responsible for retrieving data from wherever it lives (databases, APIs,
23
 * filesystems, etc.)
24
 */
25
abstract class Repository
26
{
27
28
    /** @var Container The application's DI container. */
29
    protected $container;
30
31
    /** @var Connection The database connection to the meta database. */
32
    private $metaConnection;
33
34
    /** @var Connection The database connection to the projects' databases. */
35
    private $projectsConnection;
36
37
    /** @var Connection The database connection to other tools' databases.  */
38
    private $toolsConnection;
39
40
    /** @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...
41
    private $apiConnection;
42
43
    /** @var CacheItemPoolInterface The cache. */
44
    protected $cache;
45
46
    /** @var LoggerInterface The log. */
47
    protected $log;
48
49
    /** @var Stopwatch The stopwatch for time profiling. */
50
    protected $stopwatch;
51
52
    /**
53
     * Create a new Repository with nothing but a null-logger.
54
     */
55 101
    public function __construct()
56
    {
57 101
        $this->log = new NullLogger();
58 101
    }
59
60
    /**
61
     * Set the DI container.
62
     * @param Container $container
63
     */
64 16
    public function setContainer(Container $container)
65
    {
66 16
        $this->container = $container;
67 16
        $this->cache = $container->get('cache.app');
68 16
        $this->log = $container->get('logger');
69 16
        $this->stopwatch = $container->get('debug.stopwatch');
70 16
    }
71
72
    /**
73
     * Get the NullLogger instance.
74
     * @return NullLogger
75
     */
76 1
    public function getLog()
77
    {
78 1
        return $this->log;
79
    }
80
81
    /**
82
     * Get the database connection for the 'meta' database.
83
     * @return Connection
84
     * @codeCoverageIgnore
85
     */
86
    protected function getMetaConnection()
87
    {
88
        if (!$this->metaConnection instanceof Connection) {
0 ignored issues
show
introduced by
The condition ! $this->metaConnection ...octrine\DBAL\Connection can never be true.
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()
103
    {
104
        if (!$this->projectsConnection instanceof Connection) {
0 ignored issues
show
introduced by
The condition ! $this->projectsConnect...octrine\DBAL\Connection can never be true.
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
115
     * (the one that other tools store data in).
116
     * @return Connection
117
     * @codeCoverageIgnore
118
     */
119
    protected function getToolsConnection()
120
    {
121
        if (!$this->toolsConnection instanceof Connection) {
0 ignored issues
show
introduced by
The condition ! $this->toolsConnection...octrine\DBAL\Connection can never be true.
Loading history...
122
            $this->toolsConnection = $this->container
123
                ->get('doctrine')
124
                ->getManager("toolsdb")
125
                ->getConnection();
126
        }
127
        return $this->toolsConnection;
128
    }
129
130
    /**
131
     * Get the API object for the given project.
132
     *
133
     * @param Project $project
134
     * @return MediawikiApi
135
     */
136 2
    public function getMediawikiApi(Project $project)
137
    {
138 2
        $apiPath = $this->container->getParameter('api_path');
139 2
        if ($apiPath) {
140 2
            $api = MediawikiApi::newFromApiEndpoint($project->getUrl().$apiPath);
141
        } else {
142
            $api = MediawikiApi::newFromPage($project->getUrl());
143
        }
144 2
        return $api;
145
    }
146
147
    /**
148
     * Is XTools connecting to MMF Labs?
149
     * @return boolean
150
     * @codeCoverageIgnore
151
     */
152
    public function isLabs()
153
    {
154
        return (bool)$this->container->getParameter('app.is_labs');
155
    }
156
157
    /**
158
     * Make a request to the XTools API, optionally doing so asynchronously via Guzzle.
159
     * @param string $endpoint Relative path to endpoint with relevant query parameters.
160
     * @param bool $async Set to true to asynchronously query and return a promise.
161
     * @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...
162
     */
163
    public function queryXToolsApi($endpoint, $async = false)
164
    {
165
        if (!$this->apiConnection) {
166
            $this->apiConnection = $this->container->get('guzzle.client.xtools');
167
        }
168
169
        $key = $this->container->getParameter('secret');
170
171
        // Remove trailing slash if present.
172
        $basePath = trim($this->container->getParameter('app.base_path'), '/');
173
174
        $endpoint = "$basePath/api/$endpoint/$key";
175
176
        if ($async) {
177
            return $this->apiConnection->getAsync($endpoint);
178
        } else {
179
            return $this->apiConnection->get($endpoint);
180
        }
181
    }
182
183
    /**
184
     * Normalize and quote a table name for use in SQL.
185
     *
186
     * @param string $databaseName
187
     * @param string $tableName
188
     * @param string|null $tableExtension Optional table extension, which will only get used if we're on labs.
189
     * @return string Fully-qualified and quoted table name.
190
     */
191 1
    public function getTableName($databaseName, $tableName, $tableExtension = null)
192
    {
193 1
        $mapped = false;
194
195
        // This is a workaround for a one-to-many mapping
196
        // as required by Labs. We combine $tableName with
197
        // $tableExtension in order to generate the new table name
198 1
        if ($this->isLabs() && $tableExtension !== null) {
199
            $mapped = true;
200
            $tableName = $tableName . '_' . $tableExtension;
201 1
        } elseif ($this->container->hasParameter("app.table.$tableName")) {
202
            // Use the table specified in the table mapping configuration, if present.
203
            $mapped = true;
204
            $tableName = $this->container->getParameter("app.table.$tableName");
205
        }
206
207
        // For 'revision' and 'logging' tables (actually views) on Labs, use the indexed versions
208
        // (that have some rows hidden, e.g. for revdeleted users).
209
        // This is a safeguard in case table mapping isn't properly set up.
210 1
        $isLoggingOrRevision = in_array($tableName, ['revision', 'logging', 'archive']);
211 1
        if (!$mapped && $isLoggingOrRevision && $this->isLabs()) {
212
            $tableName = $tableName."_userindex";
213
        }
214
215
        // Figure out database name.
216
        // Use class variable for the database name if not set via function parameter.
217 1
        if ($this->isLabs() && substr($databaseName, -2) != '_p') {
218
            // Append '_p' if this is labs.
219
            $databaseName .= '_p';
220
        }
221
222 1
        return "`$databaseName`.`$tableName`";
223
    }
224
225
    /**
226
     * Get a unique cache key for the given list of arguments. Assuming each argument of
227
     * your function should be accounted for, you can pass in them all with func_get_args:
228
     *   $this->getCacheKey(func_get_args(), 'unique key for function');
229
     * Arugments that are a model should implement their own getCacheKey() that returns
230
     * a unique identifier for an instance of that model. See User::getCacheKey() for example.
231
     * @param array|mixed $args Array of arguments or a single argument.
232
     * @param string $key Unique key for this function. If omitted the function name itself
233
     *   is used, which is determined using `debug_backtrace`.
234
     * @return string
235
     */
236 8
    public function getCacheKey($args, $key = null)
237
    {
238 8
        if ($key === null) {
239 1
            $key = debug_backtrace()[1]['function'];
240
        }
241
242 8
        if (!is_array($args)) {
243 8
            $args = [$args];
244
        }
245
246
        // Start with base key.
247 8
        $cacheKey = $key;
248
249
        // Loop through and determine what values to use based on type of object.
250 8
        foreach ($args as $arg) {
251
            // Zero is an acceptable value.
252 8
            if ($arg === '' || $arg === null) {
253 1
                continue;
254
            }
255
256 8
            $cacheKey .= $this->getCacheKeyFromArg($arg);
257
        }
258
259 8
        return $cacheKey;
260
    }
261
262
    /**
263
     * Get a cache-friendly string given an argument.
264
     * @param  mixed $arg
265
     * @return string
266
     */
267 8
    private function getCacheKeyFromArg($arg)
268
    {
269 8
        if (method_exists($arg, 'getCacheKey')) {
270 1
            return '.'.$arg->getCacheKey();
271 8
        } elseif (is_array($arg)) {
272
            // Assumed to be an array of objects that can be parsed into a string.
273 1
            return '.'.join('', $arg);
274
        } else {
275
            // Assumed to be a string, number or boolean.
276 8
            return '.'.md5($arg);
277
        }
278
    }
279
280
    /**
281
     * Set the cache with given options.
282
     * @param string $cacheKey
283
     * @param mixed  $value
284
     * @param string $duration Valid DateInterval string.
285
     * @return mixed The given $value.
286
     */
287 1
    public function setCache($cacheKey, $value, $duration = 'PT10M')
288
    {
289 1
        $cacheItem = $this->cache
290 1
            ->getItem($cacheKey)
291 1
            ->set($value)
292 1
            ->expiresAfter(new DateInterval($duration));
293 1
        $this->cache->save($cacheItem);
294 1
        return $value;
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
     * @return \Doctrine\DBAL\Driver\Statement
327
     * @codeCoverageIgnore
328
     */
329
    public function executeProjectsQuery($sql, $params = [])
330
    {
331
        try {
332
            return $this->getProjectsConnection()->executeQuery($sql, $params);
333
        } catch (DriverException $e) {
334
            return $this->handleDriverError($e);
335
        }
336
    }
337
338
    /**
339
     * Execute a query using the projects connection, handling certain Exceptions.
340
     * @param  QueryBuilder $qb
341
     * @return \Doctrine\DBAL\Driver\Statement
342
     * @codeCoverageIgnore
343
     */
344
    public function executeQueryBuilder(QueryBuilder $qb)
345
    {
346
        try {
347
            return $qb->execute();
348
        } catch (DriverException $e) {
349
            return $this->handleDriverError($e);
350
        }
351
    }
352
353
    /**
354
     * Special handling of some DriverExceptions, otherwise original Exception is thrown.
355
     * @param DriverException $e
356
     * @throws ServiceUnavailableHttpException
357
     * @throws DriverException
358
     * @codeCoverageIgnore
359
     */
360
    private function handleDriverError(DriverException $e)
361
    {
362
        if ($e->getErrorCode() === 1226) {
363
            throw new ServiceUnavailableHttpException(30, 'error-service-overload', null, 503);
364
        } else {
365
            throw $e;
366
        }
367
    }
368
}
369