Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
57.07% covered (warning)
57.07%
109 / 191
12.50% covered (danger)
12.50%
1 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
57.07% covered (warning)
57.07%
109 / 191
12.50% covered (danger)
12.50%
1 / 8
197.20
0.00% covered (danger)
0.00%
0 / 1
 registerExtension
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 onLoadExtensionSchemaUpdates
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
30
 getDataPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createInitialContent
77.50% covered (warning)
77.50%
31 / 40
0.00% covered (danger)
0.00%
0 / 1
7.56
 insertContentObject
83.93% covered (warning)
83.93%
47 / 56
0.00% covered (danger)
0.00%
0 / 1
15.93
 initializeZObjectJoinTable
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 updateSecondaryTables
66.67% covered (warning)
66.67%
24 / 36
0.00% covered (danger)
0.00%
0 / 1
13.70
 ensureZObjectStoreIsPresent
6.25% covered (danger)
6.25%
1 / 16
0.00% covered (danger)
0.00%
0 / 1
5.30
1<?php
2/**
3 * WikiLambda extension hooks
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\CommentStore\CommentStoreComment;
14use MediaWiki\Config\ConfigException;
15use MediaWiki\Deferred\DeferredUpdates;
16use MediaWiki\Extension\WikiLambda\Registry\ZLangRegistry;
17use MediaWiki\Extension\WikiLambda\Registry\ZTypeRegistry;
18use MediaWiki\Installer\DatabaseUpdater;
19use MediaWiki\Logger\LoggerFactory;
20use MediaWiki\MediaWikiServices;
21use MediaWiki\Revision\SlotRecord;
22use MediaWiki\Title\Title;
23use MediaWiki\User\User;
24use RecentChange;
25use RuntimeException;
26use Wikimedia\Services\NoSuchServiceException;
27
28class Hooks implements \MediaWiki\Installer\Hook\LoadExtensionSchemaUpdatesHook {
29
30    public static function registerExtension() {
31        // We define the content model regardless of if 'repo mode' is enabled
32        require_once __DIR__ . '/defines.php';
33
34        // Can't use MediaWikiServices or config objects yet, so use globals
35        global $wgWikiLambdaEnableRepoMode;
36        if ( !$wgWikiLambdaEnableRepoMode ) {
37            // Nothing for us to do.
38            return;
39        }
40
41        global $wgNamespaceContentModels;
42        $wgNamespaceContentModels[ NS_MAIN ] = CONTENT_MODEL_ZOBJECT;
43
44        global $wgNamespaceProtection;
45        $wgNamespaceProtection[ NS_MAIN ] = [ 'wikilambda-edit', 'wikilambda-create' ];
46
47        // (T267232) Prevent ZObject pages from being transcluded; sadly this isn't available as
48        // an extension.json attribute as of yet.
49        global $wgNonincludableNamespaces;
50        $wgNonincludableNamespaces[] = NS_MAIN;
51    }
52
53    /**
54     * @see https://www.mediawiki.org/wiki/Manual:Hooks/LoadExtensionSchemaUpdates
55     *
56     * @param DatabaseUpdater $updater DatabaseUpdater subclass
57     */
58    public function onLoadExtensionSchemaUpdates( $updater ) {
59        $config = MediaWikiServices::getInstance()->getMainConfig();
60        // Special form of the check to call has() first, as we'll likely be in pre-extension registry mode
61        if ( !$config->has( 'WikiLambdaEnableRepoMode' ) || !$config->get( 'WikiLambdaEnableRepoMode' ) ) {
62            // Nothing for us to do.
63            return;
64        }
65
66        $db = $updater->getDB();
67        $type = $db->getType();
68        $dir = __DIR__ . '/../sql';
69
70        if ( !in_array( $type, [ 'mysql', 'sqlite', 'postgres' ] ) ) {
71            wfWarn( "Database type '$type' is not supported by the WikiLambda extension." );
72            return;
73        }
74
75        $tables = [
76            'zobject_labels',
77            'zobject_label_conflicts',
78            'zobject_function_join',
79            'ztester_results',
80            'zlanguages',
81            'zobject_join'
82        ];
83
84        foreach ( $tables as $key => $table ) {
85            $updater->addExtensionTable( 'wikilambda_' . $table, "$dir/$type/table-$table.sql" );
86        }
87
88        // Database updates:
89        // (T285368) Add primary label field to labels table
90        $updater->addExtensionField(
91            'wikilambda_zobject_labels',
92            'wlzl_label_primary',
93            "$dir/$type/patch-add-primary-label-field.sql"
94        );
95
96        // (T262089) Add return type field to labels table
97        $updater->addExtensionField(
98            'wikilambda_zobject_labels',
99            'wlzl_return_type',
100            "$dir/$type/patch-add-return-type-field.sql"
101        );
102
103        $updater->addExtensionUpdate( [ [ self::class, 'createInitialContent' ] ] );
104        $updater->addExtensionUpdate( [    [ self::class, 'initializeZObjectJoinTable' ] ] );
105    }
106
107    /**
108     * Return path of data definition JSON files.
109     *
110     * @return string
111     */
112    protected static function getDataPath() {
113        return dirname( __DIR__ ) . '/function-schemata/data/definitions/';
114    }
115
116    /**
117     * Installer/Updater callback to create the initial "system" ZObjects on any installation. This
118     * is a callback so that it runs after the tables have been created/updated.
119     *
120     * @param DatabaseUpdater $updater
121     * @param bool $overwrite If true, overwrites the content, else skips if present
122     */
123    public static function createInitialContent( DatabaseUpdater $updater, $overwrite = false ) {
124        // Ensure that the extension is set up (namespace is defined) even when running in update.php outside of MW.
125        self::registerExtension();
126
127        $config = MediaWikiServices::getInstance()->getMainConfig();
128        if ( !$config->get( 'WikiLambdaEnableRepoMode' ) ) {
129            // Nothing for us to do.
130            return;
131        }
132
133        $contentHandler = MediaWikiServices::getInstance()->getContentHandlerFactory();
134        if ( !$contentHandler->isDefinedModel( CONTENT_MODEL_ZOBJECT ) ) {
135            if ( method_exists( $contentHandler, 'defineContentHandler' ) ) {
136                // @phan-suppress-next-line PhanUndeclaredMethod this apparently phan doesn't take the hint above
137                $contentHandler->defineContentHandler( CONTENT_MODEL_ZOBJECT, ZObjectContentHandler::class );
138            } else {
139                throw new ConfigException( 'WikiLambda content model is not registered and we cannot inject it.' );
140            }
141        }
142
143        // Note: Hard-coding the English version for messages as this can run without a Context and so no language set.
144        $creatingUserName = wfMessage( 'wikilambda-systemuser' )->inLanguage( 'en' )->text();
145        $creatingUser = User::newSystemUser( $creatingUserName, [ 'steal' => true ] );
146        // We use wikilambda-bootstrapupdatingeditsummary in maintenance scripts when updating.
147        $creatingComment = wfMessage( 'wikilambda-bootstrapcreationeditsummary' )->inLanguage( 'en' )->text();
148
149        if ( !$creatingUser ) {
150            // Something went wrong, give up.
151            return;
152        }
153
154        $initialDataToLoadPath = static::getDataPath();
155
156        $dependenciesFile = file_get_contents( $initialDataToLoadPath . 'dependencies.json' );
157        if ( $dependenciesFile === false ) {
158            throw new RuntimeException(
159                'Could not load dependencies file from function-schemata sub-repository of the WikiLambda extension.'
160                    . ' Have you initiated & fetched it? Try `git submodule update --init --recursive`.'
161            );
162        }
163        $dependencies = json_decode( $dependenciesFile, true );
164
165        $initialDataToLoadListing = array_filter(
166            scandir( $initialDataToLoadPath ),
167            static function ( $key ) {
168                return (bool)preg_match( '/^Z\d+\.json$/', $key );
169            }
170        );
171
172        // Naturally sort, so Z2/Persistent Object gets created before others
173        natsort( $initialDataToLoadListing );
174
175        $inserted = [];
176        foreach ( $initialDataToLoadListing as $filename ) {
177            static::insertContentObject(
178                $updater,
179                $filename,
180                $dependencies,
181                $creatingUser,
182                $creatingComment,
183                $overwrite,
184                $inserted
185            );
186        }
187    }
188
189    /**
190     * Inserts into the database the ZObject found in a given filename of the data directory. First checks
191     * whether the ZObject has any dependencies, according to the dependencies.json manifest file, and if so,
192     * inserts all the dependencies before trying the current ZObject.
193     *
194     * Runs in a static context and so can't be part of the normal code in ZObjectStore.
195     *
196     * @param DatabaseUpdater $updater
197     * @param string $filename
198     * @param array $dependencies
199     * @param User $user
200     * @param string $comment
201     * @param bool $overwrite
202     * @param string[] &$inserted
203     * @param string[] $track
204     * @return bool Has successfully inserted the content object
205     */
206    protected static function insertContentObject(
207        $updater, $filename, $dependencies, $user, $comment, $overwrite = false, &$inserted = [], $track = []
208    ) {
209        $initialDataToLoadPath = static::getDataPath();
210        $updateRowName = "create WikiLambda initial content - $filename";
211
212        $langReg = ZLangRegistry::singleton();
213        $typeReg = ZTypeRegistry::singleton();
214
215        // Check dependencies
216        $zid = substr( $filename, 0, -5 );
217
218        if ( array_key_exists( $zid, $dependencies ) ) {
219            $deps = $dependencies[ $zid ];
220            foreach ( $deps as $dep ) {
221                if (
222                    // Avoid circular dependencies
223                    !in_array( $dep, $track )
224                    && !$langReg->isZidCached( $dep )
225                    && !$typeReg->isZidCached( $dep )
226                ) {
227                    // Call recursively till all dependencies have been added
228                    $success = self::insertContentObject(
229                        $updater,
230                        "$dep.json",
231                        $dependencies,
232                        $user,
233                        $comment,
234                        $overwrite,
235                        $inserted,
236                        array_merge( $track, (array)$dep )
237                    );
238                    // If any of the dependencies fail, we desist on the current insertion
239                    if ( !$success ) {
240                        return false;
241                    }
242                }
243            }
244        }
245
246        $zid = substr( $filename, 0, -5 );
247        $title = Title::newFromText( $zid, NS_MAIN );
248        $services = MediaWikiServices::getInstance();
249        $page = $services->getWikiPageFactory()->newFromTitle( $title );
250
251        // If we don't want to overwrite the ZObjects, and if Zid has already been inserted,
252        // just purge the page to update secondary data and return true
253        if (
254            ( $overwrite && in_array( $zid, $inserted ) ) ||
255            ( !$overwrite && $updater->updateRowExists( $updateRowName ) )
256        ) {
257            $page->doPurge();
258            return true;
259        }
260
261        $data = file_get_contents( $initialDataToLoadPath . $filename );
262        if ( !$data ) {
263            // something went wrong, give up.
264            $updater->output( "\t❌ Unable to load file contents for {$title->getPrefixedText()}.\n" );
265            return false;
266        }
267
268        try {
269            $content = ZObjectContentHandler::makeContent( $data, $title );
270        } catch ( ZErrorException $e ) {
271            $updater->output( "\t❌ Unable to make a ZObject for {$title->getPrefixedText()}.\n" );
272            return false;
273        }
274
275        static::ensureZObjectStoreIsPresent( $services );
276
277        $pageUpdater = $page->newPageUpdater( $user );
278        $pageUpdater->setContent( SlotRecord::MAIN, $content );
279        $pageUpdater->setRcPatrolStatus( RecentChange::PRC_PATROLLED );
280
281        $pageUpdater->saveRevision(
282            CommentStoreComment::newUnsavedComment( $comment ?? '' ),
283            EDIT_AUTOSUMMARY | EDIT_NEW
284        );
285
286        if ( $pageUpdater->wasSuccessful() ) {
287            array_push( $inserted, $zid );
288            $updater->insertUpdateRow( $updateRowName );
289            if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
290                // Don't log this during unit testing, quibble thinks it means we're broken.
291                $updater->output( "\tSuccessfully created {$title->getPrefixedText()}.\n" );
292            }
293        } else {
294            $firstError = $pageUpdater->getStatus()->getErrors()[0];
295            $error = wfMessage( $firstError[ 'message' ], $firstError[ 'params' ] );
296            $updater->output( "\t❌ Unable to make a page for {$title->getPrefixedText()}$error\n" );
297        }
298
299        return $pageUpdater->wasSuccessful();
300    }
301
302    /**
303     * Installer/Updater callback to ensure that wikilambda_zobject_join has been populated for all
304     * existing functions (Z8s). This is a callback so that it runs after the tables have been
305     * created/updated.  This function can be removed when we are confident that all WikiLambda
306     * installations have a fully populated wikilambda_zobject_join table.
307     *
308     * @param DatabaseUpdater $updater
309     */
310    public static function initializeZObjectJoinTable( DatabaseUpdater $updater ) {
311        $updateKey = 'Initialized wikilambda_zobject_join for Z8s';
312
313        if ( !$updater->updateRowExists( $updateKey ) ) {
314            static::updateSecondaryTables( $updater, 'Z8' );
315            $updater->insertUpdateRow( $updateKey );
316        } else {
317            $updater->output( "...wikilambda_zobject_join table already initialized\n" );
318        }
319    }
320
321    /**
322     * Ensures that secondary DB tables have been populated for ZObjects of the given zType.  For
323     * each such ZObject, a new instance of ZObjectSecondaryDataUpdate is created and added to
324     * DeferredUpdates.
325     *
326     * N.B. This function assumes that wikilambda_zobject_labels is fully populated; it calls
327     * fetchZidsOfType to get a list of ZObjects of the given zType.
328     *
329     * Note there is a WikiLambda maintenance script (updateSecondaryTables.php) that provides
330     * similar functionality (and with some code that duplicates what's here, which could not
331     * easily be avoided).
332     *
333     * @param DatabaseUpdater $updater
334     * @param string $zType The type of ZObject for which to do updates.
335     * @param bool $verbose If true, print the ZID of each ZObject for which updating is done
336     * (default = false)
337     * @param bool $dryRun If true, do nothing, just print the output statements
338     * (default = false)
339     */
340    public static function updateSecondaryTables(
341        $updater,
342        $zType,
343        $verbose = false,
344        $dryRun = false
345    ) {
346        $services = MediaWikiServices::getInstance();
347
348        $config = $services->getMainConfig();
349        if ( !$config->get( 'WikiLambdaEnableRepoMode' ) ) {
350            // Nothing for us to do.
351            return;
352        }
353
354        static::ensureZObjectStoreIsPresent( $services );
355        $zObjectStore = WikiLambdaServices::getZObjectStore();
356        $handler = new ZObjectContentHandler( CONTENT_MODEL_ZOBJECT );
357
358        $targets = $zObjectStore->fetchZidsOfType( $zType );
359
360        if ( count( $targets ) === 0 ) {
361            if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
362                // Don't output during unit testing; causes the test to be labeled as risky.
363                $updater->output( "No ZObjects of type " . $zType . " for which secondary tables need updating\n" );
364            }
365            return;
366        }
367
368        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
369            // Don't output during unit testing; causes the test to be labeled as risky.
370            if ( $dryRun ) {
371                $updater->output( "Would have updated" );
372            } else {
373                $updater->output( "Updating" );
374            }
375            $updater->output( " secondary tables for " . count( $targets ) . " ZObjects of type " .
376                $zType . "\n" );
377        }
378
379        $offset = 0;
380        $queryLimit = 10;
381        do {
382            $contents = $zObjectStore->fetchBatchZObjects( array_slice( $targets, $offset,
383                $queryLimit ) );
384            $offset += $queryLimit;
385
386            foreach ( $contents as $zid => $persistentObject ) {
387                if ( $verbose ) {
388                    $updater->output( "  $zid" );
389                }
390                if ( $dryRun ) {
391                    continue;
392                }
393                $title = Title::newFromText( $zid, NS_MAIN );
394                $data = json_encode( $persistentObject->getSerialized() );
395                $content = $handler::makeContent( $data, $title );
396                $update = new ZObjectSecondaryDataUpdate( $title, $content );
397                DeferredUpdates::addUpdate( $update );
398            }
399            if ( $verbose ) {
400                $updater->output( "\n" );
401            }
402
403        } while ( count( $targets ) - $offset > 0 );
404    }
405
406    /**
407     * Checks for the existence of WikiLambdaZObjectStore in $services, and creates it
408     * if needed.
409     *
410     * @param MediaWikiServices $services
411     */
412    private static function ensureZObjectStoreIsPresent( $services ) {
413        // If we're in the installer, it won't have registered our extension's services yet.
414        try {
415            $services->get( 'WikiLambdaZObjectStore' );
416        } catch ( NoSuchServiceException $e ) {
417
418            $zObjectStore = new ZObjectStore(
419                $services->getDBLoadBalancerFactory(),
420                $services->getTitleFactory(),
421                $services->getWikiPageFactory(),
422                $services->getRevisionStore(),
423                $services->getUserGroupManager(),
424                LoggerFactory::getInstance( 'WikiLambda' )
425            );
426            $services->defineService(
427                'WikiLambdaZObjectStore',
428                static function () use ( $zObjectStore ) {
429                    return $zObjectStore;
430                }
431            );
432        }
433    }
434}