Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
93 / 93
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
WikifunctionsClientStore
100.00% covered (success)
100.00%
93 / 93
100.00% covered (success)
100.00%
7 / 7
20
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 insertWikifunctionsUsage
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 fetchWikifunctionsUsage
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 deleteWikifunctionsUsage
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 fetchFromZObjectCache
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 makeFunctionCallCacheKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 fetchFromFunctionCallCache
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
1 / 1
12
1<?php
2/**
3 * WikiLambda Data Access Object service
4 *
5 * @file
6 * @ingroup Extensions
7 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
8 * @license MIT
9 */
10
11namespace MediaWiki\Extension\WikiLambda;
12
13use MediaWiki\Extension\WikiLambda\Cache\MemcachedWrapper;
14use MediaWiki\Logger\LoggerFactory;
15use MediaWiki\Title\Title;
16use Psr\Log\LoggerInterface;
17use Wikimedia\Rdbms\IConnectionProvider;
18
19class WikifunctionsClientStore {
20
21    private IConnectionProvider $dbProvider;
22    private MemcachedWrapper $objectCache;
23
24    private LoggerInterface $logger;
25
26    public const CLIENT_FUNCTIONCALL_CACHE_KEY_PREFIX = 'WikiLambdaClientFunctionCall';
27
28    /**
29     * @param IConnectionProvider $dbProvider
30     */
31    public function __construct( IConnectionProvider $dbProvider ) {
32        $this->dbProvider = $dbProvider;
33
34        // This can't be injected, as the service container runs before the extension is loaded
35        $this->objectCache = WikiLambdaServices::getMemcachedWrapper();
36
37        $this->logger = LoggerFactory::getInstance( 'WikiLambdaClient' );
38    }
39
40    /**
41     * Track in wikifunctionsclient_usage the usage of a function on a page.
42     *
43     * NOTE: wfcu_targetPage is stored as getPrefixedText() (e.g. "Template:Foo bar").
44     * All readers and writers of this column must use the same representation:
45     * deleteWikifunctionsUsage() below, and WikifunctionsRecentChangesInsertJob
46     * which reconstructs a Title via Title::newFromText().
47     *
48     * @param string $targetFunction
49     * @param Title $targetPage
50     * @return bool
51     */
52    public function insertWikifunctionsUsage( string $targetFunction, Title $targetPage ): bool {
53        $dbw = $this->dbProvider->getPrimaryDatabase();
54
55        $dbw->newInsertQueryBuilder()
56            ->insertInto( 'wikifunctionsclient_usage' )
57            ->row( [
58                'wfcu_targetPage' => $targetPage->getPrefixedText(),
59                'wfcu_targetFunction' => $targetFunction,
60            ] )
61            ->set( [
62                'wfcu_targetFunction' => $targetFunction,
63            ] )
64            ->onDuplicateKeyUpdate()
65            ->uniqueIndexFields( [
66                'wfcu_targetPage',
67                'wfcu_targetFunction',
68            ] )
69            // We don't mind duplicates (i.e., the same Function is used twice on the same page)
70            ->ignore()
71            ->caller( __METHOD__ )->execute();
72
73        return (bool)$dbw->affectedRows();
74    }
75
76    /**
77     * Check in wikifunctionsclient_usage the pages on which a function is used.
78     *
79     * @param string $targetFunction
80     * @return array
81     */
82    public function fetchWikifunctionsUsage( string $targetFunction ): array {
83        $dbr = $this->dbProvider->getReplicaDatabase();
84        return $dbr->newSelectQueryBuilder()
85            ->select( 'wfcu_targetPage' )
86            ->from( 'wikifunctionsclient_usage' )
87            ->where( [ 'wfcu_targetFunction' => $targetFunction ] )
88            ->caller( __METHOD__ )
89            ->fetchFieldValues();
90    }
91
92    /**
93     * Drop tracking in wikifunctionsclient_usage of a page.
94     *
95     * NOTE: Must match the representation used by insertWikifunctionsUsage() — see the
96     * note on that method for the full list of readers/writers of wfcu_targetPage.
97     *
98     * @param Title $targetPage
99     * @return void
100     */
101    public function deleteWikifunctionsUsage( Title $targetPage ): void {
102        $dbw = $this->dbProvider->getPrimaryDatabase();
103
104        $dbw->newDeleteQueryBuilder()
105            ->deleteFrom( 'wikifunctionsclient_usage' )
106            ->where( [ 'wfcu_targetPage' => $targetPage->getPrefixedText() ] )
107            ->caller( __METHOD__ )->execute();
108    }
109
110    /**
111     * Requests the given ZObject from the ZObject cache, given its ZID.
112     * Returns null if the ZID is not available in the cache.
113     *
114     * This is the same as the first part of the ZObjectStore::fetchZObject() method, but without the
115     * repo-mode follow-up for reading from the wiki, as it's for client wikis.
116     *
117     * @param string $zid
118     * @return ?array
119     */
120    public function fetchFromZObjectCache( string $zid ): ?array {
121        $cacheKey = $this->objectCache->makeKey( ZObjectStore::ZOBJECT_CACHE_KEY_PREFIX, $zid );
122
123        $cachedObject = $this->objectCache->get( $cacheKey );
124        if ( !$cachedObject ) {
125            $this->logger->info( __METHOD__ . ' cache miss while fetching {zid}', [ 'zid' => $zid ] );
126            return null;
127        }
128
129        $json = json_decode( $cachedObject, true );
130        if ( !$json ) {
131            $this->logger->warning( __METHOD__ . ' failed parse of cached JSON for {zid}', [ 'zid' => $zid ] );
132        }
133
134        // Return successfully parsed JSON, or null
135        return $json;
136    }
137
138    /**
139     * Requests the given Function call from the ZObject cache, given its cache key.
140     * Returns null if the Function call is not available in the cache.
141     *
142     * @param array $functionCall
143     * @return string
144     */
145    public function makeFunctionCallCacheKey( array $functionCall ): string {
146        // Note that we can't use ZObjectUtils::makeCacheKeyFromZObject here, as that's repo-mode only.
147        // This means that this cache key doesn't have the revision IDs of the referenced ZObjects.
148        return $this->objectCache->makeKey(
149            self::CLIENT_FUNCTIONCALL_CACHE_KEY_PREFIX,
150            json_encode( $functionCall )
151        );
152    }
153
154    /**
155     * Fetch a Function call result from the cache, given its cache key, or delete it
156     * if not properly set.
157     *
158     * @param string $clientCacheKey
159     * @return ?array{success:bool, value:?string, type:?string, errorMessageKey:?string}
160     */
161    public function fetchFromFunctionCallCache( string $clientCacheKey ): ?array {
162        $cachedValue = $this->objectCache->get( $clientCacheKey );
163
164        if ( !$cachedValue ) {
165            $this->logger->info( __METHOD__ . ' cache miss while fetching {key}', [ 'key' => $clientCacheKey ] );
166            return null;
167        }
168
169        // Check for corrupted/invalid cache entries and delete them rather than returning them
170        if ( !is_array( $cachedValue ) ) {
171            $this->logger->warning(
172                'WikiLambda client cache entry for {key} is mal-formed, deleting it',
173                [
174                    'key' => $clientCacheKey
175                ]
176            );
177            $this->objectCache->delete( $clientCacheKey );
178            return null;
179        }
180
181        if ( !array_key_exists( 'success', $cachedValue ) || !is_bool( $cachedValue['success'] ) ) {
182            // Corrupted/invalid cache entry; delete it
183            $this->logger->warning(
184                'WikiLambda client cache entry for {key} is missing success boolean, deleting it',
185                [
186                    'key' => $clientCacheKey
187                ]
188            );
189            $this->objectCache->delete( $clientCacheKey );
190            return null;
191        }
192
193        if ( $cachedValue['success'] ) {
194            if (
195                !array_key_exists( 'value', $cachedValue ) ||
196                !array_key_exists( 'type', $cachedValue ) ||
197                !is_string( $cachedValue['value'] ) ||
198                !is_string( $cachedValue['type'] )
199            ) {
200                // Corrupted/invalid cache entry; delete it
201                $this->logger->warning(
202                    'WikiLambda client cache entry for {key} is missing value or type, deleting it',
203                    [
204                        'key' => $clientCacheKey
205                    ]
206                );
207                $this->objectCache->delete( $clientCacheKey );
208                return null;
209            }
210            return $cachedValue;
211        }
212
213        // We know the success key is false, so we need to check the error message key
214
215        if ( !array_key_exists( 'errorMessageKey', $cachedValue ) || !is_string( $cachedValue['errorMessageKey'] ) ) {
216            $this->logger->warning(
217                'WikiLambda client cache entry for {key} is missing error message key string, deleting it',
218                [
219                    'key' => $clientCacheKey
220                ]
221            );
222            $this->objectCache->delete( $clientCacheKey );
223            return null;
224        }
225
226        return $cachedValue;
227    }
228}