(bug 1495) Enable on-wiki message language fallbacks
authorMatthew Walker <mwalker@wikimedia.org>
Wed, 16 Jan 2013 07:28:54 +0000 (23:28 -0800)
committerMwalker <mwalker@wikimedia.org>
Wed, 6 Mar 2013 19:54:02 +0000 (19:54 +0000)
The core function behind wfMessage() (MessageCache->get()) did not
apply the language fallback chain to on-wiki messages.

This patch has changed the behavior to iterate over all possible
on-wiki fallbacks (starting with the user's language) before
using the built-in language cache (CDB files). Previously we only looked
for the existence of an on-wiki message in the users's language.

Performance wise, using the 'ab' language ('ru', 'en' fallbacks)
MessageCache::get (Averaged over runs and calls)
New Code: ~8.5% TET (110us/call)
Old Code: ~6.5% TET ( 90us/call)

TET: Total Execution Time

Change-Id: Iaaf6ccebd8c40c9602748c58c3a5c73c29e7aa4d

includes/cache/MessageCache.php
languages/Language.php
tests/phpunit/includes/cache/MessageCacheTest.php [new file with mode: 0644]

index 89e5325..f24e8bc 100644 (file)
@@ -578,50 +578,70 @@ class MessageCache {
        }
 
        /**
-        * Get a message from either the content language or the user language.
+        * Get a message from either the content language or the user language. The fallback
+        * language order is the users language fallback union the content language fallback.
+        * This list is then applied to find keys in the following order
+        * 1) MediaWiki:$key/$langcode (for every language except the content language where
+        *    we look at MediaWiki:$key)
+        * 2) Built-in messages via the l10n cache which is also in fallback order
         *
         * @param $key String: the message cache key
-        * @param $useDB Boolean: get the message from the DB, false to use only
-        *               the localisation
-        * @param bool|string $langcode Code of the language to get the message for, if
-        *                  it is a valid code create a language for that language,
-        *                  if it is a string but not a valid code then make a basic
-        *                  language object, if it is a false boolean then use the
-        *                  current users language (as a fallback for the old
-        *                  parameter functionality), or if it is a true boolean
-        *                  then use the wikis content language (also as a
-        *                  fallback).
+        * @param $useDB Boolean: If true will look for the message in the DB, false only
+        *        get the message from the DB, false to use only the compiled l10n cache.
+        * @param bool|string|object $langcode Code of the language to get the message for.
+        *        - If string and a valid code, will create a standard language object
+        *        - If string but not a valid code, will create a basic language object
+        *        - If boolean and false, create object from the current users language
+        *        - If boolean and true, create object from the wikis content language
+        *        - If language object, use it as given
         * @param $isFullKey Boolean: specifies whether $key is a two part key
         *                   "msg/lang".
         *
         * @throws MWException
-        * @return string|bool
+        * @return string|bool False if the message doesn't exist, otherwise the message
         */
        function get( $key, $useDB = true, $langcode = true, $isFullKey = false ) {
                global $wgLanguageCode, $wgContLang;
 
+               wfProfileIn( __METHOD__ );
+
                if ( is_int( $key ) ) {
                        // "Non-string key given" exception sometimes happens for numerical strings that become ints somewhere on their way here
                        $key = strval( $key );
                }
 
                if ( !is_string( $key ) ) {
+                       wfProfileOut( __METHOD__ );
                        throw new MWException( 'Non-string key given' );
                }
 
                if ( strval( $key ) === '' ) {
                        # Shortcut: the empty key is always missing
+                       wfProfileOut( __METHOD__ );
                        return false;
                }
 
-               $lang = wfGetLangObj( $langcode );
-               if ( !$lang ) {
-                       throw new MWException( "Bad lang code $langcode given" );
-               }
 
-               $langcode = $lang->getCode();
+               # Obtain the initial language object
+               if ( $isFullKey ) {
+                       $keyParts = explode( '/', $key );
+                       if ( count( $keyParts ) < 2 ) {
+                               throw new MWException( "Message key '$key' does not appear to be a full key." );
+                       }
 
-               $message = false;
+                       $langcode = array_pop( $keyParts );
+                       $key = implode( '/', $keyParts );
+               }
+
+               # Obtain a language object for the requested language from the passed language code
+               # Note that the language code could in fact be a language object already but we assume
+               # it's a string further below.
+               $requestedLangObj = wfGetLangObj( $langcode );
+               if ( !$requestedLangObj ) {
+                       wfProfileOut( __METHOD__ );
+                       throw new MWException( "Bad lang code $langcode given" );
+               }
+               $langcode = $requestedLangObj->getCode();
 
                # Normalise title-case input (with some inlining)
                $lckey = str_replace( ' ', '_', $key );
@@ -633,24 +653,37 @@ class MessageCache {
                        $uckey = $wgContLang->ucfirst( $lckey );
                }
 
+               # Loop through each language in the fallback list until we find something useful
+               $message = false;
+
                # Try the MediaWiki namespace
-               if( !$this->mDisable && $useDB ) {
-                       $title = $uckey;
-                       if( !$isFullKey && ( $langcode != $wgLanguageCode ) ) {
-                               $title .= '/' . $langcode;
+               if ( !$this->mDisable && $useDB ) {
+                       $fallbackChain = Language::getFallbacksIncludingSiteLanguage( $langcode );
+                       array_unshift( $fallbackChain, $langcode );
+
+                       foreach ( $fallbackChain as $langcode ) {
+                               if ( $langcode === $wgLanguageCode ) {
+                                       # Messages created in the content language will not have the /lang extension
+                                       $message = $this->getMsgFromNamespace( $uckey, $langcode );
+                               } else {
+                                       $message = $this->getMsgFromNamespace( "$uckey/$langcode", $langcode );
+                               }
+
+                               if ( $message !== false ) {
+                                       break;
+                               }
                        }
-                       $message = $this->getMsgFromNamespace( $title, $langcode );
                }
 
                # Try the array in the language object
                if ( $message === false ) {
-                       $message = $lang->getMessage( $lckey );
-                       if ( is_null( $message ) ) {
+                       $message = $requestedLangObj->getMessage( $lckey );
+                       if ( is_null ( $message ) ) {
                                $message = false;
                        }
                }
 
-               # Try the array of another language
+               # If we still have no message, maybe the key was in fact a full key so try that
                if( $message === false ) {
                        $parts = explode( '/', $lckey );
                        # We may get calls for things that are http-urls from sidebar
@@ -664,15 +697,9 @@ class MessageCache {
                        }
                }
 
-               # Is this a custom message? Try the default language in the db...
-               if( ( $message === false || $message === '-' ) &&
-                       !$this->mDisable && $useDB &&
-                       !$isFullKey && ( $langcode != $wgLanguageCode ) ) {
-                       $message = $this->getMsgFromNamespace( $uckey, $wgLanguageCode );
-               }
-
                # Final fallback
                if( $message === false ) {
+                       wfProfileOut( __METHOD__ );
                        return false;
                }
 
@@ -686,6 +713,7 @@ class MessageCache {
                                '&#160;' => "\xc2\xa0",
                        ) );
 
+               wfProfileOut( __METHOD__ );
                return $message;
        }
 
index ef6a367..01751db 100644 (file)
@@ -3984,6 +3984,45 @@ class Language {
                }
        }
 
+       /**
+        * Get the ordered list of fallback languages, ending with the fallback
+        * language chain for the site language.
+        *
+        * @since 1.21
+        * @param $code string Language code
+        * @return array
+        */
+       public static function getFallbacksIncludingSiteLanguage( $code ) {
+               global $wgLanguageCode;
+
+               // Usually, we will only store a tiny number of fallback chains, so we
+               // keep them in static memory.
+               static $fallbackLanguageCache = array();
+               $cacheKey = "{$code}-{$wgLanguageCode}";
+
+               if ( !array_key_exists( $cacheKey, $fallbackLanguageCache ) ) {
+                       $fallbacks = self::getFallbacksFor( $code );
+
+                       // Take the final 'en' off of the array before splicing
+                       if ( end( $fallbacks ) === 'en' ) {
+                               array_pop( $fallbacks );
+                       }
+                       // Append the site's fallback chain
+                       $siteFallbacks = self::getFallbacksFor( $wgLanguageCode );
+
+                       // Eliminate any languages already included in the chain
+                       $siteFallbacks = array_intersect( array_diff( $siteFallbacks, $fallbacks ), $siteFallbacks );
+                       if ( $siteFallbacks ) {
+                               $fallbacks = array_merge( $fallbacks, $siteFallbacks );
+                       }
+                       if ( end( $fallbacks ) !== 'en' ) {
+                               $fallbacks[] = 'en';
+                       }
+                       $fallbackLanguageCache[$cacheKey] = $fallbacks;
+               }
+               return $fallbackLanguageCache[$cacheKey];
+       }
+
        /**
         * Get all messages for a given language
         * WARNING: this may take a long time. If you just need all message *keys*
diff --git a/tests/phpunit/includes/cache/MessageCacheTest.php b/tests/phpunit/includes/cache/MessageCacheTest.php
new file mode 100644 (file)
index 0000000..89021dd
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+
+/**
+ * @group Database
+ * @group Cache
+ */
+class MessageCacheTest extends MediaWikiLangTestCase {
+
+       protected function setUp() {
+               parent::setUp();
+               MessageCache::singleton()->enable();
+       }
+
+       function addDBData() {
+               // Set up messages and fallbacks ab -> ru -> en
+               $this->makePage( 'FallbackLanguageTest-Full', 'ab' );
+               $this->makePage( 'FallbackLanguageTest-Full', 'ru' );
+               $this->makePage( 'FallbackLanguageTest-Full', 'en' );
+
+               // Fallbacks where ab does not exist
+               $this->makePage( 'FallbackLanguageTest-Partial', 'ru' );
+               $this->makePage( 'FallbackLanguageTest-Partial', 'en' );
+
+               // Fallback to english
+               $this->makePage( 'FallbackLanguageTest-English', 'en' );
+
+               // Full key tests -- always want russian
+               $this->makePage( 'MessageCacheTest-FullKeyTest', 'ab' );
+               $this->makePage( 'MessageCacheTest-FullKeyTest', 'ru' );
+       }
+
+       /**
+        * Helper function for addDBData -- adds a simple page to the database
+        *
+        * @param string $title Title of page to be created
+        * @param string $lang  Language and content of the created page
+        */
+       protected function makePage( $title, $lang ) {
+               global $wgContLang;
+
+               $title = Title::newFromText(
+                       ($lang == $wgContLang->getCode()) ? $title : "$title/$lang",
+                       NS_MEDIAWIKI
+               );
+               $wikiPage = new WikiPage( $title );
+               $content = ContentHandler::makeContent( $lang, $title );
+               $wikiPage->doEditContent( $content, "$lang translation test case" );
+       }
+
+       /**
+        * Test message fallbacks, bug #1495
+        *
+        * @dataProvider provideMessagesForFallback
+        */
+       function testMessageFallbacks( $message, $lang, $expectedContent ) {
+               $result = MessageCache::singleton()->get( $message, true, $lang );
+               $this->assertEquals( $expectedContent, $result, "Message fallback failed." );
+       }
+
+       function provideMessagesForFallback() {
+               return array(
+                       array( 'FallbackLanguageTest-Full', 'ab', 'ab' ),
+                       array( 'FallbackLanguageTest-Partial', 'ab', 'ru' ),
+                       array( 'FallbackLanguageTest-English', 'ab', 'en' ),
+                       array( 'FallbackLanguageTest-None', 'ab', false ),
+               );
+       }
+
+       /**
+        * There's a fallback case where the message key is given as fully qualified -- this
+        * should ignore the passed $lang and use the language from the key
+        *
+        * @dataProvider provideMessagesForFullKeys
+        */
+       function testFullKeyBehaviour( $message, $lang, $expectedContent ) {
+               $result = MessageCache::singleton()->get( $message, true, $lang, true );
+               $this->assertEquals( $expectedContent, $result, "Full key message fallback failed." );
+       }
+
+       function provideMessagesForFullKeys() {
+               return array(
+                       array( 'MessageCacheTest-FullKeyTest/ru', 'ru', 'ru' ),
+                       array( 'MessageCacheTest-FullKeyTest/ru', 'ab', 'ru' ),
+                       array( 'MessageCacheTest-FullKeyTest/ru/foo', 'ru', false ),
+               );
+       }
+
+}