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