Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 97
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
CampaignStats
0.00% covered (danger)
0.00%
0 / 97
0.00% covered (danger)
0.00%
0 / 5
306
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getStatsForRecord
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getStatsForRecords
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
90
 getUploadedMedia
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 getSummaryCounts
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Extension\MediaUploader\Campaign;
4
5use MediaWiki\Extension\MediaUploader\Config\RawConfig;
6use Title;
7use WANObjectCache;
8use Wikimedia\Rdbms\Database;
9use Wikimedia\Rdbms\IConnectionProvider;
10use Wikimedia\Rdbms\IReadableDatabase;
11
12/**
13 * Facility for retrieving statistics about campaigns.
14 */
15class CampaignStats {
16
17    private IConnectionProvider $dbProvider;
18
19    /** @var WANObjectCache */
20    private $cache;
21
22    /** @var RawConfig */
23    private $rawConfig;
24
25    /**
26     * CampaignStats constructor.
27     *
28     * @param IConnectionProvider $dbProvider
29     * @param WANObjectCache $cache
30     * @param RawConfig $rawConfig
31     *
32     * @internal Only for use by ServiceWiring
33     */
34    public function __construct(
35        IConnectionProvider $dbProvider,
36        WANObjectCache $cache,
37        RawConfig $rawConfig
38    ) {
39        $this->dbProvider = $dbProvider;
40        $this->cache = $cache;
41        $this->rawConfig = $rawConfig;
42    }
43
44    /**
45     * Get stats for a single record.
46     * @see CampaignStats::getStatsForRecords()
47     *
48     * @param CampaignRecord $record
49     *
50     * @return array|null stats record or null on failure
51     */
52    public function getStatsForRecord( CampaignRecord $record ): ?array {
53        $stats = $this->getStatsForRecords( [ $record ] );
54        return $stats[$record->getPageId() ?: -1] ?? null;
55    }
56
57    /**
58     * The records must have their titles populated. Otherwise, this will
59     * throw an exception.
60     *
61     * @param CampaignRecord[] $records
62     *
63     * @return array Map: campaign ID => stats array.
64     *   Keys: 'trackingCategory': string (DB key)
65     *         'uploadedMediaCount': int
66     *         'contributorsCount': int
67     *         'uploadedMedia': string[] (DB keys of max. 24 files)
68     */
69    public function getStatsForRecords( array $records ): array {
70        $recordMap = [];
71        foreach ( $records as $record ) {
72            // Note: the page ID won't actually ever be null, but it's hard to
73            // convince Phan to this.
74            $recordMap[$record->getPageId() ?: -1] = $record;
75        }
76
77        $cache = $this->cache;
78        $keys = $cache->makeMultiKeys(
79            array_keys( $recordMap ),
80            static function ( $id ) use ( $cache ) {
81                return $cache->makeKey(
82                    'mediauploader',
83                    'campaign-stats',
84                    $id
85                );
86            }
87        );
88
89        $fromCache = $cache->getMultiWithUnionSetCallback(
90            $keys,
91            $this->rawConfig->getSetting( 'campaignStatsMaxAge' ),
92            function ( array $ids, array &$ttls, array &$setOpts ) use ( $recordMap ) {
93                $db = $this->dbProvider->getReplicaDatabase();
94                $setOpts += Database::getCacheSetOptions( $db );
95
96                // Construct a tracking category => id map
97                $catToId = [];
98                // id => tracking category or null
99                $idToCat = [];
100                foreach ( $ids as $id ) {
101                    $catTitle = Title::newFromText(
102                        $recordMap[$id]->getTrackingCategoryName( $this->rawConfig ),
103                        NS_CATEGORY
104                    );
105
106                    if ( $catTitle === null ) {
107                        $idToCat[$id] = null;
108                    } else {
109                        $idToCat[$id] = $catTitle->getDBkey();
110                        $catToId[$catTitle->getDBkey()] = $id;
111                    }
112                }
113
114                // Do the batch queries
115                if ( $catToId ) {
116                    $summary = $this->getSummaryCounts( $db, $catToId );
117                    $media = $this->getUploadedMedia( $db, $catToId );
118                }
119
120                // Aggregate results
121                $toCache = [];
122                foreach ( $idToCat as $id => $category ) {
123                    if ( $category === null ) {
124                        $toCache[$id] = null;
125                    } else {
126                        $toCache[$id] = [
127                            'trackingCategory' => $category,
128                            'uploadedMediaCount' => $summary[$id][0] ?? 0,
129                            'contributorsCount' => $summary[$id][1] ?? 0,
130                            'uploadedMedia' => $media[$id] ?? [],
131                        ];
132                    }
133                }
134
135                return $toCache;
136            }
137        );
138
139        $result = [];
140        foreach ( $fromCache as $key => $item ) {
141            $result[$keys[$key]] = $item;
142        }
143        return $result;
144    }
145
146    /**
147     * @param IReadableDatabase $db
148     * @param int[] $categories Map: campaign DB key => campaign page ID
149     *
150     * @return string[][] campaign ID => string[], the strings are filenames
151     */
152    private function getUploadedMedia( IReadableDatabase $db, array $categories ): array {
153        $result = $db->newSelectQueryBuilder()
154            ->table( 'categorylinks' )
155            ->fields( [ 'cl_to', 'page_namespace', 'page_title' ] )
156            ->where( [
157                'cl_to' => array_keys( $categories ),
158                'cl_type' => 'file',
159            ] )
160            ->join( 'page', null, 'cl_from=page_id' )
161            ->orderBy( 'cl_timestamp', 'DESC' )
162            ->useIndex( [ 'categorylinks' => 'cl_timestamp' ] )
163            // Old, arbitrary limit. Seems fine.
164            ->limit( 24 )
165            ->fetchResultSet();
166
167        $grouped = [];
168        foreach ( $result as $row ) {
169            $key = $categories[$row->cl_to];
170
171            if ( array_key_exists( $key, $grouped ) ) {
172                $grouped[$key][] = $row->page_title;
173            } else {
174                $grouped[$key] = [ $row->page_title ];
175            }
176        }
177
178        return $grouped;
179    }
180
181    /**
182     * @param IReadableDatabase $db
183     * @param int[] $categories Map: campaign DB key => campaign page ID
184     *
185     * @return array Map: campaign ID => [ media count, contributor count ]
186     */
187    private function getSummaryCounts( IReadableDatabase $db, array $categories ): array {
188        $result = $db->newSelectQueryBuilder()
189            ->table( 'categorylinks' )
190            ->field( 'cl_to' )
191            ->field( 'COUNT(DISTINCT img_actor)', 'contributors' )
192            ->field( 'COUNT(cl_from)', 'media' )
193            ->where( [
194                'cl_to' => array_keys( $categories ),
195                'cl_type' => 'file',
196            ] )
197            ->join( 'page', null, 'cl_from=page_id' )
198            ->join( 'image', null, 'page_title=img_name' )
199            ->groupBy( 'cl_to' )
200            ->useIndex( [ 'categorylinks' => 'cl_timestamp' ] )
201            ->fetchResultSet();
202
203        $map = [];
204        foreach ( $result as $row ) {
205            $map[$categories[$row->cl_to]] = [
206                intval( $row->media ),
207                intval( $row->contributors ),
208            ];
209        }
210        return $map;
211    }
212}