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

AutoEditsRepository::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 23
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 11
nc 1
nop 10
dl 0
loc 23
rs 9.9
c 0
b 0
f 0

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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