Passed
Pull Request — main (#443)
by MusikAnimal
13:59 queued 09:36
created

AutoEditsRepository::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 21
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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

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