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 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    /**
125     * @param LinkTarget $target
126     * @return bool
127     */
128    public static function isGadgetDefinitionTitle( LinkTarget $target ): bool {
129        if ( !$target->inNamespace( NS_MEDIAWIKI ) ) {
130            return false;
131        }
132        $title = $target->getText();
133        try {
134            self::getGadgetId( $title );
135            return true;
136        } catch ( InvalidArgumentException $e ) {
137            return false;
138        }
139    }
140
141    /**
142     * @inheritDoc
143     */
144    public function getGadgetDefinitionTitle( string $id ): ?Title {
145        return Title::makeTitleSafe( NS_MEDIAWIKI, self::DEF_PREFIX . $id . self::DEF_SUFFIX );
146    }
147
148    /**
149     * @param string $id
150     * @throws InvalidArgumentException
151     * @return Gadget
152     */
153    public function getGadget( string $id ): Gadget {
154        $key = $this->getGadgetCacheKey( $id );
155        $gadget = $this->wanCache->getWithSetCallback(
156            $key,
157            self::CACHE_TTL,
158            function ( $old, &$ttl, array &$setOpts ) use ( $id ) {
159                $setOpts += Database::getCacheSetOptions( $this->dbProvider->getReplicaDatabase() );
160                $title = $this->getGadgetDefinitionTitle( $id );
161                if ( !$title ) {
162                    $ttl = WANObjectCache::TTL_UNCACHEABLE;
163                    return null;
164                }
165
166                $revRecord = $this->revLookup->getRevisionByTitle( $title );
167                if ( !$revRecord ) {
168                    $ttl = WANObjectCache::TTL_UNCACHEABLE;
169                    return null;
170                }
171
172                $content = $revRecord->getContent( SlotRecord::MAIN );
173                if ( !$content instanceof GadgetDefinitionContent ) {
174                    // Uhm...
175                    $ttl = WANObjectCache::TTL_UNCACHEABLE;
176                    return null;
177                }
178
179                $handler = $content->getContentHandler();
180                '@phan-var \MediaWiki\Extension\Gadgets\Content\GadgetDefinitionContentHandler $handler';
181                $data = wfArrayPlus2d( $content->getAssocArray(), $handler->getDefaultMetadata() );
182                return Gadget::serializeDefinition( $id, $data );
183            },
184            [
185                'checkKeys' => [ $key ],
186                'pcTTL' => WANObjectCache::TTL_PROC_SHORT,
187                'lockTSE' => 30,
188                'version' => 2,
189            ]
190        );
191
192        if ( $gadget === null ) {
193            throw new InvalidArgumentException( "Unknown gadget $id" );
194        }
195
196        return new Gadget( $gadget );
197    }
198
199    /**
200     * Update the cache for a specific Gadget whenever it is updated
201     *
202     * @param string $id
203     */
204    public function purgeGadgetEntry( $id ) {
205        $this->wanCache->touchCheckKey( $this->getGadgetCacheKey( $id ) );
206    }
207
208    /**
209     * @return string
210     */
211    private function getGadgetIdsKey() {
212        return $this->wanCache->makeKey( 'gadgets-jsonrepo-ids' );
213    }
214
215    /**
216     * @param string $id
217     * @return string
218     */
219    private function getGadgetCacheKey( $id ) {
220        return $this->wanCache->makeKey( 'gadgets-object', $id, Gadget::GADGET_CLASS_VERSION );
221    }
222}