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