Passed
Push — main ( 49d96d...eb753e )
by MusikAnimal
04:04
created

AutoEditsRepository::setUseSandbox()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 1
dl 0
loc 4
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types = 1);
4
5
namespace App\Repository;
6
7
use App\Helper\AutomatedEditsHelper;
8
use App\Model\Project;
9
use App\Model\User;
10
use Doctrine\Persistence\ManagerRegistry;
11
use GuzzleHttp\Client;
12
use PDO;
13
use Psr\Cache\CacheItemPoolInterface;
14
use Psr\Log\LoggerInterface;
15
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
16
use Symfony\Component\HttpFoundation\Session\SessionInterface;
17
use Wikimedia\IPUtils;
18
19
/**
20
 * AutoEditsRepository is responsible for retrieving data from the database
21
 * about the automated edits made by a user.
22
 * @codeCoverageIgnore
23
 */
24
class AutoEditsRepository extends UserRepository
25
{
26
    protected AutomatedEditsHelper $autoEditsHelper;
27
28
    /** @var array List of automated tools, used for fetching the tool list and filtering it. */
29
    private array $aeTools;
30
31
    /** @var bool Whether to use the /sandbox version of the config, bypassing caching. */
32
    private bool $useSandbox = false;
33
34
    /** @var array Process cache for tags/IDs. */
35
    private array $tags;
36
37
    /**
38
     * @param ManagerRegistry $managerRegistry
39
     * @param CacheItemPoolInterface $cache
40
     * @param Client $guzzle
41
     * @param LoggerInterface $logger
42
     * @param ParameterBagInterface $parameterBag
43
     * @param bool $isWMF
44
     * @param int $queryTimeout
45
     * @param ProjectRepository $projectRepo
46
     * @param AutomatedEditsHelper $autoEditsHelper
47
     * @param SessionInterface $session
48
     */
49
    public function __construct(
50
        ManagerRegistry $managerRegistry,
51
        CacheItemPoolInterface $cache,
52
        Client $guzzle,
53
        LoggerInterface $logger,
54
        ParameterBagInterface $parameterBag,
55
        bool $isWMF,
56
        int $queryTimeout,
57
        ProjectRepository $projectRepo,
58
        AutomatedEditsHelper $autoEditsHelper,
59
        SessionInterface $session
60
    ) {
61
        $this->autoEditsHelper = $autoEditsHelper;
62
        parent::__construct(
63
            $managerRegistry,
64
            $cache,
65
            $guzzle,
66
            $logger,
67
            $parameterBag,
68
            $isWMF,
69
            $queryTimeout,
70
            $projectRepo,
71
            $session
72
        );
73
    }
74
75
    /**
76
     * @param bool $useSandbox
77
     * @return AutoEditsRepository
78
     */
79
    public function setUseSandbox(bool $useSandbox): AutoEditsRepository
80
    {
81
        $this->useSandbox = $useSandbox;
82
        return $this;
83
    }
84
85
    /**
86
     * Method to give the repository access to the AutomatedEditsHelper and fetch the list of semi-automated tools.
87
     * @param Project $project
88
     * @param int|string $namespace Namespace ID or 'all'.
89
     * @return array
90
     */
91
    public function getTools(Project $project, $namespace = 'all'): array
92
    {
93
        if (!isset($this->aeTools)) {
94
            $this->aeTools = $this->autoEditsHelper->getTools($project, $this->useSandbox);
95
        }
96
97
        if ('all' !== $namespace) {
98
            // Limit by namespace.
99
            return array_filter($this->aeTools, function (array $tool) use ($namespace) {
100
                return empty($tool['namespaces']) ||
101
                    in_array((int)$namespace, $tool['namespaces']) ||
102
                    (
103
                        1 === $namespace % 2 &&
104
                        isset($tool['talk_namespaces'])
105
                    );
106
            });
107
        }
108
109
        return $this->aeTools;
110
    }
111
112
    /**
113
     * Get tools that were misconfigured, also removing them from $this->aeTools.
114
     * @param Project $project
115
     * @return string[] Labels for the invalid tools.
116
     */
117
    public function getInvalidTools(Project $project): array
118
    {
119
        $tools = $this->getTools($project);
120
        $invalidTools = $tools['invalid'] ?? [];
121
        unset($this->aeTools['invalid']);
122
        return $invalidTools;
123
    }
124
125
    /**
126
     * Overrides Repository::setCache(), and will not call the parent (which sets the cache) if using the sandbox.
127
     * @inheritDoc
128
     */
129
    public function setCache(string $cacheKey, $value, $duration = 'PT20M')
130
    {
131
        if ($this->useSandbox) {
132
            return $value;
133
        }
134
135
        return parent::setCache($cacheKey, $value, $duration);
136
    }
137
138
    /**
139
     * Get the number of edits this user made using semi-automated tools.
140
     * @param Project $project
141
     * @param User $user
142
     * @param string|int $namespace Namespace ID or 'all'
143
     * @param int|false $start Start date as Unix timestamp.
144
     * @param int|false $end End date as Unix timestamp.
145
     * @return int Result of query, see below.
146
     */
147
    public function countAutomatedEdits(
148
        Project $project,
149
        User $user,
150
        $namespace = 'all',
151
        $start = false,
152
        $end = false
153
    ): int {
154
        $cacheKey = $this->getCacheKey(func_get_args(), 'user_autoeditcount');
155
        if (!$this->useSandbox && $this->cache->hasItem($cacheKey)) {
156
            return $this->cache->getItem($cacheKey)->get();
157
        }
158
159
        $revDateConditions = $this->getDateConditions($start, $end);
160
161
        // Get the combined regex and tags for the tools
162
        [$regex, $tagIds] = $this->getToolRegexAndTags($project, false, null, $namespace);
163
164
        [$pageJoin, $condNamespace] = $this->getPageAndNamespaceSql($project, $namespace);
165
166
        $revisionTable = $project->getTableName('revision');
167
        $ipcTable = $project->getTableName('ip_changes');
168
        $commentTable = $project->getTableName('comment', 'revision');
169
        $tagTable = $project->getTableName('change_tag');
170
        $commentJoin = '';
171
        $tagJoin = '';
172
173
        $params = [];
174
175
        // IP range handling.
176
        $ipcJoin = '';
177
        $whereClause = 'rev_actor = :actorId';
178
        if ($user->isIpRange()) {
179
            $ipcJoin = "JOIN $ipcTable ON rev_id = ipc_rev_id";
180
            $whereClause = 'ipc_hex BETWEEN :startIp AND :endIp';
181
            [$params['startIp'], $params['endIp']] = IPUtils::parseRange($user->getUsername());
182
        }
183
184
        // Build SQL for detecting AutoEdits via regex and/or tags.
185
        $condTools = [];
186
        if ('' != $regex) {
187
            $commentJoin = "LEFT OUTER JOIN $commentTable ON rev_comment_id = comment_id";
188
            $condTools[] = "comment_text REGEXP :tools";
189
            $params['tools'] = $regex;
190
        }
191
        if ('' != $tagIds) {
192
            $tagJoin = "LEFT OUTER JOIN $tagTable ON ct_rev_id = rev_id";
193
            $condTools[] = "ct_tag_id IN ($tagIds)";
194
        }
195
        $condTool = 'AND (' . implode(' OR ', $condTools) . ')';
196
197
        $sql = "SELECT COUNT(DISTINCT(rev_id))
198
                FROM $revisionTable
199
                $ipcJoin
200
                $pageJoin
201
                $commentJoin
202
                $tagJoin
203
                WHERE $whereClause
204
                $condNamespace
205
                $condTool
206
                $revDateConditions";
207
208
        $resultQuery = $this->executeQuery($sql, $project, $user, $namespace, $params);
209
        $result = (int)$resultQuery->fetchOne();
210
211
        // Cache and return.
212
        return $this->setCache($cacheKey, $result);
213
    }
214
215
    /**
216
     * Get non-automated contributions for the given user.
217
     * @param Project $project
218
     * @param User $user
219
     * @param string|int $namespace Namespace ID or 'all'.
220
     * @param int|false $start Start date as Unix timestamp.
221
     * @param int|false $end End date as Unix timestamp.
222
     * @param int|false $offset Unix timestamp. Used for pagination.
223
     * @param int $limit Number of results to return.
224
     * @return string[] Result of query, with columns 'page_title', 'page_namespace', 'rev_id', 'timestamp', 'minor',
225
     *   'length', 'length_change', 'comment'.
226
     */
227
    public function getNonAutomatedEdits(
228
        Project $project,
229
        User $user,
230
        $namespace = 'all',
231
        $start = false,
232
        $end = false,
233
        $offset = false,
234
        int $limit = 50
235
    ): array {
236
        $cacheKey = $this->getCacheKey(func_get_args(), 'user_nonautoedits');
237
        if (!$this->useSandbox && $this->cache->hasItem($cacheKey)) {
238
            return $this->cache->getItem($cacheKey)->get();
239
        }
240
241
        $revDateConditions = $this->getDateConditions($start, $end, $offset, 'revs.');
242
243
        // Get the combined regex and tags for the tools
244
        [$regex, $tagIds] = $this->getToolRegexAndTags($project, false, null, $namespace);
245
246
        $pageTable = $project->getTableName('page');
247
        $revisionTable = $project->getTableName('revision');
248
        $ipcTable = $project->getTableName('ip_changes');
249
        $commentTable = $project->getTableName('comment', 'revision');
250
        $tagTable = $project->getTableName('change_tag');
251
252
        // IP range handling.
253
        $ipcJoin = '';
254
        $whereClause = 'revs.rev_actor = :actorId';
255
        $params = ['tools' => $regex];
256
        if ($user->isIpRange()) {
257
            $ipcJoin = "JOIN $ipcTable ON revs.rev_id = ipc_rev_id";
258
            $whereClause = 'ipc_hex BETWEEN :startIp AND :endIp';
259
            [$params['startIp'], $params['endIp']] = IPUtils::parseRange($user->getUsername());
260
        }
261
262
        $condNamespace = 'all' === $namespace ? '' : 'AND page_namespace = :namespace';
263
        $condTag = '' != $tagIds ? "AND NOT EXISTS (SELECT 1 FROM $tagTable
264
            WHERE ct_rev_id = revs.rev_id AND ct_tag_id IN ($tagIds))" : '';
265
266
        $sql = "SELECT
267
                    page_title,
268
                    page_namespace,
269
                    revs.rev_id AS rev_id,
270
                    revs.rev_timestamp AS timestamp,
271
                    revs.rev_minor_edit AS minor,
272
                    revs.rev_len AS length,
273
                    (CAST(revs.rev_len AS SIGNED) - IFNULL(parentrevs.rev_len, 0)) AS length_change,
274
                    comment_text AS comment
275
                FROM $pageTable
276
                JOIN $revisionTable AS revs ON (page_id = revs.rev_page)
277
                $ipcJoin
278
                LEFT JOIN $revisionTable AS parentrevs ON (revs.rev_parent_id = parentrevs.rev_id)
279
                LEFT OUTER JOIN $commentTable ON (revs.rev_comment_id = comment_id)
280
                WHERE $whereClause
281
                AND revs.rev_timestamp > 0
282
                AND comment_text NOT RLIKE :tools
283
                $condTag
284
                $revDateConditions
285
                $condNamespace
286
                GROUP BY revs.rev_id
287
                ORDER BY revs.rev_timestamp DESC
288
                LIMIT $limit";
289
290
        $resultQuery = $this->executeQuery($sql, $project, $user, $namespace, $params);
291
        $result = $resultQuery->fetchAllAssociative();
292
293
        // Cache and return.
294
        return $this->setCache($cacheKey, $result);
295
    }
296
297
    /**
298
     * Get (semi-)automated contributions for the given user, and optionally for a given tool.
299
     * @param Project $project
300
     * @param User $user
301
     * @param string|int $namespace Namespace ID or 'all'.
302
     * @param int|false $start Start date as Unix timestamp.
303
     * @param int|false $end End date as Unix timestamp.
304
     * @param string|null $tool Only get edits made with this tool. Must match the keys in the AutoEdits config.
305
     * @param int|false $offset Unix timestamp. Used for pagination.
306
     * @param int $limit Number of results to return.
307
     * @return string[] Result of query, with columns 'page_title', 'page_namespace', 'rev_id', 'timestamp', 'minor',
308
     *   'length', 'length_change', 'comment'.
309
     */
310
    public function getAutomatedEdits(
311
        Project $project,
312
        User $user,
313
        $namespace = 'all',
314
        $start = false,
315
        $end = false,
316
        ?string $tool = null,
317
        $offset = false,
318
        int $limit = 50
319
    ): array {
320
        $cacheKey = $this->getCacheKey(func_get_args(), 'user_autoedits');
321
        if (!$this->useSandbox && $this->cache->hasItem($cacheKey)) {
322
            return $this->cache->getItem($cacheKey)->get();
323
        }
324
325
        $revDateConditions = $this->getDateConditions($start, $end, $offset, 'revs.');
326
327
        // In this case there is a slight performance improvement we can make if we're not given a start date.
328
        if ('' === $revDateConditions) {
329
            $revDateConditions = 'AND revs.rev_timestamp > 0';
330
        }
331
332
        // Get the combined regex and tags for the tools
333
        [$regex, $tagIds] = $this->getToolRegexAndTags($project, false, $tool);
334
335
        $pageTable = $project->getTableName('page');
336
        $revisionTable = $project->getTableName('revision');
337
        $ipcTable = $project->getTableName('ip_changes');
338
        $commentTable = $project->getTableName('comment', 'revision');
339
        $tagTable = $project->getTableName('change_tag');
340
        $condNamespace = 'all' === $namespace ? '' : 'AND page_namespace = :namespace';
341
        $tagJoin = '';
342
        $condsTool = [];
343
344
        if ('' != $regex) {
345
            $condsTool[] = 'comment_text RLIKE :tools';
346
        }
347
348
        if ('' != $tagIds) {
349
            $tagJoin = "LEFT OUTER JOIN $tagTable ON (ct_rev_id = revs.rev_id)";
350
            $condsTool[] = "ct_tag_id IN ($tagIds)";
351
        }
352
353
        // IP range handling.
354
        $ipcJoin = '';
355
        $whereClause = 'revs.rev_actor = :actorId';
356
        $params = ['tools' => $regex];
357
        if ($user->isIpRange()) {
358
            $ipcJoin = "JOIN $ipcTable ON rev_id = ipc_rev_id";
359
            $whereClause = 'ipc_hex BETWEEN :startIp AND :endIp';
360
            [$params['startIp'], $params['endIp']] = IPUtils::parseRange($user->getUsername());
361
        }
362
363
        $sql = "SELECT
364
                    page_title,
365
                    page_namespace,
366
                    revs.rev_id AS rev_id,
367
                    revs.rev_timestamp AS timestamp,
368
                    revs.rev_minor_edit AS minor,
369
                    revs.rev_len AS length,
370
                    (CAST(revs.rev_len AS SIGNED) - IFNULL(parentrevs.rev_len, 0)) AS length_change,
371
                    comment_text AS comment
372
                FROM $pageTable
373
                JOIN $revisionTable AS revs ON (page_id = revs.rev_page)
374
                $ipcJoin
375
                LEFT JOIN $revisionTable AS parentrevs ON (revs.rev_parent_id = parentrevs.rev_id)
376
                LEFT OUTER JOIN $commentTable ON (revs.rev_comment_id = comment_id)
377
                $tagJoin
378
                WHERE $whereClause
379
                $revDateConditions
380
                $condNamespace
381
                AND (".implode(' OR ', $condsTool).")
382
                GROUP BY revs.rev_id
383
                ORDER BY revs.rev_timestamp DESC
384
                LIMIT $limit";
385
386
        $resultQuery = $this->executeQuery($sql, $project, $user, $namespace, $params);
387
        $result = $resultQuery->fetchAllAssociative();
388
389
        // Cache and return.
390
        return $this->setCache($cacheKey, $result);
391
    }
392
393
    /**
394
     * Get counts of known automated tools used by the given user.
395
     * @param Project $project
396
     * @param User $user
397
     * @param string|int $namespace Namespace ID or 'all'.
398
     * @param int|false $start Start date as Unix timestamp.
399
     * @param int|false $end End date as Unix timestamp.
400
     * @return string[] Each tool that they used along with the count and link:
401
     *                  [
402
     *                      'Twinkle' => [
403
     *                          'count' => 50,
404
     *                          'link' => 'Wikipedia:Twinkle',
405
     *                      ],
406
     *                  ]
407
     */
408
    public function getToolCounts(Project $project, User $user, $namespace = 'all', $start = false, $end = false): array
409
    {
410
        $cacheKey = $this->getCacheKey(func_get_args(), 'user_autotoolcounts');
411
        if (!$this->useSandbox && $this->cache->hasItem($cacheKey)) {
412
            return $this->cache->getItem($cacheKey)->get();
413
        }
414
415
        $sql = $this->getAutomatedCountsSql($project, $user, $namespace, $start, $end);
416
        $params = [];
417
        if ($user->isIpRange()) {
418
            [$params['startIp'], $params['endIp']] = IPUtils::parseRange($user->getUsername());
419
        }
420
        $resultQuery = $this->executeQuery($sql, $project, $user, $namespace, $params);
421
422
        $tools = $this->getTools($project, $namespace);
423
424
        // handling results
425
        $results = [];
426
427
        while ($row = $resultQuery->fetchAssociative()) {
428
            // Only track tools that they've used at least once
429
            $tool = $row['toolname'];
430
            if ($row['count'] > 0) {
431
                $results[$tool] = [
432
                    'link' => $tools[$tool]['link'],
433
                    'label' => $tools[$tool]['label'] ?? $tool,
434
                    'count' => $row['count'],
435
                ];
436
            }
437
        }
438
439
        // Sort the array by count
440
        uasort($results, function ($a, $b) {
441
            return $b['count'] - $a['count'];
442
        });
443
444
        // Cache and return.
445
        return $this->setCache($cacheKey, $results);
446
    }
447
448
    /**
449
     * Get SQL for getting counts of known automated tools used by the user.
450
     * @see self::getAutomatedCounts()
451
     * @param Project $project
452
     * @param User $user
453
     * @param string|int $namespace Namespace ID or 'all'.
454
     * @param int|false $start Start date as Unix timestamp.
455
     * @param int|false $end End date as Unix timestamp.
456
     * @return string The SQL.
457
     */
458
    private function getAutomatedCountsSql(
459
        Project $project,
460
        User $user,
461
        $namespace,
462
        $start = false,
463
        $end = false
464
    ): string {
465
        $revDateConditions = $this->getDateConditions($start, $end);
466
467
        // Load the semi-automated edit types.
468
        $tools = $this->getTools($project, $namespace);
469
470
        // Create a collection of queries that we're going to run.
471
        $queries = [];
472
473
        $revisionTable = $project->getTableName('revision');
474
        $ipcTable = $project->getTableName('ip_changes');
475
        [$pageJoin, $condNamespace] = $this->getPageAndNamespaceSql($project, $namespace);
476
        $conn = $this->getProjectsConnection($project);
477
478
        // IP range handling.
479
        $ipcJoin = '';
480
        $whereClause = 'rev_actor = :actorId';
481
        if ($user->isIpRange()) {
482
            $ipcJoin = "JOIN $ipcTable ON rev_id = ipc_rev_id";
483
            $whereClause = 'ipc_hex BETWEEN :startIp AND :endIp';
484
        }
485
486
        foreach ($tools as $toolName => $values) {
487
            [$condTool, $commentJoin, $tagJoin] = $this->getInnerAutomatedCountsSql($project, $toolName, $values);
488
489
            $toolName = $conn->quote($toolName, PDO::PARAM_STR);
490
491
            // No regex or tag provided for this tool. This can happen for tag-only tools that are in the global
492
            // configuration, but no local tag exists on the said project.
493
            if ('' === $condTool) {
494
                continue;
495
            }
496
497
            $queries[] .= "
498
                SELECT $toolName AS toolname, COUNT(DISTINCT(rev_id)) AS count
499
                FROM $revisionTable
500
                $ipcJoin
501
                $pageJoin
502
                $commentJoin
503
                $tagJoin
504
                WHERE $whereClause
505
                AND $condTool
506
                $condNamespace
507
                $revDateConditions";
508
        }
509
510
        // Combine to one big query.
511
        return implode(' UNION ', $queries);
512
    }
513
514
    /**
515
     * Get some of the inner SQL for self::getAutomatedCountsSql().
516
     * @param Project $project
517
     * @param string $toolName
518
     * @param string[] $values Values as defined in the AutoEdits config.
519
     * @return string[] [Equality clause, JOIN clause]
520
     */
521
    private function getInnerAutomatedCountsSql(Project $project, string $toolName, array $values): array
522
    {
523
        $conn = $this->getProjectsConnection($project);
524
        $commentJoin = '';
525
        $tagJoin = '';
526
        $condTool = '';
527
528
        if (isset($values['regex'])) {
529
            $commentTable = $project->getTableName('comment', 'revision');
530
            $commentJoin = "LEFT OUTER JOIN $commentTable ON rev_comment_id = comment_id";
531
            $regex = $conn->quote($values['regex'], PDO::PARAM_STR);
532
            $condTool = "comment_text REGEXP $regex";
533
        }
534
        if (isset($values['tags'])) {
535
            $tagIds = $this->getTagIdsFromNames($project, $values['tags']);
0 ignored issues
show
Bug introduced by
$values['tags'] of type string is incompatible with the type array expected by parameter $tagNames of App\Repository\AutoEdits...y::getTagIdsFromNames(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

535
            $tagIds = $this->getTagIdsFromNames($project, /** @scrutinizer ignore-type */ $values['tags']);
Loading history...
536
537
            if ($tagIds) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $tagIds of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
538
                $tagTable = $project->getTableName('change_tag');
539
                $tagJoin = "LEFT OUTER JOIN $tagTable ON ct_rev_id = rev_id";
540
                $tagClause = $this->getTagsExclusionsSql($project, $toolName, $tagIds);
541
542
                // Use tags in addition to the regex clause, if already present.
543
                // Tags are more reliable but may not be present for edits made with
544
                // older versions of the tool, before it started adding tags.
545
                if ('' === $condTool) {
546
                    $condTool = $tagClause;
547
                } else {
548
                    $condTool = "($condTool OR $tagClause)";
549
                }
550
            }
551
        }
552
553
        return [$condTool, $commentJoin, $tagJoin];
554
    }
555
556
    /**
557
     * Get the combined regex and tags for all semi-automated tools, or the given tool, ready to be used in a query.
558
     * @param Project $project
559
     * @param bool $nonAutoEdits Set to true to exclude tools with the 'contribs' flag.
560
     * @param string|null $tool
561
     * @param int|string|null $namespace Tools only used in given namespace ID, or 'all' for all namespaces.
562
     * @return array In the format: ['combined|regex', '1,2,3'] where the second element is a
563
     *   comma-separated list of the tag IDs, ready to be used in SQL.
564
     */
565
    private function getToolRegexAndTags(
566
        Project $project,
567
        bool $nonAutoEdits = false,
568
        ?string $tool = null,
569
        $namespace = null
570
    ): array {
571
        $tools = $this->getTools($project);
572
        $regexes = [];
573
        $tagIds = [];
574
575
        if ('' != $tool) {
576
            $tools = [$tools[$tool]];
577
        }
578
579
        foreach (array_values($tools) as $values) {
580
            if ($nonAutoEdits && isset($values['contribs'])) {
581
                continue;
582
            }
583
584
            if (is_numeric($namespace) &&
585
                !empty($values['namespaces']) &&
586
                !in_array((int)$namespace, $values['namespaces'])
587
            ) {
588
                continue;
589
            }
590
591
            if (isset($values['regex'])) {
592
                $regexes[] = $values['regex'];
593
            }
594
            if (isset($values['tags'])) {
595
                $tagIds = array_merge($tagIds, $this->getTagIdsFromNames($project, $values['tags']));
596
            }
597
        }
598
599
        return [
600
            implode('|', $regexes),
601
            implode(',', $tagIds),
602
        ];
603
    }
604
605
    /**
606
     * Get the IDs of tags for given Project, which are used in the IN clauses of other queries above.
607
     * This join decomposition is actually faster than JOIN'ing on change_tag_def all in one query.
608
     * @param Project $project
609
     * @return int[] Keys are the tag name, values are the IDs.
610
     */
611
    public function getTags(Project $project): array
612
    {
613
        // Use process cache; ensures we don't needlessly re-query for tag IDs
614
        // during the same request when using the ?usesandbox=1 option.
615
        if (isset($this->tags)) {
616
            return $this->tags;
617
        }
618
619
        $cacheKey = $this->getCacheKey(func_get_args(), 'ae_tag_ids');
620
        if (!$this->useSandbox && $this->cache->hasItem($cacheKey)) {
621
            return $this->cache->getItem($cacheKey)->get();
622
        }
623
624
        $conn = $this->getProjectsConnection($project);
625
626
        // Get all tag values.
627
        $tags = [];
628
        foreach (array_values($this->getTools($project)) as $values) {
629
            if (isset($values['tags'])) {
630
                $tags = array_merge(
631
                    $tags,
632
                    array_map(function ($tag) use ($conn) {
633
                        return $conn->quote($tag, PDO::PARAM_STR);
634
                    }, $values['tags'])
635
                );
636
            }
637
        }
638
639
        $tags = implode(',', $tags);
640
        $tagDefTable = $project->getTableName('change_tag_def');
641
        $sql = "SELECT ctd_name, ctd_id FROM $tagDefTable
642
                WHERE ctd_name IN ($tags)";
643
        $this->tags = $this->executeProjectsQuery($project, $sql)->fetchAllKeyValue();
644
645
        // Cache and return.
646
        return $this->setCache($cacheKey, $this->tags);
647
    }
648
649
    /**
650
     * Generate the WHERE clause to query for the given tags, filtering out exclusions ('tag_excludes' option).
651
     * For instance, Huggle edits are also tagged as Rollback, but when viewing
652
     * Rollback edits we don't want to show Huggle edits.
653
     * @param Project $project
654
     * @param string $tool
655
     * @param array $tagIds
656
     * @return string
657
     */
658
    private function getTagsExclusionsSql(Project $project, string $tool, array $tagIds): string
659
    {
660
        $tagsList = implode(',', $tagIds);
661
        $tagExcludes = $this->getTools($project)[$tool]['tag_excludes'] ?? [];
662
        $excludesSql = '';
663
664
        if ($tagExcludes && 1 === count($tagIds)) {
665
            // Get tag IDs, filtering out those for which no ID exists (meaning there is no local tag for that tool).
666
            $excludesList = implode(',', array_filter(array_map(function ($tagName) use ($project) {
667
                return $this->getTags($project)[$tagName] ?? null;
668
            }, $tagExcludes)));
669
670
            if (strlen($excludesList)) {
671
                $excludesSql = "AND ct_tag_id NOT IN ($excludesList)";
672
            }
673
        }
674
675
        return "ct_tag_id IN ($tagsList) $excludesSql";
676
    }
677
678
    /**
679
     * Get IDs for tags given the names.
680
     * @param Project $project
681
     * @param array $tagNames
682
     * @return array
683
     */
684
    private function getTagIdsFromNames(Project $project, array $tagNames): array
685
    {
686
        $allTagIds = $this->getTags($project);
687
        $tagIds = [];
688
689
        foreach ($tagNames as $tag) {
690
            if (isset($allTagIds[$tag])) {
691
                $tagIds[] = $allTagIds[$tag];
692
            }
693
        }
694
695
        return $tagIds;
696
    }
697
}
698