Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.02% covered (warning)
82.02%
73 / 89
45.45% covered (danger)
45.45%
5 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
MediaWikiGadgetsJsonRepo
82.02% covered (warning)
82.02%
73 / 89
45.45% covered (danger)
45.45%
5 / 11
24.81
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
 getGadgetIds
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
3
 handlePageUpdate
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 purgeGadgetIdsList
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGadgetId
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 isGadgetDefinitionTitle
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
3.71
 getGadgetDefinitionTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGadget
78.12% covered (warning)
78.12%
25 / 32
0.00% covered (danger)
0.00%
0 / 1
5.26
 purgeGadgetEntry
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGadgetIdsKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGadgetCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\Gadgets;
4
5use InvalidArgumentException;
6use MediaWiki\Extension\Gadgets\Content\GadgetDefinitionContent;
7use MediaWiki\Linker\LinkTarget;
8use MediaWiki\Revision\RevisionLookup;
9use MediaWiki\Revision\SlotRecord;
10use MediaWiki\Title\Title;
11use Wikimedia\ObjectCache\WANObjectCache;
12use Wikimedia\Rdbms\Database;
13use Wikimedia\Rdbms\IConnectionProvider;
14use Wikimedia\Rdbms\IExpression;
15use Wikimedia\Rdbms\LikeValue;
16
17/**
18 * Gadgets repo powered by `MediaWiki:Gadgets/<id>.json` pages.
19 *
20 * Each gadget has its own gadget definition page, using GadgetDefinitionContent.
21 */
22class MediaWikiGadgetsJsonRepo extends GadgetRepo {
23    /**
24     * How long in seconds the list of gadget ids and
25     * individual gadgets should be cached for (1 day)
26     */
27    private const CACHE_TTL = 86400;
28
29    public const DEF_PREFIX = 'Gadgets/';
30    public const DEF_SUFFIX = '.json';
31
32    private IConnectionProvider $dbProvider;
33    private WANObjectCache $wanCache;
34    private RevisionLookup $revLookup;
35
36    public function __construct(
37        IConnectionProvider $dbProvider,
38        WANObjectCache $wanCache,
39        RevisionLookup $revLookup
40    ) {
41        $this->dbProvider = $dbProvider;
42        $this->wanCache = $wanCache;
43        $this->revLookup = $revLookup;
44    }
45
46    /**
47     * Get a list of gadget ids from cache/database
48     *
49     * @return string[]
50     */
51    public function getGadgetIds(): array {
52        $key = $this->getGadgetIdsKey();
53
54        $fname = __METHOD__;
55        $dbr = $this->dbProvider->getReplicaDatabase();
56        $titles = $this->wanCache->getWithSetCallback(
57            $key,
58            self::CACHE_TTL,
59            static function ( $oldValue, &$ttl, array &$setOpts ) use ( $fname, $dbr ) {
60                $setOpts += Database::getCacheSetOptions( $dbr );
61
62                return $dbr->newSelectQueryBuilder()
63                    ->select( 'page_title' )
64                    ->from( 'page' )
65                    ->where( [
66                        'page_namespace' => NS_MEDIAWIKI,
67                        'page_content_model' => 'GadgetDefinition',
68                        $dbr->expr(
69                            'page_title',
70                            IExpression::LIKE,
71                            new LikeValue( self::DEF_PREFIX, $dbr->anyString(), self::DEF_SUFFIX )
72                        )
73                    ] )
74                    ->caller( $fname )
75                    ->fetchFieldValues();
76            },
77            [
78                'checkKeys' => [ $key ],
79                'pcTTL' => WANObjectCache::TTL_PROC_SHORT,
80                // Bump when changing the database query.
81                'version' => 2,
82                'lockTSE' => 30
83            ]
84        );
85
86        $ids = [];
87        foreach ( $titles as $title ) {
88            $id = self::getGadgetId( $title );
89            if ( $id !== '' ) {
90                $ids[] = $id;
91            }
92        }
93        return $ids;
94    }
95
96    /**
97     * @inheritDoc
98     */
99    public function handlePageUpdate( LinkTarget $target ): void {
100        if ( $this->isGadgetDefinitionTitle( $target ) ) {
101            $this->purgeGadgetIdsList();
102            $this->purgeGadgetEntry( self::getGadgetId( $target->getText() ) );
103        }
104    }
105
106    /**
107     * Purge the list of gadget ids when a page is deleted or if a new page is created
108     */
109    public function purgeGadgetIdsList(): void {
110        $this->wanCache->touchCheckKey( $this->getGadgetIdsKey() );
111    }
112
113    /**
114     * @param string $title Gadget definition page title
115     * @return string Gadget ID
116     */
117    private static function getGadgetId( string $title ): string {
118        if ( !str_starts_with( $title, self::DEF_PREFIX ) || !str_ends_with( $title, self::DEF_SUFFIX ) ) {
119            throw new InvalidArgumentException( 'Invalid definition page title' );
120        }
121        return substr( $title, strlen( self::DEF_PREFIX ), -strlen( self::DEF_SUFFIX ) );
122    }
123
124    public static function isGadgetDefinitionTitle( LinkTarget $target ): bool {
125        if ( !$target->inNamespace( NS_MEDIAWIKI ) ) {
126            return false;
127        }
128        $title = $target->getText();
129        try {
130            self::getGadgetId( $title );
131            return true;
132        } catch ( InvalidArgumentException $e ) {
133            return false;
134        }
135    }
136
137    /**
138     * @inheritDoc
139     */
140    public function getGadgetDefinitionTitle( string $id ): ?Title {
141        return Title::makeTitleSafe( NS_MEDIAWIKI, self::DEF_PREFIX . $id . self::DEF_SUFFIX );
142    }
143
144    /**
145     * @param string $id
146     * @throws InvalidArgumentException
147     * @return Gadget
148     */
149    public function getGadget( string $id ): Gadget {
150        $key = $this->getGadgetCacheKey( $id );
151        $gadget = $this->wanCache->getWithSetCallback(
152            $key,
153            self::CACHE_TTL,
154            function ( $old, &$ttl, array &$setOpts ) use ( $id ) {
155                $setOpts += Database::getCacheSetOptions( $this->dbProvider->getReplicaDatabase() );
156                $title = $this->getGadgetDefinitionTitle( $id );
157                if ( !$title ) {
158                    $ttl = WANObjectCache::TTL_UNCACHEABLE;
159                    return null;
160                }
161
162                $revRecord = $this->revLookup->getRevisionByTitle( $title );
163                if ( !$revRecord ) {
164                    $ttl = WANObjectCache::TTL_UNCACHEABLE;
165                    return null;
166                }
167
168                $content = $revRecord->getContent( SlotRecord::MAIN );
169                if ( !$content instanceof GadgetDefinitionContent ) {
170                    // Uhm...
171                    $ttl = WANObjectCache::TTL_UNCACHEABLE;
172                    return null;
173                }
174
175                $handler = $content->getContentHandler();
176                '@phan-var \MediaWiki\Extension\Gadgets\Content\GadgetDefinitionContentHandler $handler';
177                $data = wfArrayPlus2d( $content->getAssocArray(), $handler->getDefaultMetadata() );
178                return Gadget::serializeDefinition( $id, $data );
179            },
180            [
181                'checkKeys' => [ $key ],
182                'pcTTL' => WANObjectCache::TTL_PROC_SHORT,
183                'lockTSE' => 30,
184                'version' => 2,
185            ]
186        );
187
188        if ( $gadget === null ) {
189            throw new InvalidArgumentException( "Unknown gadget $id" );
190        }
191
192        return new Gadget( $gadget );
193    }
194
195    /**
196     * Update the cache for a specific Gadget whenever it is updated
197     *
198     * @param string $id
199     */
200    public function purgeGadgetEntry( $id ) {
201        $this->wanCache->touchCheckKey( $this->getGadgetCacheKey( $id ) );
202    }
203
204    /**
205     * @return string
206     */
207    private function getGadgetIdsKey() {
208        return $this->wanCache->makeKey( 'gadgets-jsonrepo-ids' );
209    }
210
211    /**
212     * @param string $id
213     * @return string
214     */
215    private function getGadgetCacheKey( $id ) {
216        return $this->wanCache->makeKey( 'gadgets-object', $id, Gadget::GADGET_CLASS_VERSION );
217    }
218}