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