Merge "Fix PhanPluginDuplicateExpressionBinaryOp in DjVuImage (#9)"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Sun, 7 Apr 2019 09:13:09 +0000 (09:13 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Sun, 7 Apr 2019 09:13:09 +0000 (09:13 +0000)
29 files changed:
.phpcs.xml
autoload.php
includes/Title.php
includes/debug/DeprecationHelper.php
includes/export/WikiExporter.php
includes/gallery/PackedHoverImageGallery.php [new file with mode: 0644]
includes/gallery/PackedOverlayImageGallery.php
includes/jobqueue/JobQueueMemory.php
includes/jobqueue/jobs/DeletePageJob.php
includes/jobqueue/jobs/UserGroupExpiryJob.php
includes/libs/rdbms/database/DBConnRef.php
includes/libs/rdbms/database/Database.php
includes/libs/rdbms/database/IDatabase.php
includes/libs/rdbms/database/MaintainableDBConnRef.php
includes/libs/rdbms/exception/DBReadOnlyRoleError.php [new file with mode: 0644]
includes/libs/rdbms/loadbalancer/LoadBalancer.php
includes/resourceloader/ResourceLoaderFileModule.php
includes/resourceloader/ResourceLoaderImage.php
includes/resourceloader/ResourceLoaderOOUIFileModule.php
includes/resourceloader/ResourceLoaderOOUIModule.php
includes/resourceloader/ResourceLoaderSkinModule.php
includes/resourceloader/ResourceLoaderWikiModule.php
languages/messages/MessagesEn.php
resources/src/mediawiki.Title/.eslintrc.json [new file with mode: 0644]
resources/src/mediawiki.Title/Title.js
tests/phpunit/MediaWikiLoggerPHPUnitTestListener.php
tests/phpunit/includes/db/LoadBalancerTest.php
tests/phpunit/includes/libs/rdbms/database/DBConnRefTest.php
tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php

index 9939c54..170e16d 100644 (file)
                <exclude-pattern>*/includes/diff/DairikiDiff\.php</exclude-pattern>
                <exclude-pattern>*/includes/Feed\.php</exclude-pattern>
                <exclude-pattern>*/includes/filerepo/file/LocalFile\.php</exclude-pattern>
-               <exclude-pattern>*/includes/gallery/PackedOverlayImageGallery\.php</exclude-pattern>
                <exclude-pattern>*/includes/htmlform/HTMLFormElement\.php</exclude-pattern>
                <exclude-pattern>*/includes/libs/filebackend/FileBackendStore\.php</exclude-pattern>
                <exclude-pattern>*/includes/libs/filebackend/FSFileBackend\.php</exclude-pattern>
index bb1b3b2..b22aeab 100644 (file)
@@ -1070,7 +1070,7 @@ $wgAutoloadLocalClasses = [
        'PPNode_Hash_Tree' => __DIR__ . '/includes/parser/Preprocessor_Hash.php',
        'PPTemplateFrame_DOM' => __DIR__ . '/includes/parser/Preprocessor_DOM.php',
        'PPTemplateFrame_Hash' => __DIR__ . '/includes/parser/Preprocessor_Hash.php',
-       'PackedHoverImageGallery' => __DIR__ . '/includes/gallery/PackedOverlayImageGallery.php',
+       'PackedHoverImageGallery' => __DIR__ . '/includes/gallery/PackedHoverImageGallery.php',
        'PackedImageGallery' => __DIR__ . '/includes/gallery/PackedImageGallery.php',
        'PackedOverlayImageGallery' => __DIR__ . '/includes/gallery/PackedOverlayImageGallery.php',
        'Page' => __DIR__ . '/includes/page/Page.php',
@@ -1636,6 +1636,7 @@ $wgAutoloadLocalClasses = [
        'Wikimedia\\Rdbms\\DBQueryError' => __DIR__ . '/includes/libs/rdbms/exception/DBQueryError.php',
        'Wikimedia\\Rdbms\\DBQueryTimeoutError' => __DIR__ . '/includes/libs/rdbms/exception/DBQueryTimeoutError.php',
        'Wikimedia\\Rdbms\\DBReadOnlyError' => __DIR__ . '/includes/libs/rdbms/exception/DBReadOnlyError.php',
+       'Wikimedia\\Rdbms\\DBReadOnlyRoleError' => __DIR__ . '/includes/libs/rdbms/exception/DBReadOnlyRoleError.php',
        'Wikimedia\\Rdbms\\DBReplicationWaitError' => __DIR__ . '/includes/libs/rdbms/exception/DBReplicationWaitError.php',
        'Wikimedia\\Rdbms\\DBTransactionError' => __DIR__ . '/includes/libs/rdbms/exception/DBTransactionError.php',
        'Wikimedia\\Rdbms\\DBTransactionSizeError' => __DIR__ . '/includes/libs/rdbms/exception/DBTransactionSizeError.php',
index 3d54750..e4f18fa 100644 (file)
@@ -58,6 +58,8 @@ class Title implements LinkTarget, IDBAccessObject {
         * Flag for use with factory methods like newFromLinkTarget() that have
         * a $forceClone parameter. If set, the method must return a new instance.
         * Without this flag, some factory methods may return existing instances.
+        *
+        * @since 1.33
         */
        const NEW_CLONE = 'clone';
 
index cd78005..91ad67e 100644 (file)
@@ -68,7 +68,7 @@ trait DeprecationHelper {
        protected function deprecatePublicProperty(
                $property, $version, $class = null, $component = null
        ) {
-               $this->deprecatedPublicProperties[$property] = [ $version, $class ?: get_class(), $component ];
+               $this->deprecatedPublicProperties[$property] = [ $version, $class ?: __CLASS__, $component ];
        }
 
        public function __get( $name ) {
@@ -79,7 +79,7 @@ trait DeprecationHelper {
                        return $this->$name;
                }
 
-               $qualifiedName = get_class() . '::$' . $name;
+               $qualifiedName = __CLASS__ . '::$' . $name;
                if ( $this->deprecationHelperGetPropertyOwner( $name ) ) {
                        // Someone tried to access a normal non-public property. Try to behave like PHP would.
                        trigger_error( "Cannot access non-public property $qualifiedName", E_USER_ERROR );
@@ -99,7 +99,7 @@ trait DeprecationHelper {
                        return;
                }
 
-               $qualifiedName = get_class() . '::$' . $name;
+               $qualifiedName = __CLASS__ . '::$' . $name;
                if ( $this->deprecationHelperGetPropertyOwner( $name ) ) {
                        // Someone tried to access a normal non-public property. Try to behave like PHP would.
                        trigger_error( "Cannot access non-public property $qualifiedName", E_USER_ERROR );
index 120632c..e02cd83 100644 (file)
@@ -469,8 +469,8 @@ class WikiExporter {
        protected function outputPageStreamBatch( $results, $lastRow ) {
                foreach ( $results as $row ) {
                        if ( $lastRow === null ||
-                               $lastRow->page_namespace != $row->page_namespace ||
-                               $lastRow->page_title != $row->page_title ) {
+                               $lastRow->page_namespace !== $row->page_namespace ||
+                               $lastRow->page_title !== $row->page_title ) {
                                if ( $lastRow !== null ) {
                                        $output = '';
                                        if ( $this->dumpUploads ) {
diff --git a/includes/gallery/PackedHoverImageGallery.php b/includes/gallery/PackedHoverImageGallery.php
new file mode 100644 (file)
index 0000000..2e1ef7d
--- /dev/null
@@ -0,0 +1,28 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * Same as Packed except different CSS is applied to make the
+ * caption only show up on hover. If a touch screen is detected,
+ * falls back to PackedHoverGallery. Degrades gracefully for
+ * screen readers.
+ */
+class PackedHoverImageGallery extends PackedOverlayImageGallery {
+}
index 0a5a457..4c72f87 100644 (file)
@@ -53,12 +53,3 @@ class PackedOverlayImageGallery extends PackedImageGallery {
                        . "\n\t\t\t</div></div>";
        }
 }
-
-/**
- * Same as Packed except different CSS is applied to make the
- * caption only show up on hover. If a touch screen is detected,
- * falls back to PackedHoverGallery. Degrades gracefully for
- * screen readers.
- */
-class PackedHoverImageGallery extends PackedOverlayImageGallery {
-}
index b6c4005..cbcd4fb 100644 (file)
@@ -151,7 +151,7 @@ class JobQueueMemory extends JobQueue {
                }
 
                $claimed =& $this->getQueueData( 'claimed' );
-               $job->setMetadata( 'claimId', null );
+               unset( $claimed[$job->getMetadata( 'claimId' )] );
        }
 
        /**
index 9b5cef4..e6dfae4 100644 (file)
@@ -4,7 +4,7 @@
  * Class DeletePageJob
  */
 class DeletePageJob extends Job {
-       public function __construct( $title, $params ) {
+       public function __construct( $title, $params = [] ) {
                parent::__construct( 'deletePage', $title, $params );
        }
 
index 0945e58..bd0df5b 100644 (file)
@@ -22,7 +22,7 @@
  */
 
 class UserGroupExpiryJob extends Job {
-       public function __construct( $params = false ) {
+       public function __construct( $params = [] ) {
                parent::__construct( 'userGroupExpiry', Title::newMainPage(), $params );
                $this->removeDuplicates = true;
        }
index cf582b7..f3ab1c5 100644 (file)
@@ -8,7 +8,6 @@ use InvalidArgumentException;
  * Helper class to handle automatically marking connections as reusable (via RAII pattern)
  * as well handling deferring the actual network connection until the handle is used
  *
- * @note: proxy methods are defined explicitly to avoid interface errors
  * @ingroup Database
  * @since 1.22
  */
@@ -19,6 +18,8 @@ class DBConnRef implements IDatabase {
        private $conn;
        /** @var array|null N-tuple of (server index, group, DatabaseDomain|string) */
        private $params;
+       /** @var int One of DB_MASTER/DB_REPLICA */
+       private $role;
 
        const FLD_INDEX = 0;
        const FLD_GROUP = 1;
@@ -27,10 +28,13 @@ class DBConnRef implements IDatabase {
 
        /**
         * @param ILoadBalancer $lb Connection manager for $conn
-        * @param Database|array $conn Database handle or (server index, query groups, domain, flags)
+        * @param Database|array $conn Database or (server index, query groups, domain, flags)
+        * @param int $role The type of connection asked for; one of DB_MASTER/DB_REPLICA
+        * @internal This method should not be called outside of LoadBalancer
         */
-       public function __construct( ILoadBalancer $lb, $conn ) {
+       public function __construct( ILoadBalancer $lb, $conn, $role ) {
                $this->lb = $lb;
+               $this->role = $role;
                if ( $conn instanceof Database ) {
                        $this->conn = $conn; // live handle
                } elseif ( is_array( $conn ) && count( $conn ) >= 4 && $conn[self::FLD_DOMAIN] !== false ) {
@@ -49,6 +53,14 @@ class DBConnRef implements IDatabase {
                return $this->conn->$name( ...$arguments );
        }
 
+       /**
+        * @return int DB_MASTER when this *requires* the master DB, otherwise DB_REPLICA
+        * @since 1.33
+        */
+       public function getReferenceRole() {
+               return $this->role;
+       }
+
        public function getServerInfo() {
                return $this->__call( __FUNCTION__, func_get_args() );
        }
@@ -255,7 +267,11 @@ class DBConnRef implements IDatabase {
        }
 
        public function query( $sql, $fname = __METHOD__, $flags = 0 ) {
-               return $this->__call( __FUNCTION__, func_get_args() );
+               if ( $this->role !== ILoadBalancer::DB_MASTER ) {
+                       $flags |= IDatabase::QUERY_REPLICA_ROLE;
+               }
+
+               return $this->__call( __FUNCTION__, [ $sql, $fname, $flags ] );
        }
 
        public function freeResult( $res ) {
@@ -310,6 +326,8 @@ class DBConnRef implements IDatabase {
        public function lockForUpdate(
                $table, $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
        ) {
+               $this->assertRoleAllowsWrites();
+
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
@@ -326,10 +344,14 @@ class DBConnRef implements IDatabase {
        }
 
        public function insert( $table, $a, $fname = __METHOD__, $options = [] ) {
+               $this->assertRoleAllowsWrites();
+
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
        public function update( $table, $values, $conds, $fname = __METHOD__, $options = [] ) {
+               $this->assertRoleAllowsWrites();
+
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
@@ -435,26 +457,36 @@ class DBConnRef implements IDatabase {
        }
 
        public function nextSequenceValue( $seqName ) {
+               $this->assertRoleAllowsWrites();
+
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
        public function replace( $table, $uniqueIndexes, $rows, $fname = __METHOD__ ) {
+               $this->assertRoleAllowsWrites();
+
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
        public function upsert(
                $table, array $rows, $uniqueIndexes, array $set, $fname = __METHOD__
        ) {
+               $this->assertRoleAllowsWrites();
+
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
        public function deleteJoin(
                $delTable, $joinTable, $delVar, $joinVar, $conds, $fname = __METHOD__
        ) {
+               $this->assertRoleAllowsWrites();
+
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
        public function delete( $table, $conds, $fname = __METHOD__ ) {
+               $this->assertRoleAllowsWrites();
+
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
@@ -462,6 +494,8 @@ class DBConnRef implements IDatabase {
                $destTable, $srcTable, $varMap, $conds,
                $fname = __METHOD__, $insertOptions = [], $selectOptions = [], $selectJoinConds = []
        ) {
+               $this->assertRoleAllowsWrites();
+
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
@@ -529,18 +563,21 @@ class DBConnRef implements IDatabase {
        }
 
        public function onTransactionResolution( callable $callback, $fname = __METHOD__ ) {
+               // DB_REPLICA role: caller might want to refresh cache after a REPEATABLE-READ snapshot
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
        public function onTransactionCommitOrIdle( callable $callback, $fname = __METHOD__ ) {
+               // DB_REPLICA role: caller might want to refresh cache after a REPEATABLE-READ snapshot
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
        public function onTransactionIdle( callable $callback, $fname = __METHOD__ ) {
-               return $this->__call( __FUNCTION__, func_get_args() );
+               return $this->onTransactionCommitOrIdle( $callback, $fname );
        }
 
        public function onTransactionPreCommitOrIdle( callable $callback, $fname = __METHOD__ ) {
+               // DB_REPLICA role: caller might want to refresh cache after a cache mutex is released
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
@@ -551,20 +588,24 @@ class DBConnRef implements IDatabase {
        public function startAtomic(
                $fname = __METHOD__, $cancelable = IDatabase::ATOMIC_NOT_CANCELABLE
        ) {
+               // Don't call assertRoleAllowsWrites(); caller might want a REPEATABLE-READ snapshot
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
        public function endAtomic( $fname = __METHOD__ ) {
+               // Don't call assertRoleAllowsWrites(); caller might want a REPEATABLE-READ snapshot
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
        public function cancelAtomic( $fname = __METHOD__, AtomicSectionIdentifier $sectionId = null ) {
+               // Don't call assertRoleAllowsWrites(); caller might want a REPEATABLE-READ snapshot
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
        public function doAtomicSection(
                $fname, callable $callback, $cancelable = self::ATOMIC_NOT_CANCELABLE
        ) {
+               // Don't call assertRoleAllowsWrites(); caller might want a REPEATABLE-READ snapshot
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
@@ -627,18 +668,26 @@ class DBConnRef implements IDatabase {
        }
 
        public function lockIsFree( $lockName, $method ) {
+               $this->assertRoleAllowsWrites();
+
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
        public function lock( $lockName, $method, $timeout = 5 ) {
+               $this->assertRoleAllowsWrites();
+
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
        public function unlock( $lockName, $method ) {
+               $this->assertRoleAllowsWrites();
+
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
        public function getScopedLockAndFlush( $lockKey, $fname, $timeout ) {
+               $this->assertRoleAllowsWrites();
+
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
@@ -674,6 +723,26 @@ class DBConnRef implements IDatabase {
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
+       /**
+        * Error out if the role is not DB_MASTER
+        *
+        * Note that the underlying connection may or may not itself be read-only.
+        * It could even be to a writable master (both server-side and to the application).
+        * This error is meant for the case when a DB_REPLICA handle was requested but a
+        * a write was attempted on that handle regardless.
+        *
+        * In configurations where the master DB has some generic read load or is the only server,
+        * DB_MASTER/DB_REPLICA will sometimes (or always) use the same connection to the master DB.
+        * This does not effect the role of DBConnRef instances.
+        * @throws DBReadOnlyRoleError
+        */
+       protected function assertRoleAllowsWrites() {
+               // DB_MASTER is "prima facie" writable
+               if ( $this->role !== ILoadBalancer::DB_MASTER ) {
+                       throw new DBReadOnlyRoleError( $this->conn, "Cannot write with role DB_REPLICA" );
+               }
+       }
+
        /**
         * Clean up the connection when out of scope
         */
index a839946..c5ef758 100644 (file)
@@ -1032,7 +1032,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
         */
        protected function assertIsWritableMaster() {
                if ( $this->getLBInfo( 'replica' ) === true ) {
-                       throw new DBUnexpectedError(
+                       throw new DBReadOnlyRoleError(
                                $this,
                                'Write operations are not allowed on replica database connections.'
                        );
@@ -1194,7 +1194,6 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
 
                $flags = (int)$flags; // b/c; this field used to be a bool
                $ignoreErrors = $this->hasFlags( $flags, self::QUERY_SILENCE_ERRORS );
-               $pseudoPermanent = $this->hasFlags( $flags, self::QUERY_PSEUDO_PERMANENT );
 
                $priorTransaction = $this->trxLevel;
                $priorWritesPending = $this->writesOrCallbacksPending();
@@ -1206,8 +1205,13 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        $this->assertIsWritableMaster();
                        # Do not treat temporary table writes as "meaningful writes" that need committing.
                        # Profile them as reads. Integration tests can override this behavior via $flags.
+                       $pseudoPermanent = $this->hasFlags( $flags, self::QUERY_PSEUDO_PERMANENT );
                        $tableType = $this->registerTempTableWrite( $sql, $pseudoPermanent );
                        $isEffectiveWrite = ( $tableType !== self::TEMP_NORMAL );
+                       # DBConnRef uses QUERY_REPLICA_ROLE to enforce the replica role for raw SQL queries
+                       if ( $isEffectiveWrite && $this->hasFlags( $flags, self::QUERY_REPLICA_ROLE ) ) {
+                               throw new DBReadOnlyRoleError( $this, "Cannot write; target role is DB_REPLICA" );
+                       }
                } else {
                        $isEffectiveWrite = false;
                }
index 90b888d..e25b4d2 100644 (file)
@@ -113,6 +113,8 @@ interface IDatabase {
         *   permanent as far as write tracking is concerned. This is useful for testing.
         */
        const QUERY_PSEUDO_PERMANENT = 2;
+       /** @var int Enforce that a query does not make effective writes */
+       const QUERY_REPLICA_ROLE = 4;
 
        /** @var bool Parameter to unionQueries() for UNION ALL */
        const UNION_ALL = true;
@@ -1011,6 +1013,7 @@ interface IDatabase {
         * @param string $valuename
         *
         * @return string
+        * @deprecated Since 1.33
         */
        public function aggregateValue( $valuedata, $valuename = 'value' );
 
index 8a2c795..10a0897 100644 (file)
@@ -30,6 +30,8 @@ class MaintainableDBConnRef extends DBConnRef implements IMaintainableDatabase {
                $fname = false,
                callable $inputCallback = null
        ) {
+               $this->assertRoleAllowsWrites();
+
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
@@ -40,14 +42,20 @@ class MaintainableDBConnRef extends DBConnRef implements IMaintainableDatabase {
                $fname = __METHOD__,
                callable $inputCallback = null
        ) {
+               $this->assertRoleAllowsWrites();
+
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
        public function dropTable( $tableName, $fName = __METHOD__ ) {
+               $this->assertRoleAllowsWrites();
+
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
        public function deadlockLoop() {
+               $this->assertRoleAllowsWrites();
+
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
@@ -66,6 +74,8 @@ class MaintainableDBConnRef extends DBConnRef implements IMaintainableDatabase {
        public function duplicateTableStructure(
                $oldName, $newName, $temporary = false, $fname = __METHOD__
        ) {
+               $this->assertRoleAllowsWrites();
+
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
@@ -74,10 +84,14 @@ class MaintainableDBConnRef extends DBConnRef implements IMaintainableDatabase {
        }
 
        public function lockTables( array $read, array $write, $method ) {
+               $this->assertRoleAllowsWrites();
+
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
        public function unlockTables( $method ) {
+               $this->assertRoleAllowsWrites();
+
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
diff --git a/includes/libs/rdbms/exception/DBReadOnlyRoleError.php b/includes/libs/rdbms/exception/DBReadOnlyRoleError.php
new file mode 100644 (file)
index 0000000..4d565ba
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Database
+ */
+
+namespace Wikimedia\Rdbms;
+
+/**
+ * Exception class for attempted DB write access to a DBConnRef with the DB_REPLICA role
+ *
+ * @ingroup Database
+ */
+class DBReadOnlyRoleError extends DBUnexpectedError {
+}
index bd22aca..da5382a 100644 (file)
@@ -834,23 +834,36 @@ class LoadBalancer implements ILoadBalancer {
                }
        }
 
-       public function getConnectionRef( $db, $groups = [], $domain = false, $flags = 0 ) {
+       public function getConnectionRef( $i, $groups = [], $domain = false, $flags = 0 ) {
                $domain = $this->resolveDomainID( $domain );
+               $role = $this->getRoleFromIndex( $i );
 
-               return new DBConnRef( $this, $this->getConnection( $db, $groups, $domain, $flags ) );
+               return new DBConnRef( $this, $this->getConnection( $i, $groups, $domain, $flags ), $role );
        }
 
-       public function getLazyConnectionRef( $db, $groups = [], $domain = false, $flags = 0 ) {
+       public function getLazyConnectionRef( $i, $groups = [], $domain = false, $flags = 0 ) {
                $domain = $this->resolveDomainID( $domain );
+               $role = $this->getRoleFromIndex( $i );
 
-               return new DBConnRef( $this, [ $db, $groups, $domain, $flags ] );
+               return new DBConnRef( $this, [ $i, $groups, $domain, $flags ], $role );
        }
 
-       public function getMaintenanceConnectionRef( $db, $groups = [], $domain = false, $flags = 0 ) {
+       public function getMaintenanceConnectionRef( $i, $groups = [], $domain = false, $flags = 0 ) {
                $domain = $this->resolveDomainID( $domain );
+               $role = $this->getRoleFromIndex( $i );
 
                return new MaintainableDBConnRef(
-                       $this, $this->getConnection( $db, $groups, $domain, $flags ) );
+                       $this, $this->getConnection( $i, $groups, $domain, $flags ), $role );
+       }
+
+       /**
+        * @param int $i Server index or DB_MASTER/DB_REPLICA
+        * @return int One of DB_MASTER/DB_REPLICA
+        */
+       private function getRoleFromIndex( $i ) {
+               return ( $i === self::DB_MASTER || $i === $this->getWriterIndex() )
+                       ? self::DB_MASTER
+                       : self::DB_REPLICA;
        }
 
        public function openConnection( $i, $domain = false, $flags = 0 ) {
index 9be5de3..031541b 100644 (file)
@@ -385,7 +385,7 @@ class ResourceLoaderFileModule extends ResourceLoaderModule {
                if ( $this->packageFiles !== null ) {
                        $packageFiles = $this->getPackageFiles( $context );
                        if ( $deprecationScript ) {
-                               $mainFile =& $packageFiles['files'][ $packageFiles['main'] ];
+                               $mainFile =& $packageFiles['files'][$packageFiles['main']];
                                $mainFile['content'] = $deprecationScript . $mainFile['content'];
                        }
                        return $packageFiles;
index 8cd5b19..d10be12 100644 (file)
@@ -73,7 +73,7 @@ class ResourceLoaderImage {
                }
                // Remove 'deprecated' key
                if ( is_array( $this->descriptor ) ) {
-                       unset( $this->descriptor[ 'deprecated' ] );
+                       unset( $this->descriptor['deprecated'] );
                }
 
                // Ensure that all files have common extension.
index e97e074..7d39a58 100644 (file)
@@ -28,15 +28,15 @@ class ResourceLoaderOOUIFileModule extends ResourceLoaderFileModule {
        use ResourceLoaderOOUIModule;
 
        public function __construct( $options = [] ) {
-               if ( isset( $options[ 'themeScripts' ] ) ) {
-                       $skinScripts = $this->getSkinSpecific( $options[ 'themeScripts' ], 'scripts' );
+               if ( isset( $options['themeScripts'] ) ) {
+                       $skinScripts = $this->getSkinSpecific( $options['themeScripts'], 'scripts' );
                        if ( !isset( $options['skinScripts'] ) ) {
                                $options['skinScripts'] = [];
                        }
                        $this->extendSkinSpecific( $options['skinScripts'], $skinScripts );
                }
-               if ( isset( $options[ 'themeStyles' ] ) ) {
-                       $skinStyles = $this->getSkinSpecific( $options[ 'themeStyles' ], 'styles' );
+               if ( isset( $options['themeStyles'] ) ) {
+                       $skinStyles = $this->getSkinSpecific( $options['themeStyles'], 'styles' );
                        if ( !isset( $options['skinStyles'] ) ) {
                                $options['skinStyles'] = [];
                        }
index 0a4e94e..0395127 100644 (file)
@@ -104,7 +104,7 @@ trait ResourceLoaderOOUIModule {
         */
        protected function getThemePath( $theme, $kind, $module ) {
                $paths = self::getThemePaths();
-               $path = $paths[ $theme ][ $kind ];
+               $path = $paths[$theme][$kind];
                $path = str_replace( '{module}', $module, $path );
                return $path;
        }
index 6393803..2dd6c17 100644 (file)
@@ -98,14 +98,14 @@ class ResourceLoaderSkinModule extends ResourceLoaderFileModule {
 
                if ( !is_array( $logo ) ) {
                        // No media queries required if we only have one variant
-                       $preloadLinks[ $logo ] = [ 'as' => 'image' ];
+                       $preloadLinks[$logo] = [ 'as' => 'image' ];
                        return $preloadLinks;
                }
 
                if ( isset( $logo['svg'] ) ) {
                        // No media queries required if we only have a 1x and svg variant
                        // because all preload-capable browsers support SVGs
-                       $preloadLinks [ $logo['svg'] ] = [ 'as' => 'image' ];
+                       $preloadLinks[$logo['svg']] = [ 'as' => 'image' ];
                        return $preloadLinks;
                }
 
@@ -124,7 +124,10 @@ class ResourceLoaderSkinModule extends ResourceLoaderFileModule {
                } );
 
                foreach ( $logosPerDppx as $dppx => $src ) {
-                       $logos[] = [ 'dppx' => $dppx, 'src' => $src ];
+                       $logos[] = [
+                               'dppx' => $dppx,
+                               'src' => $src
+                       ];
                }
 
                $logosCount = count( $logos );
@@ -138,21 +141,24 @@ class ResourceLoaderSkinModule extends ResourceLoaderFileModule {
                                // Smallest dppx
                                // min-resolution is ">=" (larger than or equal to)
                                // "not min-resolution" is essentially "<"
-                               $media_query = 'not all and (min-resolution: ' . $logos[ 1 ]['dppx'] . 'dppx)';
+                               $media_query = 'not all and (min-resolution: ' . $logos[1]['dppx'] . 'dppx)';
                        } elseif ( $i !== $logosCount - 1 ) {
                                // In between
                                // Media query expressions can only apply "not" to the entire expression
                                // (e.g. can't express ">= 1.5 and not >= 2).
                                // Workaround: Use <= 1.9999 in place of < 2.
-                               $upper_bound = floatval( $logos[ $i + 1 ]['dppx'] ) - 0.000001;
-                               $media_query = '(min-resolution: ' . $logos[ $i ]['dppx'] .
+                               $upper_bound = floatval( $logos[$i + 1]['dppx'] ) - 0.000001;
+                               $media_query = '(min-resolution: ' . $logos[$i]['dppx'] .
                                        'dppx) and (max-resolution: ' . $upper_bound . 'dppx)';
                        } else {
                                // Largest dppx
-                               $media_query = '(min-resolution: ' . $logos[ $i ]['dppx'] . 'dppx)';
+                               $media_query = '(min-resolution: ' . $logos[$i]['dppx'] . 'dppx)';
                        }
 
-                       $preloadLinks[ $logos[$i]['src'] ] = [ 'as' => 'image', 'media' => $media_query ];
+                       $preloadLinks[$logos[$i]['src']] = [
+                               'as' => 'image',
+                               'media' => $media_query
+                       ];
                }
 
                return $preloadLinks;
index a91537f..9fad348 100644 (file)
@@ -433,7 +433,7 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule {
                                // Avoid including ids or timestamps of revision/page tables so
                                // that versions are not wasted
                                $title = new TitleValue( (int)$row->page_namespace, $row->page_title );
-                               $titleInfo[ self::makeTitleKey( $title ) ] = [
+                               $titleInfo[self::makeTitleKey( $title )] = [
                                        'page_len' => $row->page_len,
                                        'page_latest' => $row->page_latest,
                                        'page_touched' => $row->page_touched,
index f325641..86ac01a 100644 (file)
@@ -438,7 +438,7 @@ $specialPageAliases = [
        'Listgrants'                => [ 'ListGrants' ],
        'Listredirects'             => [ 'ListRedirects' ],
        'ListDuplicatedFiles'       => [ 'ListDuplicatedFiles', 'ListFileDuplicates' ],
-       'Listusers'                 => [ 'ListUsers', 'UserList' ],
+       'Listusers'                 => [ 'ListUsers', 'UserList', 'Users' ],
        'Lockdb'                    => [ 'LockDB' ],
        'Log'                       => [ 'Log', 'Logs' ],
        'Lonelypages'               => [ 'LonelyPages', 'OrphanedPages' ],
diff --git a/resources/src/mediawiki.Title/.eslintrc.json b/resources/src/mediawiki.Title/.eslintrc.json
new file mode 100644 (file)
index 0000000..ad8dbb3
--- /dev/null
@@ -0,0 +1,5 @@
+{
+       "parserOptions": {
+               "sourceType": "module"
+       }
+}
index 78ae135..900dab2 100644 (file)
 /*!
  * @author Neil Kandalgaonkar, 2010
- * @author Timo Tijhof
  * @since 1.18
  */
 
-( function () {
-       /**
-        * Parse titles into an object structure. Note that when using the constructor
-        * directly, passing invalid titles will result in an exception. Use #newFromText to use the
-        * logic directly and get null for invalid titles which is easier to work with.
-        *
-        * Note that in the constructor and #newFromText method, `namespace` is the **default** namespace
-        * only, and can be overridden by a namespace prefix in `title`. If you do not want this behavior,
-        * use #makeTitle. Compare:
-        *
-        *     new mw.Title( 'Foo', NS_TEMPLATE ).getPrefixedText();                  // => 'Template:Foo'
-        *     mw.Title.newFromText( 'Foo', NS_TEMPLATE ).getPrefixedText();          // => 'Template:Foo'
-        *     mw.Title.makeTitle( NS_TEMPLATE, 'Foo' ).getPrefixedText();            // => 'Template:Foo'
-        *
-        *     new mw.Title( 'Category:Foo', NS_TEMPLATE ).getPrefixedText();         // => 'Category:Foo'
-        *     mw.Title.newFromText( 'Category:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Category:Foo'
-        *     mw.Title.makeTitle( NS_TEMPLATE, 'Category:Foo' ).getPrefixedText();   // => 'Template:Category:Foo'
-        *
-        *     new mw.Title( 'Template:Foo', NS_TEMPLATE ).getPrefixedText();         // => 'Template:Foo'
-        *     mw.Title.newFromText( 'Template:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo'
-        *     mw.Title.makeTitle( NS_TEMPLATE, 'Template:Foo' ).getPrefixedText();   // => 'Template:Template:Foo'
-        *
-        * @class mw.Title
-        */
-
-       /* Private members */
-
-       var
-               mwString = require( 'mediawiki.String' ),
-
-               toUpperMapping = require( './phpCharToUpper.json' ),
-
-               namespaceIds = mw.config.get( 'wgNamespaceIds' ),
-
-               /**
-                * @private
-                * @static
-                * @property NS_MAIN
-                */
-               NS_MAIN = namespaceIds[ '' ],
-
-               /**
-                * @private
-                * @static
-                * @property NS_TALK
-                */
-               NS_TALK = namespaceIds.talk,
-
-               /**
-                * @private
-                * @static
-                * @property NS_SPECIAL
-                */
-               NS_SPECIAL = namespaceIds.special,
-
-               /**
-                * @private
-                * @static
-                * @property NS_MEDIA
-                */
-               NS_MEDIA = namespaceIds.media,
-
-               /**
-                * @private
-                * @static
-                * @property NS_FILE
-                */
-               NS_FILE = namespaceIds.file,
-
-               /**
-                * @private
-                * @static
-                * @property FILENAME_MAX_BYTES
-                */
-               FILENAME_MAX_BYTES = 240,
-
-               /**
-                * @private
-                * @static
-                * @property TITLE_MAX_BYTES
-                */
-               TITLE_MAX_BYTES = 255,
-
-               /**
-                * Get the namespace id from a namespace name (either from the localized, canonical or alias
-                * name).
-                *
-                * Example: On a German wiki this would return 6 for any of 'File', 'Datei', 'Image' or
-                * even 'Bild'.
-                *
-                * @private
-                * @static
-                * @method getNsIdByName
-                * @param {string} ns Namespace name (case insensitive, leading/trailing space ignored)
-                * @return {number|boolean} Namespace id or boolean false
-                */
-               getNsIdByName = function ( ns ) {
-                       var id;
-
-                       // Don't cast non-strings to strings, because null or undefined should not result in
-                       // returning the id of a potential namespace called "Null:" (e.g. on null.example.org/wiki)
-                       // Also, toLowerCase throws exception on null/undefined, because it is a String method.
-                       if ( typeof ns !== 'string' ) {
-                               return false;
-                       }
-                       // TODO: Should just use local var namespaceIds here but it
-                       // breaks test which modify the config
-                       id = mw.config.get( 'wgNamespaceIds' )[ ns.toLowerCase() ];
-                       if ( id === undefined ) {
-                               return false;
-                       }
-                       return id;
-               },
-
-               /**
-                * @private
-                * @method getNamespacePrefix_
-                * @param {number} namespace
-                * @return {string}
-                */
-               getNamespacePrefix = function ( namespace ) {
-                       return namespace === NS_MAIN ?
-                               '' :
-                               ( mw.config.get( 'wgFormattedNamespaces' )[ namespace ].replace( / /g, '_' ) + ':' );
-               },
-
-               rUnderscoreTrim = /^_+|_+$/g,
-
-               rSplit = /^(.+?)_*:_*(.*)$/,
-
-               // See MediaWikiTitleCodec.php#getTitleInvalidRegex
-               rInvalid = new RegExp(
-                       '[^' + mw.config.get( 'wgLegalTitleChars' ) + ']' +
-                       // URL percent encoding sequences interfere with the ability
-                       // to round-trip titles -- you can't link to them consistently.
-                       '|%[0-9A-Fa-f]{2}' +
-                       // XML/HTML character references produce similar issues.
-                       '|&[A-Za-z0-9\u0080-\uFFFF]+;' +
-                       '|&#[0-9]+;' +
-                       '|&#x[0-9A-Fa-f]+;'
-               ),
-
-               // From MediaWikiTitleCodec::splitTitleString() in PHP
-               // Note that this is not equivalent to /\s/, e.g. underscore is included, tab is not included.
-               rWhitespace = /[ _\u00A0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]+/g,
-
-               // From MediaWikiTitleCodec::splitTitleString() in PHP
-               rUnicodeBidi = /[\u200E\u200F\u202A-\u202E]/g,
-
-               /**
-                * Slightly modified from Flinfo. Credit goes to Lupo and Flominator.
-                * @private
-                * @static
-                * @property sanitationRules
-                */
-               sanitationRules = [
-                       // "signature"
-                       {
-                               pattern: /~{3}/g,
-                               replace: '',
-                               generalRule: true
-                       },
-                       // control characters
-                       {
-                               // eslint-disable-next-line no-control-regex
-                               pattern: /[\x00-\x1f\x7f]/g,
-                               replace: '',
-                               generalRule: true
-                       },
-                       // URL encoding (possibly)
-                       {
-                               pattern: /%([0-9A-Fa-f]{2})/g,
-                               replace: '% $1',
-                               generalRule: true
-                       },
-                       // HTML-character-entities
-                       {
-                               pattern: /&(([A-Za-z0-9\x80-\xff]+|#[0-9]+|#x[0-9A-Fa-f]+);)/g,
-                               replace: '& $1',
-                               generalRule: true
-                       },
-                       // slash, colon (not supported by file systems like NTFS/Windows, Mac OS 9 [:], ext4 [/])
-                       {
-                               pattern: new RegExp( '[' + mw.config.get( 'wgIllegalFileChars', '' ) + ']', 'g' ),
-                               replace: '-',
-                               fileRule: true
-                       },
-                       // brackets, greater than
-                       {
-                               pattern: /[}\]>]/g,
-                               replace: ')',
-                               generalRule: true
-                       },
-                       // brackets, lower than
-                       {
-                               pattern: /[{[<]/g,
-                               replace: '(',
-                               generalRule: true
-                       },
-                       // everything that wasn't covered yet
-                       {
-                               pattern: new RegExp( rInvalid.source, 'g' ),
-                               replace: '-',
-                               generalRule: true
-                       },
-                       // directory structures
-                       {
-                               pattern: /^(\.|\.\.|\.\/.*|\.\.\/.*|.*\/\.\/.*|.*\/\.\.\/.*|.*\/\.|.*\/\.\.)$/g,
-                               replace: '',
-                               generalRule: true
-                       }
-               ],
-
-               /**
-                * Internal helper for #constructor and #newFromText.
-                *
-                * Based on Title.php#secureAndSplit
-                *
-                * @private
-                * @static
-                * @method parse
-                * @param {string} title
-                * @param {number} [defaultNamespace=NS_MAIN]
-                * @return {Object|boolean}
-                */
-               parse = function ( title, defaultNamespace ) {
-                       var namespace, m, id, i, fragment, ext;
-
-                       namespace = defaultNamespace === undefined ? NS_MAIN : defaultNamespace;
-
-                       title = title
-                               // Strip Unicode bidi override characters
-                               .replace( rUnicodeBidi, '' )
-                               // Normalise whitespace to underscores and remove duplicates
-                               .replace( rWhitespace, '_' )
-                               // Trim underscores
-                               .replace( rUnderscoreTrim, '' );
-
-                       // Process initial colon
-                       if ( title !== '' && title[ 0 ] === ':' ) {
-                               // Initial colon means main namespace instead of specified default
-                               namespace = NS_MAIN;
-                               title = title
-                                       // Strip colon
-                                       .slice( 1 )
-                                       // Trim underscores
-                                       .replace( rUnderscoreTrim, '' );
-                       }
-
-                       if ( title === '' ) {
-                               return false;
-                       }
-
-                       // Process namespace prefix (if any)
-                       m = title.match( rSplit );
-                       if ( m ) {
-                               id = getNsIdByName( m[ 1 ] );
-                               if ( id !== false ) {
-                                       // Ordinary namespace
-                                       namespace = id;
-                                       title = m[ 2 ];
-
-                                       // For Talk:X pages, make sure X has no "namespace" prefix
-                                       if ( namespace === NS_TALK && ( m = title.match( rSplit ) ) ) {
-                                               // Disallow titles like Talk:File:x (subject should roundtrip: talk:file:x -> file:x -> file_talk:x)
-                                               if ( getNsIdByName( m[ 1 ] ) !== false ) {
-                                                       return false;
-                                               }
-                                       }
-                               }
-                       }
-
-                       // Process fragment
-                       i = title.indexOf( '#' );
-                       if ( i === -1 ) {
-                               fragment = null;
-                       } else {
-                               fragment = title
-                                       // Get segment starting after the hash
-                                       .slice( i + 1 )
-                                       // Convert to text
-                                       // NB: Must not be trimmed ("Example#_foo" is not the same as "Example#foo")
-                                       .replace( /_/g, ' ' );
-
-                               title = title
-                                       // Strip hash
-                                       .slice( 0, i )
-                                       // Trim underscores, again (strips "_" from "bar" in "Foo_bar_#quux")
-                                       .replace( rUnderscoreTrim, '' );
-                       }
-
-                       // Reject illegal characters
-                       if ( title.match( rInvalid ) ) {
-                               return false;
-                       }
-
-                       // Disallow titles that browsers or servers might resolve as directory navigation
-                       if (
-                               title.indexOf( '.' ) !== -1 && (
-                                       title === '.' || title === '..' ||
-                                       title.indexOf( './' ) === 0 ||
-                                       title.indexOf( '../' ) === 0 ||
-                                       title.indexOf( '/./' ) !== -1 ||
-                                       title.indexOf( '/../' ) !== -1 ||
-                                       title.slice( -2 ) === '/.' ||
-                                       title.slice( -3 ) === '/..'
-                               )
-                       ) {
-                               return false;
-                       }
-
-                       // Disallow magic tilde sequence
-                       if ( title.indexOf( '~~~' ) !== -1 ) {
-                               return false;
-                       }
-
-                       // Disallow titles exceeding the TITLE_MAX_BYTES byte size limit (size of underlying database field)
-                       // Except for special pages, e.g. [[Special:Block/Long name]]
-                       // Note: The PHP implementation also asserts that even in NS_SPECIAL, the title should
-                       // be less than 512 bytes.
-                       if ( namespace !== NS_SPECIAL && mwString.byteLength( title ) > TITLE_MAX_BYTES ) {
-                               return false;
-                       }
+/**
+ * Parse titles into an object structure. Note that when using the constructor
+ * directly, passing invalid titles will result in an exception. Use #newFromText to use the
+ * logic directly and get null for invalid titles which is easier to work with.
+ *
+ * Note that in the constructor and #newFromText method, `namespace` is the **default** namespace
+ * only, and can be overridden by a namespace prefix in `title`. If you do not want this behavior,
+ * use #makeTitle. Compare:
+ *
+ *     new mw.Title( 'Foo', NS_TEMPLATE ).getPrefixedText();                  // => 'Template:Foo'
+ *     mw.Title.newFromText( 'Foo', NS_TEMPLATE ).getPrefixedText();          // => 'Template:Foo'
+ *     mw.Title.makeTitle( NS_TEMPLATE, 'Foo' ).getPrefixedText();            // => 'Template:Foo'
+ *
+ *     new mw.Title( 'Category:Foo', NS_TEMPLATE ).getPrefixedText();         // => 'Category:Foo'
+ *     mw.Title.newFromText( 'Category:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Category:Foo'
+ *     mw.Title.makeTitle( NS_TEMPLATE, 'Category:Foo' ).getPrefixedText();   // => 'Template:Category:Foo'
+ *
+ *     new mw.Title( 'Template:Foo', NS_TEMPLATE ).getPrefixedText();         // => 'Template:Foo'
+ *     mw.Title.newFromText( 'Template:Foo', NS_TEMPLATE ).getPrefixedText(); // => 'Template:Foo'
+ *     mw.Title.makeTitle( NS_TEMPLATE, 'Template:Foo' ).getPrefixedText();   // => 'Template:Template:Foo'
+ *
+ * @class mw.Title
+ */
 
-                       // Can't make a link to a namespace alone.
-                       if ( title === '' && namespace !== NS_MAIN ) {
-                               return false;
-                       }
+/* Private members */
 
-                       // Any remaining initial :s are illegal.
-                       if ( title[ 0 ] === ':' ) {
-                               return false;
-                       }
+var
+       mwString = require( 'mediawiki.String' ),
 
-                       // For backwards-compatibility with old mw.Title, we separate the extension from the
-                       // rest of the title.
-                       i = title.lastIndexOf( '.' );
-                       if ( i === -1 || title.length <= i + 1 ) {
-                               // Extensions are the non-empty segment after the last dot
-                               ext = null;
-                       } else {
-                               ext = title.slice( i + 1 );
-                               title = title.slice( 0, i );
-                       }
+       toUpperMapping = require( './phpCharToUpper.json' ),
 
-                       return {
-                               namespace: namespace,
-                               title: title,
-                               ext: ext,
-                               fragment: fragment
-                       };
-               },
+       namespaceIds = mw.config.get( 'wgNamespaceIds' ),
 
-               /**
-                * Convert db-key to readable text.
-                *
-                * @private
-                * @static
-                * @method text
-                * @param {string} s
-                * @return {string}
-                */
-               text = function ( s ) {
-                       return s.replace( /_/g, ' ' );
-               },
+       /**
+        * @private
+        * @static
+        * @property NS_MAIN
+        */
+       NS_MAIN = namespaceIds[ '' ],
 
-               /**
-                * Sanitizes a string based on a rule set and a filter
-                *
-                * @private
-                * @static
-                * @method sanitize
-                * @param {string} s
-                * @param {Array} filter
-                * @return {string}
-                */
-               sanitize = function ( s, filter ) {
-                       var i, ruleLength, rule, m, filterLength,
-                               rules = sanitationRules;
-
-                       for ( i = 0, ruleLength = rules.length; i < ruleLength; ++i ) {
-                               rule = rules[ i ];
-                               for ( m = 0, filterLength = filter.length; m < filterLength; ++m ) {
-                                       if ( rule[ filter[ m ] ] ) {
-                                               s = s.replace( rule.pattern, rule.replace );
-                                       }
-                               }
-                       }
-                       return s;
-               },
+       /**
+        * @private
+        * @static
+        * @property NS_TALK
+        */
+       NS_TALK = namespaceIds.talk,
 
-               /**
-                * Cuts a string to a specific byte length, assuming UTF-8
-                * or less, if the last character is a multi-byte one
-                *
-                * @private
-                * @static
-                * @method trimToByteLength
-                * @param {string} s
-                * @param {number} length
-                * @return {string}
-                */
-               trimToByteLength = function ( s, length ) {
-                       return mwString.trimByteLength( '', s, length ).newVal;
-               },
+       /**
+        * @private
+        * @static
+        * @property NS_SPECIAL
+        */
+       NS_SPECIAL = namespaceIds.special,
 
-               /**
-                * Cuts a file name to a specific byte length
-                *
-                * @private
-                * @static
-                * @method trimFileNameToByteLength
-                * @param {string} name without extension
-                * @param {string} extension file extension
-                * @return {string} The full name, including extension
-                */
-               trimFileNameToByteLength = function ( name, extension ) {
-                       // There is a special byte limit for file names and ... remember the dot
-                       return trimToByteLength( name, FILENAME_MAX_BYTES - extension.length - 1 ) + '.' + extension;
-               };
+       /**
+        * @private
+        * @static
+        * @property NS_MEDIA
+        */
+       NS_MEDIA = namespaceIds.media,
 
        /**
-        * @method constructor
-        * @param {string} title Title of the page. If no second argument given,
-        *  this will be searched for a namespace
-        * @param {number} [namespace=NS_MAIN] If given, will used as default namespace for the given title
-        * @throws {Error} When the title is invalid
+        * @private
+        * @static
+        * @property NS_FILE
         */
-       function Title( title, namespace ) {
-               var parsed = parse( title, namespace );
-               if ( !parsed ) {
-                       throw new Error( 'Unable to parse title' );
-               }
+       NS_FILE = namespaceIds.file,
 
-               this.namespace = parsed.namespace;
-               this.title = parsed.title;
-               this.ext = parsed.ext;
-               this.fragment = parsed.fragment;
-       }
+       /**
+        * @private
+        * @static
+        * @property FILENAME_MAX_BYTES
+        */
+       FILENAME_MAX_BYTES = 240,
 
-       /* Static members */
+       /**
+        * @private
+        * @static
+        * @property TITLE_MAX_BYTES
+        */
+       TITLE_MAX_BYTES = 255,
 
        /**
-        * Constructor for Title objects with a null return instead of an exception for invalid titles.
+        * Get the namespace id from a namespace name (either from the localized, canonical or alias
+        * name).
         *
-        * Note that `namespace` is the **default** namespace only, and can be overridden by a namespace
-        * prefix in `title`. If you do not want this behavior, use #makeTitle. See #constructor for
-        * details.
+        * Example: On a German wiki this would return 6 for any of 'File', 'Datei', 'Image' or
+        * even 'Bild'.
         *
+        * @private
         * @static
-        * @param {string} title
-        * @param {number} [namespace=NS_MAIN] Default namespace
-        * @return {mw.Title|null} A valid Title object or null if the title is invalid
+        * @method getNsIdByName
+        * @param {string} ns Namespace name (case insensitive, leading/trailing space ignored)
+        * @return {number|boolean} Namespace id or boolean false
         */
-       Title.newFromText = function ( title, namespace ) {
-               var t, parsed = parse( title, namespace );
-               if ( !parsed ) {
-                       return null;
+       getNsIdByName = function ( ns ) {
+               var id;
+
+               // Don't cast non-strings to strings, because null or undefined should not result in
+               // returning the id of a potential namespace called "Null:" (e.g. on null.example.org/wiki)
+               // Also, toLowerCase throws exception on null/undefined, because it is a String method.
+               if ( typeof ns !== 'string' ) {
+                       return false;
                }
+               // TODO: Should just use local var namespaceIds here but it
+               // breaks test which modify the config
+               id = mw.config.get( 'wgNamespaceIds' )[ ns.toLowerCase() ];
+               if ( id === undefined ) {
+                       return false;
+               }
+               return id;
+       },
 
-               t = Object.create( Title.prototype );
-               t.namespace = parsed.namespace;
-               t.title = parsed.title;
-               t.ext = parsed.ext;
-               t.fragment = parsed.fragment;
-
-               return t;
-       };
+       /**
+        * @private
+        * @method getNamespacePrefix_
+        * @param {number} namespace
+        * @return {string}
+        */
+       getNamespacePrefix = function ( namespace ) {
+               return namespace === NS_MAIN ?
+                       '' :
+                       ( mw.config.get( 'wgFormattedNamespaces' )[ namespace ].replace( / /g, '_' ) + ':' );
+       },
+
+       rUnderscoreTrim = /^_+|_+$/g,
+
+       rSplit = /^(.+?)_*:_*(.*)$/,
+
+       // See MediaWikiTitleCodec.php#getTitleInvalidRegex
+       rInvalid = new RegExp(
+               '[^' + mw.config.get( 'wgLegalTitleChars' ) + ']' +
+               // URL percent encoding sequences interfere with the ability
+               // to round-trip titles -- you can't link to them consistently.
+               '|%[0-9A-Fa-f]{2}' +
+               // XML/HTML character references produce similar issues.
+               '|&[A-Za-z0-9\u0080-\uFFFF]+;' +
+               '|&#[0-9]+;' +
+               '|&#x[0-9A-Fa-f]+;'
+       ),
+
+       // From MediaWikiTitleCodec::splitTitleString() in PHP
+       // Note that this is not equivalent to /\s/, e.g. underscore is included, tab is not included.
+       rWhitespace = /[ _\u00A0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]+/g,
+
+       // From MediaWikiTitleCodec::splitTitleString() in PHP
+       rUnicodeBidi = /[\u200E\u200F\u202A-\u202E]/g,
 
        /**
-        * Constructor for Title objects with predefined namespace.
-        *
-        * Unlike #newFromText or #constructor, this function doesn't allow the given `namespace` to be
-        * overridden by a namespace prefix in `title`. See #constructor for details about this behavior.
-        *
-        * The single exception to this is when `namespace` is 0, indicating the main namespace. The
-        * function behaves like #newFromText in that case.
-        *
+        * Slightly modified from Flinfo. Credit goes to Lupo and Flominator.
+        * @private
         * @static
-        * @param {number} namespace Namespace to use for the title
-        * @param {string} title
-        * @return {mw.Title|null} A valid Title object or null if the title is invalid
+        * @property sanitationRules
         */
-       Title.makeTitle = function ( namespace, title ) {
-               return mw.Title.newFromText( getNamespacePrefix( namespace ) + title );
-       };
+       sanitationRules = [
+               // "signature"
+               {
+                       pattern: /~{3}/g,
+                       replace: '',
+                       generalRule: true
+               },
+               // control characters
+               {
+                       // eslint-disable-next-line no-control-regex
+                       pattern: /[\x00-\x1f\x7f]/g,
+                       replace: '',
+                       generalRule: true
+               },
+               // URL encoding (possibly)
+               {
+                       pattern: /%([0-9A-Fa-f]{2})/g,
+                       replace: '% $1',
+                       generalRule: true
+               },
+               // HTML-character-entities
+               {
+                       pattern: /&(([A-Za-z0-9\x80-\xff]+|#[0-9]+|#x[0-9A-Fa-f]+);)/g,
+                       replace: '& $1',
+                       generalRule: true
+               },
+               // slash, colon (not supported by file systems like NTFS/Windows, Mac OS 9 [:], ext4 [/])
+               {
+                       pattern: new RegExp( '[' + mw.config.get( 'wgIllegalFileChars', '' ) + ']', 'g' ),
+                       replace: '-',
+                       fileRule: true
+               },
+               // brackets, greater than
+               {
+                       pattern: /[}\]>]/g,
+                       replace: ')',
+                       generalRule: true
+               },
+               // brackets, lower than
+               {
+                       pattern: /[{[<]/g,
+                       replace: '(',
+                       generalRule: true
+               },
+               // everything that wasn't covered yet
+               {
+                       pattern: new RegExp( rInvalid.source, 'g' ),
+                       replace: '-',
+                       generalRule: true
+               },
+               // directory structures
+               {
+                       pattern: /^(\.|\.\.|\.\/.*|\.\.\/.*|.*\/\.\/.*|.*\/\.\.\/.*|.*\/\.|.*\/\.\.)$/g,
+                       replace: '',
+                       generalRule: true
+               }
+       ],
 
        /**
-        * Constructor for Title objects from user input altering that input to
-        * produce a title that MediaWiki will accept as legal
+        * Internal helper for #constructor and #newFromText.
+        *
+        * Based on Title.php#secureAndSplit
         *
+        * @private
         * @static
+        * @method parse
         * @param {string} title
-        * @param {number|Object} [defaultNamespaceOrOptions=NS_MAIN]
-        *  If given, will used as default namespace for the given title.
-        *  This method can also be called with two arguments, in which case
-        *  this becomes options (see below).
-        * @param {Object} [options] additional options
-        * @param {boolean} [options.forUploading=true]
-        *  Makes sure that a file is uploadable under the title returned.
-        *  There are pages in the file namespace under which file upload is impossible.
-        *  Automatically assumed if the title is created in the Media namespace.
-        * @return {mw.Title|null} A valid Title object or null if the input cannot be turned into a valid title
+        * @param {number} [defaultNamespace=NS_MAIN]
+        * @return {Object|boolean}
         */
-       Title.newFromUserInput = function ( title, defaultNamespaceOrOptions, options ) {
-               var namespace, m, id, ext, parts,
-                       defaultNamespace;
-
-               // defaultNamespace is optional; check whether options moves up
-               if ( arguments.length < 3 && typeof defaultNamespace === 'object' ) {
-                       options = defaultNamespaceOrOptions;
-               } else {
-                       defaultNamespace = defaultNamespaceOrOptions;
-               }
-
-               // merge options into defaults
-               options = $.extend( {
-                       forUploading: true
-               }, options );
+       parse = function ( title, defaultNamespace ) {
+               var namespace, m, id, i, fragment, ext;
 
                namespace = defaultNamespace === undefined ? NS_MAIN : defaultNamespace;
 
-               // Normalise additional whitespace
-               title = title.replace( /\s/g, ' ' ).trim();
+               title = title
+                       // Strip Unicode bidi override characters
+                       .replace( rUnicodeBidi, '' )
+                       // Normalise whitespace to underscores and remove duplicates
+                       .replace( rWhitespace, '_' )
+                       // Trim underscores
+                       .replace( rUnderscoreTrim, '' );
 
                // Process initial colon
                if ( title !== '' && title[ 0 ] === ':' ) {
                        namespace = NS_MAIN;
                        title = title
                                // Strip colon
-                               .substr( 1 )
+                               .slice( 1 )
                                // Trim underscores
                                .replace( rUnderscoreTrim, '' );
                }
 
+               if ( title === '' ) {
+                       return false;
+               }
+
                // Process namespace prefix (if any)
                m = title.match( rSplit );
                if ( m ) {
                                // Ordinary namespace
                                namespace = id;
                                title = m[ 2 ];
+
+                               // For Talk:X pages, make sure X has no "namespace" prefix
+                               if ( namespace === NS_TALK && ( m = title.match( rSplit ) ) ) {
+                                       // Disallow titles like Talk:File:x (subject should roundtrip: talk:file:x -> file:x -> file_talk:x)
+                                       if ( getNsIdByName( m[ 1 ] ) !== false ) {
+                                               return false;
+                                       }
+                               }
                        }
                }
 
-               if (
-                       namespace === NS_MEDIA ||
-                       ( options.forUploading && ( namespace === NS_FILE ) )
-               ) {
-
-                       title = sanitize( title, [ 'generalRule', 'fileRule' ] );
+               // Process fragment
+               i = title.indexOf( '#' );
+               if ( i === -1 ) {
+                       fragment = null;
+               } else {
+                       fragment = title
+                               // Get segment starting after the hash
+                               .slice( i + 1 )
+                               // Convert to text
+                               // NB: Must not be trimmed ("Example#_foo" is not the same as "Example#foo")
+                               .replace( /_/g, ' ' );
 
-                       // Operate on the file extension
-                       // Although it is possible having spaces between the name and the ".ext" this isn't nice for
-                       // operating systems hiding file extensions -> strip them later on
-                       parts = title.split( '.' );
+                       title = title
+                               // Strip hash
+                               .slice( 0, i )
+                               // Trim underscores, again (strips "_" from "bar" in "Foo_bar_#quux")
+                               .replace( rUnderscoreTrim, '' );
+               }
 
-                       if ( parts.length > 1 ) {
+               // Reject illegal characters
+               if ( title.match( rInvalid ) ) {
+                       return false;
+               }
 
-                               // Get the last part, which is supposed to be the file extension
-                               ext = parts.pop();
+               // Disallow titles that browsers or servers might resolve as directory navigation
+               if (
+                       title.indexOf( '.' ) !== -1 && (
+                               title === '.' || title === '..' ||
+                               title.indexOf( './' ) === 0 ||
+                               title.indexOf( '../' ) === 0 ||
+                               title.indexOf( '/./' ) !== -1 ||
+                               title.indexOf( '/../' ) !== -1 ||
+                               title.slice( -2 ) === '/.' ||
+                               title.slice( -3 ) === '/..'
+                       )
+               ) {
+                       return false;
+               }
 
-                               // Remove whitespace of the name part (that W/O extension)
-                               title = parts.join( '.' ).trim();
+               // Disallow magic tilde sequence
+               if ( title.indexOf( '~~~' ) !== -1 ) {
+                       return false;
+               }
 
-                               // Cut, if too long and append file extension
-                               title = trimFileNameToByteLength( title, ext );
+               // Disallow titles exceeding the TITLE_MAX_BYTES byte size limit (size of underlying database field)
+               // Except for special pages, e.g. [[Special:Block/Long name]]
+               // Note: The PHP implementation also asserts that even in NS_SPECIAL, the title should
+               // be less than 512 bytes.
+               if ( namespace !== NS_SPECIAL && mwString.byteLength( title ) > TITLE_MAX_BYTES ) {
+                       return false;
+               }
 
-                       } else {
+               // Can't make a link to a namespace alone.
+               if ( title === '' && namespace !== NS_MAIN ) {
+                       return false;
+               }
 
-                               // Missing file extension
-                               title = parts.join( '.' ).trim();
+               // Any remaining initial :s are illegal.
+               if ( title[ 0 ] === ':' ) {
+                       return false;
+               }
 
-                               // Name has no file extension and a fallback wasn't provided either
-                               return null;
-                       }
+               // For backwards-compatibility with old mw.Title, we separate the extension from the
+               // rest of the title.
+               i = title.lastIndexOf( '.' );
+               if ( i === -1 || title.length <= i + 1 ) {
+                       // Extensions are the non-empty segment after the last dot
+                       ext = null;
                } else {
-
-                       title = sanitize( title, [ 'generalRule' ] );
-
-                       // Cut titles exceeding the TITLE_MAX_BYTES byte size limit
-                       // (size of underlying database field)
-                       if ( namespace !== NS_SPECIAL ) {
-                               title = trimToByteLength( title, TITLE_MAX_BYTES );
-                       }
+                       ext = title.slice( i + 1 );
+                       title = title.slice( 0, i );
                }
 
-               // Any remaining initial :s are illegal.
-               title = title.replace( /^:+/, '' );
-
-               return Title.newFromText( title, namespace );
-       };
+               return {
+                       namespace: namespace,
+                       title: title,
+                       ext: ext,
+                       fragment: fragment
+               };
+       },
 
        /**
-        * Sanitizes a file name as supplied by the user, originating in the user's file system
-        * so it is most likely a valid MediaWiki title and file name after processing.
-        * Returns null on fatal errors.
+        * Convert db-key to readable text.
         *
+        * @private
         * @static
-        * @param {string} uncleanName The unclean file name including file extension but
-        *   without namespace
-        * @return {mw.Title|null} A valid Title object or null if the title is invalid
+        * @method text
+        * @param {string} s
+        * @return {string}
         */
-       Title.newFromFileName = function ( uncleanName ) {
+       text = function ( s ) {
+               return s.replace( /_/g, ' ' );
+       },
 
-               return Title.newFromUserInput( 'File:' + uncleanName, {
-                       forUploading: true
-               } );
-       };
+       /**
+        * Sanitizes a string based on a rule set and a filter
+        *
+        * @private
+        * @static
+        * @method sanitize
+        * @param {string} s
+        * @param {Array} filter
+        * @return {string}
+        */
+       sanitize = function ( s, filter ) {
+               var i, ruleLength, rule, m, filterLength,
+                       rules = sanitationRules;
+
+               for ( i = 0, ruleLength = rules.length; i < ruleLength; ++i ) {
+                       rule = rules[ i ];
+                       for ( m = 0, filterLength = filter.length; m < filterLength; ++m ) {
+                               if ( rule[ filter[ m ] ] ) {
+                                       s = s.replace( rule.pattern, rule.replace );
+                               }
+                       }
+               }
+               return s;
+       },
 
        /**
-        * Get the file title from an image element
+        * Cuts a string to a specific byte length, assuming UTF-8
+        * or less, if the last character is a multi-byte one
         *
-        *     var title = mw.Title.newFromImg( $( 'img:first' ) );
+        * @private
+        * @static
+        * @method trimToByteLength
+        * @param {string} s
+        * @param {number} length
+        * @return {string}
+        */
+       trimToByteLength = function ( s, length ) {
+               return mwString.trimByteLength( '', s, length ).newVal;
+       },
+
+       /**
+        * Cuts a file name to a specific byte length
         *
+        * @private
         * @static
-        * @param {HTMLElement|jQuery} img The image to use as a base
-        * @return {mw.Title|null} The file title or null if unsuccessful
+        * @method trimFileNameToByteLength
+        * @param {string} name without extension
+        * @param {string} extension file extension
+        * @return {string} The full name, including extension
         */
-       Title.newFromImg = function ( img ) {
-               var matches, i, regex, src, decodedSrc,
+       trimFileNameToByteLength = function ( name, extension ) {
+               // There is a special byte limit for file names and ... remember the dot
+               return trimToByteLength( name, FILENAME_MAX_BYTES - extension.length - 1 ) + '.' + extension;
+       };
+
+/**
+ * @method constructor
+ * @param {string} title Title of the page. If no second argument given,
+ *  this will be searched for a namespace
+ * @param {number} [namespace=NS_MAIN] If given, will used as default namespace for the given title
+ * @throws {Error} When the title is invalid
+ */
+function Title( title, namespace ) {
+       var parsed = parse( title, namespace );
+       if ( !parsed ) {
+               throw new Error( 'Unable to parse title' );
+       }
 
-                       // thumb.php-generated thumbnails
-                       thumbPhpRegex = /thumb\.php/,
-                       regexes = [
-                               // Thumbnails
-                               /\/[a-f0-9]\/[a-f0-9]{2}\/([^\s/]+)\/[^\s/]+-[^\s/]*$/,
+       this.namespace = parsed.namespace;
+       this.title = parsed.title;
+       this.ext = parsed.ext;
+       this.fragment = parsed.fragment;
+}
+
+/* Static members */
+
+/**
+ * Constructor for Title objects with a null return instead of an exception for invalid titles.
+ *
+ * Note that `namespace` is the **default** namespace only, and can be overridden by a namespace
+ * prefix in `title`. If you do not want this behavior, use #makeTitle. See #constructor for
+ * details.
+ *
+ * @static
+ * @param {string} title
+ * @param {number} [namespace=NS_MAIN] Default namespace
+ * @return {mw.Title|null} A valid Title object or null if the title is invalid
+ */
+Title.newFromText = function ( title, namespace ) {
+       var t, parsed = parse( title, namespace );
+       if ( !parsed ) {
+               return null;
+       }
+
+       t = Object.create( Title.prototype );
+       t.namespace = parsed.namespace;
+       t.title = parsed.title;
+       t.ext = parsed.ext;
+       t.fragment = parsed.fragment;
+
+       return t;
+};
+
+/**
+ * Constructor for Title objects with predefined namespace.
+ *
+ * Unlike #newFromText or #constructor, this function doesn't allow the given `namespace` to be
+ * overridden by a namespace prefix in `title`. See #constructor for details about this behavior.
+ *
+ * The single exception to this is when `namespace` is 0, indicating the main namespace. The
+ * function behaves like #newFromText in that case.
+ *
+ * @static
+ * @param {number} namespace Namespace to use for the title
+ * @param {string} title
+ * @return {mw.Title|null} A valid Title object or null if the title is invalid
+ */
+Title.makeTitle = function ( namespace, title ) {
+       return mw.Title.newFromText( getNamespacePrefix( namespace ) + title );
+};
+
+/**
+ * Constructor for Title objects from user input altering that input to
+ * produce a title that MediaWiki will accept as legal
+ *
+ * @static
+ * @param {string} title
+ * @param {number|Object} [defaultNamespaceOrOptions=NS_MAIN]
+ *  If given, will used as default namespace for the given title.
+ *  This method can also be called with two arguments, in which case
+ *  this becomes options (see below).
+ * @param {Object} [options] additional options
+ * @param {boolean} [options.forUploading=true]
+ *  Makes sure that a file is uploadable under the title returned.
+ *  There are pages in the file namespace under which file upload is impossible.
+ *  Automatically assumed if the title is created in the Media namespace.
+ * @return {mw.Title|null} A valid Title object or null if the input cannot be turned into a valid title
+ */
+Title.newFromUserInput = function ( title, defaultNamespaceOrOptions, options ) {
+       var namespace, m, id, ext, parts,
+               defaultNamespace;
+
+       // defaultNamespace is optional; check whether options moves up
+       if ( arguments.length < 3 && typeof defaultNamespace === 'object' ) {
+               options = defaultNamespaceOrOptions;
+       } else {
+               defaultNamespace = defaultNamespaceOrOptions;
+       }
+
+       // merge options into defaults
+       options = $.extend( {
+               forUploading: true
+       }, options );
+
+       namespace = defaultNamespace === undefined ? NS_MAIN : defaultNamespace;
+
+       // Normalise additional whitespace
+       title = title.replace( /\s/g, ' ' ).trim();
+
+       // Process initial colon
+       if ( title !== '' && title[ 0 ] === ':' ) {
+               // Initial colon means main namespace instead of specified default
+               namespace = NS_MAIN;
+               title = title
+                       // Strip colon
+                       .substr( 1 )
+                       // Trim underscores
+                       .replace( rUnderscoreTrim, '' );
+       }
+
+       // Process namespace prefix (if any)
+       m = title.match( rSplit );
+       if ( m ) {
+               id = getNsIdByName( m[ 1 ] );
+               if ( id !== false ) {
+                       // Ordinary namespace
+                       namespace = id;
+                       title = m[ 2 ];
+               }
+       }
 
-                               // Full size images
-                               /\/[a-f0-9]\/[a-f0-9]{2}\/([^\s/]+)$/,
+       if (
+               namespace === NS_MEDIA ||
+               ( options.forUploading && ( namespace === NS_FILE ) )
+       ) {
 
-                               // Thumbnails in non-hashed upload directories
-                               /\/([^\s/]+)\/[^\s/]+-(?:\1|thumbnail)[^\s/]*$/,
+               title = sanitize( title, [ 'generalRule', 'fileRule' ] );
 
-                               // Full-size images in non-hashed upload directories
-                               /\/([^\s/]+)$/
-                       ],
+               // Operate on the file extension
+               // Although it is possible having spaces between the name and the ".ext" this isn't nice for
+               // operating systems hiding file extensions -> strip them later on
+               parts = title.split( '.' );
 
-                       recount = regexes.length;
+               if ( parts.length > 1 ) {
 
-               src = img.jquery ? img[ 0 ].src : img.src;
+                       // Get the last part, which is supposed to be the file extension
+                       ext = parts.pop();
 
-               matches = src.match( thumbPhpRegex );
+                       // Remove whitespace of the name part (that W/O extension)
+                       title = parts.join( '.' ).trim();
 
-               if ( matches ) {
-                       return mw.Title.newFromText( 'File:' + mw.util.getParamValue( 'f', src ) );
+                       // Cut, if too long and append file extension
+                       title = trimFileNameToByteLength( title, ext );
+
+               } else {
+
+                       // Missing file extension
+                       title = parts.join( '.' ).trim();
+
+                       // Name has no file extension and a fallback wasn't provided either
+                       return null;
                }
+       } else {
 
-               decodedSrc = decodeURIComponent( src );
+               title = sanitize( title, [ 'generalRule' ] );
 
-               for ( i = 0; i < recount; i++ ) {
-                       regex = regexes[ i ];
-                       matches = decodedSrc.match( regex );
+               // Cut titles exceeding the TITLE_MAX_BYTES byte size limit
+               // (size of underlying database field)
+               if ( namespace !== NS_SPECIAL ) {
+                       title = trimToByteLength( title, TITLE_MAX_BYTES );
+               }
+       }
 
-                       if ( matches && matches[ 1 ] ) {
-                               return mw.Title.newFromText( 'File:' + matches[ 1 ] );
-                       }
+       // Any remaining initial :s are illegal.
+       title = title.replace( /^:+/, '' );
+
+       return Title.newFromText( title, namespace );
+};
+
+/**
+ * Sanitizes a file name as supplied by the user, originating in the user's file system
+ * so it is most likely a valid MediaWiki title and file name after processing.
+ * Returns null on fatal errors.
+ *
+ * @static
+ * @param {string} uncleanName The unclean file name including file extension but
+ *   without namespace
+ * @return {mw.Title|null} A valid Title object or null if the title is invalid
+ */
+Title.newFromFileName = function ( uncleanName ) {
+
+       return Title.newFromUserInput( 'File:' + uncleanName, {
+               forUploading: true
+       } );
+};
+
+/**
+ * Get the file title from an image element
+ *
+ *     var title = mw.Title.newFromImg( $( 'img:first' ) );
+ *
+ * @static
+ * @param {HTMLElement|jQuery} img The image to use as a base
+ * @return {mw.Title|null} The file title or null if unsuccessful
+ */
+Title.newFromImg = function ( img ) {
+       var matches, i, regex, src, decodedSrc,
+
+               // thumb.php-generated thumbnails
+               thumbPhpRegex = /thumb\.php/,
+               regexes = [
+                       // Thumbnails
+                       /\/[a-f0-9]\/[a-f0-9]{2}\/([^\s/]+)\/[^\s/]+-[^\s/]*$/,
+
+                       // Full size images
+                       /\/[a-f0-9]\/[a-f0-9]{2}\/([^\s/]+)$/,
+
+                       // Thumbnails in non-hashed upload directories
+                       /\/([^\s/]+)\/[^\s/]+-(?:\1|thumbnail)[^\s/]*$/,
+
+                       // Full-size images in non-hashed upload directories
+                       /\/([^\s/]+)$/
+               ],
+
+               recount = regexes.length;
+
+       src = img.jquery ? img[ 0 ].src : img.src;
+
+       matches = src.match( thumbPhpRegex );
+
+       if ( matches ) {
+               return mw.Title.newFromText( 'File:' + mw.util.getParamValue( 'f', src ) );
+       }
+
+       decodedSrc = decodeURIComponent( src );
+
+       for ( i = 0; i < recount; i++ ) {
+               regex = regexes[ i ];
+               matches = decodedSrc.match( regex );
+
+               if ( matches && matches[ 1 ] ) {
+                       return mw.Title.newFromText( 'File:' + matches[ 1 ] );
                }
+       }
+
+       return null;
+};
+
+/**
+ * Check if a given namespace is a talk namespace
+ *
+ * See MWNamespace::isTalk in PHP
+ *
+ * @param {number} namespaceId Namespace ID
+ * @return {boolean} Namespace is a talk namespace
+ */
+Title.isTalkNamespace = function ( namespaceId ) {
+       return !!( namespaceId > NS_MAIN && namespaceId % 2 );
+};
+
+/**
+ * Check if signature buttons should be shown in a given namespace
+ *
+ * See MWNamespace::wantSignatures in PHP
+ *
+ * @param {number} namespaceId Namespace ID
+ * @return {boolean} Namespace is a signature namespace
+ */
+Title.wantSignaturesNamespace = function ( namespaceId ) {
+       return Title.isTalkNamespace( namespaceId ) ||
+               mw.config.get( 'wgExtraSignatureNamespaces' ).indexOf( namespaceId ) !== -1;
+};
+
+/**
+ * Whether this title exists on the wiki.
+ *
+ * @static
+ * @param {string|mw.Title} title prefixed db-key name (string) or instance of Title
+ * @return {boolean|null} Boolean if the information is available, otherwise null
+ * @throws {Error} If title is not a string or mw.Title
+ */
+Title.exists = function ( title ) {
+       var match,
+               obj = Title.exist.pages;
+
+       if ( typeof title === 'string' ) {
+               match = obj[ title ];
+       } else if ( title instanceof Title ) {
+               match = obj[ title.toString() ];
+       } else {
+               throw new Error( 'mw.Title.exists: title must be a string or an instance of Title' );
+       }
 
+       if ( typeof match !== 'boolean' ) {
                return null;
-       };
+       }
+
+       return match;
+};
+
+/**
+ * Store page existence
+ *
+ * @static
+ * @property {Object} exist
+ * @property {Object} exist.pages Keyed by title. Boolean true value indicates page does exist.
+ *
+ * @property {Function} exist.set The setter function.
+ *
+ *  Example to declare existing titles:
+ *
+ *     Title.exist.set( ['User:John_Doe', ...] );
+ *
+ *  Example to declare titles nonexistent:
+ *
+ *     Title.exist.set( ['File:Foo_bar.jpg', ...], false );
+ *
+ * @property {string|Array} exist.set.titles Title(s) in strict prefixedDb title form
+ * @property {boolean} [exist.set.state=true] State of the given titles
+ * @return {boolean}
+ */
+Title.exist = {
+       pages: {},
+
+       set: function ( titles, state ) {
+               var i, len,
+                       pages = this.pages;
+
+               titles = Array.isArray( titles ) ? titles : [ titles ];
+               state = state === undefined ? true : !!state;
+
+               for ( i = 0, len = titles.length; i < len; i++ ) {
+                       pages[ titles[ i ] ] = state;
+               }
+               return true;
+       }
+};
+
+/**
+ * Normalize a file extension to the common form, making it lowercase and checking some synonyms,
+ * and ensure it's clean. Extensions with non-alphanumeric characters will be discarded.
+ * Keep in sync with File::normalizeExtension() in PHP.
+ *
+ * @param {string} extension File extension (without the leading dot)
+ * @return {string} File extension in canonical form
+ */
+Title.normalizeExtension = function ( extension ) {
+       var
+               lower = extension.toLowerCase(),
+               squish = {
+                       htm: 'html',
+                       jpeg: 'jpg',
+                       mpeg: 'mpg',
+                       tiff: 'tif',
+                       ogv: 'ogg'
+               };
+       if ( Object.prototype.hasOwnProperty.call( squish, lower ) ) {
+               return squish[ lower ];
+       } else if ( /^[0-9a-z]+$/.test( lower ) ) {
+               return lower;
+       } else {
+               return '';
+       }
+};
+
+/**
+ * PHP's strtoupper differs from String.toUpperCase in a number of cases (T147646).
+ *
+ * @param {string} chr Unicode character
+ * @return {string} Unicode character, in upper case, according to the same rules as in PHP
+ */
+Title.phpCharToUpper = function ( chr ) {
+       var mapped = toUpperMapping[ chr ];
+       return mapped || chr.toUpperCase();
+};
+
+/* Public members */
+
+Title.prototype = {
+       constructor: Title,
 
        /**
-        * Check if a given namespace is a talk namespace
+        * Get the namespace number
         *
-        * See MWNamespace::isTalk in PHP
+        * Example: 6 for "File:Example_image.svg".
         *
-        * @param {number} namespaceId Namespace ID
-        * @return {boolean} Namespace is a talk namespace
+        * @return {number}
         */
-       Title.isTalkNamespace = function ( namespaceId ) {
-               return !!( namespaceId > NS_MAIN && namespaceId % 2 );
-       };
+       getNamespaceId: function () {
+               return this.namespace;
+       },
 
        /**
-        * Check if signature buttons should be shown in a given namespace
+        * Get the namespace prefix (in the content language)
         *
-        * See MWNamespace::wantSignatures in PHP
+        * Example: "File:" for "File:Example_image.svg".
+        * In #NS_MAIN this is '', otherwise namespace name plus ':'
         *
-        * @param {number} namespaceId Namespace ID
-        * @return {boolean} Namespace is a signature namespace
+        * @return {string}
         */
-       Title.wantSignaturesNamespace = function ( namespaceId ) {
-               return Title.isTalkNamespace( namespaceId ) ||
-                       mw.config.get( 'wgExtraSignatureNamespaces' ).indexOf( namespaceId ) !== -1;
-       };
+       getNamespacePrefix: function () {
+               return getNamespacePrefix( this.namespace );
+       },
 
        /**
-        * Whether this title exists on the wiki.
+        * Get the page name without extension or namespace prefix
         *
-        * @static
-        * @param {string|mw.Title} title prefixed db-key name (string) or instance of Title
-        * @return {boolean|null} Boolean if the information is available, otherwise null
-        * @throws {Error} If title is not a string or mw.Title
+        * Example: "Example_image" for "File:Example_image.svg".
+        *
+        * For the page title (full page name without namespace prefix), see #getMain.
+        *
+        * @return {string}
         */
-       Title.exists = function ( title ) {
-               var match,
-                       obj = Title.exist.pages;
-
-               if ( typeof title === 'string' ) {
-                       match = obj[ title ];
-               } else if ( title instanceof Title ) {
-                       match = obj[ title.toString() ];
-               } else {
-                       throw new Error( 'mw.Title.exists: title must be a string or an instance of Title' );
-               }
-
-               if ( typeof match !== 'boolean' ) {
-                       return null;
+       getName: function () {
+               if (
+                       mw.config.get( 'wgCaseSensitiveNamespaces' ).indexOf( this.namespace ) !== -1 ||
+                       !this.title.length
+               ) {
+                       return this.title;
                }
-
-               return match;
-       };
+               return mw.Title.phpCharToUpper( this.title[ 0 ] ) + this.title.slice( 1 );
+       },
 
        /**
-        * Store page existence
+        * Get the page name (transformed by #text)
         *
-        * @static
-        * @property {Object} exist
-        * @property {Object} exist.pages Keyed by title. Boolean true value indicates page does exist.
+        * Example: "Example image" for "File:Example_image.svg".
         *
-        * @property {Function} exist.set The setter function.
+        * For the page title (full page name without namespace prefix), see #getMainText.
         *
-        *  Example to declare existing titles:
+        * @return {string}
+        */
+       getNameText: function () {
+               return text( this.getName() );
+       },
+
+       /**
+        * Get the extension of the page name (if any)
         *
-        *     Title.exist.set( ['User:John_Doe', ...] );
+        * @return {string|null} Name extension or null if there is none
+        */
+       getExtension: function () {
+               return this.ext;
+       },
+
+       /**
+        * Shortcut for appendable string to form the main page name.
         *
-        *  Example to declare titles nonexistent:
+        * Returns a string like ".json", or "" if no extension.
         *
-        *     Title.exist.set( ['File:Foo_bar.jpg', ...], false );
+        * @return {string}
+        */
+       getDotExtension: function () {
+               return this.ext === null ? '' : '.' + this.ext;
+       },
+
+       /**
+        * Get the main page name
         *
-        * @property {string|Array} exist.set.titles Title(s) in strict prefixedDb title form
-        * @property {boolean} [exist.set.state=true] State of the given titles
-        * @return {boolean}
+        * Example: "Example_image.svg" for "File:Example_image.svg".
+        *
+        * @return {string}
         */
-       Title.exist = {
-               pages: {},
+       getMain: function () {
+               return this.getName() + this.getDotExtension();
+       },
 
-               set: function ( titles, state ) {
-                       var i, len,
-                               pages = this.pages;
+       /**
+        * Get the main page name (transformed by #text)
+        *
+        * Example: "Example image.svg" for "File:Example_image.svg".
+        *
+        * @return {string}
+        */
+       getMainText: function () {
+               return text( this.getMain() );
+       },
 
-                       titles = Array.isArray( titles ) ? titles : [ titles ];
-                       state = state === undefined ? true : !!state;
+       /**
+        * Get the full page name
+        *
+        * Example: "File:Example_image.svg".
+        * Most useful for API calls, anything that must identify the "title".
+        *
+        * @return {string}
+        */
+       getPrefixedDb: function () {
+               return this.getNamespacePrefix() + this.getMain();
+       },
 
-                       for ( i = 0, len = titles.length; i < len; i++ ) {
-                               pages[ titles[ i ] ] = state;
-                       }
-                       return true;
-               }
-       };
+       /**
+        * Get the full page name (transformed by #text)
+        *
+        * Example: "File:Example image.svg" for "File:Example_image.svg".
+        *
+        * @return {string}
+        */
+       getPrefixedText: function () {
+               return text( this.getPrefixedDb() );
+       },
 
        /**
-        * Normalize a file extension to the common form, making it lowercase and checking some synonyms,
-        * and ensure it's clean. Extensions with non-alphanumeric characters will be discarded.
-        * Keep in sync with File::normalizeExtension() in PHP.
+        * Get the page name relative to a namespace
         *
-        * @param {string} extension File extension (without the leading dot)
-        * @return {string} File extension in canonical form
+        * Example:
+        *
+        * - "Foo:Bar" relative to the Foo namespace becomes "Bar".
+        * - "Bar" relative to any non-main namespace becomes ":Bar".
+        * - "Foo:Bar" relative to any namespace other than Foo stays "Foo:Bar".
+        *
+        * @param {number} namespace The namespace to be relative to
+        * @return {string}
         */
-       Title.normalizeExtension = function ( extension ) {
-               var
-                       lower = extension.toLowerCase(),
-                       squish = {
-                               htm: 'html',
-                               jpeg: 'jpg',
-                               mpeg: 'mpg',
-                               tiff: 'tif',
-                               ogv: 'ogg'
-                       };
-               if ( Object.prototype.hasOwnProperty.call( squish, lower ) ) {
-                       return squish[ lower ];
-               } else if ( /^[0-9a-z]+$/.test( lower ) ) {
-                       return lower;
+       getRelativeText: function ( namespace ) {
+               if ( this.getNamespaceId() === namespace ) {
+                       return this.getMainText();
+               } else if ( this.getNamespaceId() === NS_MAIN ) {
+                       return ':' + this.getPrefixedText();
                } else {
-                       return '';
+                       return this.getPrefixedText();
                }
-       };
+       },
 
        /**
-        * PHP's strtoupper differs from String.toUpperCase in a number of cases (T147646).
+        * Get the fragment (if any).
         *
-        * @param {string} chr Unicode character
-        * @return {string} Unicode character, in upper case, according to the same rules as in PHP
+        * Note that this method (by design) does not include the hash character and
+        * the value is not url encoded.
+        *
+        * @return {string|null}
         */
-       Title.phpCharToUpper = function ( chr ) {
-               var mapped = toUpperMapping[ chr ];
-               return mapped || chr.toUpperCase();
-       };
-
-       /* Public members */
-
-       Title.prototype = {
-               constructor: Title,
-
-               /**
-                * Get the namespace number
-                *
-                * Example: 6 for "File:Example_image.svg".
-                *
-                * @return {number}
-                */
-               getNamespaceId: function () {
-                       return this.namespace;
-               },
-
-               /**
-                * Get the namespace prefix (in the content language)
-                *
-                * Example: "File:" for "File:Example_image.svg".
-                * In #NS_MAIN this is '', otherwise namespace name plus ':'
-                *
-                * @return {string}
-                */
-               getNamespacePrefix: function () {
-                       return getNamespacePrefix( this.namespace );
-               },
-
-               /**
-                * Get the page name without extension or namespace prefix
-                *
-                * Example: "Example_image" for "File:Example_image.svg".
-                *
-                * For the page title (full page name without namespace prefix), see #getMain.
-                *
-                * @return {string}
-                */
-               getName: function () {
-                       if (
-                               mw.config.get( 'wgCaseSensitiveNamespaces' ).indexOf( this.namespace ) !== -1 ||
-                               !this.title.length
-                       ) {
-                               return this.title;
-                       }
-                       return mw.Title.phpCharToUpper( this.title[ 0 ] ) + this.title.slice( 1 );
-               },
-
-               /**
-                * Get the page name (transformed by #text)
-                *
-                * Example: "Example image" for "File:Example_image.svg".
-                *
-                * For the page title (full page name without namespace prefix), see #getMainText.
-                *
-                * @return {string}
-                */
-               getNameText: function () {
-                       return text( this.getName() );
-               },
-
-               /**
-                * Get the extension of the page name (if any)
-                *
-                * @return {string|null} Name extension or null if there is none
-                */
-               getExtension: function () {
-                       return this.ext;
-               },
-
-               /**
-                * Shortcut for appendable string to form the main page name.
-                *
-                * Returns a string like ".json", or "" if no extension.
-                *
-                * @return {string}
-                */
-               getDotExtension: function () {
-                       return this.ext === null ? '' : '.' + this.ext;
-               },
-
-               /**
-                * Get the main page name
-                *
-                * Example: "Example_image.svg" for "File:Example_image.svg".
-                *
-                * @return {string}
-                */
-               getMain: function () {
-                       return this.getName() + this.getDotExtension();
-               },
-
-               /**
-                * Get the main page name (transformed by #text)
-                *
-                * Example: "Example image.svg" for "File:Example_image.svg".
-                *
-                * @return {string}
-                */
-               getMainText: function () {
-                       return text( this.getMain() );
-               },
-
-               /**
-                * Get the full page name
-                *
-                * Example: "File:Example_image.svg".
-                * Most useful for API calls, anything that must identify the "title".
-                *
-                * @return {string}
-                */
-               getPrefixedDb: function () {
-                       return this.getNamespacePrefix() + this.getMain();
-               },
-
-               /**
-                * Get the full page name (transformed by #text)
-                *
-                * Example: "File:Example image.svg" for "File:Example_image.svg".
-                *
-                * @return {string}
-                */
-               getPrefixedText: function () {
-                       return text( this.getPrefixedDb() );
-               },
-
-               /**
-                * Get the page name relative to a namespace
-                *
-                * Example:
-                *
-                * - "Foo:Bar" relative to the Foo namespace becomes "Bar".
-                * - "Bar" relative to any non-main namespace becomes ":Bar".
-                * - "Foo:Bar" relative to any namespace other than Foo stays "Foo:Bar".
-                *
-                * @param {number} namespace The namespace to be relative to
-                * @return {string}
-                */
-               getRelativeText: function ( namespace ) {
-                       if ( this.getNamespaceId() === namespace ) {
-                               return this.getMainText();
-                       } else if ( this.getNamespaceId() === NS_MAIN ) {
-                               return ':' + this.getPrefixedText();
-                       } else {
-                               return this.getPrefixedText();
-                       }
-               },
-
-               /**
-                * Get the fragment (if any).
-                *
-                * Note that this method (by design) does not include the hash character and
-                * the value is not url encoded.
-                *
-                * @return {string|null}
-                */
-               getFragment: function () {
-                       return this.fragment;
-               },
-
-               /**
-                * Get the URL to this title
-                *
-                * @see mw.util#getUrl
-                * @param {Object} [params] A mapping of query parameter names to values,
-                *     e.g. `{ action: 'edit' }`.
-                * @return {string}
-                */
-               getUrl: function ( params ) {
-                       var fragment = this.getFragment();
-                       if ( fragment ) {
-                               return mw.util.getUrl( this.toString() + '#' + fragment, params );
-                       } else {
-                               return mw.util.getUrl( this.toString(), params );
-                       }
-               },
-
-               /**
-                * Check if the title is in a talk namespace
-                *
-                * @return {boolean} The title is in a talk namespace
-                */
-               isTalkPage: function () {
-                       return Title.isTalkNamespace( this.getNamespaceId() );
-               },
-
-               /**
-                * Get the title for the associated talk page
-                *
-                * @return {mw.Title|null} The title for the associated talk page, null if not available
-                */
-               getTalkPage: function () {
-                       if ( !this.canHaveTalkPage() ) {
-                               return null;
-                       }
-                       return this.isTalkPage() ?
-                               this :
-                               Title.makeTitle( this.getNamespaceId() + 1, this.getMainText() );
-               },
+       getFragment: function () {
+               return this.fragment;
+       },
 
-               /**
-                * Get the title for the subject page of a talk page
-                *
-                * @return {mw.Title|null} The title for the subject page of a talk page, null if not available
-                */
-               getSubjectPage: function () {
-                       return this.isTalkPage() ?
-                               Title.makeTitle( this.getNamespaceId() - 1, this.getMainText() ) :
-                               this;
-               },
+       /**
+        * Get the URL to this title
+        *
+        * @see mw.util#getUrl
+        * @param {Object} [params] A mapping of query parameter names to values,
+        *     e.g. `{ action: 'edit' }`.
+        * @return {string}
+        */
+       getUrl: function ( params ) {
+               var fragment = this.getFragment();
+               if ( fragment ) {
+                       return mw.util.getUrl( this.toString() + '#' + fragment, params );
+               } else {
+                       return mw.util.getUrl( this.toString(), params );
+               }
+       },
 
-               /**
-                * Check the the title can have an associated talk page
-                *
-                * @return {boolean} The title can have an associated talk page
-                */
-               canHaveTalkPage: function () {
-                       return this.getNamespaceId() >= NS_MAIN;
-               },
+       /**
+        * Check if the title is in a talk namespace
+        *
+        * @return {boolean} The title is in a talk namespace
+        */
+       isTalkPage: function () {
+               return Title.isTalkNamespace( this.getNamespaceId() );
+       },
 
-               /**
-                * Whether this title exists on the wiki.
-                *
-                * @see #static-method-exists
-                * @return {boolean|null} Boolean if the information is available, otherwise null
-                */
-               exists: function () {
-                       return Title.exists( this );
+       /**
+        * Get the title for the associated talk page
+        *
+        * @return {mw.Title|null} The title for the associated talk page, null if not available
+        */
+       getTalkPage: function () {
+               if ( !this.canHaveTalkPage() ) {
+                       return null;
                }
-       };
+               return this.isTalkPage() ?
+                       this :
+                       Title.makeTitle( this.getNamespaceId() + 1, this.getMainText() );
+       },
 
        /**
-        * @alias #getPrefixedDb
-        * @method
+        * Get the title for the subject page of a talk page
+        *
+        * @return {mw.Title|null} The title for the subject page of a talk page, null if not available
         */
-       Title.prototype.toString = Title.prototype.getPrefixedDb;
+       getSubjectPage: function () {
+               return this.isTalkPage() ?
+                       Title.makeTitle( this.getNamespaceId() - 1, this.getMainText() ) :
+                       this;
+       },
 
        /**
-        * @alias #getPrefixedText
-        * @method
+        * Check the the title can have an associated talk page
+        *
+        * @return {boolean} The title can have an associated talk page
         */
-       Title.prototype.toText = Title.prototype.getPrefixedText;
+       canHaveTalkPage: function () {
+               return this.getNamespaceId() >= NS_MAIN;
+       },
 
-       // Expose
-       mw.Title = Title;
+       /**
+        * Whether this title exists on the wiki.
+        *
+        * @see #static-method-exists
+        * @return {boolean|null} Boolean if the information is available, otherwise null
+        */
+       exists: function () {
+               return Title.exists( this );
+       }
+};
+
+/**
+ * @alias #getPrefixedDb
+ * @method
+ */
+Title.prototype.toString = Title.prototype.getPrefixedDb;
+
+/**
+ * @alias #getPrefixedText
+ * @method
+ */
+Title.prototype.toText = Title.prototype.getPrefixedText;
 
-}() );
+// Expose
+mw.Title = Title;
index f87afb0..8f34180 100644 (file)
@@ -83,6 +83,11 @@ class MediaWikiLoggerPHPUnitTestListener extends PHPUnit_Framework_BaseTestListe
        private function formatLogs( array $logs ) {
                $message = [];
                foreach ( $logs as $log ) {
+                       if ( $log['channel'] === 'PHPUnitCommand' ) {
+                               // Don't print the log of PHPUnit events while running PHPUnit,
+                               // because PHPUnit is already printing those already.
+                               continue;
+                       }
                        $message[] = sprintf(
                                '[%s] [%s] %s %s',
                                $log['channel'],
index cc01c7d..4291bcc 100644 (file)
@@ -307,11 +307,16 @@ class LoadBalancerTest extends MediaWikiTestCase {
 
                $i = $lb->getWriterIndex();
                $this->assertEquals( null, $lb->getAnyOpenConnection( $i ) );
+
                $conn1 = $lb->getConnection( $i );
                $this->assertNotEquals( null, $conn1 );
                $this->assertEquals( $conn1, $lb->getAnyOpenConnection( $i ) );
+               $this->assertFalse( $conn1->getFlag( DBO_TRX ) );
+
                $conn2 = $lb->getConnection( $i, [], false, $lb::CONN_TRX_AUTOCOMMIT );
                $this->assertNotEquals( null, $conn2 );
+               $this->assertFalse( $conn2->getFlag( DBO_TRX ) );
+
                if ( $lb->getServerAttributes( $i )[Database::ATTR_DB_LEVEL_LOCKING] ) {
                        $this->assertEquals( null,
                                $lb->getAnyOpenConnection( $i, $lb::CONN_TRX_AUTOCOMMIT ) );
@@ -355,7 +360,7 @@ class LoadBalancerTest extends MediaWikiTestCase {
                                'type' => $wgDBtype,
                                'dbDirectory' => $wgSQLiteDataDir,
                                'load' => 0,
-                               'flags' => DBO_TRX // REPEATABLE-READ for consistency
+                               'flags' => DBO_TRX // simulate a web request with DBO_TRX
                        ],
                ];
 
@@ -428,4 +433,60 @@ class LoadBalancerTest extends MediaWikiTestCase {
                $conn1->close();
                $conn2->close();
        }
+
+       public function testDBConnRefReadsMasterAndReplicaRoles() {
+               $lb = $this->newSingleServerLocalLoadBalancer();
+
+               $rConn = $lb->getConnectionRef( DB_REPLICA );
+               $wConn = $lb->getConnectionRef( DB_MASTER );
+               $wConn2 = $lb->getConnectionRef( 0 );
+
+               $v = [ 'value' => '1', '1' ];
+               $sql = 'SELECT MAX(1) AS value';
+               foreach ( [ $rConn, $wConn, $wConn2 ] as $conn ) {
+                       $conn->clearFlag( $conn::DBO_TRX );
+
+                       $res = $conn->query( $sql, __METHOD__ );
+                       $this->assertEquals( $v, $conn->fetchRow( $res ) );
+
+                       $res = $conn->query( $sql, __METHOD__, $conn::QUERY_REPLICA_ROLE );
+                       $this->assertEquals( $v, $conn->fetchRow( $res ) );
+               }
+
+               $wConn->getScopedLockAndFlush( 'key', __METHOD__, 1 );
+               $wConn2->getScopedLockAndFlush( 'key2', __METHOD__, 1 );
+       }
+
+       /**
+        * @expectedException \Wikimedia\Rdbms\DBReadOnlyRoleError
+        */
+       public function testDBConnRefWritesReplicaRole() {
+               $lb = $this->newSingleServerLocalLoadBalancer();
+
+               $rConn = $lb->getConnectionRef( DB_REPLICA );
+
+               $rConn->query( 'DELETE FROM sometesttable WHERE 1=0' );
+       }
+
+       /**
+        * @expectedException \Wikimedia\Rdbms\DBReadOnlyRoleError
+        */
+       public function testDBConnRefWritesReplicaRoleIndex() {
+               $lb = $this->newMultiServerLocalLoadBalancer();
+
+               $rConn = $lb->getConnectionRef( 1 );
+
+               $rConn->query( 'DELETE FROM sometesttable WHERE 1=0' );
+       }
+
+       /**
+        * @expectedException \Wikimedia\Rdbms\DBReadOnlyRoleError
+        */
+       public function testDBConnRefWritesReplicaRoleInsert() {
+               $lb = $this->newMultiServerLocalLoadBalancer();
+
+               $rConn = $lb->getConnectionRef( DB_REPLICA );
+
+               $rConn->insert( 'test', [ 't' => 1 ], __METHOD__ );
+       }
 }
index 9b72b95..33e5c3b 100644 (file)
@@ -75,12 +75,12 @@ class DBConnRefTest extends PHPUnit\Framework\TestCase {
         */
        private function getDBConnRef( ILoadBalancer $lb = null ) {
                $lb = $lb ?: $this->getLoadBalancerMock();
-               return new DBConnRef( $lb, $this->getDatabaseMock() );
+               return new DBConnRef( $lb, $this->getDatabaseMock(), DB_MASTER );
        }
 
        public function testConstruct() {
                $lb = $this->getLoadBalancerMock();
-               $ref = new DBConnRef( $lb, $this->getDatabaseMock() );
+               $ref = new DBConnRef( $lb, $this->getDatabaseMock(), DB_MASTER );
 
                $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) );
        }
@@ -99,10 +99,19 @@ class DBConnRefTest extends PHPUnit\Framework\TestCase {
 
                $ref = new DBConnRef(
                        $lb,
-                       [ DB_MASTER, [ 'test' ], 'dummy', ILoadBalancer::CONN_TRX_AUTOCOMMIT ]
+                       [ DB_MASTER, [ 'test' ], 'dummy', ILoadBalancer::CONN_TRX_AUTOCOMMIT ],
+                       DB_MASTER
                );
 
                $this->assertInstanceOf( ResultWrapper::class, $ref->select( 'whatever', '*' ) );
+               $this->assertEquals( DB_MASTER, $ref->getReferenceRole() );
+
+               $ref2 = new DBConnRef(
+                       $lb,
+                       [ DB_MASTER, [ 'test' ], 'dummy', ILoadBalancer::CONN_TRX_AUTOCOMMIT ],
+                       DB_REPLICA
+               );
+               $this->assertEquals( DB_REPLICA, $ref2->getReferenceRole() );
        }
 
        public function testDestruct() {
@@ -124,7 +133,7 @@ class DBConnRefTest extends PHPUnit\Framework\TestCase {
                $this->setExpectedException( InvalidArgumentException::class, '' );
 
                $lb = $this->getLoadBalancerMock();
-               new DBConnRef( $lb, 17 ); // bad constructor argument
+               new DBConnRef( $lb, 17, DB_REPLICA ); // bad constructor argument
        }
 
        /**
@@ -137,7 +146,7 @@ class DBConnRefTest extends PHPUnit\Framework\TestCase {
                $lb->expects( $this->never() )
                        ->method( 'getConnection' );
 
-               $ref = new DBConnRef( $lb, [ DB_REPLICA, [], 'dummy', 0 ] );
+               $ref = new DBConnRef( $lb, [ DB_REPLICA, [], 'dummy', 0 ], DB_REPLICA );
 
                $this->assertSame( 'dummy', $ref->getDomainID() );
        }
@@ -156,7 +165,7 @@ class DBConnRefTest extends PHPUnit\Framework\TestCase {
                $this->assertInternalType( 'string', $ref->__toString() );
 
                $lb = $this->getLoadBalancerMock();
-               $ref = new DBConnRef( $lb, [ DB_MASTER, [], 'test', 0 ] );
+               $ref = new DBConnRef( $lb, [ DB_MASTER, [], 'test', 0 ], DB_MASTER );
                $this->assertInternalType( 'string', $ref->__toString() );
        }
 
@@ -166,7 +175,49 @@ class DBConnRefTest extends PHPUnit\Framework\TestCase {
         */
        public function testClose() {
                $lb = $this->getLoadBalancerMock();
-               $ref = new DBConnRef( $lb, [ DB_REPLICA, [], 'dummy', 0 ] );
+               $ref = new DBConnRef( $lb, [ DB_REPLICA, [], 'dummy', 0 ], DB_MASTER );
                $ref->close();
        }
+
+       /**
+        * @covers Wikimedia\Rdbms\DBConnRef::getReferenceRole
+        */
+       public function testGetReferenceRole() {
+               $lb = $this->getLoadBalancerMock();
+               $ref = new DBConnRef( $lb, [ DB_REPLICA, [], 'dummy', 0 ], DB_REPLICA );
+               $this->assertSame( DB_REPLICA, $ref->getReferenceRole() );
+
+               $ref = new DBConnRef( $lb, [ DB_MASTER, [], 'dummy', 0 ], DB_MASTER );
+               $this->assertSame( DB_MASTER, $ref->getReferenceRole() );
+
+               $ref = new DBConnRef( $lb, [ 1, [], 'dummy', 0 ], DB_REPLICA );
+               $this->assertSame( DB_REPLICA, $ref->getReferenceRole() );
+
+               $ref = new DBConnRef( $lb, [ 0, [], 'dummy', 0 ], DB_MASTER );
+               $this->assertSame( DB_MASTER, $ref->getReferenceRole() );
+       }
+
+       /**
+        * @covers Wikimedia\Rdbms\DBConnRef::getReferenceRole
+        * @expectedException Wikimedia\Rdbms\DBReadOnlyRoleError
+        * @dataProvider provideRoleExceptions
+        */
+       public function testRoleExceptions( $method, $args ) {
+               $lb = $this->getLoadBalancerMock();
+               $ref = new DBConnRef( $lb, [ DB_REPLICA, [], 'dummy', 0 ], DB_REPLICA );
+               $ref->$method( ...$args );
+       }
+
+       function provideRoleExceptions() {
+               return [
+                       [ 'insert', [ 'table', [ 'a' => 1 ] ] ],
+                       [ 'update', [ 'table', [ 'a' => 1 ], [ 'a' => 2 ] ] ],
+                       [ 'delete', [ 'table', [ 'a' => 1 ] ] ],
+                       [ 'replace', [ 'table', [ 'a' ], [ 'a' => 1 ] ] ],
+                       [ 'upsert', [ 'table', [ 'a' => 1 ], [ 'a' ], [ 'a = a + 1' ] ] ],
+                       [ 'lock', [ 'k', 'method' ] ],
+                       [ 'unlock', [ 'k', 'method' ] ],
+                       [ 'getScopedLockAndFlush', [ 'k', 'method', 1 ] ]
+               ];
+       }
 }
index 825c9b9..9ab3a2d 100644 (file)
@@ -120,7 +120,7 @@ Deprecation message.' ]
                // phpcs:enable
                $expected = self::expandVariables( $expected );
 
-               $this->assertEquals( $expected, $client->getHeadHtml() );
+               $this->assertSame( $expected, (string)$client->getHeadHtml() );
        }
 
        /**
@@ -137,7 +137,7 @@ Deprecation message.' ]
                        . '<script async="" src="/w/load.php?lang=nl&amp;modules=startup&amp;only=scripts&amp;skin=fallback&amp;target=example"></script>';
                // phpcs:enable
 
-               $this->assertEquals( $expected, $client->getHeadHtml() );
+               $this->assertSame( $expected, (string)$client->getHeadHtml() );
        }
 
        /**
@@ -154,7 +154,7 @@ Deprecation message.' ]
                        . '<script async="" src="/w/load.php?lang=nl&amp;modules=startup&amp;only=scripts&amp;safemode=1&amp;skin=fallback"></script>';
                // phpcs:enable
 
-               $this->assertEquals( $expected, $client->getHeadHtml() );
+               $this->assertSame( $expected, (string)$client->getHeadHtml() );
        }
 
        /**
@@ -171,7 +171,7 @@ Deprecation message.' ]
                        . '<script async="" src="/w/load.php?lang=nl&amp;modules=startup&amp;only=scripts&amp;skin=fallback"></script>';
                // phpcs:enable
 
-               $this->assertEquals( $expected, $client->getHeadHtml() );
+               $this->assertSame( $expected, (string)$client->getHeadHtml() );
        }
 
        public function testGetBodyHtml() {
@@ -193,7 +193,7 @@ Deprecation message.' ]
                        . '});</script>';
                // phpcs:enable
 
-               $this->assertEquals( $expected, $client->getBodyHtml() );
+               $this->assertSame( $expected, (string)$client->getBodyHtml() );
        }
 
        public static function provideMakeLoad() {
@@ -339,7 +339,7 @@ Deprecation message.' ]
                $context->getResourceLoader()->register( self::makeSampleModules() );
                $actual = ResourceLoaderClientHtml::makeLoad( $context, $modules, $type, $extraQuery, false );
                $expected = self::expandVariables( $expected );
-               $this->assertEquals( $expected, (string)$actual );
+               $this->assertSame( $expected, (string)$actual );
        }
 
        public function testGetDocumentAttributes() {