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

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