Merge "rdbms: implement IDatabase::serverIsReadOnly() for sqlite/mssql"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Fri, 28 Jun 2019 22:18:33 +0000 (22:18 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Fri, 28 Jun 2019 22:18:33 +0000 (22:18 +0000)
103 files changed:
.gitattributes
.gitignore
.phpcs.xml
RELEASE-NOTES-1.34
composer.json
docs/export-0.11.xsd [new file with mode: 0644]
docs/hooks.txt
includes/DefaultSettings.php
includes/Defines.php
includes/OutputPage.php
includes/Revision/RevisionStore.php
includes/Setup.php
includes/api/ApiQuery.php
includes/api/ApiQueryImageInfo.php
includes/api/i18n/ar.json
includes/api/i18n/en.json
includes/api/i18n/fr.json
includes/api/i18n/mk.json
includes/api/i18n/qqq.json
includes/db/DatabaseOracle.php
includes/export/WikiExporter.php
includes/export/XmlDumpWriter.php
includes/installer/i18n/vi.json
includes/libs/rdbms/connectionmanager/ConnectionManager.php
includes/libs/rdbms/connectionmanager/SessionConsistentConnectionManager.php
includes/libs/rdbms/database/DBConnRef.php
includes/libs/rdbms/database/Database.php
includes/libs/rdbms/database/DatabaseMssql.php
includes/libs/rdbms/database/DatabasePostgres.php
includes/libs/rdbms/database/DatabaseSqlite.php
includes/libs/rdbms/database/IDatabase.php
includes/libs/rdbms/database/resultwrapper/FakeResultWrapper.php
includes/libs/rdbms/loadbalancer/ILoadBalancer.php
includes/libs/rdbms/loadbalancer/LoadBalancer.php
includes/specials/SpecialEmailUser.php
languages/i18n/ast.json
languages/i18n/ban.json
languages/i18n/be-tarask.json
languages/i18n/bn.json
languages/i18n/ca.json
languages/i18n/cy.json
languages/i18n/diq.json
languages/i18n/en-gb.json
languages/i18n/en.json
languages/i18n/eo.json
languages/i18n/fr.json
languages/i18n/gl.json
languages/i18n/gom-deva.json
languages/i18n/hy.json
languages/i18n/ia.json
languages/i18n/jv.json
languages/i18n/lt.json
languages/i18n/my.json
languages/i18n/nan.json
languages/i18n/nqo.json
languages/i18n/pt.json
languages/i18n/sk.json
languages/i18n/sq.json
languages/i18n/sr-ec.json
languages/i18n/sv.json
languages/i18n/tr.json
languages/i18n/yue.json
languages/i18n/zh-hans.json
maintenance/findHooks.php
maintenance/includes/TextPassDumper.php
phpunit.xml.dist [new file with mode: 0644]
resources/src/jquery.tablesorter/jquery.tablesorter.js
resources/src/jquery/jquery.makeCollapsible.js
resources/src/jquery/jquery.suggestions.js
resources/src/mediawiki.Title/Title.js
resources/src/mediawiki.action/mediawiki.action.edit.preview.js
resources/src/mediawiki.legacy/protect.js
resources/src/mediawiki.page.gallery.js
resources/src/mediawiki.special.userlogin.signup.js
resources/src/mediawiki.toc/toc.js
resources/src/mediawiki.widgets.datetime/DateTimeInputWidget.js
tests/common/TestsAutoLoader.php
tests/phpunit/MediaWikiIntegrationTestCase.php [new file with mode: 0644]
tests/phpunit/MediaWikiTestCase.php [deleted file]
tests/phpunit/MediaWikiUnitTestCase.php
tests/phpunit/bootstrap.maintenance.php [new file with mode: 0644]
tests/phpunit/bootstrap.php
tests/phpunit/includes/Revision/McrReadNewRevisionStoreDbTest.php
tests/phpunit/includes/Revision/McrRevisionStoreDbTest.php
tests/phpunit/includes/Revision/McrWriteBothRevisionStoreDbTest.php
tests/phpunit/includes/Revision/NoContentModelRevisionStoreDbTest.php
tests/phpunit/includes/Revision/PreMcrRevisionStoreDbTest.php
tests/phpunit/includes/Revision/RevisionQueryInfoTest.php
tests/phpunit/includes/Revision/RevisionStoreDbTestBase.php
tests/phpunit/includes/db/DatabaseSqliteTest.php [deleted file]
tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php
tests/phpunit/integration/includes/db/DatabaseSqliteTest.php [new file with mode: 0644]
tests/phpunit/maintenance/DumpAsserter.php
tests/phpunit/maintenance/backup_PageTest.php
tests/phpunit/suite.xml
tests/phpunit/tests/MediaWikiTestCaseTest.php
tests/phpunit/unit-tests.xml [deleted file]
tests/phpunit/unit/initUnitTests.php [deleted file]
tests/qunit/suites/resources/jquery/jquery.makeCollapsible.test.js
tests/qunit/suites/resources/jquery/jquery.tablesorter.parsers.test.js
tests/qunit/suites/resources/jquery/jquery.tablesorter.test.js
tests/qunit/suites/resources/mediawiki/mediawiki.toc.test.js
tests/qunit/suites/resources/mediawiki/mediawiki.util.test.js

index 81b7a33..145caeb 100644 (file)
@@ -10,4 +10,4 @@ package.json export-ignore
 README.mediawiki export-ignore
 Gemfile* export-ignore
 vendor/pear/net_smtp/README.rst export-ignore
-
+phpunit.xml.dist export-ignore
index 8cacb1e..a76270e 100644 (file)
@@ -52,6 +52,7 @@ npm-debug.log
 node_modules/
 /resources/lib/.foreign
 /tests/phpunit/phpunit.phar
+phpunit.xml
 /tests/selenium/log
 .eslintcache
 
index 9ccf565..76234a2 100644 (file)
                <!-- Skip violations in some tests for now -->
                <exclude-pattern>*/tests/phpunit/includes/GlobalFunctions/*\.php</exclude-pattern>
                <exclude-pattern>*/tests/phpunit/maintenance/*\.php</exclude-pattern>
+               <exclude-pattern>*/tests/phpunit/integration/includes/GlobalFunctions/*\.php</exclude-pattern>
        </rule>
 
        <rule ref="Generic.Files.OneObjectStructurePerFile.MultipleFound">
index fdc9e05..acd82d6 100644 (file)
@@ -327,6 +327,8 @@ because of Phabricator reports.
   engines.
 * Skin::escapeSearchLink() is deprecated. Use Skin::getSearchLink() or the skin
   template option 'searchaction' instead.
+* LoadBalancer::haveIndex() and LoadBalancer::isNonZeroLoad() have
+  been deprecated.
 
 === Other changes in 1.34 ===
 * …
index 428c1a8..df51621 100644 (file)
                "test": [
                        "composer lint",
                        "composer phpcs"
-               ]
+               ],
+               "phpunit": "vendor/bin/phpunit",
+               "phpunit:unit": "vendor/bin/phpunit --colors=always --testsuite=unit",
+               "phpunit:integration": "vendor/bin/phpunit --colors=always --testsuite=integration",
+               "phpunit:coverage": "php -d zend_extensions=xdebug.so vendor/bin/phpunit --testsuite=unit --exclude-group Dump,Broken,ParserFuzz,Stub"
        },
        "config": {
                "optimize-autoloader": true,
diff --git a/docs/export-0.11.xsd b/docs/export-0.11.xsd
new file mode 100644 (file)
index 0000000..6dbc63b
--- /dev/null
@@ -0,0 +1,335 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+       This is an XML Schema description of the format
+       output by MediaWiki's Special:Export system.
+
+       Version 0.2 adds optional basic file upload info support,
+       which is used by our OAI export/import submodule.
+
+       Version 0.3 adds some site configuration information such
+       as a list of defined namespaces.
+
+       Version 0.4 adds per-revision delete flags, log exports,
+       discussion threading data, a per-page redirect flag, and
+       per-namespace capitalization.
+
+       Version 0.5 adds byte count per revision.
+
+       Version 0.6 adds a separate namespace tag, and resolves the
+       redirect target and adds a separate sha1 tag for each revision.
+
+       Version 0.7 adds a unique identity constraint for both page and
+       revision identifiers. See also bug 4220.
+       Fix type for <ns> from "positiveInteger" to "nonNegativeInteger" to allow 0
+       Moves <logitem> to its right location.
+       Add parentid to revision.
+       Fix type for <id> within <contributor> to "nonNegativeInteger"
+
+       Version 0.8 adds support for a <model> and a <format> tag for
+       each revision. See contenthandler.txt.
+
+       Version 0.9 adds the database name to the site information.
+
+       Version 0.10 moved the <model> and <format> tags before the <text> tag.
+
+       Version 0.11 introduced <content> tag.
+
+       The canonical URL to the schema document is:
+       http://www.mediawiki.org/xml/export-0.11.xsd
+
+       Use the namespace:
+       http://www.mediawiki.org/xml/export-0.11/
+-->
+<schema xmlns="http://www.w3.org/2001/XMLSchema"
+               xmlns:mw="http://www.mediawiki.org/xml/export-0.11/"
+               targetNamespace="http://www.mediawiki.org/xml/export-0.11/"
+               elementFormDefault="qualified">
+
+       <annotation>
+               <documentation xml:lang="en">
+                       MediaWiki's page export format
+               </documentation>
+       </annotation>
+
+       <!-- Need this to reference xml:lang -->
+       <import namespace="http://www.w3.org/XML/1998/namespace"
+                       schemaLocation="http://www.w3.org/2001/xml.xsd" />
+
+       <!-- Our root element -->
+       <element name="mediawiki" type="mw:MediaWikiType">
+               <!-- Page ID contraint, see bug 4220 -->
+               <unique name="PageIDConstraint">
+                       <selector xpath="mw:page" />
+                       <field xpath="mw:id" />
+               </unique>
+               <!-- Revision ID contraint, see bug 4220 -->
+               <unique name="RevIDConstraint">
+                       <selector xpath="mw:page/mw:revision" />
+                       <field xpath="mw:id" />
+               </unique>
+       </element>
+
+       <complexType name="MediaWikiType">
+               <sequence>
+                       <element name="siteinfo" type="mw:SiteInfoType"
+                                        minOccurs="0" maxOccurs="1" />
+                       <element name="page" type="mw:PageType"
+                                        minOccurs="0" maxOccurs="unbounded" />
+                       <element name="logitem" type="mw:LogItemType"
+                                        minOccurs="0" maxOccurs="unbounded" />
+               </sequence>
+               <attribute name="version" type="string" use="required" />
+               <attribute ref="xml:lang" use="required" />
+       </complexType>
+
+       <complexType name="SiteInfoType">
+               <sequence>
+                       <element name="sitename" type="string" minOccurs="0" />
+            <element name="dbname" type="string" minOccurs="0" />
+                       <element name="base" type="anyURI" minOccurs="0" />
+                       <element name="generator" type="string" minOccurs="0" />
+                       <element name="case" type="mw:CaseType" minOccurs="0" />
+                       <element name="namespaces" type="mw:NamespacesType" minOccurs="0" />
+               </sequence>
+       </complexType>
+
+       <simpleType name="CaseType">
+               <restriction base="NMTOKEN">
+                       <!-- Cannot have two titles differing only by case of first letter. -->
+                       <!-- Default behavior through 1.5, $wgCapitalLinks = true -->
+                       <enumeration value="first-letter" />
+
+                       <!-- Complete title is case-sensitive -->
+                       <!-- Behavior when $wgCapitalLinks = false -->
+                       <enumeration value="case-sensitive" />
+
+                       <!-- Cannot have non-case senstitive titles eg [[FOO]] == [[Foo]] -->
+                       <!-- Not yet implemented as of MediaWiki 1.18 -->
+                       <enumeration value="case-insensitive" />
+               </restriction>
+       </simpleType>
+
+       <simpleType name="DeletedFlagType">
+               <restriction base="NMTOKEN">
+                       <enumeration value="deleted" />
+               </restriction>
+       </simpleType>
+
+       <complexType name="NamespacesType">
+               <sequence>
+                       <element name="namespace" type="mw:NamespaceType"
+                                        minOccurs="0" maxOccurs="unbounded" />
+               </sequence>
+       </complexType>
+
+       <complexType name="NamespaceType">
+               <simpleContent>
+                       <extension base="string">
+                               <attribute name="key" type="integer" />
+                               <attribute name="case" type="mw:CaseType" />
+                       </extension>
+               </simpleContent>
+       </complexType>
+
+       <complexType name="RedirectType">
+               <simpleContent>
+                       <extension base="string">
+                               <attribute name="title" type="string" />
+                       </extension>
+               </simpleContent>
+       </complexType>
+
+       <simpleType name="ContentModelType">
+               <restriction base="string">
+                       <pattern value="[a-zA-Z][-+./a-zA-Z0-9]*" />
+               </restriction>
+       </simpleType>
+
+       <simpleType name="ContentFormatType">
+               <restriction base="string">
+                       <pattern value="[a-zA-Z][-+.a-zA-Z0-9]*/[a-zA-Z][-+.a-zA-Z0-9]*" />
+               </restriction>
+       </simpleType>
+
+       <complexType name="PageType">
+               <sequence>
+                       <!-- Title in text form. (Using spaces, not underscores; with namespace ) -->
+                       <element name="title" type="string" />
+
+                       <!-- Namespace in canonical form -->
+                       <element name="ns" type="nonNegativeInteger" />
+
+                       <!-- optional page ID number -->
+                       <element name="id" type="positiveInteger" />
+
+                       <!-- flag if the current revision is a redirect -->
+                       <element name="redirect" type="mw:RedirectType" minOccurs="0" maxOccurs="1" />
+
+                       <!-- comma-separated list of string tokens, if present -->
+                       <element name="restrictions" type="string" minOccurs="0" />
+
+                       <!-- Zero or more sets of revision or upload data -->
+                       <choice minOccurs="0" maxOccurs="unbounded">
+                               <element name="revision" type="mw:RevisionType" />
+                               <element name="upload" type="mw:UploadType" />
+                       </choice>
+
+                       <!-- Zero or One sets of discussion threading data -->
+                       <element name="discussionthreadinginfo" minOccurs="0" maxOccurs="1" type="mw:DiscussionThreadingInfo" />
+               </sequence>
+       </complexType>
+
+       <complexType name="RevisionType">
+               <sequence>
+                       <element name="id" type="positiveInteger" />
+                       <element name="parentid" type="positiveInteger" minOccurs="0" maxOccurs="1"/>
+                       <element name="timestamp" type="dateTime" />
+                       <element name="contributor" type="mw:ContributorType" />
+                       <element name="minor" minOccurs="0" maxOccurs="1"/>
+                       <element name="comment" type="mw:CommentType"/>
+                       <!-- corresponds to slot origin for the main slot -->
+                       <element name="origin" type="positiveInteger" />
+                       <!-- the main slot's content model -->
+                       <element name="model" type="mw:ContentModelType" />
+                       <!-- the main slot's serialization format -->
+                       <element name="format" type="mw:ContentFormatType" />
+                       <!-- the main slot's serialized content -->
+                       <element name="text" type="mw:TextType"/>
+                       <element name="content" type="mw:ContentType" minOccurs="0" maxOccurs="unbounded"/>
+                       <!-- sha1 of the revision, a combined sha1 of content in all slots -->
+                       <element name="sha1" type="string" />
+               </sequence>
+       </complexType>
+
+       <complexType name="ContentType">
+               <sequence>
+                       <!-- corresponds to slot role_name -->
+                       <element name="role" type="mw:SlotRoleType" />
+                       <!-- corresponds to slot origin -->
+                       <element name="origin" type="positiveInteger" />
+                       <element name="model" type="mw:ContentModelType" />
+                       <element name="format" type="mw:ContentFormatType" />
+                       <element name="text" type="mw:ContentTextType" />
+               </sequence>
+       </complexType>
+
+       <simpleType name="SlotRoleType">
+               <restriction base="string">
+                       <pattern value="[a-zA-Z][-+./a-zA-Z0-9]*" />
+               </restriction>
+       </simpleType>
+
+       <complexType name="ContentTextType">
+               <simpleContent>
+                       <extension base="string">
+                               <attribute ref="xml:space" default="preserve" />
+                               <!-- This allows deleted=deleted on non-empty elements, but XSD is not omnipotent -->
+                               <attribute name="deleted" type="mw:DeletedFlagType" />
+                               <attribute name="location" type="anyURI" />
+                               <attribute name="bytes" type="nonNegativeInteger" />
+                       </extension>
+               </simpleContent>
+       </complexType>
+
+       <complexType name="LogItemType">
+               <sequence>
+                       <element name="id" type="positiveInteger" />
+                       <element name="timestamp" type="dateTime" />
+                       <element name="contributor" type="mw:ContributorType" />
+                       <element name="comment" type="mw:CommentType" minOccurs="0" />
+                       <element name="type" type="string" />
+                       <element name="action" type="string" />
+                       <element name="text" type="mw:LogTextType" minOccurs="0" maxOccurs="1" />
+                       <element name="logtitle" type="string" minOccurs="0" maxOccurs="1" />
+                       <element name="params" type="mw:LogParamsType" minOccurs="0" maxOccurs="1" />
+               </sequence>
+       </complexType>
+
+       <complexType name="CommentType">
+               <simpleContent>
+                       <extension base="string">
+                               <!-- This allows deleted=deleted on non-empty elements, but XSD is not omnipotent -->
+                               <attribute name="deleted" type="mw:DeletedFlagType" />
+                       </extension>
+               </simpleContent>
+       </complexType>
+
+       <complexType name="TextType">
+               <simpleContent>
+                       <extension base="string">
+                               <attribute ref="xml:space" default="preserve" />
+                               <!-- This allows deleted=deleted on non-empty elements, but XSD is not omnipotent -->
+                               <attribute name="deleted" type="mw:DeletedFlagType" />
+                               <!-- This isn't a good idea; we should be using "ID" instead of "NMTOKEN" -->
+                               <!-- However, "NMTOKEN" is strictest definition that is both compatible with existing -->
+                               <!-- usage ([0-9]+) and with the "ID" type. -->
+                               <attribute name="id" type="NMTOKEN" />
+                               <attribute name="location" type="anyURI" />
+                               <attribute name="sha1" type="string"/>
+                               <attribute name="bytes" type="nonNegativeInteger" />
+                       </extension>
+               </simpleContent>
+       </complexType>
+
+       <complexType name="LogTextType">
+               <simpleContent>
+                       <extension base="string">
+                               <!-- This allows deleted=deleted on non-empty elements, but XSD is not omnipotent -->
+                               <attribute name="deleted" type="mw:DeletedFlagType" />
+                       </extension>
+               </simpleContent>
+       </complexType>
+
+       <complexType name="LogParamsType">
+               <simpleContent>
+                       <extension base="string">
+                               <attribute ref="xml:space" default="preserve" />
+                       </extension>
+               </simpleContent>
+       </complexType>
+
+       <complexType name="ContributorType">
+               <sequence>
+                       <element name="username" type="string" minOccurs="0" />
+                       <element name="id" type="nonNegativeInteger" minOccurs="0" />
+
+                       <element name="ip" type="string" minOccurs="0" />
+               </sequence>
+               <!-- This allows deleted=deleted on non-empty elements, but XSD is not omnipotent -->
+               <attribute name="deleted" type="mw:DeletedFlagType" />
+       </complexType>
+
+       <complexType name="UploadType">
+               <sequence>
+                       <!-- Revision-style data... -->
+                       <element name="timestamp" type="dateTime" />
+                       <element name="contributor" type="mw:ContributorType" />
+                       <element name="comment" type="string" minOccurs="0" />
+
+                       <!-- Filename. (Using underscores, not spaces. No 'File:' namespace marker.) -->
+                       <element name="filename" type="string" />
+
+                       <!-- URI at which this resource can be obtained -->
+                       <element name="src" type="anyURI" />
+
+                       <element name="size" type="positiveInteger" />
+
+                       <!-- TODO: add other metadata fields -->
+               </sequence>
+       </complexType>
+
+       <!-- Discussion threading data for LiquidThreads -->
+       <complexType name="DiscussionThreadingInfo">
+               <sequence>
+                       <element name="ThreadSubject" type="string" />
+                       <element name="ThreadParent" type="positiveInteger" />
+                       <element name="ThreadAncestor" type="positiveInteger" />
+                       <element name="ThreadPage" type="string" />
+                       <element name="ThreadID" type="positiveInteger" />
+                       <element name="ThreadAuthor" type="string" />
+                       <element name="ThreadEditStatus" type="string" />
+                       <element name="ThreadType" type="string" />
+               </sequence>
+       </complexType>
+
+</schema>
index f0b720b..4750560 100644 (file)
@@ -3985,8 +3985,9 @@ $title: The title of the page.
 add extra metadata.
 &$obj: The XmlDumpWriter object.
 &$out: The text being output.
-$row: The database row for the revision.
-$text: The revision text.
+$row: The database row for the revision being dumped. DEPRECATED, use $rev instead.
+$text: The revision text to be dumped. DEPRECATED, use $rev instead.
+$rev: The RevisionRecord that is being dumped to XML
 
 More hooks might be available but undocumented, you can execute
 "php maintenance/findHooks.php" to find hidden ones.
index 10155f6..a32af36 100644 (file)
@@ -7425,7 +7425,7 @@ $wgSpecialPages = [];
 /**
  * Array mapping class names to filenames, for autoloading.
  */
-$wgAutoloadClasses = [];
+$wgAutoloadClasses = $wgAutoloadClasses ?? [];
 
 /**
  * Switch controlling legacy case-insensitive classloading.
index e5cd5ed..648e493 100644 (file)
@@ -322,4 +322,5 @@ define( 'MIGRATION_NEW', 0x30000000 | SCHEMA_COMPAT_NEW );
  * were already unsupported at the time these constants were introduced.
  */
 define( 'XML_DUMP_SCHEMA_VERSION_10', '0.10' );
+define( 'XML_DUMP_SCHEMA_VERSION_11', '0.11' );
 /**@}*/
index ad375fa..28e0a31 100644 (file)
@@ -29,9 +29,9 @@ use Wikimedia\WrappedString;
 use Wikimedia\WrappedStringList;
 
 /**
- * This class should be covered by a general architecture document which does
- * not exist as of January 2011.  This is one of the Core classes and should
- * be read at least once by any new developers.
+ * This is one of the Core classes and should
+ * be read at least once by any new developers. Also documented at
+ * https://www.mediawiki.org/wiki/Manual:Architectural_modules/OutputPage
  *
  * This class is used to prepare the final rendering. A skin is then
  * applied to the output parameters (links, javascript, html, categories ...).
index faa162a..56867eb 100644 (file)
@@ -63,6 +63,7 @@ use Wikimedia\Rdbms\Database;
 use Wikimedia\Rdbms\DBConnRef;
 use Wikimedia\Rdbms\IDatabase;
 use Wikimedia\Rdbms\ILoadBalancer;
+use Wikimedia\Rdbms\ResultWrapper;
 
 /**
  * Service for looking up page revisions.
@@ -1606,10 +1607,11 @@ class RevisionStore
        /**
         * @param int $revId The revision to load slots for.
         * @param int $queryFlags
+        * @param Title $title
         *
         * @return SlotRecord[]
         */
-       private function loadSlotRecords( $revId, $queryFlags ) {
+       private function loadSlotRecords( $revId, $queryFlags, Title $title ) {
                $revQuery = self::getSlotsQueryInfo( [ 'content' ] );
 
                list( $dbMode, $dbOptions ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
@@ -1626,12 +1628,45 @@ class RevisionStore
                        $revQuery['joins']
                );
 
+               $slots = $this->constructSlotRecords( $revId, $res, $queryFlags, $title );
+
+               return $slots;
+       }
+
+       /**
+        * Factory method for SlotRecords based on known slot rows.
+        *
+        * @param int $revId The revision to load slots for.
+        * @param object[]|ResultWrapper $slotRows
+        * @param int $queryFlags
+        * @param Title $title
+        *
+        * @return SlotRecord[]
+        */
+       private function constructSlotRecords( $revId, $slotRows, $queryFlags, Title $title ) {
                $slots = [];
 
-               foreach ( $res as $row ) {
-                       // resolve role names and model names from in-memory cache, instead of joining.
-                       $row->role_name = $this->slotRoleStore->getName( (int)$row->slot_role_id );
-                       $row->model_name = $this->contentModelStore->getName( (int)$row->content_model );
+               foreach ( $slotRows as $row ) {
+                       // Resolve role names and model names from in-memory cache, if they were not joined in.
+                       if ( !isset( $row->role_name ) ) {
+                               $row->role_name = $this->slotRoleStore->getName( (int)$row->slot_role_id );
+                       }
+
+                       if ( !isset( $row->model_name ) ) {
+                               if ( isset( $row->content_model ) ) {
+                                       $row->model_name = $this->contentModelStore->getName( (int)$row->content_model );
+                               } else {
+                                       // We may get here if $row->model_name is set but null, perhaps because it
+                                       // came from rev_content_model, which is NULL for the default model.
+                                       $slotRoleHandler = $this->slotRoleRegistry->getRoleHandler( $row->role_name );
+                                       $row->model_name = $slotRoleHandler->getDefaultModel( $title );
+                               }
+                       }
+
+                       if ( !isset( $row->content_id ) && isset( $row->rev_text_id ) ) {
+                               $row->slot_content_id
+                                       = $this->emulateContentId( intval( $row->rev_text_id ) );
+                       }
 
                        $contentCallback = function ( SlotRecord $slot ) use ( $queryFlags ) {
                                return $this->loadSlotContent( $slot, null, null, null, $queryFlags );
@@ -1650,13 +1685,14 @@ class RevisionStore
        }
 
        /**
-        * Factory method for RevisionSlots.
+        * Factory method for RevisionSlots based on a revision ID.
         *
         * @note If other code has a need to construct RevisionSlots objects, this should be made
         * public, since RevisionSlots instances should not be constructed directly.
         *
         * @param int $revId
         * @param object $revisionRow
+        * @param object[]|null $slotRows
         * @param int $queryFlags
         * @param Title $title
         *
@@ -1666,10 +1702,15 @@ class RevisionStore
        private function newRevisionSlots(
                $revId,
                $revisionRow,
+               $slotRows,
                $queryFlags,
                Title $title
        ) {
-               if ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW ) ) {
+               if ( $slotRows ) {
+                       $slots = new RevisionSlots(
+                               $this->constructSlotRecords( $revId, $slotRows, $queryFlags, $title )
+                       );
+               } elseif ( !$this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_NEW ) ) {
                        $mainSlot = $this->emulateMainSlot_1_29( $revisionRow, $queryFlags, $title );
                        // @phan-suppress-next-line PhanTypeInvalidCallableArraySize false positive
                        $slots = new RevisionSlots( [ SlotRecord::MAIN => $mainSlot ] );
@@ -1677,8 +1718,8 @@ class RevisionStore
                        // XXX: do we need the same kind of caching here
                        // that getKnownCurrentRevision uses (if $revId == page_latest?)
 
-                       $slots = new RevisionSlots( function () use( $revId, $queryFlags ) {
-                               return $this->loadSlotRecords( $revId, $queryFlags );
+                       $slots = new RevisionSlots( function () use( $revId, $queryFlags, $title ) {
+                               return $this->loadSlotRecords( $revId, $queryFlags, $title );
                        } );
                }
 
@@ -1752,7 +1793,7 @@ class RevisionStore
                // Legacy because $row may have come from self::selectFields()
                $comment = $this->commentStore->getCommentLegacy( $db, 'ar_comment', $row, true );
 
-               $slots = $this->newRevisionSlots( $row->ar_rev_id, $row, $queryFlags, $title );
+               $slots = $this->newRevisionSlots( $row->ar_rev_id, $row, null, $queryFlags, $title );
 
                return new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $this->wikiId );
        }
@@ -1762,7 +1803,7 @@ class RevisionStore
         *
         * MCR migration note: this replaces Revision::newFromRow
         *
-        * @param object $row
+        * @param object $row A database row generated from a query based on getQueryInfo()
         * @param int $queryFlags
         * @param Title|null $title
         * @param bool $fromCache if true, the returned RevisionRecord will ensure that no stale
@@ -1774,6 +1815,32 @@ class RevisionStore
                $queryFlags = 0,
                Title $title = null,
                $fromCache = false
+       ) {
+               return $this->newRevisionFromRowAndSlots( $row, null, $queryFlags, $title, $fromCache );
+       }
+
+       /**
+        * @param object $row A database row generated from a query based on getQueryInfo()
+        * @param null|object[] $slotRows Database rows generated from a query based on
+        *        getSlotsQueryInfo with the 'content' flag set.
+        * @param int $queryFlags
+        * @param Title|null $title
+        * @param bool $fromCache if true, the returned RevisionRecord will ensure that no stale
+        *   data is returned from getters, by querying the database as needed
+        *
+        * @return RevisionRecord
+        * @throws MWException
+        * @see RevisionFactory::newRevisionFromRow
+        *
+        * MCR migration note: this replaces Revision::newFromRow
+        *
+        */
+       public function newRevisionFromRowAndSlots(
+               $row,
+               $slotRows,
+               $queryFlags = 0,
+               Title $title = null,
+               $fromCache = false
        ) {
                Assert::parameterType( 'object', $row, '$row' );
 
@@ -1807,7 +1874,7 @@ class RevisionStore
                // Legacy because $row may have come from self::selectFields()
                $comment = $this->commentStore->getCommentLegacy( $db, 'rev_comment', $row, true );
 
-               $slots = $this->newRevisionSlots( $row->rev_id, $row, $queryFlags, $title );
+               $slots = $this->newRevisionSlots( $row->rev_id, $row, $slotRows, $queryFlags, $title );
 
                // If this is a cached row, instantiate a cache-aware revision class to avoid stale data.
                if ( $fromCache ) {
@@ -2405,21 +2472,24 @@ class RevisionStore
 
                if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_READ_OLD ) ) {
                        $db = $this->getDBConnectionRef( DB_REPLICA );
-                       $ret['tables']['slots'] = 'revision';
+                       $ret['tables'][] = 'revision';
 
-                       $ret['fields']['slot_revision_id'] = 'slots.rev_id';
+                       $ret['fields']['slot_revision_id'] = 'rev_id';
                        $ret['fields']['slot_content_id'] = 'NULL';
-                       $ret['fields']['slot_origin'] = 'slots.rev_id';
+                       $ret['fields']['slot_origin'] = 'rev_id';
                        $ret['fields']['role_name'] = $db->addQuotes( SlotRecord::MAIN );
 
                        if ( in_array( 'content', $options, true ) ) {
-                               $ret['fields']['content_size'] = 'slots.rev_len';
-                               $ret['fields']['content_sha1'] = 'slots.rev_sha1';
+                               $ret['fields']['content_size'] = 'rev_len';
+                               $ret['fields']['content_sha1'] = 'rev_sha1';
                                $ret['fields']['content_address']
-                                       = $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] );
+                                       = $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'rev_text_id' ] );
+
+                               // Allow the content_id field to be emulated later
+                               $ret['fields']['rev_text_id'] = 'rev_text_id';
 
                                if ( $this->contentHandlerUseDB ) {
-                                       $ret['fields']['model_name'] = 'slots.rev_content_model';
+                                       $ret['fields']['model_name'] = 'rev_content_model';
                                } else {
                                        $ret['fields']['model_name'] = 'NULL';
                                }
index 54e6795..641f1f9 100644 (file)
@@ -807,7 +807,9 @@ if ( $wgRequest->getCookie( 'UseDC', '' ) === 'master' ) {
 
 // Useful debug output
 if ( $wgCommandLineMode ) {
-       wfDebug( "\n\nStart command line script $self\n" );
+       if ( isset( $self ) ) {
+               wfDebug( "\n\nStart command line script $self\n" );
+       }
 } else {
        $debug = "\n\nStart request {$wgRequest->getMethod()} {$wgRequest->getRequestURL()}\n";
 
index eeb0cf7..bdb0dc2 100644 (file)
@@ -441,6 +441,7 @@ class ApiQuery extends ApiBase {
                $exporter = new WikiExporter( $this->getDB() );
                $sink = new DumpStringOutput;
                $exporter->setOutputSink( $sink );
+               $exporter->setSchemaVersion( $this->mParams['exportschema'] );
                $exporter->openStream();
                foreach ( $exportTitles as $title ) {
                        $exporter->pageByTitle( $title );
@@ -479,6 +480,10 @@ class ApiQuery extends ApiBase {
                        'indexpageids' => false,
                        'export' => false,
                        'exportnowrap' => false,
+                       'exportschema' => [
+                               ApiBase::PARAM_DFLT => WikiExporter::schemaVersion(),
+                               ApiBase::PARAM_TYPE => XmlDumpWriter::$supportedSchemas,
+                       ],
                        'iwurl' => false,
                        'continue' => [
                                ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
index e123a2a..0791426 100644 (file)
@@ -757,58 +757,6 @@ class ApiQueryImageInfo extends ApiQueryBase {
                );
        }
 
-       /**
-        * Returns array key value pairs of properties and their descriptions
-        *
-        * @deprecated since 1.25
-        * @param string $modulePrefix
-        * @return array
-        */
-       private static function getProperties( $modulePrefix = '' ) {
-               return [
-                       'timestamp' => ' timestamp     - Adds timestamp for the uploaded version',
-                       'user' => ' user          - Adds the user who uploaded the image version',
-                       'userid' => ' userid        - Add the user ID that uploaded the image version',
-                       'comment' => ' comment       - Comment on the version',
-                       'parsedcomment' => ' parsedcomment - Parse the comment on the version',
-                       'canonicaltitle' => ' canonicaltitle - Adds the canonical title of the image file',
-                       'url' => ' url           - Gives URL to the image and the description page',
-                       'size' => ' size          - Adds the size of the image in bytes, ' .
-                               'its height and its width. Page count and duration are added if applicable',
-                       'dimensions' => ' dimensions    - Alias for size', // B/C with Allimages
-                       'sha1' => ' sha1          - Adds SHA-1 hash for the image',
-                       'mime' => ' mime          - Adds MIME type of the image',
-                       'thumbmime' => ' thumbmime     - Adds MIME type of the image thumbnail' .
-                               ' (requires url and param ' . $modulePrefix . 'urlwidth)',
-                       'mediatype' => ' mediatype     - Adds the media type of the image',
-                       'metadata' => ' metadata      - Lists Exif metadata for the version of the image',
-                       'commonmetadata' => ' commonmetadata - Lists file format generic metadata ' .
-                               'for the version of the image',
-                       'extmetadata' => ' extmetadata   - Lists formatted metadata combined ' .
-                               'from multiple sources. Results are HTML formatted.',
-                       'archivename' => ' archivename   - Adds the file name of the archive ' .
-                               'version for non-latest versions',
-                       'bitdepth' => ' bitdepth      - Adds the bit depth of the version',
-                       'uploadwarning' => ' uploadwarning - Used by the Special:Upload page to ' .
-                               'get information about an existing file. Not intended for use outside MediaWiki core',
-               ];
-       }
-
-       /**
-        * Returns the descriptions for the properties provided by getPropertyNames()
-        *
-        * @deprecated since 1.25
-        * @param array $filter List of properties to filter out
-        * @param string $modulePrefix
-        * @return array
-        */
-       public static function getPropertyDescriptions( $filter = [], $modulePrefix = '' ) {
-               return array_merge(
-                       [ 'What image information to get:' ],
-                       array_values( array_diff_key( static::getProperties( $modulePrefix ), array_flip( $filter ) ) )
-               );
-       }
-
        protected function getExamplesMessages() {
                return [
                        'action=query&titles=File:Albert%20Einstein%20Head.jpg&prop=imageinfo'
index 8d7aaa3..70515eb 100644 (file)
        "apihelp-query-param-indexpageids": "تضمين قسم إضافي لمعرفات الصفحات يسرد جميع معرفات الصفحات التي تم إرجاعها.",
        "apihelp-query-param-export": "تصدير المراجعات الحالية لجميع الصفحات المعينة أو المولدة.",
        "apihelp-query-param-exportnowrap": "إعادة تصدير XML دون التفاف عليه في نتيجة XML (نفس شكل [[Special:Export|خاص:تصدير]]). يمكن استخدامها فقط مع $1export.",
+       "apihelp-query-param-exportschema": "استهداف الإصدار المحدد من تنسيق تفريغ XML عند التصدير، يمكن استخدامه مع <var>$1export</var> فقط.",
        "apihelp-query-param-iwurl": "ما إذا كنت تريد الحصول على المسار الكامل إذا كان العنوان رابط إنترويكي.",
        "apihelp-query-param-rawcontinue": "إرجاع <samp>query-continue</samp> بيانات خام للاستمرار.",
        "apihelp-query-example-revisions": "جلب [[Special:ApiHelp/query+siteinfo|معلومات الموقع]] و[[Special:ApiHelp/query+revisions|مراجعات]] <kbd>Main Page</kbd>.",
index 9843af4..cae7687 100644 (file)
        "apihelp-query-param-indexpageids": "Include an additional pageids section listing all returned page IDs.",
        "apihelp-query-param-export": "Export the current revisions of all given or generated pages.",
        "apihelp-query-param-exportnowrap": "Return the export XML without wrapping it in an XML result (same format as [[Special:Export]]). Can only be used with $1export.",
+       "apihelp-query-param-exportschema": "Target the given version of the XML dump format when exporting. Can only be used with <var>$1export</var>.",
        "apihelp-query-param-iwurl": "Whether to get the full URL if the title is an interwiki link.",
        "apihelp-query-param-rawcontinue": "Return raw <samp>query-continue</samp> data for continuation.",
        "apihelp-query-example-revisions": "Fetch [[Special:ApiHelp/query+siteinfo|site info]] and [[Special:ApiHelp/query+revisions|revisions]] of <kbd>Main Page</kbd>.",
index acd1d78..8442213 100644 (file)
        "apihelp-query+languageinfo-paramvalue-prop-code": "Le code de langue (ce code est spécifique à MédiaWiki, bien qu’il y ait des recouvrements avec d’autres standards).",
        "apihelp-query+languageinfo-paramvalue-prop-bcp47": "Le code de langue BCP-47.",
        "apihelp-query+languageinfo-paramvalue-prop-dir": "La direction d’écriture de la langue (<code>ltr</code> ou <code>rtl</code>).",
-       "apihelp-query+languageinfo-paramvalue-prop-autonym": "L’autonyme d&une langue, c’est-à-dire son nom dans cette langue.",
+       "apihelp-query+languageinfo-paramvalue-prop-autonym": "L’autonyme d'une langue, c’est-à-dire son nom dans cette langue.",
        "apihelp-query+languageinfo-paramvalue-prop-name": "Le nom de la langue dans la langue spécifiée par le paramètre <var>lilang</var>, avec application des langues de secours si besoin.",
        "apihelp-query+languageinfo-paramvalue-prop-fallbacks": "Les codes de langue des langues de secours configurées pour cette langue. Le secours implicite final en 'en' n’est pas inclus (mais certaines langues peuvent avoir 'en' en secours explicitement).",
        "apihelp-query+languageinfo-paramvalue-prop-variants": "Les codes de langue des variantes supportées par cette langue.",
index fa4110e..26c7f10 100644 (file)
@@ -17,9 +17,9 @@
        "apihelp-main-param-servedby": "Вклучи го домаќинското име што го услужило барањето во исходот.",
        "apihelp-main-param-curtimestamp": "Вклучи тековно време и време и датум во исходот.",
        "apihelp-main-param-origin": "Кога му пристапувате на Пирлогот користејќи повеќедоменско AJAX-барање (CORS), задајте му го на ова изворниот домен. Ова мора да се вклучи во секое подготвително барање и затоа мора да биде дел од URI на барањето (не главната содржина во POST). Ова мора точно да се совпаѓа со еден од изворниците на заглавието Origin:, така што мора да е зададен на нешто како <kbd>https://mk.wikipedia.org</kbd>  or <kbd>https://meta.wikimedia.org</kbd>. Ако овој параметар не се совпаѓа со заглавието <code>Origin</code>:, ќе се појави одговор 403. Ако се совпаѓа, а изворникот е на бел список (на допуштени), тогаш ќе се зададе заглавието <code>Access-Control-Allow-Origin</code>.",
-       "apihelp-main-param-uselang": "Јазик за преведување на пораките. Список на јазични кодови ќе најдете на <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> со <kbd>siprop=languages</kbd> или укажете <kbd>user</kbd> за да го користите тековно зададениот јазик корисникот, или пак укажете <kbd>content</kbd> за да го користите јазикот на содржината на ова вики.",
+       "apihelp-main-param-uselang": "Јазик за преведување на пораките. <kbd>[[Special:ApiHelp/query+siteinfo|action=query&meta=siteinfo]]</kbd> со <kbd>siprop=languages</kbd> дава список на јазични кодови, или укажете <kbd>user</kbd> за да го користите тековно зададениот јазик корисникот, или пак укажете <kbd>content</kbd> за да го користите јазикот на содржината на ова вики.",
        "apihelp-block-summary": "Блокирај корисник.",
-       "apihelp-block-param-user": "Корисничко име, IP-адреса или IP-опсег ако сакате да блокирате.",
+       "apihelp-block-param-user": "Корисничко име, IP-адреса или IP-опсег ако сакате да блокирате. Не може да се користи заедно со <var>$1userid</var>",
        "apihelp-block-param-expiry": "Време на истек. Може да биде релативно (на пр. <kbd>5 months</kbd> или „2 недели“) или пак апсолутно (на пр. <kbd>2014-09-18T12:34:56Z</kbd>). Ако го зададете <kbd>infinite</kbd>, <kbd>indefinite</kbd> или <kbd>never</kbd>, блокот ќе трае засекогаш.",
        "apihelp-block-param-reason": "Причина за блокирање.",
        "apihelp-block-param-anononly": "Блокирај само анонимни корисници (т.е. оневозможи анонимно уредување од оваа IP-адреса).",
@@ -30,6 +30,7 @@
        "apihelp-block-param-allowusertalk": "Овозможи му на корисникот да ја уредува неговата разговорна страница (зависи од <var>[[mw:Special:MyLanguage/Manual:$wgBlockAllowsUTEdit|$wgBlockAllowsUTEdit]]</var>).",
        "apihelp-block-param-reblock": "Ако корисникот е веќе блокиран, наметни врз постоечкиот блок.",
        "apihelp-block-param-watchuser": "Набљудувај ја корисничката страница и разговорна страница на овој корисник или IP-адреса",
+       "apihelp-block-param-tags": "Ознаки за примена врз ставката во дневникот на блокирања.",
        "apihelp-block-example-ip-simple": "Блокирај ја IP-адресата <kbd>192.0.2.5</kbd> три дена со причината <kbd>Прва опомена</kbd>.",
        "apihelp-block-example-user-complex": "Блокирај го корисникот <kbd>Vandal</kbd> (Вандал) бесконечно со причината <kbd>Vandal</kbd> (Вандализам) и оневозможи создавање на нови сметки и праќање е-пошта.",
        "apihelp-checktoken-summary": "Проверка на полноважноста на шифрата од <kbd>[[Special:ApiHelp/query+tokens|action=query&meta=tokens]]</kbd>.",
        "apihelp-login-example-login": "Најава",
        "apihelp-logout-summary": "Одјави се и исчисти ги податоците на седницата.",
        "apihelp-logout-example-logout": "Одјави го тековниот корисник",
+       "apihelp-mergehistory-summary": "Спојување на истории на страници.",
        "apihelp-move-summary": "Премести страница.",
        "apihelp-move-param-from": "Наслов на страницата што треба да се премести. Не може да се користи заедно со <var>$1fromid</var>.",
        "apihelp-move-param-fromid": "Назнака на страницата што треба да се премести. Не може да се користи заедно со <var>$1from</var>.",
        "apihelp-query+allcategories-param-from": "Од која категорија да почне набројувањето.",
        "apihelp-query+allcategories-param-to": "На која категорија да запре набројувањето.",
        "apihelp-query+allcategories-param-dir": "Насока на подредувањето.",
+       "apihelp-query+allcategories-param-prop": "Кои својства да се дадат:",
        "apihelp-query+alldeletedrevisions-param-from": "Почни го исписот од овој наслов.",
        "apihelp-query+alldeletedrevisions-param-to": "Запри го исписот на овој наслов.",
        "apihelp-query+alldeletedrevisions-example-user": "Список на последните 50 избришани придонеси на корисникот <kbd>Example</kbd>.",
        "apihelp-query+alldeletedrevisions-example-ns-main": "Список на последните 50 избришани преработки во главниот именски простор.",
+       "apihelp-query+allfileusages-param-prop": "Кои информации да се вклучат:",
+       "apihelp-query+allfileusages-paramvalue-prop-title": "Го додава насловот на податотеката.",
+       "apihelp-query+allfileusages-param-limit": "Колку вкупно ставки да се дадат.",
+       "apihelp-query+allfileusages-param-dir": "Насока на исписот.",
+       "apihelp-query+allfileusages-example-unique": "Испиши единствени наслови на податотеки.",
+       "apihelp-query+allfileusages-example-unique-generator": "Ги дава сите наслови на податотеки, означувајќи ги отсутните.",
+       "apihelp-query+allfileusages-example-generator": "Дава страници што ги содржат податотеките.",
+       "apihelp-query+allimages-param-dir": "Насока на исписот.",
        "apihelp-query+allimages-example-b": "Прикажи список на податотеки што почнуваат со буквата <kbd>B</kbd>.",
        "apihelp-query+allimages-example-recent": "Прикажи список на неодамна подигнати податотеки сличен на [[Special:NewFiles]]",
        "apihelp-query+allimages-example-generator": "Прикажи информации за околу 4 податотеки што почнуваат со буквата <kbd>T</kbd>.",
        "apihelp-query+allpages-param-minsize": "Ограничи на страници со барем олку бајти.",
        "apihelp-query+allpages-param-maxsize": "Ограничи на страници со највеќе олку бајти.",
        "apihelp-query+allpages-param-prtype": "Ограничи на само заштитени страници.",
+       "apihelp-query+allpages-param-limit": "Колку вкупно страници да се дадат.",
+       "apihelp-query+allpages-param-dir": "Насока на исписот.",
+       "apihelp-query+allredirects-param-prop": "Кои информации да се вклучат:",
+       "apihelp-query+allredirects-paramvalue-prop-title": "Го додава насловот на пренасочувањето.",
+       "apihelp-query+allredirects-param-namespace": "Именскиот простор што се набројува.",
+       "apihelp-query+allredirects-param-limit": "Колку вкупно ставки да се дадат.",
+       "apihelp-query+allredirects-param-dir": "Насока на исписот.",
+       "apihelp-query+allrevisions-param-start": "Од кој датум и време да почне набројувањето.",
+       "apihelp-query+allrevisions-param-end": "На кој датум и време да запре набројувањето.",
+       "apihelp-query+alltransclusions-param-prop": "Кои информации да се вклучат:",
+       "apihelp-query+alltransclusions-param-namespace": "Именскиот простор што се набројува.",
+       "apihelp-query+alltransclusions-param-limit": "Колку вкупно ставки да се дадат.",
+       "apihelp-query+alltransclusions-param-dir": "Насока на исписот.",
+       "apihelp-query+allusers-param-from": "Од кое корисничко име да почне набројувањето.",
+       "apihelp-query+allusers-param-to": "На кое корисничко име да престане набројувањето.",
+       "apihelp-query+allusers-param-prefix": "Пребарај ги сите корисници што почнуваат со оваа вредност.",
+       "apihelp-query+allusers-param-dir": "Насока на подредувањето.",
+       "apihelp-query+allusers-param-group": "Вклучи ги корисниците само од дадените групи.",
+       "apihelp-query+allusers-param-excludegroup": "Исклучи ги корисниците од дадените групи.",
+       "apihelp-query+allusers-param-prop": "Кои информации да се вклучат:",
+       "apihelp-query+allusers-paramvalue-prop-blockinfo": "Ги додава информациите за тековното блокирање на корисникот.",
+       "apihelp-query+allusers-param-limit": "Колку вкупно кориснички имиња да се дадат.",
+       "apihelp-query+backlinks-param-namespace": "Именскиот простор што се набројува.",
+       "apihelp-query+backlinks-param-dir": "Насока на исписот.",
        "apihelp-query+backlinks-example-simple": "Прикажи врски до <kbd>Main page</kbd>.",
        "apihelp-query+backlinks-example-generator": "Дава информации за страниците што водат до <kbd>Main page</kbd>.",
        "apihelp-query+blocks-summary": "Список на сите блокирани корисници и IP-адреси",
        "apihelp-query+blocks-param-end": "На кој датум и време да запре набројувањето.",
        "apihelp-query+blocks-param-ids": "Список на назнаки на блоковите за испис (незадолжително)",
        "apihelp-query+blocks-param-users": "Список на корисници што ќе се пребаруваат (незадолжително)",
+       "apihelp-query+blocks-param-prop": "Кои својства да се дадат:",
+       "apihelp-query+categories-param-dir": "Насока на исписот.",
+       "apihelp-query+categorymembers-param-prop": "Кои информации да се вклучат:",
+       "apihelp-query+categorymembers-param-limit": "Највеќе страници за прикажување.",
        "apihelp-query+deletedrevs-paraminfo-modes": "{{PLURAL:$1|Режим|Режими}}: $2",
+       "apihelp-query+deletedrevs-param-start": "Од кој датум и време да почне набројувањето.",
+       "apihelp-query+deletedrevs-param-end": "На кој датум и време да запре набројувањето.",
+       "apihelp-query+deletedrevs-param-from": "Почни го исписот од овој наслов.",
+       "apihelp-query+deletedrevs-param-to": "Запри го исписот на овој наслов.",
+       "apihelp-query+deletedrevs-param-prefix": "Пребарај ги сите наслови на страници што почнуваат со оваа вредност.",
+       "apihelp-query+disabled-summary": "Овој модул за барања е оневозможен.",
+       "apihelp-query+duplicatefiles-param-dir": "Насока на исписот.",
+       "apihelp-query+embeddedin-param-namespace": "Именскиот простор што се набројува.",
+       "apihelp-query+embeddedin-param-dir": "Насока на исписот.",
+       "apihelp-query+embeddedin-param-filterredir": "Како да се филтрираат пренасочувањата.",
+       "apihelp-query+embeddedin-param-limit": "Колку вкупно страници да се дадат.",
+       "apihelp-query+extlinks-param-limit": "Колку врски да се дадат.",
+       "apihelp-query+exturlusage-param-prop": "Кои информации да се вклучат:",
+       "apihelp-query+exturlusage-param-limit": "Колку страници да се дадат.",
+       "apihelp-query+filearchive-param-from": "Наслов на сликата од која ќе почне набројувањето.",
+       "apihelp-query+filearchive-param-to": "Наслов на сликата на која ќе запре набројувањето.",
+       "apihelp-query+filearchive-param-prefix": "Пребарај ги сите наслови на слики што почнуваат со оваа вредност.",
+       "apihelp-query+filearchive-param-limit": "Колку вкупно слики да се дадат.",
+       "apihelp-query+filearchive-param-dir": "Насока на исписот.",
+       "apihelp-query+filearchive-param-prop": "Кои информации за слики да се дадат:",
+       "apihelp-query+filearchive-example-simple": "Прикажи список на сите избришани податотеки.",
+       "apihelp-query+fileusage-param-prop": "Кои својства да се дадат:",
+       "apihelp-query+imageinfo-param-prop": "Кои информации за податотеки да се дадат:",
        "apihelp-query+imageinfo-param-urlheight": "Слично на $1urlwidth.",
+       "apihelp-query+images-param-limit": "Колку податотеки да се дадат.",
+       "apihelp-query+images-param-dir": "Насока на исписот.",
+       "apihelp-query+imageusage-param-namespace": "Именскиот простор што се набројува.",
+       "apihelp-query+imageusage-param-dir": "Насока на исписот.",
+       "apihelp-query+iwbacklinks-param-limit": "Колку вкупно страници да се дадат.",
+       "apihelp-query+iwbacklinks-param-prop": "Кои својства да се дадат:",
+       "apihelp-query+iwbacklinks-param-dir": "Насока на исписот.",
+       "apihelp-query+iwlinks-param-dir": "Насока на исписот.",
+       "apihelp-query+langbacklinks-param-limit": "Колку вкупно страници да се дадат.",
+       "apihelp-query+langbacklinks-param-prop": "Кои својства да се дадат:",
+       "apihelp-query+langbacklinks-param-dir": "Насока на исписот.",
+       "apihelp-query+langlinks-param-dir": "Насока на исписот.",
+       "apihelp-query+links-param-limit": "Колку врски да се дадат.",
+       "apihelp-query+links-param-dir": "Насока на исписот.",
+       "apihelp-query+linkshere-param-prop": "Кои својства да се дадат:",
+       "apihelp-query+logevents-param-prop": "Кои својства да се дадат:",
+       "apihelp-query+logevents-param-start": "Од кој датум и време да почне набројувањето.",
+       "apihelp-query+logevents-param-end": "На кој датум и време да запре набројувањето.",
+       "apihelp-query+pageswithprop-param-prop": "Кои информации да се вклучат:",
+       "apihelp-query+pageswithprop-param-limit": "Највеќе страници за прикажување.",
+       "apihelp-query+prefixsearch-param-search": "Низа за пребарување.",
+       "apihelp-query+prefixsearch-param-limit": "Највеќе ставки во исходот за прикажување.",
+       "apihelp-query+protectedtitles-param-limit": "Колку вкупно страници да се дадат.",
+       "apihelp-query+protectedtitles-param-prop": "Кои својства да се дадат:",
+       "apihelp-query+random-param-filterredir": "Како да се филтрираат пренасочувањата.",
+       "apihelp-query+recentchanges-param-start": "Од кој датум и време да почне набројувањето.",
+       "apihelp-query+recentchanges-param-end": "На кој датум и време да запре набројувањето.",
+       "apihelp-query+recentchanges-param-limit": "Колку вкупно промени да се дадат.",
+       "apihelp-query+redirects-param-prop": "Кои својства да се дадат:",
+       "apihelp-query+redirects-param-limit": "Колку пренасочувања да се дадат.",
+       "apihelp-query+redirects-example-simple": "Дај список на пренасочувања до [[Main Page|Главната страница]].",
        "apihelp-query+revisions-example-last5": "Дај ги последните 5 преработки на <kbd>Главна страница</kbd>.",
        "apihelp-query+revisions-example-first5": "Дај ги првите 5 преработки на <kbd>Главна страница</kbd>.",
        "apihelp-query+revisions-example-first5-after": "Дај ги првите 5 преработки на <kbd>Главна страница</kbd> направени по 2006-05-01 (1 мај 2006 г.)",
        "apihelp-query+revisions-example-first5-not-localhost": "Дај ги првите 5 преработки на <kbd>Главна страница</kbd> кои не се направени од анонимниот корисник „127.0.0.1“",
        "apihelp-query+revisions-example-first5-user": "Дај ги првите 5 преработки на <kbd>Главна страница</kbd> кои се направени од корисникот „зададен од МедијаВики“ (<kbd>MediaWiki default</kbd>)",
+       "apihelp-query+search-param-namespace": "Пребарување само во овие именски простори.",
+       "apihelp-query+search-param-info": "Кои метаподатоци да се дадат.",
+       "apihelp-query+search-param-prop": "Кои својства да се дадат:",
+       "apihelp-query+search-paramvalue-prop-score": "Занемарено.",
+       "apihelp-query+search-paramvalue-prop-hasrelated": "Занемарено.",
+       "apihelp-query+search-param-limit": "Колку вкупно страници да се дадат.",
        "apihelp-query+search-example-simple": "Побарај <kbd>meaning</kbd>.",
        "apihelp-query+search-example-text": "Побарај го <kbd>meaning</kbd> по текстовите.",
        "apihelp-query+search-example-generator": "Дај информации за страниците што излегуваат во исходот од пребарувањето на <kbd>meaning</kbd>.",
        "apihelp-query+siteinfo-summary": "Дај општи информации за мрежното место.",
+       "apihelp-query+siteinfo-param-prop": "Кои информации да се дадат:",
+       "apihelp-query+tags-param-limit": "Најголемиот број на ознаки за наведување во списокот.",
+       "apihelp-query+tags-param-prop": "Кои својства да се дадат:",
+       "apihelp-query+templates-param-limit": "Колку шаблони да се дадат.",
+       "apihelp-query+templates-param-dir": "Насока на исписот.",
+       "apihelp-query+transcludedin-param-prop": "Кои својства да се дадат:",
+       "apihelp-query+usercontribs-paramvalue-prop-patrolled": "Ги означува проверените уредувања.",
+       "apihelp-query+usercontribs-paramvalue-prop-autopatrolled": "Ги означува самопроверените уредувања.",
+       "apihelp-query+userinfo-param-prop": "Кои информации да се вклучат:",
+       "apihelp-query+users-param-prop": "Кои информации да се вклучат:",
+       "apihelp-query+watchlist-param-start": "Од кој датум и време да почне набројувањето.",
+       "apihelp-query+watchlist-param-end": "На кој датум и време да запре набројувањето.",
+       "apihelp-query+watchlist-paramvalue-type-new": "Создавања на страници.",
+       "apihelp-query+watchlist-paramvalue-type-log": "Дневнички записи.",
+       "apihelp-query+watchlistraw-param-dir": "Насока на исписот.",
+       "apihelp-revisiondelete-param-suppress": "Дали се притајуваат податоци од администраторите на ист начин како и за останатите.",
+       "apihelp-revisiondelete-param-tags": "Ознаки за примена врз ставката во дневникот на бришења.",
        "apihelp-upload-param-filename": "Целно име на податотеката.",
        "apihelp-upload-param-comment": "Коментар при подигање. Се користи и како првичен текст на страницата за нови податотеки ако не е укажано <var>$1text</var>.",
        "apihelp-upload-param-text": "Првичен текст на страницата за нови податотеки.",
index 279c0c1..d5de23f 100644 (file)
        "apihelp-query-param-indexpageids": "{{doc-apihelp-param|query|indexpageids}}",
        "apihelp-query-param-export": "{{doc-apihelp-param|query|export}}",
        "apihelp-query-param-exportnowrap": "{{doc-apihelp-param|query|exportnowrap}}",
+       "apihelp-query-param-exportschema": "{{doc-apihelp-param|query|exportschema}}",
        "apihelp-query-param-iwurl": "{{doc-apihelp-param|query|iwurl}}",
        "apihelp-query-param-rawcontinue": "{{doc-apihelp-param|query|rawcontinue}}",
        "apihelp-query-example-revisions": "{{doc-apihelp-example|query}}",
index 4af62a0..c716e4d 100644 (file)
@@ -178,7 +178,7 @@ class DatabaseOracle extends Database {
        }
 
        function execFlags() {
-               return $this->trxLevel ? OCI_NO_AUTO_COMMIT : OCI_COMMIT_ON_SUCCESS;
+               return $this->trxLevel() ? OCI_NO_AUTO_COMMIT : OCI_COMMIT_ON_SUCCESS;
        }
 
        /**
@@ -548,7 +548,7 @@ class DatabaseOracle extends Database {
                        }
                }
 
-               if ( !$this->trxLevel ) {
+               if ( !$this->trxLevel() ) {
                        oci_commit( $this->conn );
                }
 
@@ -942,26 +942,24 @@ class DatabaseOracle extends Database {
        }
 
        protected function doBegin( $fname = __METHOD__ ) {
-               $this->trxLevel = 1;
-               $this->doQuery( 'SET CONSTRAINTS ALL DEFERRED' );
+               $this->query( 'SET CONSTRAINTS ALL DEFERRED' );
        }
 
        protected function doCommit( $fname = __METHOD__ ) {
-               if ( $this->trxLevel ) {
+               if ( $this->trxLevel() ) {
                        $ret = oci_commit( $this->conn );
                        if ( !$ret ) {
                                throw new DBUnexpectedError( $this, $this->lastError() );
                        }
-                       $this->trxLevel = 0;
-                       $this->doQuery( 'SET CONSTRAINTS ALL IMMEDIATE' );
+                       $this->query( 'SET CONSTRAINTS ALL IMMEDIATE' );
                }
        }
 
        protected function doRollback( $fname = __METHOD__ ) {
-               if ( $this->trxLevel ) {
+               if ( $this->trxLevel() ) {
                        oci_rollback( $this->conn );
-                       $this->trxLevel = 0;
-                       $this->doQuery( 'SET CONSTRAINTS ALL IMMEDIATE' );
+                       $ignoreErrors = true;
+                       $this->query( 'SET CONSTRAINTS ALL IMMEDIATE', $fname, $ignoreErrors );
                }
        }
 
@@ -1338,7 +1336,7 @@ class DatabaseOracle extends Database {
                        }
                }
 
-               if ( !$this->trxLevel ) {
+               if ( !$this->trxLevel() ) {
                        oci_commit( $this->conn );
                }
 
index fb1053c..f834fb1 100644 (file)
@@ -27,6 +27,7 @@
  * @defgroup Dump Dump
  */
 
+use MediaWiki\MediaWikiServices as MediaWikiServicesAlias;
 use Wikimedia\Rdbms\IResultWrapper;
 use Wikimedia\Rdbms\IDatabase;
 
@@ -52,8 +53,8 @@ class WikiExporter {
        const LOGS = 8;
        const RANGE = 16;
 
-       const TEXT = 0;
-       const STUB = 1;
+       const TEXT = XmlDumpWriter::WRITE_CONTENT;
+       const STUB = XmlDumpWriter::WRITE_STUB;
 
        const BATCH_SIZE = 50000;
 
@@ -339,18 +340,28 @@ class WikiExporter {
                        );
                }
 
-               $revOpts = [ 'page' ];
-
-               $revQuery = Revision::getQueryInfo( $revOpts );
+               $revQuery = MediaWikiServicesAlias::getInstance()->getRevisionStore()->getQueryInfo(
+                       [ 'page' ]
+               );
+               $slotQuery = MediaWikiServicesAlias::getInstance()->getRevisionStore()->getSlotsQueryInfo(
+                       [ 'content' ]
+               );
 
-               // We want page primary rather than revision
+               // We want page primary rather than revision.
+               // We also want to join in the slots and content tables.
+               // NOTE: This means we may get multiple rows per revision, and more rows
+               // than the batch size! Should be ok, since the max number of slots is
+               // fixed and low (dozens at worst).
                $tables = array_merge( [ 'page' ], array_diff( $revQuery['tables'], [ 'page' ] ) );
+               $tables = array_merge( $tables, array_diff( $slotQuery['tables'], $tables ) );
                $join = $revQuery['joins'] + [
-                               'revision' => $revQuery['joins']['page']
+                               'revision' => $revQuery['joins']['page'],
+                               'slots' => [ 'JOIN', [ 'slot_revision_id = rev_id' ] ],
+                               'content' => [ 'JOIN', [ 'content_id = slot_content_id' ] ],
                        ];
                unset( $join['page'] );
 
-               $fields = $revQuery['fields'];
+               $fields = array_merge( $revQuery['fields'], $slotQuery['fields'] );
                $fields[] = 'page_restrictions';
 
                if ( $this->text != self::STUB ) {
@@ -387,7 +398,6 @@ class WikiExporter {
                        # Full history dumps...
                        # query optimization for history stub dumps
                        if ( $this->text == self::STUB ) {
-                               $tables = $revQuery['tables'];
                                $opts[] = 'STRAIGHT_JOIN';
                                $opts['USE INDEX']['revision'] = 'rev_page_id';
                                unset( $join['revision'] );
@@ -464,24 +474,36 @@ class WikiExporter {
        }
 
        /**
-        * Runs through a query result set dumping page and revision records.
-        * The result set should be sorted/grouped by page to avoid duplicate
-        * page records in the output.
+        * Runs through a query result set dumping page, revision, and slot records.
+        * The result set should join the page, revision, slots, and content tables,
+        * and be sorted/grouped by page and revision to avoid duplicate page records in the output.
         *
         * @param IResultWrapper $results
         * @param object $lastRow the last row output from the previous call (or null if none)
         * @return object the last row processed
         */
        protected function outputPageStreamBatch( $results, $lastRow ) {
-               foreach ( $results as $row ) {
+               $rowCarry = null;
+               while ( true ) {
+                       $slotRows = $this->getSlotRowBatch( $results, $rowCarry );
+
+                       if ( !$slotRows ) {
+                               break;
+                       }
+
+                       // All revision info is present in all slot rows.
+                       // Use the first slot row as the revision row.
+                       $revRow = $slotRows[0];
+
                        if ( $this->limitNamespaces &&
-                               !in_array( $row->page_namespace, $this->limitNamespaces ) ) {
-                               $lastRow = $row;
+                               !in_array( $revRow->page_namespace, $this->limitNamespaces ) ) {
+                               $lastRow = $revRow;
                                continue;
                        }
+
                        if ( $lastRow === null ||
-                               $lastRow->page_namespace !== $row->page_namespace ||
-                               $lastRow->page_title !== $row->page_title ) {
+                               $lastRow->page_namespace !== $revRow->page_namespace ||
+                               $lastRow->page_title !== $revRow->page_title ) {
                                if ( $lastRow !== null ) {
                                        $output = '';
                                        if ( $this->dumpUploads ) {
@@ -490,17 +512,52 @@ class WikiExporter {
                                        $output .= $this->writer->closePage();
                                        $this->sink->writeClosePage( $output );
                                }
-                               $output = $this->writer->openPage( $row );
-                               $this->sink->writeOpenPage( $row, $output );
+                               $output = $this->writer->openPage( $revRow );
+                               $this->sink->writeOpenPage( $revRow, $output );
                        }
-                       $output = $this->writer->writeRevision( $row );
-                       $this->sink->writeRevision( $row, $output );
-                       $lastRow = $row;
+                       $output = $this->writer->writeRevision( $revRow, $slotRows );
+                       $this->sink->writeRevision( $revRow, $output );
+                       $lastRow = $revRow;
+               }
+
+               if ( $rowCarry ) {
+                       throw new LogicException( 'Error while processing a stream of slot rows' );
                }
 
                return $lastRow;
        }
 
+       /**
+        * Returns all slot rows for a revision.
+        * Takes and returns a carry row from the last batch;
+        *
+        * @param IResultWrapper|array $results
+        * @param null|object &$carry A row carried over from the last call to getSlotRowBatch()
+        *
+        * @return object[]
+        */
+       protected function getSlotRowBatch( $results, &$carry = null ) {
+               $slotRows = [];
+               $prev = null;
+
+               if ( $carry ) {
+                       $slotRows[] = $carry;
+                       $prev = $carry;
+                       $carry = null;
+               }
+
+               while ( $row = $results->fetchObject() ) {
+                       if ( $prev && $prev->rev_id !== $row->rev_id ) {
+                               $carry = $row;
+                               break;
+                       }
+                       $slotRows[] = $row;
+                       $prev = $row;
+               }
+
+               return $slotRows;
+       }
+
        /**
         * Final page stream output, after all batches are complete
         *
index 0659ec1..bedfe13 100644 (file)
  * @file
  */
 use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RevisionRecord;
 use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Revision\SlotRecord;
+use MediaWiki\Revision\SuppressedDataException;
 use MediaWiki\Storage\SqlBlobStore;
+use Wikimedia\Assert\Assert;
 
 /**
  * @ingroup Dump
  */
 class XmlDumpWriter {
+
+       /** Output serialized revision content. */
+       const WRITE_CONTENT = 0;
+
+       /** Only output subs for revision content. */
+       const WRITE_STUB = 1;
+
+       /**
+        * Only output subs for revision content, indicating that the content has been
+        * deleted/suppressed. For internal use only.
+        */
+       const WRITE_STUB_DELETED = 2;
+
        /**
         * @var string[] the schema versions supported for output
         * @final
         */
        public static $supportedSchemas = [
                XML_DUMP_SCHEMA_VERSION_10,
+               XML_DUMP_SCHEMA_VERSION_11
        ];
 
+       /**
+        * @var string which schema version the generated XML should comply to.
+        * One of the values from self::$supportedSchemas, using the SCHEMA_VERSION_XX
+        * constants.
+        */
+       private $schemaVersion;
+
        /**
         * Title of the currently processed page
         *
@@ -45,6 +70,40 @@ class XmlDumpWriter {
         */
        private $currentTitle = null;
 
+       /**
+        * @var int Whether to output revision content or just stubs. WRITE_CONTENT or WRITE_STUB.
+        */
+       private $contentMode;
+
+       /**
+        * XmlDumpWriter constructor.
+        *
+        * @param int $contentMode WRITE_CONTENT or WRITE_STUB.
+        * @param string $schemaVersion which schema version the generated XML should comply to.
+        * One of the values from self::$supportedSchemas, using the XML_DUMP_SCHEMA_VERSION_XX
+        * constants.
+        */
+       public function __construct(
+               $contentMode = self::WRITE_CONTENT,
+               $schemaVersion = XML_DUMP_SCHEMA_VERSION_11
+       ) {
+               Assert::parameter(
+                       in_array( $contentMode, [ self::WRITE_CONTENT, self::WRITE_STUB ] ),
+                       '$contentMode',
+                       'must be one of the following constants: WRITE_CONTENT or WRITE_STUB.'
+               );
+
+               Assert::parameter(
+                       in_array( $schemaVersion, self::$supportedSchemas ),
+                       '$schemaVersion',
+                       'must be one of the following schema versions: '
+                               . implode( ',', self::$supportedSchemas )
+               );
+
+               $this->contentMode = $contentMode;
+               $this->schemaVersion = $schemaVersion;
+       }
+
        /**
         * Opens the XML output stream's root "<mediawiki>" element.
         * This does not include an xml directive, so is safe to include
@@ -56,7 +115,7 @@ class XmlDumpWriter {
         * @return string
         */
        function openStream() {
-               $ver = WikiExporter::schemaVersion();
+               $ver = $this->schemaVersion;
                return Xml::element( 'mediawiki', [
                        'xmlns'              => "http://www.mediawiki.org/xml/export-$ver/",
                        'xmlns:xsi'          => "http://www.w3.org/2001/XMLSchema-instance",
@@ -177,7 +236,7 @@ class XmlDumpWriter {
         */
        public function openPage( $row ) {
                $out = "  <page>\n";
-               $this->currentTitle = Title::makeTitle( $row->page_namespace, $row->page_title );
+               $this->currentTitle = Title::newFromRow( $row );
                $canonicalTitle = self::canonicalTitle( $this->currentTitle );
                $out .= '    ' . Xml::elementClean( 'title', [], $canonicalTitle ) . "\n";
                $out .= '    ' . Xml::element( 'ns', [], strval( $row->page_namespace ) ) . "\n";
@@ -237,144 +296,204 @@ class XmlDumpWriter {
         * data filled in from the given database row.
         *
         * @param object $row
+        * @param null|object[] $slotRows
+        *
         * @return string
+        * @throws FatalError
+        * @throws MWException
         * @private
         */
-       function writeRevision( $row ) {
+       function writeRevision( $row, $slotRows = null ) {
+               $rev = $this->getRevisionStore()->newRevisionFromRowAndSlots(
+                       $row,
+                       $slotRows,
+                       0,
+                       $this->currentTitle
+               );
+
                $out = "    <revision>\n";
-               $out .= "      " . Xml::element( 'id', null, strval( $row->rev_id ) ) . "\n";
-               if ( isset( $row->rev_parent_id ) && $row->rev_parent_id ) {
-                       $out .= "      " . Xml::element( 'parentid', null, strval( $row->rev_parent_id ) ) . "\n";
+               $out .= "      " . Xml::element( 'id', null, strval( $rev->getId() ) ) . "\n";
+
+               if ( $rev->getParentId() ) {
+                       $out .= "      " . Xml::element( 'parentid', null, strval( $rev->getParentId() ) ) . "\n";
                }
 
-               $out .= $this->writeTimestamp( $row->rev_timestamp );
+               $out .= $this->writeTimestamp( $rev->getTimestamp() );
 
-               if ( isset( $row->rev_deleted ) && ( $row->rev_deleted & Revision::DELETED_USER ) ) {
+               if ( $rev->isDeleted( Revision::DELETED_USER ) ) {
                        $out .= "      " . Xml::element( 'contributor', [ 'deleted' => 'deleted' ] ) . "\n";
                } else {
                        // empty values get written out as uid 0, see T224221
-                       $out .= $this->writeContributor( $row->rev_user ?: 0, $row->rev_user_text );
+                       $user = $rev->getUser();
+                       $out .= $this->writeContributor(
+                               $user ? $user->getId() : 0,
+                               $user ? $user->getName() : ''
+                       );
                }
 
-               if ( isset( $row->rev_minor_edit ) && $row->rev_minor_edit ) {
+               if ( $rev->isMinor() ) {
                        $out .= "      <minor/>\n";
                }
-               if ( isset( $row->rev_deleted ) && ( $row->rev_deleted & Revision::DELETED_COMMENT ) ) {
+               if ( $rev->isDeleted( Revision::DELETED_COMMENT ) ) {
                        $out .= "      " . Xml::element( 'comment', [ 'deleted' => 'deleted' ] ) . "\n";
                } else {
-                       $comment = CommentStore::getStore()->getComment( 'rev_comment', $row )->text;
-                       if ( $comment != '' ) {
-                               $out .= "      " . Xml::elementClean( 'comment', [], strval( $comment ) ) . "\n";
-                       }
+                       $out .= "      "
+                               . Xml::elementClean( 'comment', [], strval( $rev->getComment()->text ) )
+                               . "\n";
+               }
+
+               $contentMode = $rev->isDeleted( Revision::DELETED_TEXT ) ? self::WRITE_STUB_DELETED
+                       : $this->contentMode;
+
+               foreach ( $rev->getSlots()->getSlots() as $slot ) {
+                       $out .= $this->writeSlot( $slot, $contentMode );
                }
 
-               // TODO: rev_content_model no longer exists with MCR, see T174031
-               if ( isset( $row->rev_content_model ) && !is_null( $row->rev_content_model ) ) {
-                       $content_model = strval( $row->rev_content_model );
+               if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
+                       $out .= "      <sha1/>\n";
                } else {
-                       // probably using $wgContentHandlerUseDB = false;
-                       $content_model = ContentHandler::getDefaultModelFor( $this->currentTitle );
+                       $out .= "      " . Xml::element( 'sha1', null, strval( $rev->getSha1() ) ) . "\n";
                }
 
-               $content_handler = ContentHandler::getForModelID( $content_model );
+               // Avoid PHP 7.1 warning from passing $this by reference
+               $writer = $this;
+               $text = $rev->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
+               Hooks::run( 'XmlDumpWriterWriteRevision', [ &$writer, &$out, $row, $text, $rev ] );
 
-               // TODO: rev_content_format no longer exists with MCR, see T174031
-               if ( isset( $row->rev_content_format ) && !is_null( $row->rev_content_format ) ) {
-                       $content_format = strval( $row->rev_content_format );
-               } else {
-                       // probably using $wgContentHandlerUseDB = false;
-                       $content_format = $content_handler->getDefaultFormat();
+               $out .= "    </revision>\n";
+
+               return $out;
+       }
+
+       /**
+        * @param SlotRecord $slot
+        * @param int $contentMode see the WRITE_XXX constants
+        *
+        * @return string
+        */
+       private function writeSlot( SlotRecord $slot, $contentMode ) {
+               $isMain = $slot->getRole() === SlotRecord::MAIN;
+               $isV11 = $this->schemaVersion >= XML_DUMP_SCHEMA_VERSION_11;
+
+               if ( !$isV11 && !$isMain ) {
+                       // ignore extra slots
+                       return '';
                }
 
-               $out .= "      " . Xml::element( 'model', null, strval( $content_model ) ) . "\n";
-               $out .= "      " . Xml::element( 'format', null, strval( $content_format ) ) . "\n";
+               $out = '';
+               $indent = '      ';
 
-               $text = '';
-               if ( isset( $row->rev_deleted ) && ( $row->rev_deleted & Revision::DELETED_TEXT ) ) {
-                       $out .= "      " . Xml::element( 'text', [ 'deleted' => 'deleted' ] ) . "\n";
-               } elseif ( isset( $row->old_text ) ) {
-                       // Raw text from the database may have invalid chars
-                       $text = strval( Revision::getRevisionText( $row ) );
-                       try {
-                               $text = $content_handler->exportTransform( $text, $content_format );
-                       }
-                       catch ( Exception $ex ) {
-                               if ( $ex instanceof MWException || $ex instanceof RuntimeException ) {
-                                       // leave text as is; that's the way it goes
-                                       wfLogWarning( 'exportTransform failed on text for revid ' . $row->rev_id . "\n" );
-                               } else {
-                                       throw $ex;
-                               }
-                       }
-                       $out .= "      " . Xml::elementClean( 'text',
-                               [ 'xml:space' => 'preserve', 'bytes' => intval( $row->rev_len ) ],
-                               strval( $text ) ) . "\n";
-               } elseif ( isset( $row->_load_content ) ) {
-                       // TODO: make this fully MCR aware, see T174031
-                       $rev = $this->getRevisionStore()->newRevisionFromRow( $row, 0, $this->currentTitle );
-                       $slot = $rev->getSlot( 'main' );
-                       try {
-                               $content = $slot->getContent();
+               if ( !$isMain ) {
+                       // non-main slots are wrapped into an additional element.
+                       $out .= '      ' . Xml::openElement( 'content' ) . "\n";
+                       $indent .= '  ';
+                       $out .= $indent . Xml::element( 'role', null, strval( $slot->getRole() ) ) . "\n";
+               }
 
-                               if ( $content instanceof TextContent ) {
-                                       // HACK: For text based models, bypass the serialization step.
-                                       // This allows extensions (like Flow)that use incompatible combinations
-                                       // of serialization format and content model.
-                                       $text = $content->getNativeData();
-                               } else {
-                                       $text = $content->serialize( $content_format );
-                               }
-                               $text = $content_handler->exportTransform( $text, $content_format );
-                               $out .= "      " . Xml::elementClean( 'text',
-                                       [ 'xml:space' => 'preserve', 'bytes' => intval( $slot->getSize() ) ],
-                                       strval( $text ) ) . "\n";
+               if ( $isV11 ) {
+                       $out .= $indent . Xml::element( 'origin', null, strval( $slot->getOrigin() ) ) . "\n";
+               }
+
+               $contentModel = $slot->getModel();
+               $contentHandler = ContentHandler::getForModelID( $contentModel );
+               $contentFormat = $contentHandler->getDefaultFormat();
+
+               // XXX: The content format is only relevant when actually outputting serialized content.
+               // It should probably be an attribute on the text tag.
+               $out .= $indent . Xml::element( 'model', null, strval( $contentModel ) ) . "\n";
+               $out .= $indent . Xml::element( 'format', null, strval( $contentFormat ) ) . "\n";
+
+               $textAttributes = [
+                       'xml:space' => 'preserve',
+                       'bytes' => $slot->getSize(),
+               ];
+
+               if ( $isV11 ) {
+                       $textAttributes['sha1'] = $slot->getSha1();
+               }
+
+               if ( $contentMode === self::WRITE_CONTENT ) {
+                       try {
+                               // write <text> tag
+                               $out .= $this->writeText( $slot->getContent(), $textAttributes, $indent );
+                       } catch ( SuppressedDataException $ex ) {
+                               // NOTE: this shouldn't happen, since the caller is supposed to have checked
+                               // for suppressed content!
+                               // write <text> placeholder tag
+                               $textAttributes['deleted'] = 'deleted';
+                               $out .= $indent . Xml::element( 'text', $textAttributes ) . "\n";
                        }
                        catch ( Exception $ex ) {
                                if ( $ex instanceof MWException || $ex instanceof RuntimeException ) {
-                                       // there's no provsion in the schema for an attribute that will let
+                                       // there's no provision in the schema for an attribute that will let
                                        // the user know this element was unavailable due to error; an empty
                                        // tag is the best we can do
-                                       $out .= "      " . Xml::element( 'text' ) . "\n";
-                                       wfLogWarning( 'failed to load content for revid ' . $row->rev_id . "\n" );
+                                       $out .= $indent . Xml::element( 'text' ) . "\n";
+                                       wfLogWarning(
+                                               'failed to load content slot ' . $slot->getRole() . ' for revision '
+                                               . $slot->getRevision() . "\n"
+                                       );
                                } else {
                                        throw $ex;
                                }
                        }
-               } elseif ( isset( $row->rev_text_id ) ) {
-                       // Stub output for pre-MCR schema
-                       // TODO: MCR: rev_text_id only exists in the pre-MCR schema. Remove this when
-                       // we drop support for the old schema.
-                       $out .= "      " . Xml::element( 'text',
-                               [ 'id' => $row->rev_text_id, 'bytes' => intval( $row->rev_len ) ],
-                               "" ) . "\n";
+               } elseif ( $contentMode === self::WRITE_STUB_DELETED ) {
+                       // write <text> placeholder tag
+                       $textAttributes['deleted'] = 'deleted';
+                       $out .= $indent . Xml::element( 'text', $textAttributes ) . "\n";
                } else {
-                       // Backwards-compatible stub output for MCR aware schema
-                       // TODO: MCR: emit content addresses instead of text ids, see T174031, T199121
-                       $rev = $this->getRevisionStore()->newRevisionFromRow( $row, 0, $this->currentTitle );
-                       $slot = $rev->getSlot( 'main' );
+                       // write <text> stub tag
+                       if ( $isV11 ) {
+                               $textAttributes['location'] = $slot->getAddress();
+                       }
 
+                       // Output the numerical text ID if possible, for backwards compatibility.
                        // Note that this is currently the ONLY reason we have a BlobStore here at all.
                        // When removing this line, check whether the BlobStore has become unused.
                        $textId = $this->getBlobStore()->getTextIdFromAddress( $slot->getAddress() );
-                       $out .= "      " . Xml::element( 'text',
-                                       [ 'id' => $textId, 'bytes' => intval( $slot->getSize() ) ],
-                                       "" ) . "\n";
+                       if ( $textId ) {
+                               $textAttributes['id'] = $textId;
+                       } elseif ( !$isV11 ) {
+                               throw new InvalidArgumentException(
+                                       'Cannot produce stubs for non-text-table content blobs with schema version '
+                                       . $this->schemaVersion
+                               );
+                       }
+
+                       $out .= $indent . Xml::element( 'text', $textAttributes ) . "\n";
                }
 
-               if ( isset( $row->rev_sha1 )
-                       && $row->rev_sha1
-                       && !( $row->rev_deleted & Revision::DELETED_TEXT )
-               ) {
-                       $out .= "      " . Xml::element( 'sha1', null, strval( $row->rev_sha1 ) ) . "\n";
-               } else {
-                       $out .= "      <sha1/>\n";
+               if ( !$isMain ) {
+                       $out .= '      ' . Xml::closeElement( 'content' ) . "\n";
                }
 
-               // Avoid PHP 7.1 warning from passing $this by reference
-               $writer = $this;
-               Hooks::run( 'XmlDumpWriterWriteRevision', [ &$writer, &$out, $row, $text ] );
+               return $out;
+       }
 
-               $out .= "    </revision>\n";
+       /**
+        * @param Content $content
+        * @param string[] $textAttributes
+        * @param string $indent
+        *
+        * @return string
+        */
+       private function writeText( Content $content, $textAttributes, $indent ) {
+               $out = '';
+
+               $contentHandler = $content->getContentHandler();
+               $contentFormat = $contentHandler->getDefaultFormat();
+
+               if ( $content instanceof TextContent ) {
+                       // HACK: For text based models, bypass the serialization step. This allows extensions (like Flow)
+                       // that use incompatible combinations of serialization format and content model.
+                       $data = $content->getNativeData();
+               } else {
+                       $data = $content->serialize( $contentFormat );
+               }
+
+               $data = $contentHandler->exportTransform( $data, $contentFormat );
+               $textAttributes['bytes'] = $size = strlen( $data ); // make sure to use the actual size
+               $out .= $indent . Xml::elementClean( 'text', $textAttributes, strval( $data ) ) . "\n";
 
                return $out;
        }
index cedcee1..4f083c6 100644 (file)
@@ -55,7 +55,7 @@
        "config-env-php": "PHP $1 đã được cài đặt.",
        "config-env-hhvm": "HHVM $1 được cài đặt.",
        "config-unicode-using-intl": "Sẽ sử dụng [https://pecl.php.net/intl phần mở rộng PECL intl] để chuẩn hóa Unicode.",
-       "config-unicode-pure-php-warning": "<strong>Cảnh báo:</strong>  [https://pecl.php.net/intl intl PECL extension] không được phép xử lý Unicode chuẩn hóa, trả lại thực thi PHP-gốc chậm.\nNếu bạn chạy một site lưu lượng lớn, bạn phải để ý qua một chút trên  [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode normalization].",
+       "config-unicode-pure-php-warning": "<strong>Cảnh báo:</strong>  [https://pecl.php.net/intl PECL intl extension] không được phép xử lý Unicode chuẩn hóa, trả lại thực thi PHP-gốc chậm.\nNếu bạn chạy một site lưu lượng lớn, bạn nên đọc qua  [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations chuẩn hóa Unicode].",
        "config-unicode-update-warning": "<strong>Cảnh báo:</strong> Phiên bản cài đặt của gói Unicode chuẩn hóa sử dụng một phiên bản cũ của thư viện [http://site.icu-project.org/ the ICU project].\nBạn phải [https://www.mediawiki.org/wiki/Special:MyLanguage/nâng cấp Unicode_normalization_considerations] nếu bạn quan tâm đến việc sử dụng Unicode.",
        "config-no-db": "Không tìm thấy một trình điều khiển cơ sở dữ liệu phù hợp! Bạn cần phải cài một trình điều khiển cơ sở dữ liệu cho PHP.\n{{PLURAL:$2|Loại|Các loại}} cơ sở dữ liệu sau đây được hỗ trợ: $1.\n\nNếu bạn đã biên dịch PHP lấy, cấu hình lại nó mà kích hoạt một trình khách cơ sở dữ liệu, ví dụ bằng lệnh <code>./configure --with-mysqli</code>.\nNếu bạn đã cài PHP từ một gói Debian hoặc Ubuntu, thì bạn cũng cần phải cài ví dụ gói <code>php-mysql</code>.",
        "config-outdated-sqlite": "<strong>Chú ý:</strong> Bạn có SQLite $1, phiên bản này thấp hơn phiên bản yêu câu tối thiểu $2. SQLite sẽ không có tác dụng.",
index 27e6138..50a0b0e 100644 (file)
@@ -73,7 +73,7 @@ class ConnectionManager {
         * @param int $i
         * @param string[]|null $groups
         *
-        * @return Database
+        * @return IDatabase
         */
        private function getConnection( $i, array $groups = null ) {
                $groups = $groups === null ? $this->groups : $groups;
@@ -97,7 +97,7 @@ class ConnectionManager {
         *
         * @since 1.29
         *
-        * @return Database
+        * @return IDatabase
         */
        public function getWriteConnection() {
                return $this->getConnection( DB_MASTER );
@@ -111,7 +111,7 @@ class ConnectionManager {
         *
         * @param string[]|null $groups
         *
-        * @return Database
+        * @return IDatabase
         */
        public function getReadConnection( array $groups = null ) {
                $groups = $groups === null ? $this->groups : $groups;
index aa3bea8..ccb73d7 100644 (file)
@@ -64,7 +64,7 @@ class SessionConsistentConnectionManager extends ConnectionManager {
         *
         * @param string[]|null $groups
         *
-        * @return Database
+        * @return IDatabase
         */
        public function getReadConnection( array $groups = null ) {
                if ( $this->forceWriteConnection ) {
@@ -77,7 +77,7 @@ class SessionConsistentConnectionManager extends ConnectionManager {
        /**
         * @since 1.29
         *
-        * @return Database
+        * @return IDatabase
         */
        public function getWriteConnection() {
                $this->prepareForUpdates();
index 8af6bb3..c8e31df 100644 (file)
@@ -598,6 +598,10 @@ class DBConnRef implements IDatabase {
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
+       public function onAtomicSectionCancel( callable $callback, $fname = __METHOD__ ) {
+               return $this->__call( __FUNCTION__, func_get_args() );
+       }
+
        public function setTransactionListener( $name, callable $callback = null ) {
                return $this->__call( __FUNCTION__, func_get_args() );
        }
index 971257f..760d137 100644 (file)
@@ -38,6 +38,7 @@ use InvalidArgumentException;
 use UnexpectedValueException;
 use Exception;
 use RuntimeException;
+use Throwable;
 
 /**
  * Relational database abstraction object
@@ -104,9 +105,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        /** @var array Map of (table name => 1) for TEMPORARY tables */
        protected $sessionTempTables = [];
 
-       /** @var int Whether there is an active transaction (1 or 0) */
-       protected $trxLevel = 0;
-       /** @var string Hexidecimal string if a transaction is active or empty string otherwise */
+       /** @var string ID of the active transaction or the empty string otherwise */
        protected $trxShortId = '';
        /** @var int Transaction status */
        protected $trxStatus = self::STATUS_TRX_NONE;
@@ -148,6 +147,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        private $trxPreCommitCallbacks = [];
        /** @var array[] List of (callable, method name, atomic section id) */
        private $trxEndCallbacks = [];
+       /** @var array[] List of (callable, method name, atomic section id) */
+       private $trxSectionCancelCallbacks = [];
        /** @var callable[] Map of (name => callable) */
        private $trxRecurringCallbacks = [];
        /** @var bool Whether to suppress triggering of transaction end callbacks */
@@ -509,12 +510,12 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                return $res;
        }
 
-       public function trxLevel() {
-               return $this->trxLevel;
+       final public function trxLevel() {
+               return ( $this->trxShortId != '' ) ? 1 : 0;
        }
 
        public function trxTimestamp() {
-               return $this->trxLevel ? $this->trxTimestamp : null;
+               return $this->trxLevel() ? $this->trxTimestamp : null;
        }
 
        /**
@@ -617,20 +618,21 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        public function writesPending() {
-               return $this->trxLevel && $this->trxDoneWrites;
+               return $this->trxLevel() && $this->trxDoneWrites;
        }
 
        public function writesOrCallbacksPending() {
-               return $this->trxLevel && (
+               return $this->trxLevel() && (
                        $this->trxDoneWrites ||
                        $this->trxIdleCallbacks ||
                        $this->trxPreCommitCallbacks ||
-                       $this->trxEndCallbacks
+                       $this->trxEndCallbacks ||
+                       $this->trxSectionCancelCallbacks
                );
        }
 
        public function preCommitCallbacksPending() {
-               return $this->trxLevel && $this->trxPreCommitCallbacks;
+               return $this->trxLevel() && $this->trxPreCommitCallbacks;
        }
 
        /**
@@ -648,7 +650,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        public function pendingWriteQueryDuration( $type = self::ESTIMATE_TOTAL ) {
-               if ( !$this->trxLevel ) {
+               if ( !$this->trxLevel() ) {
                        return false;
                } elseif ( !$this->trxDoneWrites ) {
                        return 0.0;
@@ -678,7 +680,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        public function pendingWriteCallers() {
-               return $this->trxLevel ? $this->trxWriteCallers : [];
+               return $this->trxLevel() ? $this->trxWriteCallers : [];
        }
 
        public function pendingWriteRowsAffected() {
@@ -698,7 +700,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                foreach ( [
                        $this->trxIdleCallbacks,
                        $this->trxPreCommitCallbacks,
-                       $this->trxEndCallbacks
+                       $this->trxEndCallbacks,
+                       $this->trxSectionCancelCallbacks
                ] as $callbacks ) {
                        foreach ( $callbacks as $callback ) {
                                $fnames[] = $callback[1];
@@ -866,7 +869,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                // This should mostly do nothing if the connection is already closed
                if ( $this->conn ) {
                        // Roll back any dangling transaction first
-                       if ( $this->trxLevel ) {
+                       if ( $this->trxLevel() ) {
                                if ( $this->trxAtomicLevels ) {
                                        // Cannot let incomplete atomic sections be committed
                                        $levels = $this->flatAtomicSectionList();
@@ -1153,7 +1156,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        final protected function executeQuery( $sql, $fname, $flags ) {
                $this->assertHasConnectionHandle();
 
-               $priorTransaction = $this->trxLevel;
+               $priorTransaction = $this->trxLevel();
 
                if ( $this->isWriteQuery( $sql ) ) {
                        # In theory, non-persistent writes are allowed in read-only mode, but due to things
@@ -1243,7 +1246,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                // Keep track of whether the transaction has write queries pending
                if ( $isPermWrite ) {
                        $this->lastWriteTime = microtime( true );
-                       if ( $this->trxLevel && !$this->trxDoneWrites ) {
+                       if ( $this->trxLevel() && !$this->trxDoneWrites ) {
                                $this->trxDoneWrites = true;
                                $this->trxProfiler->transactionWritingIn(
                                        $this->server, $this->getDomainID(), $this->trxShortId );
@@ -1273,7 +1276,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
 
                if ( $ret !== false ) {
                        $this->lastPing = $startTime;
-                       if ( $isPermWrite && $this->trxLevel ) {
+                       if ( $isPermWrite && $this->trxLevel() ) {
                                $this->updateTrxWriteQueryTime( $sql, $queryRuntime, $this->affectedRows() );
                                $this->trxWriteCallers[] = $fname;
                        }
@@ -1322,7 +1325,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
         */
        private function beginIfImplied( $sql, $fname ) {
                if (
-                       !$this->trxLevel &&
+                       !$this->trxLevel() &&
                        $this->getFlag( self::DBO_TRX ) &&
                        $this->isTransactableQuery( $sql )
                ) {
@@ -1452,7 +1455,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                // https://www.postgresql.org/docs/9.4/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
                $this->sessionNamedLocks = [];
                // Session loss implies transaction loss
-               $this->trxLevel = 0;
+               $oldTrxShortId = $this->consumeTrxShortId();
                $this->trxAtomicCounter = 0;
                $this->trxIdleCallbacks = []; // T67263; transaction already lost
                $this->trxPreCommitCallbacks = []; // T67263; transaction already lost
@@ -1461,7 +1464,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        $this->trxProfiler->transactionWritingOut(
                                $this->server,
                                $this->getDomainID(),
-                               $this->trxShortId,
+                               $oldTrxShortId,
                                $this->pendingWriteQueryDuration( self::ESTIMATE_TOTAL ),
                                $this->trxWriteAffectedRows
                        );
@@ -1487,6 +1490,18 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                }
        }
 
+       /**
+        * Reset the transaction ID and return the old one
+        *
+        * @return string The old transaction ID or the empty string if there wasn't one
+        */
+       private function consumeTrxShortId() {
+               $old = $this->trxShortId;
+               $this->trxShortId = '';
+
+               return $old;
+       }
+
        /**
         * Checks whether the cause of the error is detected to be a timeout.
         *
@@ -1984,7 +1999,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        public function lockForUpdate(
                $table, $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
        ) {
-               if ( !$this->trxLevel && !$this->getFlag( self::DBO_TRX ) ) {
+               if ( !$this->trxLevel() && !$this->getFlag( self::DBO_TRX ) ) {
                        throw new DBUnexpectedError(
                                $this,
                                __METHOD__ . ': no transaction is active nor is DBO_TRX set'
@@ -3331,21 +3346,21 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        final public function onTransactionResolution( callable $callback, $fname = __METHOD__ ) {
-               if ( !$this->trxLevel ) {
+               if ( !$this->trxLevel() ) {
                        throw new DBUnexpectedError( $this, "No transaction is active." );
                }
                $this->trxEndCallbacks[] = [ $callback, $fname, $this->currentAtomicSectionId() ];
        }
 
        final public function onTransactionCommitOrIdle( callable $callback, $fname = __METHOD__ ) {
-               if ( !$this->trxLevel && $this->getTransactionRoundId() ) {
+               if ( !$this->trxLevel() && $this->getTransactionRoundId() ) {
                        // Start an implicit transaction similar to how query() does
                        $this->begin( __METHOD__, self::TRANSACTION_INTERNAL );
                        $this->trxAutomatic = true;
                }
 
                $this->trxIdleCallbacks[] = [ $callback, $fname, $this->currentAtomicSectionId() ];
-               if ( !$this->trxLevel ) {
+               if ( !$this->trxLevel() ) {
                        $this->runOnTransactionIdleCallbacks( self::TRIGGER_IDLE );
                }
        }
@@ -3355,13 +3370,13 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        final public function onTransactionPreCommitOrIdle( callable $callback, $fname = __METHOD__ ) {
-               if ( !$this->trxLevel && $this->getTransactionRoundId() ) {
+               if ( !$this->trxLevel() && $this->getTransactionRoundId() ) {
                        // Start an implicit transaction similar to how query() does
                        $this->begin( __METHOD__, self::TRANSACTION_INTERNAL );
                        $this->trxAutomatic = true;
                }
 
-               if ( $this->trxLevel ) {
+               if ( $this->trxLevel() ) {
                        $this->trxPreCommitCallbacks[] = [ $callback, $fname, $this->currentAtomicSectionId() ];
                } else {
                        // No transaction is active nor will start implicitly, so make one for this callback
@@ -3376,11 +3391,18 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                }
        }
 
+       final public function onAtomicSectionCancel( callable $callback, $fname = __METHOD__ ) {
+               if ( !$this->trxLevel() || !$this->trxAtomicLevels ) {
+                       throw new DBUnexpectedError( $this, "No atomic section is open (got $fname)." );
+               }
+               $this->trxSectionCancelCallbacks[] = [ $callback, $fname, $this->currentAtomicSectionId() ];
+       }
+
        /**
         * @return AtomicSectionIdentifier|null ID of the topmost atomic section level
         */
        private function currentAtomicSectionId() {
-               if ( $this->trxLevel && $this->trxAtomicLevels ) {
+               if ( $this->trxLevel() && $this->trxAtomicLevels ) {
                        $levelInfo = end( $this->trxAtomicLevels );
 
                        return $levelInfo[1];
@@ -3390,6 +3412,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        /**
+        * Hoist callback ownership for callbacks in a section to a parent section.
+        * All callbacks should have an owner that is present in trxAtomicLevels.
         * @param AtomicSectionIdentifier $old
         * @param AtomicSectionIdentifier $new
         */
@@ -3411,13 +3435,35 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                                $this->trxEndCallbacks[$key][2] = $new;
                        }
                }
+               foreach ( $this->trxSectionCancelCallbacks as $key => $info ) {
+                       if ( $info[2] === $old ) {
+                               $this->trxSectionCancelCallbacks[$key][2] = $new;
+                       }
+               }
        }
 
        /**
+        * Update callbacks that were owned by cancelled atomic sections.
+        *
+        * Callbacks for "on commit" should never be run if they're owned by a
+        * section that won't be committed.
+        *
+        * Callbacks for "on resolution" need to reflect that the section was
+        * rolled back, even if the transaction as a whole commits successfully.
+        *
+        * Callbacks for "on section cancel" should already have been consumed,
+        * but errors during the cancellation itself can prevent that while still
+        * destroying the section. Hoist any such callbacks to the new top section,
+        * which we assume will itself have to be cancelled or rolled back to
+        * resolve the error.
+        *
         * @param AtomicSectionIdentifier[] $sectionIds ID of an actual savepoint
+        * @param AtomicSectionIdentifier|null $newSectionId New top section ID.
         * @throws UnexpectedValueException
         */
-       private function modifyCallbacksForCancel( array $sectionIds ) {
+       private function modifyCallbacksForCancel(
+               array $sectionIds, AtomicSectionIdentifier $newSectionId = null
+       ) {
                // Cancel the "on commit" callbacks owned by this savepoint
                $this->trxIdleCallbacks = array_filter(
                        $this->trxIdleCallbacks,
@@ -3436,8 +3482,17 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        if ( in_array( $entry[2], $sectionIds, true ) ) {
                                $callback = $entry[0];
                                $this->trxEndCallbacks[$key][0] = function () use ( $callback ) {
+                                       // @phan-suppress-next-line PhanInfiniteRecursion No recursion at all here, phan is confused
                                        return $callback( self::TRIGGER_ROLLBACK, $this );
                                };
+                               // This "on resolution" callback no longer belongs to a section.
+                               $this->trxEndCallbacks[$key][2] = null;
+                       }
+               }
+               // Hoist callback ownership for section cancel callbacks to the new top section
+               foreach ( $this->trxSectionCancelCallbacks as $key => $entry ) {
+                       if ( in_array( $entry[2], $sectionIds, true ) ) {
+                               $this->trxSectionCancelCallbacks[$key][2] = $newSectionId;
                        }
                }
        }
@@ -3473,7 +3528,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
         * @throws Exception
         */
        public function runOnTransactionIdleCallbacks( $trigger ) {
-               if ( $this->trxLevel ) { // sanity
+               if ( $this->trxLevel() ) { // sanity
                        throw new DBUnexpectedError( $this, __METHOD__ . ': a transaction is still open.' );
                }
 
@@ -3492,6 +3547,14 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        );
                        $this->trxIdleCallbacks = []; // consumed (and recursion guard)
                        $this->trxEndCallbacks = []; // consumed (recursion guard)
+
+                       // Only run trxSectionCancelCallbacks on rollback, not commit.
+                       // But always consume them.
+                       if ( $trigger === self::TRIGGER_ROLLBACK ) {
+                               $callbacks = array_merge( $callbacks, $this->trxSectionCancelCallbacks );
+                       }
+                       $this->trxSectionCancelCallbacks = []; // consumed (recursion guard)
+
                        foreach ( $callbacks as $callback ) {
                                ++$count;
                                list( $phpCallback ) = $callback;
@@ -3559,6 +3622,46 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                return $count;
        }
 
+       /**
+        * Actually run any "atomic section cancel" callbacks.
+        *
+        * @param int $trigger IDatabase::TRIGGER_* constant
+        * @param AtomicSectionIdentifier[]|null $sectionId Section IDs to cancel,
+        *  null on transaction rollback
+        */
+       private function runOnAtomicSectionCancelCallbacks(
+               $trigger, array $sectionIds = null
+       ) {
+               /** @var Exception|Throwable $e */
+               $e = null; // first exception
+
+               $notCancelled = [];
+               do {
+                       $callbacks = $this->trxSectionCancelCallbacks;
+                       $this->trxSectionCancelCallbacks = []; // consumed (recursion guard)
+                       foreach ( $callbacks as $entry ) {
+                               if ( $sectionIds === null || in_array( $entry[2], $sectionIds, true ) ) {
+                                       try {
+                                               $entry[0]( $trigger, $this );
+                                       } catch ( Exception $ex ) {
+                                               ( $this->errorLogger )( $ex );
+                                               $e = $e ?: $ex;
+                                       } catch ( Throwable $ex ) {
+                                               // @todo: Log?
+                                               $e = $e ?: $ex;
+                                       }
+                               } else {
+                                       $notCancelled[] = $entry;
+                               }
+                       }
+               } while ( count( $this->trxSectionCancelCallbacks ) );
+               $this->trxSectionCancelCallbacks = $notCancelled;
+
+               if ( $e !== null ) {
+                       throw $e; // re-throw any first Exception/Throwable
+               }
+       }
+
        /**
         * Actually run any "transaction listener" callbacks.
         *
@@ -3656,7 +3759,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        ) {
                $savepointId = $cancelable === self::ATOMIC_CANCELABLE ? self::$NOT_APPLICABLE : null;
 
-               if ( !$this->trxLevel ) {
+               if ( !$this->trxLevel() ) {
                        $this->begin( $fname, self::TRANSACTION_INTERNAL ); // sets trxAutomatic
                        // If DBO_TRX is set, a series of startAtomic/endAtomic pairs will result
                        // in all changes being in one transaction to keep requests transactional.
@@ -3682,7 +3785,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        final public function endAtomic( $fname = __METHOD__ ) {
-               if ( !$this->trxLevel || !$this->trxAtomicLevels ) {
+               if ( !$this->trxLevel() || !$this->trxAtomicLevels ) {
                        throw new DBUnexpectedError( $this, "No atomic section is open (got $fname)." );
                }
 
@@ -3718,71 +3821,83 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        final public function cancelAtomic(
                $fname = __METHOD__, AtomicSectionIdentifier $sectionId = null
        ) {
-               if ( !$this->trxLevel || !$this->trxAtomicLevels ) {
+               if ( !$this->trxLevel() || !$this->trxAtomicLevels ) {
                        throw new DBUnexpectedError( $this, "No atomic section is open (got $fname)." );
                }
 
-               $excisedFnames = [];
-               if ( $sectionId !== null ) {
-                       // Find the (last) section with the given $sectionId
-                       $pos = -1;
-                       foreach ( $this->trxAtomicLevels as $i => list( $asFname, $asId, $spId ) ) {
-                               if ( $asId === $sectionId ) {
-                                       $pos = $i;
+               $excisedIds = [];
+               $newTopSection = $this->currentAtomicSectionId();
+               try {
+                       $excisedFnames = [];
+                       if ( $sectionId !== null ) {
+                               // Find the (last) section with the given $sectionId
+                               $pos = -1;
+                               foreach ( $this->trxAtomicLevels as $i => list( $asFname, $asId, $spId ) ) {
+                                       if ( $asId === $sectionId ) {
+                                               $pos = $i;
+                                       }
                                }
+                               if ( $pos < 0 ) {
+                                       throw new DBUnexpectedError( $this, "Atomic section not found (for $fname)" );
+                               }
+                               // Remove all descendant sections and re-index the array
+                               $len = count( $this->trxAtomicLevels );
+                               for ( $i = $pos + 1; $i < $len; ++$i ) {
+                                       $excisedFnames[] = $this->trxAtomicLevels[$i][0];
+                                       $excisedIds[] = $this->trxAtomicLevels[$i][1];
+                               }
+                               $this->trxAtomicLevels = array_slice( $this->trxAtomicLevels, 0, $pos + 1 );
+                               $newTopSection = $this->currentAtomicSectionId();
                        }
-                       if ( $pos < 0 ) {
-                               throw new DBUnexpectedError( $this, "Atomic section not found (for $fname)" );
-                       }
-                       // Remove all descendant sections and re-index the array
-                       $excisedIds = [];
-                       $len = count( $this->trxAtomicLevels );
-                       for ( $i = $pos + 1; $i < $len; ++$i ) {
-                               $excisedFnames[] = $this->trxAtomicLevels[$i][0];
-                               $excisedIds[] = $this->trxAtomicLevels[$i][1];
-                       }
-                       $this->trxAtomicLevels = array_slice( $this->trxAtomicLevels, 0, $pos + 1 );
-                       $this->modifyCallbacksForCancel( $excisedIds );
-               }
 
-               // Check if the current section matches $fname
-               $pos = count( $this->trxAtomicLevels ) - 1;
-               list( $savedFname, $savedSectionId, $savepointId ) = $this->trxAtomicLevels[$pos];
+                       // Check if the current section matches $fname
+                       $pos = count( $this->trxAtomicLevels ) - 1;
+                       list( $savedFname, $savedSectionId, $savepointId ) = $this->trxAtomicLevels[$pos];
 
-               if ( $excisedFnames ) {
-                       $this->queryLogger->debug( "cancelAtomic: canceling level $pos ($savedFname) " .
-                               "and descendants " . implode( ', ', $excisedFnames ) );
-               } else {
-                       $this->queryLogger->debug( "cancelAtomic: canceling level $pos ($savedFname)" );
-               }
+                       if ( $excisedFnames ) {
+                               $this->queryLogger->debug( "cancelAtomic: canceling level $pos ($savedFname) " .
+                                       "and descendants " . implode( ', ', $excisedFnames ) );
+                       } else {
+                               $this->queryLogger->debug( "cancelAtomic: canceling level $pos ($savedFname)" );
+                       }
 
-               if ( $savedFname !== $fname ) {
-                       throw new DBUnexpectedError(
-                               $this,
-                               "Invalid atomic section ended (got $fname but expected $savedFname)."
-                       );
-               }
+                       if ( $savedFname !== $fname ) {
+                               throw new DBUnexpectedError(
+                                       $this,
+                                       "Invalid atomic section ended (got $fname but expected $savedFname)."
+                               );
+                       }
 
-               // Remove the last section (no need to re-index the array)
-               array_pop( $this->trxAtomicLevels );
-               $this->modifyCallbacksForCancel( [ $savedSectionId ] );
+                       // Remove the last section (no need to re-index the array)
+                       array_pop( $this->trxAtomicLevels );
+                       $excisedIds[] = $savedSectionId;
+                       $newTopSection = $this->currentAtomicSectionId();
 
-               if ( $savepointId !== null ) {
-                       // Rollback the transaction to the state just before this atomic section
-                       if ( $savepointId === self::$NOT_APPLICABLE ) {
-                               $this->rollback( $fname, self::FLUSHING_INTERNAL );
-                       } else {
-                               $this->doRollbackToSavepoint( $savepointId, $fname );
-                               $this->trxStatus = self::STATUS_TRX_OK; // no exception; recovered
-                               $this->trxStatusIgnoredCause = null;
+                       if ( $savepointId !== null ) {
+                               // Rollback the transaction to the state just before this atomic section
+                               if ( $savepointId === self::$NOT_APPLICABLE ) {
+                                       $this->rollback( $fname, self::FLUSHING_INTERNAL );
+                                       // Note: rollback() will run trxSectionCancelCallbacks
+                               } else {
+                                       $this->doRollbackToSavepoint( $savepointId, $fname );
+                                       $this->trxStatus = self::STATUS_TRX_OK; // no exception; recovered
+                                       $this->trxStatusIgnoredCause = null;
+
+                                       // Run trxSectionCancelCallbacks now.
+                                       $this->runOnAtomicSectionCancelCallbacks( self::TRIGGER_CANCEL, $excisedIds );
+                               }
+                       } elseif ( $this->trxStatus > self::STATUS_TRX_ERROR ) {
+                               // Put the transaction into an error state if it's not already in one
+                               $this->trxStatus = self::STATUS_TRX_ERROR;
+                               $this->trxStatusCause = new DBUnexpectedError(
+                                       $this,
+                                       "Uncancelable atomic section canceled (got $fname)."
+                               );
                        }
-               } elseif ( $this->trxStatus > self::STATUS_TRX_ERROR ) {
-                       // Put the transaction into an error state if it's not already in one
-                       $this->trxStatus = self::STATUS_TRX_ERROR;
-                       $this->trxStatusCause = new DBUnexpectedError(
-                               $this,
-                               "Uncancelable atomic section canceled (got $fname)."
-                       );
+               } finally {
+                       // Fix up callbacks owned by the sections that were just cancelled.
+                       // All callbacks should have an owner that is present in trxAtomicLevels.
+                       $this->modifyCallbacksForCancel( $excisedIds, $newTopSection );
                }
 
                $this->affectedRowCount = 0; // for the sake of consistency
@@ -3811,7 +3926,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                }
 
                // Protect against mismatched atomic section, transaction nesting, and snapshot loss
-               if ( $this->trxLevel ) {
+               if ( $this->trxLevel() ) {
                        if ( $this->trxAtomicLevels ) {
                                $levels = $this->flatAtomicSectionList();
                                $msg = "$fname: Got explicit BEGIN while atomic section(s) $levels are open.";
@@ -3831,6 +3946,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                $this->assertHasConnectionHandle();
 
                $this->doBegin( $fname );
+               $this->trxShortId = sprintf( '%06x', mt_rand( 0, 0xffffff ) );
                $this->trxStatus = self::STATUS_TRX_OK;
                $this->trxStatusIgnoredCause = null;
                $this->trxAtomicCounter = 0;
@@ -3839,7 +3955,6 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                $this->trxDoneWrites = false;
                $this->trxAutomaticAtomic = false;
                $this->trxAtomicLevels = [];
-               $this->trxShortId = sprintf( '%06x', mt_rand( 0, 0xffffff ) );
                $this->trxWriteDuration = 0.0;
                $this->trxWriteQueryCount = 0;
                $this->trxWriteAffectedRows = 0;
@@ -3861,10 +3976,10 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
         *
         * @see Database::begin()
         * @param string $fname
+        * @throws DBError
         */
        protected function doBegin( $fname ) {
                $this->query( 'BEGIN', $fname );
-               $this->trxLevel = 1;
        }
 
        final public function commit( $fname = __METHOD__, $flush = self::FLUSHING_ONE ) {
@@ -3873,7 +3988,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        throw new DBUnexpectedError( $this, "$fname: invalid flush parameter '$flush'." );
                }
 
-               if ( $this->trxLevel && $this->trxAtomicLevels ) {
+               if ( $this->trxLevel() && $this->trxAtomicLevels ) {
                        // There are still atomic sections open; this cannot be ignored
                        $levels = $this->flatAtomicSectionList();
                        throw new DBUnexpectedError(
@@ -3883,7 +3998,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                }
 
                if ( $flush === self::FLUSHING_INTERNAL || $flush === self::FLUSHING_ALL_PEERS ) {
-                       if ( !$this->trxLevel ) {
+                       if ( !$this->trxLevel() ) {
                                return; // nothing to do
                        } elseif ( !$this->trxAutomatic ) {
                                throw new DBUnexpectedError(
@@ -3891,7 +4006,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                                        "$fname: Flushing an explicit transaction, getting out of sync."
                                );
                        }
-               } elseif ( !$this->trxLevel ) {
+               } elseif ( !$this->trxLevel() ) {
                        $this->queryLogger->error(
                                "$fname: No transaction to commit, something got out of sync." );
                        return; // nothing to do
@@ -3908,6 +4023,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
 
                $writeTime = $this->pendingWriteQueryDuration( self::ESTIMATE_DB_APPLY );
                $this->doCommit( $fname );
+               $oldTrxShortId = $this->consumeTrxShortId();
                $this->trxStatus = self::STATUS_TRX_NONE;
 
                if ( $this->trxDoneWrites ) {
@@ -3915,7 +4031,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        $this->trxProfiler->transactionWritingOut(
                                $this->server,
                                $this->getDomainID(),
-                               $this->trxShortId,
+                               $oldTrxShortId,
                                $writeTime,
                                $this->trxWriteAffectedRows
                        );
@@ -3933,16 +4049,16 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
         *
         * @see Database::commit()
         * @param string $fname
+        * @throws DBError
         */
        protected function doCommit( $fname ) {
-               if ( $this->trxLevel ) {
+               if ( $this->trxLevel() ) {
                        $this->query( 'COMMIT', $fname );
-                       $this->trxLevel = 0;
                }
        }
 
        final public function rollback( $fname = __METHOD__, $flush = self::FLUSHING_ONE ) {
-               $trxActive = $this->trxLevel;
+               $trxActive = $this->trxLevel();
 
                if ( $flush !== self::FLUSHING_INTERNAL
                        && $flush !== self::FLUSHING_ALL_PEERS
@@ -3958,6 +4074,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        $this->assertHasConnectionHandle();
 
                        $this->doRollback( $fname );
+                       $oldTrxShortId = $this->consumeTrxShortId();
                        $this->trxStatus = self::STATUS_TRX_NONE;
                        $this->trxAtomicLevels = [];
                        // Estimate the RTT via a query now that trxStatus is OK
@@ -3967,7 +4084,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                                $this->trxProfiler->transactionWritingOut(
                                        $this->server,
                                        $this->getDomainID(),
-                                       $this->trxShortId,
+                                       $oldTrxShortId,
                                        $writeTime,
                                        $this->trxWriteAffectedRows
                                );
@@ -4001,13 +4118,13 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
         *
         * @see Database::rollback()
         * @param string $fname
+        * @throws DBError
         */
        protected function doRollback( $fname ) {
-               if ( $this->trxLevel ) {
+               if ( $this->trxLevel() ) {
                        # Disconnects cause rollback anyway, so ignore those errors
                        $ignoreErrors = true;
                        $this->query( 'ROLLBACK', $fname, $ignoreErrors );
-                       $this->trxLevel = 0;
                }
        }
 
@@ -4025,7 +4142,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        public function explicitTrxActive() {
-               return $this->trxLevel && ( $this->trxAtomicLevels || !$this->trxAutomatic );
+               return $this->trxLevel() && ( $this->trxAtomicLevels || !$this->trxAutomatic );
        }
 
        public function duplicateTableStructure(
@@ -4177,7 +4294,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
         * @since 1.27
         */
        final protected function getRecordedTransactionLagStatus() {
-               return ( $this->trxLevel && $this->trxReplicaLag !== null )
+               return ( $this->trxLevel() && $this->trxReplicaLag !== null )
                        ? [ 'lag' => $this->trxReplicaLag, 'since' => $this->trxTimestamp() ]
                        : null;
        }
@@ -4697,6 +4814,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        // Open a new connection resource without messing with the old one
                        $this->conn = false;
                        $this->trxEndCallbacks = []; // don't copy
+                       $this->trxSectionCancelCallbacks = []; // don't copy
                        $this->handleSessionLossPreconnect(); // no trx or locks anymore
                        $this->open(
                                $this->server,
@@ -4724,7 +4842,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
         * Run a few simple sanity checks and close dangling connections
         */
        public function __destruct() {
-               if ( $this->trxLevel && $this->trxDoneWrites ) {
+               if ( $this->trxLevel() && $this->trxDoneWrites ) {
                        trigger_error( "Uncommitted DB writes (transaction from {$this->trxFname})." );
                }
 
index 0ee36bd..50aaff2 100644 (file)
@@ -28,6 +28,7 @@
 namespace Wikimedia\Rdbms;
 
 use Exception;
+use RuntimeException;
 use stdClass;
 use Wikimedia\AtEase\AtEase;
 
@@ -1082,13 +1083,10 @@ class DatabaseMssql extends Database {
                $this->query( 'ROLLBACK TRANSACTION ' . $this->addIdentifierQuotes( $identifier ), $fname );
        }
 
-       /**
-        * Begin a transaction, committing any previously open transaction
-        * @param string $fname
-        */
        protected function doBegin( $fname = __METHOD__ ) {
-               sqlsrv_begin_transaction( $this->conn );
-               $this->trxLevel = 1;
+               if ( !sqlsrv_begin_transaction( $this->conn ) ) {
+                       $this->reportQueryError( $this->lastError(), $this->lastErrno(), 'BEGIN', $fname );
+               }
        }
 
        /**
@@ -1096,8 +1094,9 @@ class DatabaseMssql extends Database {
         * @param string $fname
         */
        protected function doCommit( $fname = __METHOD__ ) {
-               sqlsrv_commit( $this->conn );
-               $this->trxLevel = 0;
+               if ( !sqlsrv_commit( $this->conn ) ) {
+                       $this->reportQueryError( $this->lastError(), $this->lastErrno(), 'COMMIT', $fname );
+               }
        }
 
        /**
@@ -1106,8 +1105,17 @@ class DatabaseMssql extends Database {
         * @param string $fname
         */
        protected function doRollback( $fname = __METHOD__ ) {
-               sqlsrv_rollback( $this->conn );
-               $this->trxLevel = 0;
+               if ( !sqlsrv_rollback( $this->conn ) ) {
+                       $this->queryLogger->error(
+                               "{fname}\t{db_server}\t{errno}\t{error}\t",
+                               $this->getLogContext( [
+                                       'errno' => $this->lastErrno(),
+                                       'error' => $this->lastError(),
+                                       'fname' => $fname,
+                                       'trace' => ( new RuntimeException() )->getTraceAsString()
+                               ] )
+                       );
+               }
        }
 
        /**
index a19a1a4..92eac90 100644 (file)
@@ -1072,7 +1072,7 @@ __INDEXATTR__;
         * @param string $desiredSchema
         */
        public function determineCoreSchema( $desiredSchema ) {
-               if ( $this->trxLevel ) {
+               if ( $this->trxLevel() ) {
                        // We do not want the schema selection to change on ROLLBACK or INSERT SELECT.
                        // See https://www.postgresql.org/docs/8.3/sql-set.html
                        throw new DBUnexpectedError(
index b9d721e..17f12d3 100644 (file)
@@ -825,7 +825,6 @@ class DatabaseSqlite extends Database {
                } else {
                        $this->query( 'BEGIN', $fname );
                }
-               $this->trxLevel = 1;
        }
 
        /**
index a462916..fca2c00 100644 (file)
@@ -42,6 +42,8 @@ interface IDatabase {
        const TRIGGER_COMMIT = 2;
        /** @var int Callback triggered by ROLLBACK */
        const TRIGGER_ROLLBACK = 3;
+       /** @var int Callback triggered by atomic section cancel (ROLLBACK TO SAVEPOINT) */
+       const TRIGGER_CANCEL = 4;
 
        /** @var string Transaction is requested by regular caller outside of the DB layer */
        const TRANSACTION_EXPLICIT = '';
@@ -1580,6 +1582,9 @@ interface IDatabase {
         *
         * This is useful for combining cooperative locks and DB transactions.
         *
+        * Note this is called when the whole transaction is resolved. To take action immediately
+        * when an atomic section is cancelled, use onAtomicSectionCancel().
+        *
         * @note do not assume that *other* IDatabase instances will be AUTOCOMMIT mode
         *
         * The callback takes the following arguments:
@@ -1661,6 +1666,31 @@ interface IDatabase {
         */
        public function onTransactionPreCommitOrIdle( callable $callback, $fname = __METHOD__ );
 
+       /**
+        * Run a callback when the atomic section is cancelled.
+        *
+        * The callback is run just after the current atomic section, any outer
+        * atomic section, or the whole transaction is rolled back.
+        *
+        * An error is thrown if no atomic section is pending. The atomic section
+        * need not have been created with the ATOMIC_CANCELABLE flag.
+        *
+        * Queries in the function may be running in the context of an outer
+        * transaction or may be running in AUTOCOMMIT mode. The callback should
+        * use atomic sections if necessary.
+        *
+        * @note do not assume that *other* IDatabase instances will be AUTOCOMMIT mode
+        *
+        * The callback takes the following arguments:
+        *   - IDatabase::TRIGGER_CANCEL or IDatabase::TRIGGER_ROLLBACK
+        *   - This IDatabase instance
+        *
+        * @param callable $callback
+        * @param string $fname Caller name
+        * @since 1.34
+        */
+       public function onAtomicSectionCancel( callable $callback, $fname = __METHOD__ );
+
        /**
         * Run a callback after each time any transaction commits or rolls back
         *
index 3709de7..2ca3d7d 100644 (file)
@@ -35,7 +35,7 @@ class FakeResultWrapper extends ResultWrapper {
 
                $this->next();
 
-               return is_object( $row ) ? (array)$row : $row;
+               return is_object( $row ) ? get_object_vars( $row ) : $row;
        }
 
        function seek( $pos ) {
index 4d148b4..b086beb 100644 (file)
@@ -314,22 +314,6 @@ interface ILoadBalancer {
         */
        public function getWriterIndex();
 
-       /**
-        * Returns true if the specified index is a valid server index
-        *
-        * @param int $i
-        * @return bool
-        */
-       public function haveIndex( $i );
-
-       /**
-        * Returns true if the specified index is valid and has non-zero load
-        *
-        * @param int $i
-        * @return bool
-        */
-       public function isNonZeroLoad( $i );
-
        /**
         * Get the number of servers defined in configuration
         *
index 7f12d14..44d526c 100644 (file)
@@ -1306,10 +1306,24 @@ class LoadBalancer implements ILoadBalancer {
                return 0;
        }
 
+       /**
+        * Returns true if the specified index is a valid server index
+        *
+        * @param int $i
+        * @return bool
+        * @deprecated Since 1.34
+        */
        public function haveIndex( $i ) {
                return array_key_exists( $i, $this->servers );
        }
 
+       /**
+        * Returns true if the specified index is valid and has non-zero load
+        *
+        * @param int $i
+        * @return bool
+        * @deprecated Since 1.34
+        */
        public function isNonZeroLoad( $i ) {
                return array_key_exists( $i, $this->servers ) && $this->genericLoads[$i] != 0;
        }
index 122fa9b..b42cdea 100644 (file)
@@ -381,7 +381,7 @@ class SpecialEmailUser extends UnlistedSpecialPage {
                                'specialmute-email-footer',
                                $specialMutePage->getCanonicalURL(),
                                $context->getUser()->getName()
-                       );
+                       )->inContentLanguage()->text();
                }
 
                // Check and increment the rate limits
index d8319f0..b3707a6 100644 (file)
        "history": "Historial de la páxina",
        "history_short": "Historial",
        "history_small": "historial",
-       "updatedmarker": "anovada dende la mio visita cabera",
+       "updatedmarker": "anovada dende la to visita cabera",
        "printableversion": "Versión pa imprentar",
        "permalink": "Enllaz permanente",
        "print": "Imprentar",
index 91d0395..1bc2f18 100644 (file)
        "viewsourcelink": "cingak wit",
        "editsectionhint": "Uah pahan: $1",
        "toc": "Daging",
-       "showtoc": "edengang",
+       "showtoc": "sinahang",
        "hidetoc": "engkebang",
        "collapsible-expand": "buka",
        "confirmable-confirm": "{{GENDER:$1|Jero}} yakin?",
        "createacct-benefit-heading": "{{SITENAME}} kakaryanin olih anak sakadi jero.",
        "createacct-benefit-body1": "{{PLURAL:$1|uahan}}",
        "createacct-benefit-body2": "{{PLURAL:$1|kaca}}",
-       "createacct-benefit-body3": "{{PLURAL:$1|sang anuut}} anyar",
+       "createacct-benefit-body3": "{{PLURAL:$1|sang anuut}} sané mangkin",
        "mailmypassword": "nyumu ngaryanin kruna sandi",
        "loginlanguagelabel": "Basa: $1",
        "pt-login": "Manjing log",
        "savearticle-start": "Raksa kaca...",
        "publishpage-start": "Terbitang kaca…",
        "preview": "tayangan sadurungnyane",
-       "showpreview": "cingak sane lintang",
+       "showpreview": "Sinahang preview",
        "showdiff": "Cingak uahan",
        "anoneditwarning": "<strong>Pingetan:</strong> Ida dané nénten kacatet ngranjing. Alamat IP ida dané jagi kacatet ring sejarah (indik sané dumunan) ring lembar puniki. Yening ida dane <strong>[$1 log in]</strong> utawi <strong>[$2 create an account]</strong>, your edits will be attributed to your username, along with other benefits.",
        "loginreqlink": "manjing log",
        "history-feed-description": "Babad uahan kaca puniki ring wiki",
        "history-feed-item-nocomment": "$1 ring $2",
        "rev-delundel": "gentos pangatonan",
+       "rev-showdeleted": "sinahang",
        "revdelete-hide-comment": "Uah ringkesan",
        "revdel-restore": "gentos pangatonan",
        "pagehist": "Babad kaca",
        "prev-page": "kaca sadurungnyané",
        "prevn-title": "$1 {{PLURAL:$1|asil}} sadurunge",
        "nextn-title": "$1 {{PLURAL:$1|asil}} selanturnyane",
-       "shown-title": "ngantenang $1{{PLURAL:$1|asil}} sabilang lembar",
+       "shown-title": "Sinahang $1 {{PLURAL:$1|asil}} per kaca",
        "viewprevnext": "Cingak ($1 {{int:pipe-separator}}$2)($3)",
        "searchmenu-exists": "wenten lembar sane mamurda \"[[:$1]]\" ring wiki puniki. {{PLURAL:$2|0=| cingakin taler asil rerehan lianan sane kapolihang}}",
        "searchmenu-new": "<strong> ngawi lembar \"[[:$1]] ring wiki puniki </ strong>! {{{{PLURAL:$2|}}| 0 = | cingak teler lembar sane kapolihang ring pangreregan | cingak taler asil pangrerehan sane kapolihang}}",
        "searchprofile-images": "multimedia",
        "searchprofile-everything": "Samian",
        "searchprofile-advanced": "lanturane",
-       "searchprofile-articles-tooltip": "ngarereh ring $1",
+       "searchprofile-articles-tooltip": "Rereh ring $1",
        "searchprofile-images-tooltip": "Rereh berkas",
        "searchprofile-everything-tooltip": "pangrereh ring samian isi (taler lembar wecana)",
        "searchprofile-advanced-tooltip": "pangrereh ring genah pesengan sane kasinahang",
        "action-editsemiprotected": "uah kaca sané kasaibin \"{{int:protect-level-autoconfirmed}}\"",
        "nchanges": "$1{{PLURAL:$1|panguwahan|uwah-uwahan}}",
        "enhancedrc-history": "babad",
-       "recentchanges": "Uahan anyar",
-       "recentchanges-legend": "pilihan panguwahan sane anyar",
+       "recentchanges": "Uahan sané mangkin",
+       "recentchanges-legend": "Opsi uahan sané mangkin",
+       "recentchanges-summary": "Track uahan sané mangkin ring wikiné indik kaca puniki.",
        "recentchanges-feed-description": "molihang pagentosan anyar ring wiki ring \"umpan\" puniki",
        "recentchanges-label-newpage": "Uahan puniki makarya kaca anyar",
        "recentchanges-label-minor": "Punika uahan alit",
        "recentchanges-label-bot": "penguwahan puniki kalaksanayang antuk bot",
        "recentchanges-label-unpatrolled": "Uahan puniki durung kapatroli",
        "recentchanges-legend-newpage": "{{int:recentchanges-label-newpage}} (taler cingak [[Special:NewPages|bacakan kaca anyar]])",
+       "recentchanges-submit": "Sinahang",
+       "rcfilters-activefilters-show": "Sinahang",
        "rcfilters-savedqueries-remove": "Usap",
        "rcfilters-filter-minor-label": "Uahan alit",
        "rcfilters-filter-major-label": "Uahan tan alit",
        "rcnotefrom": "Ring beten puniki inggih punika {{PLURAL:$5|panguwahan}} saking <strong>$3, $4</strong> (kaedengang ngantos <strong>$1</strong> panguwahan).",
        "rclistfrom": "edengang  penguwahan sane anyar wit saking $3 $2",
        "rcshowhideminor": "$1 uahan alit",
-       "rcshowhideminor-show": "Edengang",
+       "rcshowhideminor-show": "Sinahang",
        "rcshowhideminor-hide": "Engkebang",
        "rcshowhidebots": "$1 bot",
-       "rcshowhidebots-show": "Edengang",
+       "rcshowhidebots-show": "Sinahang",
        "rcshowhidebots-hide": "Engkebang",
        "rcshowhideliu": "$1 sang anganggé madaptar",
-       "rcshowhideliu-show": "Edengang",
+       "rcshowhideliu-show": "Sinahang",
        "rcshowhideliu-hide": "engkebang",
        "rcshowhideanons": "$1 sang anganggé tan kauningin",
-       "rcshowhideanons-show": "Edengang",
+       "rcshowhideanons-show": "Sinahang",
        "rcshowhideanons-hide": "Engkebang",
-       "rcshowhidepatr": "$1 suntingan sane kapatroli",
+       "rcshowhidepatr": "$1 uahan sané kapatroli",
+       "rcshowhidepatr-show": "Sinahang",
        "rcshowhidemine": "$1 uahan titiang",
-       "rcshowhidemine-show": "Edengang",
+       "rcshowhidemine-show": "Sinahang",
        "rcshowhidemine-hide": "Engkebang",
+       "rcshowhidecategorization-show": "Sinahang",
        "rclinks": "Edengang untat $1 gentosan anyar $2 dina kaping untat",
        "diff": "bina",
        "hist": "bbd",
        "hide": "engkebang",
-       "show": "edengang",
+       "show": "Sinahang",
        "minoreditletter": "a",
        "newpageletter": "A",
        "boteditletter": "b",
        "rc-enhanced-expand": "edengang rerincian",
        "rc-enhanced-hide": "engkebang rerincian",
        "rc-old-title": "witnyané kakaryanin pinaka \"$1\"",
-       "recentchangeslinked": "pangentos sane wenten paiketane",
-       "recentchangeslinked-toolbox": "pangentos sane wenten paiketane",
-       "recentchangeslinked-title": "panguwahan sane mapaiketan ring $1",
+       "recentchangeslinked": "Uahan mapaiketan",
+       "recentchangeslinked-toolbox": "Uahan mapaiketan",
+       "recentchangeslinked-title": "Uahan sané mapaiketan $1",
        "recentchangeslinked-summary": "lembar kautamayang puniki ngicenin kepahan penguwahan kaping untat ring lembar-lembar sana mapaiket. Lembar sane [[Special:Watchlist|ida dane iwasin]] mapinget antuk sesuratan tebel",
        "recentchangeslinked-page": "Peséngan kaca:",
-       "recentchangeslinked-to": "edengang panguwahan sakin lembar-lembar sane mapaiket antuk lembar-lembar sane kaedengang",
-       "upload": "ngunggahang berkas",
-       "uploadlogpage": "Log pangunggahan",
+       "recentchangeslinked-to": "Sinahang uahan saking kaca-kaca sané linked kaca puniki",
+       "upload": "Unggahang berkas",
+       "uploadlogpage": "Log unggahan",
        "filedesc": "Ringkesan",
        "savefile": "Raksa berkas",
        "upload-dialog-button-save": "Raksa",
+       "backend-fail-delete": "Tan prasida ngusapin berkas \"$1\".",
        "license": "kepahan lugra",
        "license-header": "kepahan lugra",
        "listfiles-delete": "usap",
        "filehist-user": "Sang anganggé",
        "filehist-dimensions": "ukuran",
        "filehist-comment": "tureksa",
-       "imagelinks": "penganggen berkas",
+       "imagelinks": "Panganggén berkas",
        "linkstoimage": "nyarengin {{PLURAL:$1|pranala|$1pranala}} ring pupulan puniki",
        "nolinkstoimage": "Nénten wénten kaca sané nganggén berkas puniki.",
        "sharedupload-desc-here": "pupulan puniki mawit saking $1 lan minab kaanggen olih proyek-proyek sane lianan. Deskripsi saking [$2 lebar deskripsinyane] kaarahin ring ungkur puniki",
        "upload-disallowed-here": "Jero nénten dados numpuk berkas puniki.",
        "filedelete": "Usap $1",
        "filedelete-submit": "Usap",
+       "filedelete-success": "<strong>$1</strong> sampun kausapin.",
        "filedelete-maintenance-title": "Nénten prasida ngusapin berkas",
        "randompage": "Kaca punapi kémanten",
        "statistics": "Statistik",
        "statistics-articles": "Kaca daging",
        "brokenredirects-edit": "uah",
        "brokenredirects-delete": "usap",
+       "withoutinterwiki-submit": "Sinahang",
        "nbytes": "$1{{PLURAL:$1|bita}}",
        "nmembers": "$1 {{PLURAL:$1|krama}}",
        "prefixindex": "Makasami kaca sané mapangater",
+       "prefixindex-submit": "Sinahang",
        "protectedpages": "Kaca sané kasaibin",
        "protectedpages-page": "Kaca",
        "protectedpages-performer": "Sang anganggé sané nyaibin",
        "usereditcount": "$1 {{PLURAL:$1|uahan}}",
        "usercreated": "{{GENDER:$3|kakaryanin}} ring $1 galah $2",
        "newpages": "Kaca anyar",
+       "newpages-submit": "Sinahang",
        "move": "Gingsirang",
        "pager-newer-n": "{{PLURAL:$1|1 lewih anyar|$1 lewih anyar}}",
        "pager-older-n": "{{PLURAL:$1|1 lewih suwe|$1 lewih anyar}}",
        "booksources-search-legend": "Rereh wit buku",
        "booksources-search": "Rereh",
        "log": "Log",
+       "logeventslist-submit": "Sinahang",
        "all-logs-page": "Makasami log publik",
        "allpages": "Makasami kaca",
        "allarticles": "Makasami kaca",
        "allpagessubmit": "lanturang",
        "categories": "Golongan",
+       "categories-submit": "Sinahang",
        "deletedcontributions": "Pituut sang anganggé sané kausapin",
        "linksearch-line": "$1 masambung saking $2",
+       "listusers-submit": "Sinahang",
        "listgrouprights-members": "kepahan krama",
        "emailuser": "email sane nganggo niki",
        "watchlist": "kepahan peninjoan",
        "watch": "cingak",
        "unwatch": "tan sida maninjo",
        "watchlist-details": "{{PLURAL:$1|$1 lembar}} ring paninjoan ida dane, nenten sareng lembar wacana.",
-       "wlshowlast": "Cingak $1 jam $2 rahina sané lintang",
+       "wlshowlast": "Sinahang $1 jam $2 rahina sané lintang",
+       "watchlist-submit": "Sinahang",
        "wlshowhideminor": "uahan alit",
        "watchlist-options": "milih kepahan peninjo",
        "enotif_subject_deleted": "Kaca {{SITENAME}} $1 sampun {{GENDER:$2|kausap}} $2",
        "enotif_body_intro_deleted": "Kaca{{SITENAME}} $1 sampun {{GENDER:$2|kausapin}} ring $PAGEEDITDATE olih $2, cingak $3.",
        "deletepage": "Usap kaca",
        "delete-confirm": "Usap \"$1\"",
+       "historyaction-submit": "Sinahang uahan",
        "actioncomplete": "pelaksanan sampun wusan",
        "actionfailed": "pelaksana luput",
        "dellogpage": "log pangapus",
        "sp-contributions-newbies": "Cingak pituut wantah saking akun anyar",
        "sp-contributions-blocklog": "log pemblokiran",
        "sp-contributions-deleted": "pituut {{GENDER:$1|sang anganggé}} sané kausapin",
-       "sp-contributions-uploads": "unggahang",
+       "sp-contributions-uploads": "unggahan",
        "sp-contributions-logs": "log",
        "sp-contributions-talk": "pabligbagan",
        "sp-contributions-search": "Rereh pituut",
        "whatlinkshere-filters": "Panyaring",
        "ipboptions": "2 jam:2 hours,1 dina:1 day,3 dina:3 days,1 minggu:1 week,2 minggu:2 weeks,1 sasih:1 month,3 sasih:3 months,6 sasih:6 months,1 taun:1 year,tanpa wates:infinite",
        "ipb-pages-label": "Kaca",
+       "block-prevent-edit": "Nguahin",
        "ipblocklist": "ngempetin sane nganggo",
        "blocklist-nousertalk": "tan prasida nguahin kaca pabligbagan praragan",
        "blocklist-editing-page": "kaca",
        "tooltip-pt-watchlist": "kepahan-kepahan lembar sane katinjo titiang",
        "tooltip-pt-mycontris": "Bacakan pituut {{GENDER:|jero}}",
        "tooltip-pt-login": "Jero kaaptiang mangda manjing log; yadiastun nénten wajib",
-       "tooltip-pt-logout": "medal saking Log",
+       "tooltip-pt-logout": "Medal log",
        "tooltip-pt-createaccount": "Jero kaaptiang mangda makarya akun miwah manjing log; yadiastun nénten wajib",
        "tooltip-ca-talk": "Pabligbagan indik kaca daging",
        "tooltip-ca-edit": "Uah kaca puniki",
        "tooltip-n-mainpage-description": "Cingak kaca utama",
        "tooltip-n-portal": "Indik proyék, sané prasida kalaksanayang, genah ngrereh wantuan",
        "tooltip-n-currentevents": "molihang warta indik kawentenan kawentenan sane pinih anyar",
-       "tooltip-n-recentchanges": "Bacakan uahan anyar ring wiki",
+       "tooltip-n-recentchanges": "Bacakan uahan sané mangkin ring wiki",
        "tooltip-n-randompage": "Cihnayang kaca napi kémanten",
        "tooltip-n-help": "Genah ngrereh wantuan",
        "tooltip-t-whatlinkshere": "Bacakan makasami kaca ring wiki sané nuju iriki",
-       "tooltip-t-recentchangeslinked": "Pagentosan anyar lembar sane maduwe pranala nuju lembar puniki",
+       "tooltip-t-recentchangeslinked": "Uahan sané mangkin saking kaca-kaca sané linked ring kaca puniki",
        "tooltip-feed-atom": "\"atom feed\" anggen lembar puniki",
        "tooltip-t-contributions": "Bacakan pituut olih {{GENDER:$1|sang anganggé puniki}}",
        "tooltip-t-emailuser": "Ngirim surel majeng ring {{GENDER:$1|penganggo puniki}}",
        "tooltip-minoredit": "pingetin puniki dados panguwahan kidik",
        "tooltip-save": "Raksa uahan jero",
        "tooltip-preview": "Pagentosan sane dumun duwen ida dane, mangda anggen niki sadurung jagi nyimpen!",
-       "tooltip-diff": "Cingak uahan sané karyanin jero ring suratannyané",
+       "tooltip-diff": "Sinahang uahan sané karyanin jero ring sesuratannyané",
        "tooltip-compareselectedversions": "cingak binane makekalih kepahan lembar sane kasudi",
        "tooltip-watch": "imbuhin lembar niki ring daftar paninjoan ida dane",
        "tooltip-rollback": "\"nguliang\" muwungan jagi ngabecikang ring lembar puniki nuju haturan sane untat ngangge apisan klik",
        "tags-active-no": "Nénten",
        "tags-edit": "uah",
        "tags-delete": "usap",
+       "tags-delete-title": "Usap tag",
        "compare-page2": "Kaca 2",
        "logentry-delete-delete": "$1 {{GENDER:$2|ngusapin}} kaca $3",
        "logentry-move-move": "$1 {{GENDER:$2|ngingsirang}} kaca $3 ring $4",
        "logentry-newusers-create": "Akun sang anganggé $1 {{GENDER:$2|kakaryanin}}",
        "logentry-newusers-autocreate": "Akun sang anganggé $1 {{GENDER:$2|kakaryanin}} otomatis",
        "logentry-protect-protect": "$1 {{GENDER:$2|nyaibin}} $3 $4",
+       "logentry-upload-upload": "$1 {{GENDER:$2|ngunggahang}} $3",
+       "logentry-upload-overwrite": "$1 {{GENDER:$2|ngunggahang}} vèrsi anyar saking $3",
        "searchsuggest-search": "Rereh ring {{SITENAME}}",
        "duration-days": "$1 {{PLURAL:$1|rahina}}",
        "pagelanguage": "Uah basa ring kaca",
index 312da36..ae5f2cb 100644 (file)
        "edit-error-short": "Памылка: $1",
        "edit-error-long": "Памылкі:\n\n$1",
        "specialmute": "Заглушаныя ўдзельнікі",
+       "specialmute-success": "Вашыя налады заглушэньня былі пасьпяхова абноўленыя. Глядзіце ўсіх заглушаных удзельнікаў на старонцы [[Special:Preferences]].",
+       "specialmute-submit": "Пацьвердзіць",
        "revid": "вэрсія $1",
        "pageid": "Ідэнтыфікатар старонкі $1",
        "interfaceadmin-info": "$1\n\nДазволы на рэдагаваньне агульнасайтавых CSS/JS/JSON-файлаў былі нядаўна вылучаныя з права <code>editinterface</code>. Калі вы не разумееце, чаму атрымліваеце гэтую памылку, глядзіце [[mw:MediaWiki_1.32/interface-admin]].",
index 2fc856d..dbc65ab 100644 (file)
        "history": "পাতার ইতিহাস",
        "history_short": "ইতিহাস",
        "history_small": "ইতিহাস",
-       "updatedmarker": "à¦\86মার শেষ পরিদর্শনের পর থেকে হালনাগাদকৃত",
+       "updatedmarker": "à¦\86পনার শেষ পরিদর্শনের পর থেকে হালনাগাদকৃত",
        "printableversion": "ছাপার যোগ্য সংস্করণ",
        "permalink": "স্থায়ী সংযোগ",
        "print": "মুদ্রণ",
        "restrictionsfield-help": "লাইন প্রতি একটি আইপি ঠিকানা বা CIDR পরিসীমা। সবকিছু সক্রিয় করতে ব্যবহার করুন: :<pre>0.0.0.0/0\n::/0</pre>",
        "edit-error-short": "ত্রুটি: $1",
        "edit-error-long": "ত্রুটিসমূহ:\n\n$1",
+       "specialmute-submit": "নিশ্চিত করুন",
        "revid": "সংশোধন $1",
        "pageid": "পাতার আইডি $1",
        "rawhtml-notallowed": "&lt;html&gt; ট্যাগ স্বাভাবিক পৃষ্ঠাগুলির বাহিরে ব্যবহার করা যাবে না।",
        "passwordpolicies-policy-maximalpasswordlength": "পাসওয়ার্ড $1 {{PLURAL:$1|অক্ষরের}} চেয়ে কম দীর্ঘ হতে হবে",
        "passwordpolicies-policy-passwordnotinlargeblacklist": "পাসওয়ার্ড ১,০০,০০০ সর্বাধিক ব্যবহৃত পাসওয়ার্ডের তালিকায় থাকতে পারবে না।",
        "unprotected-js": "নিরাপত্তার কারণে জাভাস্ক্রিপ্ট অনিরাপদ পৃষ্ঠা থেকে লোড করা যাবে না। শুধুমাত্র মিডিয়াউইকি: নামস্থান বা ব্যবহারকারী উপপাতায় জাভাস্ক্রিপ্ট তৈরি করুন",
-       "userlogout-continue": "à¦\86পনি à¦¯à¦¦à¦¿ à¦ªà§\8dরসà§\8dথান à¦\95রতà§\87 à¦\9aান à¦¦à¦¯à¦¼à¦¾ à¦\95রà§\87 [$1 à¦ªà§\8dরসà§\8dথান à¦ªà¦¾à¦¤à¦¾à¦¯à¦¼ à¦¯à¦¾à¦¨]।"
+       "userlogout-continue": "à¦\86পনি à¦\95ি à¦ªà§\8dরসà§\8dথান à¦\95রতà§\87 à¦\9aান?"
 }
index 4ba7b23..765e5f0 100644 (file)
        "movethispage": "Trasllada la pàgina",
        "unusedimagestext": "Els següents fitxers existeixen però estan incorporats en cap altra pàgina.\nTingueu en compte que altres llocs web poden enllaçar un fitxer amb un URL directe i estar llistat ací tot i estar en ús actiu.",
        "unusedcategoriestext": "Les pàgines de categoria següents existeixen encara que cap altra pàgina o categoria les utilitza.",
-       "notargettitle": "No hi ha pàgina en blanc",
+       "notargettitle": "No hi ha cap objectiu",
        "notargettext": "No heu especificat a quina pàgina dur a terme aquesta funció.",
        "nopagetitle": "No existeix aquesta pàgina",
        "nopagetext": "La pàgina que heu especificat no existeix.",
        "restrictionsfield-label": "Intervals d'IP permesos:",
        "edit-error-short": "Error: $1",
        "edit-error-long": "Errors:\n\n$1",
+       "specialmute-submit": "Confirma",
+       "specialmute-error-invalid-user": "No s’ha trobat el nom d’usuari que heu indicat.",
        "revid": "revisió $1",
        "pageid": "ID de pàgina $1",
        "rawhtml-notallowed": "No és possible fer servir les etiquetes &lt;html&gt; fora de les pàgines normals.",
index a55f7eb..54f14e9 100644 (file)
        "history": "Hanes y dudalen",
        "history_short": "Hanes",
        "history_small": "hanes",
-       "updatedmarker": "diwygiwyd ers i mi ymweld ddiwethaf",
+       "updatedmarker": "diwygiwyd ers eich ymweliad ddiwethaf",
        "printableversion": "Fersiwn argraffu",
        "permalink": "Dolen barhaol",
        "print": "Argraffu",
        "badarticleerror": "Mae'n amhosib cyflawni'r weithred hon ar y dudalen hon.",
        "cannotdelete": "Mae'n amhosib dileu'r dudalen neu'r ddelwedd \"$1\".\nEfallai fod rhywun arall eisoes wedi'i dileu.",
        "cannotdelete-title": "Ni ellir dileu'r dudalen '$1'",
+       "delete-scheduled": "Bydd \"$1\" yn cael ei dileu.\nMam inni yw amynedd!",
        "delete-hook-aborted": "Terfynwyd y dilead cyn pryd gan fachyn.\nNi roddodd eglurhad.",
        "no-null-revision": "Ni lwyddwyd i wneud diwygiad newydd heb unrhyw newid ynddo, i'r dudalen \"$1\"",
        "badtitle": "Teitl gwael",
        "cascadeprotected": "Diogelwyd y ddalen hon rhag ei newid, oherwydd ei bod wedi ei chynnwys yn y {{PLURAL:$1|ddalen ganlynol|dalennau canlynol}}, a ddiogelwyd, gyda'r dewisiad hwn yn weithredol: $2",
        "namespaceprotected": "Nid oes caniatâd gennych i olygu tudalennau yn y parth '''$1'''.",
        "customcssprotected": "Nid oes caniatâd ganddoch i olygu'r dudalen CSS hon oherwydd bod gosodiadau personol defnyddiwr arall arno.",
+       "customjsonprotected": "Nid oes gennych yr hawl i olygu tudalen JASON, gan ei bod yn cynnwys dewisiadau (settings) defnyddiwr arall.",
        "customjsprotected": "Nid oes caniatâd ganddoch i olygu'r dudalen JavaScript hon oherwydd bod gosodiadau personol defnyddiwr arall arno.",
+       "sitecssprotected": "Nid oes gennych yr hawl i olygu tudalen CSS, gan y gall effeithio pob ymwelydd.",
+       "sitejsonprotected": "Nid oes gennych yr hawl i olygu tudalen JASON yma, gan y gall effeithio pob ymwelydd.",
+       "sitejsprotected": "Nid oes gennych yr hawl i olygu'r dudalen JASON yma gan y gall effeithio pob ymwelydd.",
        "mycustomcssprotected": "Does dim caniatad gennych i olygu'r dudalen CSS hon.",
+       "mycustomjsonprotected": "Nid oes gennych yr hawl i olygu tudalen JASON yma.",
        "mycustomjsprotected": "Does dim caniatad gennych i olygu'r dudalen JavaScript hon.",
        "myprivateinfoprotected": "Nid oes caniatad gennych i olygu eich manylion personol preifat.",
        "mypreferencesprotected": "Nid oes caniatad gennych i olygu eich dewisiadau eich hunan.",
        "virus-scanfailed": "methodd y sgan (côd $1)",
        "virus-unknownscanner": "gwrthfirysydd anhysbys:",
        "logouttext": "'''Rydych wedi allgofnodi.'''\n\nSylwer y bydd rhai tudalennau yn parhau i ymddangos fel ag yr oeddent pan oeddech wedi mewngofnodi hyd nes i chi glirio celc eich porwr.",
+       "logging-out-notify": "Rydych yn cael eich hallgofnodi, rhowswch funud...",
+       "logout-failed": "Methu allgofnodi ar hyn o bryd: $1",
        "cannotlogoutnow-title": "Ni ellir allgofnodi ar hyn o bryd",
        "cannotlogoutnow-text": "Ni ellir allgofnodi tra'n defnyddio $1.",
        "welcomeuser": "Croeso, $1!",
        "badretype": "Nid yw'r cyfrineiriau'n union yr un fath.",
        "usernameinprogress": "Mae creu cyfrif i'r enw-defnyddiwr hwn wrthi'n cael ei brosesu. Daliwch eich gafael!",
        "userexists": "Mae rhywun arall wedi dewis yr enw defnyddiwr hwn. \nDewiswch un arall os gwelwch yn dda.",
+       "createacct-normalization": "Oherwydd cyfyngiadau technegol, bydd eich enw defnyddiwr yn cael ei ailenwi yn \"$2\".",
        "loginerror": "Problem mewngofnodi",
        "createacct-error": "Nam wrth greu cyfrif",
        "createaccounterror": "Ni lwyddwyd i greu'r cyfrif: $1",
        "passwordtooshort": "Mae'n rhaid fod gan gyfrinair o leia $1 {{PLURAL:$1|nod}}.",
        "passwordtoolong": "Ni chaiff cyfrinair fod yn hirach na {{PLURAL:$1|1 llythyren|$1 llythyren}}.",
        "passwordtoopopular": "Chewch chi ddim defnyddio cyfrineiriau cyffredin. Dewisiwch un unigryw a gwahanol!",
+       "passwordinlargeblacklist": "Mae'r cyfrinair yma ar restr o rai sy'n rhy gyffredin o'r hanner. dewisiwch un mwy unigryw, gwahanol.",
        "password-name-match": "Rhaid i'ch cyfrinair a'ch enw defnyddiwr fod yn wahanol i'w gilydd.",
        "password-login-forbidden": "Gwaharddwyd defnyddio'r enw defnyddiwr a'r cyfrinair hwn.",
        "mailmypassword": "Ailosoder y cyfrinair",
        "changepassword-success": "Newidiwyd eich cyfrinair!",
        "changepassword-throttled": "Rydych wedi ceisio logio mewn yn rhy aml.\nArhoswch am $1 cyn trio eto.",
        "botpasswords": "Cyfrineiriau bots",
+       "botpasswords-disabled": "Ni ellir creu cyfrinair gyda bot.",
        "botpasswords-label-appid": "Enw bot:",
        "botpasswords-label-create": "Dechrau",
        "botpasswords-label-update": "Diweddaru",
        "editpage-invalidcontentmodel-text": "Nid yw'r model \"$1\" ar gael.",
        "editpage-notsupportedcontentformat-title": "Dydy fformat y cynnwys hwn ddim yn cael ei gefnogi gennym.",
        "editpage-notsupportedcontentformat-text": "Dydy'r fformat $1 ar y cynnwys ddim yn cael ei gefnogi gan y model $2.",
+       "slot-name-main": "Prif",
        "content-model-wikitext": "cystrawen wici",
        "content-model-text": "testun plaen",
        "content-model-javascript": "JavaScript",
        "content-model-css": "CSS",
        "content-json-empty-object": "Dim gwrthrych",
        "content-json-empty-array": "Rhesi gwag",
+       "deprecated-self-close-category": "Tudalennau gyda tagiau HTML annilys.",
        "duplicate-args-warning": "<strong>Rhybudd:</strong> Mae [[:$1]] yn galw [[:$2]] gyda mwy nag un gwerthrif (''value'') i baramedr \"$3\". Dim ond y gwerthrif diwethaf gaiff ei ddefnyddio.",
        "duplicate-args-category": "Tudalennau gyda meysydd deublyg yn y Nodion",
        "duplicate-args-category-desc": "Mae'r dudalen hon yn cynnwys meysydd yn y Nodion, ddwy waith e.e.  <code><nowiki>{{foo|bar=1|bar=2}}</nowiki></code> neu <code><nowiki>{{foo|bar|1=baz}}</nowiki></code>.",
        "page_first": "cyntaf",
        "page_last": "olaf",
        "histlegend": "Cymharu dau fersiwn: marciwch y cylchoedd ar y ddau fersiwn i'w cymharu, yna pwyswch ar 'return' neu'r botwm 'Cymharer y fersiynau dewisedig'.<br />\nEglurhad: '''({{int:cur}})''' = gwahaniaethau rhyngddo a'r fersiwn cyfredol,\n'''({{int:last}})''' = gwahaniaethau rhyngddo a'r fersiwn cynt, '''({{int:minoreditletter}})''' = golygiad bychan",
-       "history-fieldset-title": "Chwilio drwy'r hanes",
+       "history-fieldset-title": "Hidlo'r adolygiadau",
        "history-show-deleted": "Dangos y rhai a ddilëwyd yn unig",
        "histfirst": "cynharaf",
        "histlast": "diweddaraf",
        "diff-multi-manyusers": "(Ni ddangosir {{PLURAL:$1|yr $1 diwygiad|yr $1 diwygiad|y $1 ddiwygiad|y $1 diwygiad|y $1 diwygiad|y $1 diwygiad}} rhyngol gan mwy na $2 {{PLURAL:$2|o ddefnyddwyr}}.)",
        "difference-missing-revision": "Ni chafwyd hyd i $1 {{PLURAL:$2|diwygiad|diwygiad|ddiwygiad|diwygiad}} o'r gwahaniaeth ($1) {{PLURAL:$2|hwn}}.\n\nFel arfer, fe ddigwydd hyn pan mae person wedi dilyn hen gyswllt gwahaniaeth i dudalen sydd erbyn hyn wedi cael ei ddileu.\nMae manylion pellach i'w cael yn [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} lòg y dileuon].",
        "searchresults": "Canlyniadau'r chwiliad",
+       "search-filter-title-prefix-reset": "Chwilio pob tudalen",
        "searchresults-title": "Canlyniadau chwilio am \"$1\"",
        "titlematches": "Teitlau erthygl yn cyfateb",
        "textmatches": "Testun erthygl yn cyfateb",
        "action-applychangetags": "rhowch y tagiau ar waith, gyda'ch newidiadau",
        "action-deletechangetags": "dilewch tagiau o'r gronfa ddata",
        "action-purge": "carthwch y ddalen",
+       "action-editinterface": "golygwch y rhyngwyneb",
+       "action-unblockself": "dadflocio eich hunan",
        "nchanges": "$1 {{PLURAL:$1|newid|newid|newid|newid|newid|o newidiadau}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|ers eich ymweliad diwethaf}}",
        "enhancedrc-history": "hanes",
        "recentchanges-legend-newpage": "{{int:recentchanges-label-newpage}} (gweler hefyd [[Special:NewPages|restr y tudalennau newydd]])",
        "recentchanges-legend-plusminus": "(''±123'')",
        "recentchanges-submit": "Dangos",
+       "rcfilters-tag-remove": "Dileu '$1'",
        "rcfilters-legend-heading": "<strong>Rhestr o fyrfoddau:</strong>",
        "rcfilters-other-review-tools": "Teclynau adolygu eraill",
        "rcfilters-group-results-by-page": "Canlyniadau'r grwp bob yn ddalen",
        "rcfilters-activefilters": "Hidlau sydd ar waith",
        "rcfilters-activefilters-hide": "Cuddio",
+       "rcfilters-activefilters-show": "Dangos",
        "rcfilters-advancedfilters": "Ffiltrau ychwanegol",
        "rcfilters-limit-title": "Canlyniadau a ddangosir",
        "rcfilters-date-popup-title": "Cyfnod (i'w chwilio)",
        "rcfilters-savedqueries-rename": "Ailenwi",
        "rcfilters-savedqueries-setdefault": "Gosod yn ddiofyn (''Set as default'')",
        "rcfilters-savedqueries-unsetdefault": "Diddymu fel gweithred ddiofyn (''Remove as default'')",
-       "rcfilters-savedqueries-remove": "Cael gwared",
+       "rcfilters-savedqueries-remove": "Dileu",
        "rcfilters-savedqueries-new-name-label": "Enw",
        "rcfilters-savedqueries-new-name-placeholder": "Disgrifiwch bwrpas y ffiltr",
        "rcfilters-savedqueries-apply-label": "Crewch ffiltr",
        "rcfilters-restore-default-filters": "Ailosodwch y ffiltrau di-ofyn",
        "rcfilters-clear-all-filters": "Cliriwch yr holl hidlau (ffiltrau)",
-       "rcfilters-search-placeholder": "Ffiltrwch y newidiadau diweddaraf",
+       "rcfilters-search-placeholder": "FNewidiadau'r hidl (ffiltr) - defnyddiwch y blwch chwilio",
        "rcfilters-invalid-filter": "Hidl annilys",
        "rcfilters-empty-filter": "Dim hidlau ar waith",
        "rcfilters-filterlist-title": "Hidlau (ffiltrau)",
-       "rcfilters-filterlist-feedbacklink": "Rhowch adborth ar yr hidlau beta",
+       "rcfilters-filterlist-whatsthis": "Sut mae'r rhain yn gweithio?",
+       "rcfilters-filterlist-feedbacklink": "Rhowch adborth ar y teclynau hidlo",
        "rcfilters-highlightbutton-title": "Amlygwch y canlyniadau",
        "rcfilters-highlightmenu-title": "Dewisiwch liw",
        "rcfilters-highlightmenu-help": "Dewisiwch liw sy'n cyd-fynd gyda'r nodwedd hon",
        "rcfilters-filter-user-experience-level-unregistered-label": "Heb gofrestru",
        "rcfilters-filter-user-experience-level-unregistered-description": "Golygyddion nad ydynt wedi cofrestru.",
        "rcfilters-filter-user-experience-level-newcomer-label": "Newydd-ddyfodiaid",
-       "rcfilters-filter-user-experience-level-newcomer-description": "Defnyddwyr cofrestredig gyda llai na 10 golygiad a 4 diwrnod o weithgaredd.",
+       "rcfilters-filter-user-experience-level-newcomer-description": "Defnyddwyr cofrestredig gyda llai na 10 golygiad neu 4 diwrnod o weithgaredd.",
        "rcfilters-filter-user-experience-level-learner-label": "Dysgwyr",
        "rcfilters-filter-user-experience-level-learner-description": "Defnyddwyr cofrestredig ble mae eu profiad yn syrthio rhwng \"Newydd-ddyfodiaid\" a \"Defnyddwyr profiadol.\"",
        "rcfilters-filter-user-experience-level-experienced-label": "Defnyddwyr profiadol",
        "rcfilters-filter-humans-description": "Golygiadau a wnaed gan olygyddion go-iawn.",
        "rcfilters-filtergroup-reviewstatus": "Statws adolygu",
        "rcfilters-filter-reviewstatus-unpatrolled-label": "Heb ei gadarnhau (''Unpatrolled'')",
+       "rcfilters-filter-reviewstatus-manual-label": "Patrol gyda llaw a llygad",
+       "rcfilters-filter-reviewstatus-auto-label": "Patroliwyd yn otomatig",
        "rcfilters-filtergroup-significance": "Arwyddocaol",
        "rcfilters-filter-minor-label": "Golygiadau bach",
        "rcfilters-filter-minor-description": "Golygiadau a nodwyd gan y golygydd fel rhai bach.",
        "rcfilters-filter-watchlist-watched-label": "Ar y Rhestr Wylio",
        "rcfilters-filter-watchlist-watched-description": "Newidiadau i'r dalennau yn eich Rhestr Wylio.",
        "rcfilters-filter-watchlist-watchednew-label": "Newidiadau newydd i'ch Rhestr Wylio",
+       "rcfilters-filter-watchlist-watchednew-description": "Newidiadau yn y Rhestr wylio nad ydych wedi ymweld a nhw ers i'r newidiadau gael eu gwneud.",
        "rcfilters-filter-watchlist-notwatched-label": "Heb fod yn eich Rhestr Wylio",
        "rcfilters-filter-watchlist-notwatched-description": "Popeth ar wahan i'r newidiadau i'ch Rhestr Wylio.",
        "rcfilters-filter-watchlistactivity-unseen-label": "Newidiadau heb eu gweld gennych",
        "uploadstash-bad-path-invalid": "'Dyw'r llwybr ddim yn gywir.",
        "invalid-chunk-offset": "Atred annilys i'r talpiau",
        "img-auth-accessdenied": "Ni chaniatawyd mynediad",
-       "img-auth-nopathinfo": "PATH_INFO yn eisiau.\nNid yw'ch gweinydd wedi ei osod i fedru pasio'r wybodaeth hon.\nEfallai ei fod wedi ei seilio ar CGI, ac heb fod yn gallu cynnal img_auth.\nGweler https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Image_Authorization.",
+       "img-auth-nopathinfo": "Gwybodaeth am y llwybr yn eisiau.\nNid yw'ch gweinydd wedi ei osod i fedru pasio'r  REQUEST_URI a/neu PATH_INFO.\nOs ydyw yna trowch y $wgUsePathInfo ymlaen.\n\nGweler https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Image_Authorization.",
        "img-auth-notindir": "Nid yw'r llwybr y gwneuthpwyd cais amdano yn y cyfeiriadur uwchlwytho ffurfweddedig.",
        "img-auth-badtitle": "Ddim yn gallu gwneud teitl dilys o \"$1\".",
        "img-auth-nofile": "Nid oes ffeil a'r enw \"$1\" ar gael.",
        "http-timed-out": "Goroedi wedi digwydd ar y cais HTTP.",
        "http-curl-error": "Cafwyd gwall wrth nôl yr URL: $1",
        "http-bad-status": "Cafwyd trafferth yn ystod y cais HTTP: $1 $2",
+       "http-internal-error": "Gwall mewnol HTTP.",
        "upload-curl-error6": "Wedi methu cyrraedd yr URL",
        "upload-curl-error6-text": "Ni chyrhaeddwyd yr URL a roddwyd.\nGwiriwch yr URL a sicrhau bod y wefan ar waith.",
        "upload-curl-error28": "Goroedi wrth uwchlwytho",
        "prefixindex": "Pob tudalen yn ôl parth",
        "prefixindex-namespace": "Pob tudalen â rhagddodiad penodol (y parth $1)",
        "prefixindex-submit": "Dangos",
-       "prefixindex-strip": "Diosg y rhagddodiad wrth restru",
+       "prefixindex-strip": "Cuddio'r rhagddodiad yn y canfyddiadau",
        "shortpages": "Erthyglau byr",
        "longpages": "Tudalennau hirion",
        "deadendpages": "Tudalennau heb gysylltiadau ynddynt",
        "apisandbox-dynamic-parameters": "Paramedrau ychwanegol",
        "apisandbox-dynamic-parameters-add-label": "Ychwanegu paramedrau",
        "apisandbox-dynamic-parameters-add-placeholder": "Enw'r paramedr",
+       "apisandbox-add-multi": "Ychwanegu",
        "apisandbox-results": "Canlyniadau",
        "apisandbox-continue": "Parhau",
        "apisandbox-continue-clear": "Clirio",
        "speciallogtitlelabel": "Targed (teitl neu {{ns:user}}:username ar gyfer y defnyddiwr):",
        "log": "Logiau",
        "logeventslist-submit": "Dangos",
+       "logeventslist-patrol-log": "Log patrolio",
+       "logeventslist-tag-log": "Log y tagiau",
        "all-logs-page": "Pob lòg cyhoeddus",
        "alllogstext": "Mae pob cofnod yn holl logiau {{SITENAME}} wedi cael eu rhestru yma.\nGallwch weld chwiliad mwy penodol trwy ddewis y math o lòg, enw'r defnyddiwr, neu'r dudalen benodedig.\nSylwer bod llythrennau mawr neu fach o bwys i'r chwiliad.",
        "logempty": "Does dim eitemau yn cyfateb yn y lòg.",
        "delete-confirm": "Dileu \"$1\"",
        "delete-legend": "Dileu",
        "historywarning": "<strong>Rhybudd:</strong> bu tua $1 {{PLURAL:$1|golygiad|golygiad|olygiad|golygiad|golygiad|o olygiadau}} yn hanes y dudalen rydych ar fin ei dileu:",
-       "historyaction-submit": "Dangos",
+       "historyaction-submit": "Dangos yr adolygiadau",
        "confirmdeletetext": "Rydych chi ar fin dileu tudalen neu ddelwedd, ynghŷd â'i hanes, o'r data-bas, a hynny'n barhaol.\nOs gwelwch yn dda, cadarnhewch eich bod chi wir yn bwriadu gwneud hyn, eich bod yn deall y canlyniadau, ac yn ei wneud yn ôl [[{{MediaWiki:Policy-url}}|polisïau {{SITENAME}}]].",
        "actioncomplete": "Wedi cwblhau'r weithred",
        "actionfailed": "Methodd y weithred",
        "dellogpage": "Lòg dileuon",
        "dellogpagetext": "Ceir rhestr isod o'r dileadau diweddaraf.",
        "deletionlog": "lòg dileuon",
+       "log-name-create": "Log creu tudalennau",
+       "log-description-create": "Nodir isod y rhestr o'r tudalennau newydd mwyaf diweddar.",
+       "logentry-create-create": "$1 {{GENDER:$2|created}} tudalen $3",
        "reverted": "Wedi gwrthdroi i'r golygiad cynt",
        "deletecomment": "Rheswm:",
        "deleteotherreason": "Rheswm arall:",
        "deleting-backlinks-warning": "'''Rhybudd:''' Mae [[Special:WhatLinksHere/{{FULLPAGENAME}}|tudalennau eraill]] yn cysylltu i'r ddalen rydych ar fin ei dileu.",
        "deleting-subpages-warning": "<strong>Rhybudd:</strong> Mae gan y ddalen rydych ar fin ei dileu [[Special:PrefixIndex/{{FULLPAGENAME}}/|{{PLURAL:$1|is-ddalen|$1 is-ddalennau|51=dros 50 o is-ddalennau}}]].",
        "rollback": "Gwrthdroi golygiadau",
+       "rollback-confirmation-confirm": "Cadarnhewch:",
+       "rollback-confirmation-yes": "Troi'n ol",
+       "rollback-confirmation-no": "Canslo",
        "rollbacklink": "gwrthdröer",
        "rollbacklinkcount": "gwrthdröer $1 {{PLURAL:$1||golygiad|olygiad|golygiad}}",
        "rollbacklinkcount-morethan": "gwrthdröer mwy na $1 {{PLURAL:$1||golygiad|olygiad|golygiad}}",
        "revertpage-nouser": "Wedi gwrthdroi golygiadau gan ddefnyddiwr cudd; wedi adfer y golygiad diweddaraf gan {{GENDER:$1|[[User:$1|$1]]}}",
        "rollback-success": "Gwrthdrowyd y golygiadau gan {{GENDER:$3|$1}};\nailosodwyd y golygiad olaf gan {{GENDER:$4|$2}}.",
        "sessionfailure-title": "Sesiwn wedi methu",
-       "sessionfailure": "Mae'n debyg fod yna broblem gyda'ch sesiwn mewngofnodi; diddymwyd y weithred er mwyn diogelu'r sustem rhag ddefnyddwyr maleisus. Gwasgwch botwm 'nôl' eich porwr ac ail-lwythwch y dudalen honno, yna ceisiwch eto.",
+       "sessionfailure": "Mae'n debyg fod yna broblem gyda'ch sesiwn mewngofnodi;\ndiddymwyd y weithred er mwyn diogelu'r sustem rhag ddefnyddwyr maleisus.\nAilgyflwynwch y ffurflen.",
        "changecontentmodel-title-label": "Teitl y ddalen",
        "changecontentmodel-reason-label": "Rheswm:",
        "changecontentmodel-submit": "Newid",
        "undelete-search-title": "Chwilio drwy'r tudalennau dilëedig",
        "undelete-search-box": "Chwilio tudalennau a ddilëwyd",
        "undelete-search-prefix": "Dangos tudalennau gan ddechrau gyda:",
+       "undelete-search-full": "Dangos tudalennau sy'n cynnwys:",
        "undelete-search-submit": "Chwilio",
        "undelete-no-results": "Ni chafwyd hyd i dudalennau cyfatebol yn archif y dileuon.",
        "undelete-filename-mismatch": "Nid oes modd dad-ddileu'r golygiad ffeil â'r stamp amser $1: nid oedd enw'r ffeil yn cydweddu",
        "ipb-disableusertalk": "Atal y defnyddiwr hwn rhag golygu ei dudalen/ei thudalen sgwrs ei hunan wrth i'r bloc fod yn weithredol",
        "ipb-change-block": "Ailflocio'r defnyddiwr hwn gyda'r gosodiadau hyn",
        "ipb-confirm": "Cadarnhau'r rhwystr",
+       "ipb-pages-label": "Tudalennau",
+       "ipb-namespaces-label": "Parthenwau",
        "badipaddress": "Cyfeiriad IP annilys.",
        "blockipsuccesssub": "Llwyddodd y rhwystr",
        "blockipsuccesstext": "Mae [[Special:Contributions/$1|$1]] wedi cael ei flocio.<br />\nGweler y [[Special:BlockList|rhestr blociau]] er mwyn arolygu blociau.",
        "ipb-blocklist": "Dangos y blociau cyfredol",
        "ipb-blocklist-contribs": "Cyfraniadau {{GENDER:$1|$1}}",
        "block-expiry": "Am gyfnod:",
+       "block-prevent-edit": "Golygu",
+       "block-reason": "Rhesymau:",
+       "block-target": "Cyfeiriad IP y Defnyddiwr",
        "unblockip": "Dadflocio defnyddiwr",
        "unblockiptext": "Defnyddiwch y ffurflen isod i ail-alluogi golygiadau gan ddefnyddiwr neu o gyfeiriad IP a fu gynt wedi'i flocio.",
        "ipusubmit": "Tynnu'r rhwystr hwn",
        "unblocked-id": "Tynnwyd rhwystr $1",
        "unblocked-ip": "Mae [[Special:Contributions/$1|$1]] wedi ei atal.",
        "blocklist": "Defnyddwyr a rwystrwyd",
+       "autoblocklist": "Rhwystrau otomatig",
        "autoblocklist-submit": "Chwilio",
+       "autoblocklist-legend": "Rhestr o rwystrau otomatig",
+       "autoblocklist-localblocks": "{{PLURAL:$1|autoblock|autoblocks}} lleol",
+       "autoblocklist-total-autoblocks": "Cyfanswm y rhwystrau otomatig: $1",
        "ipblocklist": "Defnyddwyr a rwystrwyd",
        "ipblocklist-legend": "Dod o hyd i ddefnyddiwr a rwystrwyd",
        "blocklist-userblocks": "Cuddio rhwystrau cyfrifon",
        "emailblock": "rhwystrwyd e-bostio",
        "blocklist-nousertalk": "ni all olygu ei dudalen/ei thudalen sgwrs ei hunan",
        "ipblocklist-empty": "Mae'r rhestr rwystrau'n wag.",
-       "ipblocklist-no-results": "Nid yw cyfeiriad IP neu enw defnyddiwr yr ymholiad wedi'i rwystro.",
+       "ipblocklist-no-results": "Ni chafwyd hyn i rwystrau o nanut yma ar gyfer cyfeiriad IP neu enw defnyddiwr.",
        "blocklink": "rhwystro",
        "unblocklink": "dadrwystro",
        "change-blocklink": "newid y rhwystr",
        "delete_and_move_text": "==Angen dileu==\n\nMae'r erthygl \"[[:$1]]\" yn bodoli'n barod. Ydych chi am ddileu'r erthygl er mwyn paratoi lle?",
        "delete_and_move_confirm": "Ie, dileu'r dudalen",
        "delete_and_move_reason": "Wedi'i dileu er mwyn gallu symud y dudalen \"[[$1]]\" i gymryd ei lle",
-       "selfmove": "Mae'r teitlau hen a newydd yn union yr un peth;\nnid yw'n bosib cyflawnu'r symud.",
+       "selfmove": "Mae'r teitlau yr un peth;\nnid yw'n symud tudalen iddi hi ei hun.",
        "immobile-source-namespace": "Ni ellir symud tudalennau yn y parth \"$1\".",
        "immobile-target-namespace": "Ni ellir symud tudalennau i'r parth \"$1\".",
        "immobile-target-namespace-iw": "Nid yw cyswllt rhyngwici yn nod dilys wrth symud tudalen.",
        "fix-double-redirects": "Yn diwygio unrhyw ailgyfeiriadau sy'n cysylltu i'r teitl gwreiddiol",
        "move-leave-redirect": "Creu tudalen ail-gyfeirio â'r teitl gwreiddiol",
        "protectedpagemovewarning": "'''Sylwer:''' Clowyd y dudalen ac felly dim ond defnyddwyr a galluoedd gweinyddu ganddynt sy'n gallu ei symud.\nDyma'r cofnod lòg diweddaraf, er gwybodaeth:",
-       "semiprotectedpagemovewarning": "'''Sylwer:''' Clowyd y dudalen ac felly dim ond defnyddwyr mewngofnodedig sy'n gallu ei symud.\nDyma'r cofnod lòg diweddaraf, er gwybodaeth:",
+       "semiprotectedpagemovewarning": "<strong>Sylwer:</strong> Clowyd y dudalen. Dim ond defnyddwyr mewngofnodedig sy'n gallu ei symud.\nDyma'r cofnod lòg diweddaraf isod, er gwybodaeth:",
        "move-over-sharedrepo": "Mae'r ffeil [[:$1]] ar gael mewn storfa gyfrannol. Pe byddech yn symud y ffeil i'r teitl hwn, yna byddai'r ffeil o'r storfa gyfrannol yn cael ei disodli.",
        "file-exists-sharedrepo": "Mae'r enw y dewisoch ar y ffeil yn cael ei ddefnyddio'n barod ar storfa gyfrannol.\nDewiswch enw arall os gwelwch yn dda.",
        "export": "Allforio tudalennau",
        "previousdiff": "← Y fersiwn gynt",
        "nextdiff": "Y fersiwn dilynol →",
        "mediawarning": "'''Rhybudd''': Gallasai'r math hwn o ffeil gynnwys côd maleisus.\nMae'n bosib y bydd eich cyfrifiadur yn cael ei danseilio wrth ddefnyddio'r ffeil.",
-       "imagemaxsize": "Maint mwyaf y delweddau:<br />''(ar y tudalennau disgrifiad)''",
+       "imagemaxsize": "Ceir cyfyngiad ar faint tudalennau disgrifio'r ffeil:",
        "thumbsize": "Maint mân-lun :",
        "widthheightpage": "$1 × $2, $3 {{PLURAL:$3|tudalen|dudalen|dudalen|tudalen|thudalen|tudalen}}",
        "file-info": "maint y ffeil: $1, ffurf MIME: $2",
        "confirm-unwatch-top": "Tynner y dudalen hon oddi ar eich rhestr wylio?",
        "confirm-rollback-button": "Iawn",
        "confirm-rollback-top": "Dadwneud golygiadau'r ddalen hon?",
+       "confirm-mcrrestore-title": "Adfer diwygiadau",
+       "confirm-mcrundo-title": "Dadwneud y newidiadau",
+       "mcrundofailed": "Methwyd gwrthdroi",
        "quotation-marks": "'$1'",
        "imgmultipageprev": "← i'r dudalen gynt",
        "imgmultipagenext": "i'r dudalen nesaf →",
        "version-poweredby-others": "eraill",
        "version-poweredby-translators": "cyfieithwyr translatewiki.net",
        "version-credits-summary": "Hoffem gydnabod cyfraniad y bobl canlynol i [[Special:Version|MediaWiki]].",
-       "version-license-info": "Meddalwedd rhydd yw MediaWiki; gallwch ei ddefnyddio a'i addasu yn ôl termau'r GNU General Public License a gyhoeddir gan Free Software Foundation; naill ai fersiwn 2 o'r Drwydded, neu unrhyw fersiwn diweddarach o'ch dewis.\n\nCyhoeddir MediaWiki yn y gobaith y bydd o ddefnydd, ond HEB UNRHYW WARANT; heb hyd yn oed gwarant ymhlyg o FARCHNADWYEDD nag o FOD YN ADDAS AT RYW BWRPAS ARBENNIG. Gweler y GNU General Public License am fanylion pellach.\n\nDylech fod wedi derbyn [{{SERVER}}{{SCRIPTPATH}}/COPYING gopi o GNU General Public License] gyda'r rhaglen hon; os nad ydych, ysgrifennwch at Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, neu [//www.gnu.org/licenses/old-licenses/gpl-2.0.html gallwch ei ddarllen ar y we].",
+       "version-license-info": "Meddalwedd rhydd ac agored yw MediaWiki; gallwch ei ailddosbarthu a'i addasu yn ôl termau'r GNU General Public License a gyhoeddir gan Free Software Foundation; naill ai fersiwn 2 o'r Drwydded, neu unrhyw fersiwn diweddarach o'ch dewis.\n\nCyhoeddir MediaWiki yn y gobaith y bydd o ddefnydd, ond <em>HEB UNRHYW WARANT</em>; heb hyd yn oed gwarant ymhlyg o <strong>FARCHNADWYEDD</strong> nag o <strong>FOD YN ADDAS AT RYW BWRPAS ARBENNIG</strong>. Gweler y GNU General Public License am fanylion pellach.\n\nDylech fod wedi derbyn [{{SERVER}}{{SCRIPTPATH}}/COPYING copi o GNU General Public License] gyda'r rhaglen hon; os nad ydych, ysgrifennwch at Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA, neu [//www.gnu.org/licenses/old-licenses/gpl-2.0.html gallwch ei ddarllen ar y we].",
        "version-software": "Meddalwedd gosodedig",
        "version-software-product": "Cynnyrch",
        "version-software-version": "Fersiwn",
        "redirect-file": "Enwau ffeiliau",
        "redirect-logid": "Log yr ID",
        "redirect-not-exists": "Heb lwyddo i'w ganfod",
+       "redirect-not-numeric": "Nid yw'r gwerth yn rhif",
        "fileduplicatesearch": "Chwilio am ffeiliau dyblyg",
        "fileduplicatesearch-summary": "Chwilier am ffeiliau dyblyg ar sail ei werth stwnsh.",
        "fileduplicatesearch-filename": "Enw'r ffeil:",
        "specialpages-group-developer": "Arfau ar gyfer y Datblygwr",
        "blankpage": "Tudalen wag",
        "intentionallyblankpage": "Gadawyd y dudalen hon yn wag o fwriad",
+       "disabledspecialpage-disabled": "Anallugwyd y dudalen gan weinyddwr y system.",
        "external_image_whitelist": " #Leave this line exactly as it is<pre>\n#Gosodwch ddarnau o ymadroddion rheolaidd (y rhan sy'n cael ei osod rhwng y //) isod\n#Caiff y rhain eu cysefeillio gyda URL y delweddau allanol (a chyswllt poeth atynt)\n#Dangosir y rhai sy'n cysefeillio fel delweddau; dangosir cyswllt at y ddelwedd yn unig ar gyfer y lleill\n#Caiff y llinellau sy'n dechrau gyda # eu trin fel sylwadau\n#Nid yw'n gwahaniaethu rhwng llythrennau mawr a bach\n\n#Put all regex fragments above this line. Leave this line exactly as it is</pre>",
        "tags": "Tagiau newidiadau",
        "tag-filter": "Hidl [[Special:Tags|tagiau]]:",
        "tag-filter-submit": "Hidlo",
        "tag-list-wrapper": "[[Special:Tags|{{PLURAL:$1|Tag|Tagiau}}]]: $2",
+       "tag-mw-undo": "Dadwneud",
        "tags-title": "Tagiau",
        "tags-intro": "Dyma restr o'r tagiau y mae'r meddalwedd yn defnyddio i farcio golygiad, ynghyd â'r rhesymau dros eu defnyddio.",
        "tags-tag": "Enw'r tag",
        "logentry-rights-autopromote": "{{GENDER:$2|Dyrchafwyd}} $1 yn awtomatig o $4 i $5",
        "logentry-upload-upload": "Mae $1 {{GENDER:$2|wedi uwchlwytho}} $3",
        "logentry-upload-overwrite": "Mae $1 {{GENDER:$2|wedi uwchlwytho}} fersiwn newydd o $3",
-       "logentry-upload-revert": "Mae $1 {{GENDER:$2|wedi uwchlwytho}} $3",
+       "logentry-upload-revert": "Mae $1 {{GENDER:$2|wedi gwrthdroi}} $3 i fersiwn hyn",
        "rightsnone": "(dim)",
        "feedback-adding": "Wrthi'n ychwanegu adborth i'r dudalen...",
        "feedback-bugcheck": "Iawn! Gwnewch yn siwr yn gyntaf nag ydy hwn yn un o'r [$1 bygiau hysbys].",
        "api-error-emptypage": "Ni chaniateir dechrau tudalen newydd, a honno'n wag.",
        "api-error-publishfailed": "Gwall mewnol: methodd y gweinydd â chyhoeddi'r ffeil dros dro.",
        "api-error-stashfailed": "Gwall mewnol: methodd y gweinydd â rhoi'r ffeil dros dro ar gadw.",
-       "api-error-unknown-warning": "Rhybudd anhysbys: $1",
+       "api-error-unknown-warning": "Rhybudd anhysbys: \"$1\".",
        "api-error-unknownerror": "Gwall anhysbys: \"$1\".",
        "duration-seconds": "$1 {{PLURAL:$1|eiliad}}",
        "duration-minutes": "$1 {{PLURAL:$1|munud|munud|funud|munud|munud|munud}}",
        "limitreport-expensivefunctioncount": "Nifer y ffwythiannau dosrannu sy'n dreth ar adnoddau",
        "expandtemplates": "Ehangu'r nodynnau",
        "expand_templates_title": "Teitl y cyd-destun, ar gyfer {{FULLPAGENAME}}, etc.:",
-       "expand_templates_input": "Cynnwys y mewnbwn:",
+       "expand_templates_input": "Codwici'r mewnbwn:",
        "expand_templates_output": "Y canlyniad",
        "expand_templates_xml_output": "Yr allbwn XML",
        "expand_templates_html_output": "Allbwn HTML crai",
index 2c1eb37..42a92d9 100644 (file)
        "november": "Tışrino Peyên",
        "december": "Kanun",
        "january-gen": "Çele",
-       "february-gen": "Gucige",
-       "march-gen": "Adar",
+       "february-gen": "Şıbat",
+       "march-gen": "Mert",
        "april-gen": "Nisane",
        "may-gen": "Gulane",
        "june-gen": "Heziran",
        "november-gen": "Tışrino Peyên",
        "december-gen": "Kanun",
        "jan": "Çel",
-       "feb": "Gcg",
+       "feb": "Şbt",
        "mar": "Adr",
        "apr": "Nsn",
        "may": "Gul",
        "nov": "Tşp",
        "dec": "Gğn",
        "january-date": "$1 Çele",
-       "february-date": "$1 Gucige",
+       "february-date": "$1 Şıbat",
        "march-date": "$1 Adar",
        "april-date": "$1 Nisane",
        "may-date": "$1 Gulane",
        "nstab-template": "Şablon",
        "nstab-help": "Perra pasti",
        "nstab-category": "Kategoriye",
-       "mainpage-nstab": "Pela seri",
+       "mainpage-nstab": "Pera seri",
        "nosuchaction": "Fealiyeto wınasi çıniyo",
        "nosuchactiontext": "URL ra kar qebul nêbı.\nŞıma belka URL şaş nuşt, ya zi gıreyi şaş ra ameyi.\nKeyepelê {{SITENAME}} eşkeno xeta aşkera bıkero.",
        "nosuchspecialpage": "Pela hısusiya wınasiyên çıniya.",
index 6f81c4b..b42423f 100644 (file)
        "history": "Page history",
        "history_short": "History",
        "history_small": "history",
-       "updatedmarker": "updated since my last visit",
+       "updatedmarker": "updated since your last visit",
        "printableversion": "Printable version",
        "permalink": "Permanent link",
        "print": "Print",
index 244b281..e33e9bd 100644 (file)
        "specialmute-error-invalid-user": "The username requested could not be found.",
        "specialmute-error-email-blacklist-disabled": "Muting users from sending you emails is not enabled.",
        "specialmute-error-email-preferences": "You must confirm your email address before you can mute a user. You may do so from [[Special:Preferences]].",
-       "specialmute-email-footer": "[$1 Manage email preferences for {{BIDI:$2}}.]",
+       "specialmute-email-footer": "To manage email preferences for {{BIDI:$2}} please visit <$1>.",
        "specialmute-login-required": "Please log in to change your mute preferences.",
        "revid": "revision $1",
        "pageid": "page ID $1",
index ec385bd..b1bc55d 100644 (file)
        "history": "Paĝa historio",
        "history_short": "Historio",
        "history_small": "historio",
-       "updatedmarker": "ĝisdatigita de post mia lasta vizito",
+       "updatedmarker": "ĝisdatigita de post via lasta vizito",
        "printableversion": "Presebla versio",
        "permalink": "Konstanta ligilo",
        "print": "Presi",
        "edit-error-short": "Eraro: $1",
        "edit-error-long": "Eraroj:\n\n$1",
        "specialmute": "Silentigi",
+       "specialmute-success": "Sukcese ĝisdatiĝis viaj preferoj pri kaŝado de mesaĝoj. Vi povas vidi ĉiujn silentigitajn uzantojn ĉe [[Special:Preferences]].",
        "specialmute-submit": "Konfirmi",
        "specialmute-label-mute-email": "Kaŝi retmesaĝojn el ĉi tiu uzanto",
+       "specialmute-header": "Bonvolu elekti viajn preferojn pri kaŝado de mesaĝoj el {{BIDI:[[User:$1]]}}.",
        "specialmute-error-invalid-user": "La petita uzantnomo ne troviĝis.",
+       "specialmute-error-email-blacklist-disabled": "Malŝaltiĝis kaŝado de retmesaĝoj el specifaj uzantoj.",
+       "specialmute-error-email-preferences": "Vi povas konfirmi vian retpoŝtan adreson, antaŭ vi povas kaŝi mesaĝojn. Vi povas tion fari ĉe [[Special:Preferences]].",
        "specialmute-email-footer": "[$1 Administri preferojn pri retpoŝto por {{BIDI:$2}}.]",
+       "specialmute-login-required": "Bonvolu ensaluti por konservi vian preferon pri kaŝado de mesaĝoj.",
        "revid": "revizio $1",
        "pageid": "Identigilo de paĝo $1",
        "interfaceadmin-info": "$1\n\nPermesoj pri redaktado de tut-retejaj CSS/JavaScript/JSON-dosieroj estis lastatempe disigitaj for de la rajto <code>editinterface</code>. Se vi ne komprenas kial vi ricevis ĉi tiun eraron, vidu la paĝon [[mw:MediaWiki_1.32/interface-admin]].",
index cee8a41..e1ea96c 100644 (file)
        "restrictionsfield-help": "Une adresse IP ou une plage CIDR par ligne. Pour tout activer, utiliser :<pre>0.0.0.0/0\n::/0</pre>",
        "edit-error-short": "Erreur : $1",
        "edit-error-long": "Erreurs :\n\n$1",
+       "specialmute": "Muet",
+       "specialmute-submit": "Confirmer",
+       "specialmute-error-invalid-user": "Le nom d'utilisateur demandé n'a pu être trouvé.",
        "revid": "version $1",
        "pageid": "ID de page $1",
        "interfaceadmin-info": "$1\n\nLes droits pour modifier les fichiers CSS/JS/JSON globaux au site ont été récemment séparés du droit <code>editinterface</code>. Si vous ne comprenez pas pourquoi vous avez cette erreur, voyez [[mw:MediaWiki_1.32/interface-admin]].",
index 48b6019..46b48e2 100644 (file)
        "history": "Historial da páxina",
        "history_short": "Historial",
        "history_small": "historial",
-       "updatedmarker": "actualizado desde a miña última visita",
+       "updatedmarker": "actualizado desde a a última visita",
        "printableversion": "Versión para imprimir",
        "permalink": "Ligazón permanente",
        "print": "Imprimir",
index dd7ba71..0062c0b 100644 (file)
@@ -12,7 +12,8 @@
                        "Vaishali Parab",
                        "The Discoverer",
                        "Cliffa fernandes",
-                       "Rxy"
+                       "Rxy",
+                       "Isidore Dantas"
                ]
        },
        "tog-hideminor": "हालींच बदल केल्ल्यांतले बारीक संपादन लिपय",
        "nstab-template": "सांचो",
        "nstab-help": "आदाराचें पान",
        "nstab-category": "वर्ग",
+       "mainpage-nstab": "मुखेल पान",
        "nosuchaction": "असले तरेचे कार्य ना",
        "nosuchspecialpage": "असले कांयच विशेश पान ना",
        "error": "चूक",
index 4cb740b..0ed8f56 100644 (file)
        "histfirst": "ամենահին",
        "histlast": "ամենաթարմ",
        "historysize": "({{PLURAL:$1|1 բայթ|$1 բայթ}})",
-       "historyempty": "(դատարկ)",
+       "historyempty": "դատարկ",
        "history-feed-title": "Փոփոխությունների պատմություն",
        "history-feed-description": "Վիքիի այս էջի փոփոխումների պատմություն",
        "history-feed-item-nocomment": "$1՝ $2",
        "revdelete-unsuppress": "Հանել սահմանափակումները վերականգնված տարբերակներից",
        "revdelete-log": "Պատճառ.",
        "revdelete-submit": "Կիրառել ընտրված {{PLURAL:$1|տարբերակի|տարբերակների}} վրա",
-       "revdelete-success": "'''Տարբերակի տեսանելիությունը բարեհաջող թարմացված է։'''",
+       "revdelete-success": "Տարբերակի տեսանելիությունը թարմացված է։",
        "revdelete-failure": "Խմբագրման տեսանելիություն հնարավոր չէր փոփոխել՝\n$1",
-       "logdelete-success": "'''Իրադարձության տեսանելիությունը փոփոխված է։'''",
+       "logdelete-success": "Իրադարձության տեսանելիությունը փոփոխված է։",
        "revdel-restore": "Փոխել տեսանելիությունը",
        "pagehist": "Էջի պատմություն",
        "deletedhist": "Ջնջումների պատմություն",
        "contribsub2": "{{GENDER:$3|$1}}-ի ներդրումները ($2)",
        "contributions-subtitle": "{{GENDER:$3|$1}}-ի համար",
        "nocontribs": "Այս չափանիշներին համապատասխանող փոփոխություններ չեն գտնվել։",
-       "uctop": " վերջինը",
+       "uctop": "վերջինը",
        "month": "Սկսած ամսից (և վաղ)՝",
        "year": "Սկսած տարեթվից (և վաղ)՝",
        "sp-contributions-newbies": "Ցույց տալ միայն նորաստեղծ հաշիվներից կատարված ներդրումները",
index 2a92a13..5dec6c7 100644 (file)
        "restrictionsfield-help": "Un adresse IP o intervallo CIDR per linea. Pro activar toto, usa:<pre>0.0.0.0/0\n::/0</pre>",
        "edit-error-short": "Error: $1",
        "edit-error-long": "Errores:\n\n$1",
+       "specialmute": "Silentio",
+       "specialmute-success": "Tu preferentias de silentio ha essite actualisate. Vide tote le usatores silentiate in [[Special:Preferences]].",
+       "specialmute-submit": "Confirmar",
+       "specialmute-label-mute-email": "Silentiar e-mail de iste usator",
+       "specialmute-header": "Selige tu preferentias de silentio pro {{BIDI:[[User:$1]]}}.",
+       "specialmute-error-invalid-user": "Le nomine de usator que tu requestava non pote esser trovate.",
+       "specialmute-error-email-blacklist-disabled": "Le silentiamento de usatores pro inviar te e-mail non ha essite activate.",
+       "specialmute-error-email-preferences": "Tu debe confirmar tu adresse de e-mail ante de poter silentiar un usator. Face isto in [[Special:Preferences]].",
+       "specialmute-email-footer": "[$1 Gerer preferentias de e-mail pro {{BIDI:$2}}.]",
+       "specialmute-login-required": "Es necessari aperir session pro cambiar le preferentias de silentio.",
        "revid": "version $1",
        "pageid": "ID de pagina $1",
        "interfaceadmin-info": "$1\n\nLe permissiones pro modificar le files CSS/JS/JSON global del sito ha recentemente essite separate del privilegio <code>editinterface</code>. Si tu non comprende proque tu recipe iste error, vide [[mw:MediaWiki_1.32/interface-admin]].",
index 92f091d..8bf1a30 100644 (file)
        "editingcomment": "Mbesut $1 (pérangan anyar)",
        "editconflict": "Cengkah besutan: $1",
        "explainconflict": "Wong liya wis mbesut kaca iki wiwit panjenengan lekas mbesut.\nBagian dhuwur tèks iki ngamot tèks kaca vèrsi saiki.\nPangowahan kang panjenengan lakoni dituduhaké ing bagian ngisor tèks.\nPanjenengan namung prelu nggabungaké pangowahan panjenengan karo tèks kang wis ana.\n'''Namung''' tèks ing bagian dhuwur kaca kang bakal kasimpen manawa panjenengan mencèt \"$1\".",
-       "yourtext": "Tèksé panjenengan",
+       "yourtext": "Tèksmu",
        "storedversion": "Owahan kasimpen",
        "editingold": "'''PÈNGET:''' Panjenengan mbesut revisi lawas saka siji kaca. Yèn versi iki panjenengan simpen, mengko pangowahan-pangowahan kang wis digawé wiwit revisi iki bakal ilang.",
        "yourdiff": "Béda",
index d404d89..2e5a6ba 100644 (file)
        "pageinfo-authors": "Skirtingų autorių skaičius",
        "pageinfo-recent-edits": "Paskutinųjų keitimų skaičius (per $1 laikotarpį)",
        "pageinfo-recent-authors": "Pastarųjų skirtingų redaguotojų skaičius",
-       "pageinfo-magic-words": "Magiškas(-i) {{PLURAL:$1|žodis|žodžiai}} ($1)",
+       "pageinfo-magic-words": "Magiški {{PLURAL:$1|žodis|žodžiai}} ($1)",
        "pageinfo-hidden-categories": "{{PLURAL:$1|Paslėpta kategorija|Paslėptos kategorijos|Paslėptų kategorijų}} ($1)",
        "pageinfo-templates": "{{PLURAL:$1|Įtrauktas šablonas|Įtraukti šablonai|Įtrauktų šablonų}} ($1)",
        "pageinfo-transclusions": "{{PLURAL:$1|Įtrauktas puslapis|Įtraukti puslapiai|Įtrauktų puslapių}} ($1)",
index 2878062..094937d 100644 (file)
        "explainconflict": "သင် စတင်တည်းဖြတ်ကတည်းက တစ်စုံတစ်ယောက်မှ ဤစာမျက်နှာကို ပြောင်းလဲခဲ့သည်။ အပေါ်ပိုင်းဧရိယာတွင် လက်ရှိတည်ရှိနေသော စာမျက်နှာစာသား ပါဝင်သည်။ သင်၏ပြောင်းလဲချက်များကို အောက်ပိုင်းစာသားဧရိယာတွင် ပြသပေးထားသည်။ သင်၏ပြောင်းလဲချက်များကို ရှိနှင့်ပြီးသားစာသားတွင် ပေါင်းစပ်ရမည်ဖြစ်ပါသည်။ \"$1\" ကို သင်နှိပ်လိုက်ပါက အပေါ်ပိုင်းဧရိယာရှိ စာသား<strong>သာလျင်</strong> သိမ်းဆည်းသွားမည်ဖြစ်ပါသည်။",
        "yourtext": "သင့်စာသား",
        "storedversion": "သိမ်းဆည်းထားသောမူ",
+       "editingold": "<strong>သတိပေးချက်: သင်သည် ဤစာမျက်နှာ၏ ခေတ်နောက်ကျသောမူကို တည်းဖြတ်နေခြင်းဖြစ်သည်။</strong>\nသိမ်းဆည်းလိုက်ပါက ယခင်မူဟောင်းမှ မည်သည့်ပြောင်းလဲချက်များမဆို ပျောက်ဆုံးသွားမည်ဖြစ်သည်။",
        "yourdiff": "ကွဲပြားချက်များ",
        "copyrightwarning": "{{SITENAME}} တွင် ရေးသားမှုအားလုံးကို $2 အောက်တွင် ဖြန့်ဝေရန် ဆုံးဖြတ်ပြီး ဖြစ်သည်ကို ကျေးဇူးပြု၍ သတိပြုပါ။။ (အသေးစိတ်ကို $1 တွင်ကြည့်ပါ။)\nအကယ်၍ သင့်ရေးသားချက်များကို အညှာအတာမရှိ တည်းဖြတ်ခံရခြင်း၊ စိတ်တိုင်းကျ ဖြန့်ဝေခံရခြင်းတို့ကို အလိုမရှိပါက ဤနေရာတွင် မတင်ပါနှင့်။<br />\nသင်သည် ဤဆောင်းပါးကို သင်ကိုယ်တိုင်ရေးသားခြင်း၊ သို့မဟုတ် အများပြည်သူဆိုင်ရာဒိုမိန်းများ၊ ယင်းကဲ့သို့ လွတ်လပ်သည့် ရင်းမြစ်မှ ကူးယူထားခြင်း ဖြစ်ကြောင်းလည်း ဝန်ခံ ကတိပြုပါသည်။\n<strong>မူပိုင်ခွင့်ရှိသော စာ၊ပုံများကို ခွင့်ပြုချက်မရှိဘဲ မတင်ပါနှင့်။</strong>",
        "copyrightwarning2": "{{SITENAME}} တွင် ရေးသားမှုအားလုံးသည် အခြားပုံပိုးသူများ၏ တည်းဖြတ်၊ ပြောင်းလဲ၊ ဖယ်ရှားခံရနိုင်သည်ကို သတိပြုပါ။\nအကယ်၍ သင့်ရေးသားချက်များကို အညှာအတာမရှိ တည်းဖြတ်ခံရခြင်း၊ စိတ်တိုင်းကျ ဖြန့်ဝေခံရခြင်းတို့ကို အလိုမရှိပါက ဤနေရာတွင် မတင်ပါနှင့်။<br />\nသင်သည် ဤဆောင်းပါးကို သင်ကိုယ်တိုင်ရေးသားခြင်း၊ သို့မဟုတ် အများပြည်သူဆိုင်ရာဒိုမိန်းများ၊ ယင်းကဲ့သို့ လွတ်လပ်သည့် ရင်းမြစ်မှ ကူးယူထားခြင်း ဖြစ်ကြောင်းလည်း ဝန်ခံ ကတိပြုပါသည် (အသေးစိတ်ကို $1 တွင်ကြည့်ပါ)။\n<strong>မူပိုင်ခွင့်ရှိသော စာ၊ပုံများကို ခွင့်ပြုချက်မရှိဘဲ မတင်ပါနှင့်။</strong>",
index 2900229..c60f4b4 100644 (file)
@@ -12,7 +12,8 @@
                        "Liuxinyu970226",
                        "Yoxem",
                        "Matěj Suchánek",
-                       "Reke"
+                       "Reke",
+                       "LNDDYL"
                ]
        },
        "tog-underline": "Liân-kiat oē té-sûn:",
@@ -27,7 +28,7 @@
        "tog-editsectiononrightclick": "Chiàⁿ ji̍h toāⁿ-lo̍h phiau-tê to̍h ē-tàng pian-chi̍p toāⁿ-lo̍h",
        "tog-watchcreations": "Kā goá khui ê ia̍h kah chiūⁿ-chái ê tóng-àn ka-ji̍p kàm-sī-toaⁿ lāi-té",
        "tog-watchdefault": "Kā goá pian-chi̍p kòe ê ia̍h kah tóng-àn ka-ji̍p kàm-sī-toaⁿ lāi-té",
-       "tog-watchmoves": "Kā goá soá ê ia̍h kah tóng-àn ka-ji̍p kàm-sī-toaⁿ",
+       "tog-watchmoves": "Kā góa sóa ê ia̍h kah tóng-àn ka-ji̍p kàm-sī-toaⁿ",
        "tog-watchdeletion": "Kā goá thâi tiāu ê ia̍h kah tóng-àn ka-ji̍p kàm-sī-toaⁿ",
        "tog-watchuploads": "Chiong góa ap-ló͘ ê tóng-àn ka-ji̍p kam-sī-toaⁿ",
        "tog-watchrollback": "Chiong góa í-keng ká--tńg-khì ê ia̍h-bīn ka-ji̍p góa-ê kam-sī-toaⁿ",
        "block-log-flags-anononly": "Kaⁿ-taⁿ bô-miâ iōng-chiá",
        "block-log-flags-nocreate": "Khui kháu-chō thêng-iōng ah",
        "locknoconfirm": "Lí bô kau \"khak-tēng\" ê keh-á.",
-       "move-page": "$1",
+       "move-page": "Sóa $1",
        "move-page-legend": "Sóa ia̍h",
        "movepagetext": "Ē-kha chit ê form> iōng lâi kái 1 ê ia̍h ê piau-tê (miâ-chheng); só·-ū siong-koan ê le̍k-sú ē tòe leh sóa khì sin piau-tê.\nKū piau-tê ē chiâⁿ-chò 1 ia̍h choán khì sin piau-tê ê choán-ia̍h.\nLiân khì kū piau-tê ê liân-kiat (link) bē khì tāng--tio̍h; ē-kì-tit chhiau-chhōe siang-thâu (double) ê a̍h-sī kò·-chiòng ê choán-ia̍h.\nLí ū chek-jīm khak-tēng liân-kiat kè-sio̍k liân tio̍h ūi.\n\nSin piau-tê nā í-keng tī leh (bô phian-chi̍p koè ê khang ia̍h, choán-ia̍h bô chún-sǹg), tō bô-hoat-tō· soá khì hia.\nChe piaú-sī nā ū têng-tâⁿ, ē-sái kā sin ia̍h soà tńg-khì goân-lâi ê kū ia̍h.\n\n'''SÈ-JĪ!'''\nTùi chē lâng tha̍k ê ia̍h lâi kóng, soá-ūi sī toā tiâu tāi-chì.\nLiâu--lo̍h-khì chìn-chêng, chhiáⁿ seng khak-tēng lí ū liáu-kái chiah-ê hiō-kó.",
-       "movepagetalktext": "Siong-koan ê thó-lūn-ia̍h (chún ū) oân-nâ ē chū-tōng tòe leh sóa-ūi. Í-hā ê chêng-hêng '''bô chún-sǹg''': *Beh kā chit ia̍h tùi 1 ê miâ-khong-kan (namespace) s khì lēng-gōa 1 ê miâ-khong-kan, *Sin piau-tê í-keng ū iōng--kòe ê thó-lūn-ia̍h, he̍k-chiá *Ē-kha ê sió-keh-á bô phah-kau. Í-siōng ê chêng-hêng nā-chún tī leh, lí chí-hó iōng jîn-kang ê hong-sek sóa ia̍h a̍h-sī kā ha̍p-pèng (nā ū su-iàu).",
+       "movepagetalktext": "Siong-koan ê thó-lūn-ia̍h (chún ū) oân-nâ ē chū-tōng tòe leh sóa-ūi. Í-hā ê chêng-hêng '''bô chún-sǹg''': *Beh kā chit ia̍h tùi 1 ê miâ-khong-kan (namespace) sóa khì lēng-gōa 1 ê miâ-khong-kan, *Sin piau-tê í-keng ū iōng--kòe ê thó-lūn-ia̍h, he̍k-chiá *Ē-kha ê sió-keh-á bô phah-kau. Í-siōng ê chêng-hêng nā-chún tī leh, lí chí-hó iōng jîn-kang ê hong-sek sóa ia̍h a̍h-sī kā ha̍p-pèng (nā ū su-iàu).",
        "movenologintext": "Lí it-tēng ài sī chù-chheh ê iōng-chiá jī-chhiáⁿ ū [[Special:UserLogin|teng-ji̍p]] chiah ē-tàng sóa ia̍h.",
        "newtitle": "Khì sin piau-tê:",
        "move-watch": "Kàm-sī chit ia̍h",
        "movetalk": "Sūn-sòa sóa thó-lūn-ia̍h",
        "movepage-page-moved": "$1 í-keng sóa khì tī $2.",
        "movepage-page-unmoved": "$1 chit ia̍h hô hoat-tō͘ sóa khì $2.",
-       "movelogpagetext": "Ē-kha lia̍t-chhut hông s-ūi ê ia̍h.",
+       "movelogpagetext": "Ē-kha lia̍t-chhut hông sóa-ūi ê ia̍h.",
        "movereason": "Lí-iû:",
        "revertmove": "hôe-tńg",
        "delete_and_move_reason": "Thâi-ia̍h hō͘ \"[[$1]]\" thang sóa-ia̍h kòe--lâi",
        "selfmove": "Goân piau-tê kap sin piau-tê sio-siâng; bô hoat-tō· sóa.",
-       "protectedpagemovewarning": "'''KÉNG-KÒ: Pún ia̍h só tiâu leh. Kan-taⁿ ū hêng-chèng te̍k-koân ê iōng-chiá (sysop) ē-sái s tín-tāng.'''\nĒ-kha ū choè-kīn ê kì-lio̍k thang chham-khó:",
+       "protectedpagemovewarning": "'''KÉNG-KÒ: Pún ia̍h só tiâu leh. Kan-taⁿ ū hêng-chèng te̍k-koân ê iōng-chiá (sysop) ē-sái sóa tín-tāng.'''\nĒ-kha ū choè-kīn ê kì-lio̍k thang chham-khó:",
        "export": "Su-chhut ia̍h",
        "exportcuronly": "Hān hiān-chhú-sî ê siu-téng-pún, mài pau-koat kui-ê le̍k-sú",
        "allmessages": "Hē-thóng sìn-sit",
        "tooltip-ca-delete": "Thâi chit ia̍h",
        "tooltip-ca-move": "Sóa chit ia̍h",
        "tooltip-ca-watch": "共這頁加入去你的監視單",
-       "tooltip-ca-unwatch": "Lí ê kàm-sī-toaⁿ s tiàu chit ia̍h.",
+       "tooltip-ca-unwatch": "Lí ê kàm-sī-toaⁿ sóa tiàu chit ia̍h.",
        "tooltip-search": "Chhoé {{SITENAME}}",
        "tooltip-search-go": "Nā ū kāng-miâ--ê, tō khì hit-ia̍h.",
        "tooltip-search-fulltext": "Chhoé ū chia-ê jī ê ia̍h",
        "autosumm-changed-redirect-target": "Choán-ia̍h bo̍k-phiau kái [[$1]] kòe [[$2]] oân-sêng",
        "autosumm-new": "$1 ê ia̍h í-keng kiàn-li̍p",
        "watchlistedit-normal-submit": "Mài kàm-sī",
-       "watchlistedit-normal-done": "Í-keng uì lí ê kám-sī-toaⁿ s {{PLURAL:$1|ia̍h}} cháu:",
+       "watchlistedit-normal-done": "Í-keng uì lí ê kám-sī-toaⁿ sóa {{PLURAL:$1|ia̍h}} cháu:",
        "watchlisttools-edit": "Khoàⁿ koh kái kàm-sī-toaⁿ",
        "watchlisttools-raw": "Kái chhiⁿ ê kàm-sī-toaⁿ",
        "signature": "[[{{ns:user}}:$1|$2]] ([[{{ns:user_talk}}:$1|thó-lūn]])",
index d2d6457..3dad188 100644 (file)
        "userrights-expiry-existing": "ߕߋ߲߭ߕߋ߲߭ ߛߕߊߝߊ߫ ߕߎߡߊ: $3߸ $2",
        "userrights-expiry-othertime": "ߕߎ߬ߡߊ߬ ߜߘߍ:",
        "userrights-expiry-options": "ߕߟߋ߬ ߁: ߕߟߋ߬ ߁߸ ߞߎ߲߬ߢߐ߰ ߁: ߞߎ߲߬ߢߐ߰ ߁߸ ߞߊߙߏ߫ ߁: ߞߊߙߏ߫ ߁߸ ߞߊߙߏ߫ ߃: ߞߊߙߏ߫ ߃߸ ߞߊߙߏ߫ ߆: ߞߊߙߏ߫ ߆߸ ߛߊ߲߬ ߁: ߛߊ߲߬ ߁",
+       "userrights-invalid-expiry": "ߞߙߎ  \"$1\" ߛߕߊ ߝߊ߫ ߕߎߡߊ ߓߍ߲߬ߣߍ߲߫ ߕߍ߫.",
+       "userrights-expiry-in-past": "ߞߙߎ  \"$1\" ߛߕߊ ߝߊ߫ ߕߎߡߊ ߓߘߊ߫ ߕߊ߬ߡߌ߲߬.",
        "group": "ߞߙߎ:",
        "group-user": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ",
        "group-autoconfirmed": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ߬ ߞߍߒߖߘߍߦߋ߫ ߟߊߛߙߋߦߊߣߍ߲",
        "group-bot": "ߓߏߕ",
        "group-sysop": "ߞߎ߲߬ߠߊ߬ߛߌ߰ߟߊ",
        "group-bureaucrat": "ߛߓߍߘߟߊߡߐ߮",
+       "group-suppress": "ߛߎ߬ߔߙߋߛߐ߬",
        "group-all": "(ߊ߬ ߓߍ߯)",
        "group-user-member": "{{GENDER:$1|ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ}}",
        "group-autoconfirmed-member": "{{GENDER:$1|ߞߍߒߖߘߍߦߋ߫ ߟߊߛߙߋߦߊߟߌ ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ}}",
        "group-bot-member": "{{GENDER:$1|ߓߏߕ}}",
+       "group-sysop-member": "{{GENDER:$1|ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ}}",
        "group-bureaucrat-member": "{{GENDER:$1|ߛߓߍߘߟߊߡߐ߮}}",
        "grouppage-user": "{{ns:project}}: ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ",
        "grouppage-bot": "{{ns:project}}:ߓߏߕ",
        "newuserlogpage": "ߖߊ߬ߕߋ߬ߘߊ߬ ߓߘߊ߫ ߟߊߞߊ߬ ߌ ߜߊ߲߬ߞߎ߲߬",
        "newuserlogpagetext": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ ߟߊ߫ ߘߎ߲ߛߓߍ߫ ߛߌ߲ߘߌߣߍ߲ ߘߏ߫ ߟߋ߬ ߦߋ߫ ߣߌ߲߬.",
        "rightslog": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ ߜߊ߲߬ߞߎ߲߬ ߢߊ߬ ߓߘߍ",
+       "rightslogtext": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ ߤߊߞߍ ߡߊߦߟߍ߬ߡߊ߲߫ ߘߎ߲ߛߓߍ ߟߋ߬ ߦߋ߫ ߣߌ߲߬",
        "action-read": "ߞߐߜߍ ߣߌ߲߬ ߘߐߞߊ߬ߙߊ߲߬",
        "action-edit": "ߞߐߜߍ ߣߌ߲߬ ߡߊߦߟߍ߬ߡߊ߲߬",
        "action-createpage": "ߞߐߜߍ ߣߌ߲߬ ߛߌ߲ߘߌ߫",
        "rcfilters-savedqueries-unsetdefault": "ߊ߬ ߓߐ߫ ߓߐߛߎ߲ ߘߐ߫",
        "rcfilters-savedqueries-remove": "ߊ߬ ߖߏ߰ߛߌ߬",
        "rcfilters-savedqueries-new-name-label": "ߕߐ߮",
+       "rcfilters-savedqueries-apply-label": "ߛߍ߲ߛߍ߲ߟߊ߲ ߛߌ߲ߘߌ߫",
+       "rcfilters-savedqueries-apply-and-setdefault-label": "ߓߐߛߎ߲ ߛߍ߲ߛߍ߲ߟߊ߲ ߛߌ߲ߘߌ߫",
        "rcfilters-savedqueries-cancel-label": "ߊ߬ ߘߐߛߊ߬",
+       "rcfilters-savedqueries-add-new-title": "ߕߋ߲߬ߕߋ߲߬ ߛߍ߲ߛߍ߲ߟߊ߲ ߟߊ߬ߓߍ߲߬ߢߐ߲߰ߡߊ ߟߊߞߎ߲߬ߘߎ߬",
+       "rcfilters-savedqueries-already-saved": "ߛߍ߲ߛߍ߲ߟߊ߲ ߣߌ߲߬ ߓߘߊ߫ ߓߊ߲߫ ߠߊߞߎ߲߬ߘߎ߬ ߟߊ߫.ߌ ߟߊ߫ ߟߊ߬ߓߍ߲߬ߢߐ߲߰ߡߊ ߡߊߝߊ߬ߟߋ߲߬ ߞߊ߬ ߛߍ߲ߛߍ߲ߟߊ߲߫ ߟߊߞߎ߲߬ߘߎ߬ߣߍ߲ ߘߏ߫ ߛߌ߲ߘߌ߫.",
+       "rcfilters-clear-all-filters": "ߛߍ߲ߛߍ߲ߟߊ߲ ߓߍ߯ ߛߊߣߌ߲ߧߊ߫",
+       "rcfilters-show-new-changes": "ߡߊ߬ߦߟߍ߬ߡߊ߲߬ߠߌ߲߬ ߞߎߘߊ ߟߎ߬ ߦߋ߫ ߞߊ߬ߦߌ߯ $1",
        "rcfilters-filterlist-whatsthis": "ߣߌ߲߬ ߦߋ߫ ߓߊ߯ߙߊ߫ ߟߊ߫ ߘߌ߬؟",
        "rcfilters-highlightbutton-title": "ߞߐߝߟߌ߫ ߡߊߦߋߙߋ߲ߣߍ߲ ߠߎ߬",
        "rcfilters-highlightmenu-title": "ߞߐ߬ߟߐ ߘߏ߫ ߓߊߓߌ߬ߟߊ߬",
        "recentchangeslinked-summary": "ߞߐߜߍ ߕߐ߮ ߟߊߘߏ߲߬߸ ߞߊ߬ ߞߐߜߍ ߛߘߌ߬ߜߋ߲ ߡߊ߬ߦߟߍ߬ߡߊ߲߬ߠߌ߲ ߦߋ߫߸ ߥߟߊ߫ \nߞߊ߬ ߝߘߊ߫ ߞߐߜߍ ߣߌ߲߬ ߠߊ߫. (ߖߐ߲߬ߛߊ߬ ߌ ߘߌ߫ ߦߌߟߡߊ ߛߌ߲߬ߝߏ߲ ߠߎ߬ ߦߋ߫߸ ߣߌ߲߬ ߠߊߘߏ߲߬ {{ns:category}}: ߦߌߟߡߊ ߕߐ߮). ߦߟߍ߬ߡߊ߲߬ ߡߍ߲ ߦߋ߫ ߞߐߜߍ ߣߌ߲߬ [[Special:Watchlist|your Watchlist]] ߘߐ߫߸ ߏ߬ ߦߋ߫ <strong>ߛߓߍߘߋ߲߫ ߞߎ߲ߓߊ</strong> ߟߋ߬ ߘߐ߫.",
        "recentchangeslinked-page": "ߞߐߜߍ ߕߐ߮:",
        "recentchangeslinked-to": "ߞߐߜߍ ߛߘߌ߬ߜߋ߲ ߠߎ߬ ߦߌ߬ߘߊ߬߸ ߞߊ߬ ߞߐߜߍ ߣߌ߬ ߞߋߟߋ߲ߘߌ߫",
+       "recentchanges-page-added-to-category": "[[:$1]] ߓߘߊ߫ ߟߊߘߏ߲߬ ߦߌߟߡߊ ߘߐ߫",
+       "recentchanges-page-removed-from-category": "[[:$1]] ߛߋ߲߬ߓߐ߫ ߦߌߟߡߊ ߘߐ߫",
+       "autochange-username": "ߡߋߘߌߦߊ߫-ߥߞߌ ߞߍߒߖߘߍߦߋ߫ ߡߊߦߟߍߡߊ߲ߠߌ߲",
        "upload": "ߞߐߕߐ߮ ߟߊߦߟߍ",
        "uploadbtn": "ߞߐߕߐ߮ ߟߊߦߟߍ߬",
+       "reuploaddesc": "ߟߊ߬ߦߟߍ߬ߟߌ ߘߐߛߊ߬ ߊ߬ ߣߌ߫ ߞߵߌ ߞߐߛߊ߬ߦߌ߬ ߟߊ߬ߦߟߍ߬ߟߌ ߖߙߎߡߎ߲ ߘߐ߫",
+       "uploadnologin": "ߌ ߜߊ߲߬ߞߎ߲߬ߣߍ߲߬ ߕߍ߫",
+       "uploadnologintext": "ߖߊ߰ߣߌ߲߫ $1 ߞߊ߬ ߞߐߕߐ߮ ߟߎ߬ ߟߊߦߟߍ߬.",
        "uploadlogpage": "ߜߊ߲߬ߞߎ߲߬ߠߌ߲ ߘߏ߫ ߟߊߦߟߍ߬",
        "filename": "ߞߐߕߐ߮ ߕߐ߮",
        "filedesc": "ߟߊߘߛߏߣߍ߲",
        "fileuploadsummary": "ߟߊ߬ߘߛߏ߬ߟߌ:",
        "filereuploadsummary": "ߞߐߕߐ߮ ߡߊߦߟߍ߬ߡߊ߲:",
        "filesource": "ߛߎ߲:",
+       "empty-file": "ߌ ߣߊ߬ ߞߐߕߐ߮ ߡߍ߲ ߞߙߊߓߊ߫ ߟߊ߫߸ ߊ߬ ߘߐߞߏߟߏ߲ ߠߋ߬ ߕߘߍ߬.",
+       "file-too-large": "ߌ ߟߊ߫ ߞߐߕߐ߮ ߞߙߊߓߊߣߍ߲ ߓߏ߲߬ߓߊ߫ ߕߘߍ߬ ߞߏߖߎ߰߹",
+       "filename-tooshort": "ߞߐߕߐ߮ ߕߐ߮ ߛߘߎ߬ߡߊ߲߬ ߞߏߖߎ߰.",
+       "filetype-banned": "ߞߐߕߐ߮ ߛߎ߯ߦߊ ߟߊߕߐ߲ߣߍ߲߫ ߠߋ߬.",
+       "verification-error": "ߞߐߕߐ߮ ߣߌ߲߬ ߡߊ߫ ߕߊ߬ߡߌ߲߬ ߞߐߕߐ߮ ߝߛߍ߬ߝߛߍ߬ ߦߌߟߊ.",
+       "hookaborted": "ߌ ߕߘߍ߬ ߦߋ߫ ߡߊ߬ߦߟߍ߬ߡߊ߲߬ߠߌ߲ ߡߍ߲ ߞߍ߫ ߞߏ ߘߐ߫߸ ߊ߬ ߘߐߛߊ߬ߣߍ߲߬ ߦߋ߫ ߘߐ߬ߥߙߊ߬ߟߌ ߟߋ߬ ߓߟߏ߫.",
+       "illegal-filename": "ߞߐߕߐ߮ ߕߐ߮ ߟߊߘߌ߬ߢߍ߬ߣߍ߲߬ ߕߍ߫.",
+       "uploadwarning": "ߟߊ߬ߦߟߍ߬ߟߌ ߖߊ߬ߛߙߋ߬ߡߊ߬ߟߊ",
+       "uploadwarning-text": "ߞߐߕߐ߮ ߘߎ߰ߟߊ߬ߘߐ߫ ߞߊ߲ߛߓߍߟߌ ߡߊ߬ߦߟߍ߬ߡߊ߲߫ ߖߊ߰ߣߌ߲߬߸ ߞߵߊ߬ ߡߊߝߍߣߍ߲߫ ߕߎ߲߯.",
        "savefile": "ߞߐߕߐ߮ ߟߊߞߎ߲߬ߘߎ߬",
+       "upload-source": "ߞߐߕߐ߮ ߛߎ߲",
+       "sourceurl": "URL ߛߎ߲:",
+       "destfilename": "ߞߐߕߐ߮ ߕߐ߮ ߞߎ߲߬ߕߋߟߋ߲:",
+       "upload-maxfilesize": "ߞߐߕߐ߮ ߢߊ߲ߞߊ߲ ߞߐߘߊ߲: $1",
+       "upload-description": "ߞߐߕߐ߮ ߞߊ߲߬ߛߓߍߟߌ",
+       "upload-options": "ߟߊ߬ߦߟߍ߬ߟߌ ߢߣߊߕߊߟߌ",
+       "watchthisupload": "ߞߐߕߐ߮ ߣߌ߲߬ ߘߐߜߍ߫",
        "upload-dialog-title": "ߞߐߕߐ߮ ߟߊߦߟߍ߬",
        "upload-dialog-button-cancel": "ߊ߬ ߘߐߛߊ߬",
        "upload-dialog-button-back": "ߌ ߞߐߛߊ߬ߦߌ߬",
+       "upload-dialog-button-done": "ߊ߬ ߓߘߊ߫ ߞߍ߫",
+       "upload-dialog-button-save": "ߊ߬ ߟߊߞߎ߲߬ߘߎ߬",
+       "upload-dialog-button-upload": "ߟߊ߬ߦߟߍ߬ߟߌ",
+       "upload-form-label-infoform-title": "ߝߊߙߊ߲ߝߊ߯ߛߟߌ",
+       "upload-form-label-infoform-name": "ߕߐ߮",
+       "upload-form-label-usage-title": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߌ",
+       "upload-form-label-usage-filename": "ߞߐߕߐ߮ ߕߐ߮",
+       "upload-form-label-own-work": "ߒ ߖߘߍ߬ߞߊ߬ߣߌ߲߬ ߓߊ߯ߙߊ ߟߋ߬",
+       "upload-form-label-infoform-categories": "ߦߌߟߡߊ ߟߎ߬",
+       "upload-form-label-infoform-date": "ߕߎ߬ߡߊ߬ߘߊ",
+       "upload-form-label-own-work-message-generic-local": "ߒ ߧߴߊ߬ ߟߊߛߙߋߦߊ߫ ߟߊ߫ ߞߏ߫ ߒ ߧߋ߫ ߞߐߕߐ߮ ߣߌ߲߬ ߠߊߦߟߍ߬ ߞߊ߲߬ ߞߊ߬ ߓߍ߲߬ ߗߋߘߊ ߛߙߊߕߌ ߣߌ߫ ߕߌ߰ߦߊ ߤߊߞߍ ߡߊ߬ {{SITENAME}} ߞߊ߲߬",
+       "backend-fail-delete": "ߞߐߕߐ߮ ߕߴߛߋ߫ ߖߏ߰ߛߌ߬ ߟߊ߫  \"$1\".",
+       "backend-fail-describe": "ߡߋߕߊߘߕߊ ߞߐߕߐ߮ ߕߴߛߋ߫ ߡߊߦߟߍ߬ߡߊ߲߫ ߠߊ߫  \"$1\".",
+       "backend-fail-store": "ߞߐߕߐ߮  \"$1\" ߕߍ߫ ߛߐ߲߬ ߟߊߡߙߊ߬ ߟߊ߫ ߦߊ߲߬  \"$2\"",
+       "backend-fail-copy": "ߊ߬ ߕߍ߫ ߣߊ߬ ߛߐ߲߬ ߠߊ߫ ߞߐߕߐ߮  \"$1\" ߓߊߦߟߍ߬ߡߊ߲߬ ߠߊ߫ ߦߊ߲߬  \"$2\".",
+       "img-auth-nofile": "ߞߐߕߐ߮  \"$1\" ߕߍ߫ ߦߋ߲߬.",
+       "http-request-error": "HTTP ߡߊ߬ߢߌ߬ߣߌ߲߬ߠߌ߲ ߓߘߊ߫ ߗߌߙߏ߲߫ ߝߎ߬ߕߎ߲߬ߕߌ߬ ߡߊߟߐ߲ߓߊߟߌ ߘߏ߫ ߞߏߛߐ߲߬.",
+       "http-read-error": "HTTP ߘߐ߬ߞߊ߬ߙߊ߲߬ߠߌ߲ ߝߎ߬ߕߎ߲߬ߕߌ.",
+       "http-timed-out": "HTTP ߡߊ߬ߢߌ߬ߣߌ߲߬ߠߌ߲ ߕߎ߬ߡߊ ߓߘߊ߫ ߕߊ߬ߡߌ߲߬.",
+       "http-curl-error": "URL: $1 ߕߌߙߌ߲ߠߌ߲ ߝߎ߬ߕߎ߲߬ߕߌ",
+       "http-bad-status": "ߝߙߋߞߋ ߕߘߍ߬ ߦߋ߫ ߦߋ߲߬ HTTP ߡߊߢߌߣߌ߲ߠߌ߲: $1 $2 ߘߐ߫",
+       "http-internal-error": "HTTP ߞߣߐߟߊ ߘߐ߫ ߝߎߕߎ߲ߕߌ.",
+       "upload-curl-error6": "ߌ ߕߍ߫ ߣߊ߬ URL ߡߊߛߐ߬ߘߐ߲߬ ߠߊ߫",
        "license": "ߟߊ߬ߘߌ߬ߢߍ߬ߟߌ ߦߴߌ ߘߐ߫:",
        "license-header": "ߟߊ߬ߘߌ߬ߢߍ߬ߟߌ ߦߴߌ ߘߐ߫",
        "imgfile": "ߞߐߕߐ߮",
index 4c7b336..f765ef4 100644 (file)
        "history": "Histórico",
        "history_short": "Histórico",
        "history_small": "histórico",
-       "updatedmarker": "atualizado desde a minha última visita",
+       "updatedmarker": "atualizado desde a sua última visita",
        "printableversion": "Versão para impressão",
        "permalink": "Hiperligação permanente",
        "print": "Imprimir",
index 6b462ae..1606205 100644 (file)
        "subject-preview": "Náhľad predmetu:",
        "previewerrortext": "Pri pokuse o zobrazenie náhľadu došlo k chybe.",
        "blockedtitle": "Používateľ je zablokovaný",
-       "blockedtext": "'''Vaše používateľské meno alebo IP adresa bola zablokovaná.'''\n\nZablokoval vás správca $1. Udáva tento dôvod:<br />''$2''\n\n* Blokovanie začalo: $8\n* Blokovanie vyprší: $6\n* Kto mal byť zablokovaný: $7\n\nMôžete kontaktovať $1 alebo s jedného z ďalších [[{{MediaWiki:Grouppage-sysop}}|správcov]] a prediskutovať blokovanie.\nUvedomte si, že nemôžete použiť funkciu „{{int:Emailuser}}“, pokiaľ nemáte registrovanú platnú e-mailovú adresu vo svojich [[Special:Preferences|nastaveniach]].\nVaša IP adresa je $3 a ID blokovania je #$5.\nProsím, uveďte oba tieto údaje do každej správy, ktorú posielate.",
+       "blockedtext": "<strong>Vaše používateľské meno alebo IP adresa bola zablokovaná.</strong>\n\nZablokoval vás správca $1.\nUdáva tento dôvod: <em>$2</em>.\n\n* Blokovanie začalo: $8\n* Blokovanie vyprší: $6\n* Kto mal byť zablokovaný: $7\n\nAk chcete prediskutovať blokovanie, kontaktujte $1 alebo iného [[{{MediaWiki:Grouppage-sysop}}|správcu]].\nFunkciu „{{int:emailuser}}“ môžete použiť, iba ak máte registrovanú platnú e-mailovú adresu vo svojich [[Special:Preferences|nastaveniach]] a jej použitie nebolo zablokované.\nVaša súčasná IP adresa je $3 a ID blokovania je #$5.\nProsím, uveďte všetky tieto údaje do každej správy, ktorú posielate.",
        "autoblockedtext": "Vaša IP adresa bola automaticky zablokovaná, pretože ju používa iný používateľ, ktorého zablokoval $1.\nUdaný dôvod zablokovania:\n\n:''$2''\n\n* Blokovanie začalo: $8\n* Blokovanie vyprší: $6\n* Blokovanie sa týka: $7\n\nAk potrebujete informácie o blokovaní, môžete kontaktovať $1 alebo niektorého iného\n[[{{MediaWiki:Grouppage-sysop}}|správcu]].\n\nPozn.: Nemôžete použiť funkciu „{{int:emailuser}}“, ak ste si vo svojich\n[[Special:Preferences|používateľských nastaveniach]] nezaregistrovali platnú e-mailovú adresu.\n\nVaša aktuálna IP adresa je $3. ID vášho blokovania je $5.\nProsím, uveďte tieto podrobnosti v akýchkoľvek otázkach, ktoré sa opýtate.",
        "systemblockedtext": "Vaša IP adresa bola automaticky zablokovaná.\nUdaný dôvod zablokovania:\n\n:<em>$2</em>\n\n* Blokovanie začalo: $8\n* Blokovanie vyprší: $6\n* Blokovanie sa týka: $7\n\nVaša aktuálna IP adresa je $3.\nProsím, uveďte tieto podrobnosti v akýchkoľvek otázkach, ktoré sa opýtate.",
        "blockednoreason": "nebol uvedený dôvod",
        "accmailtext": "Náhodne vytvorené heslo pre používateľa [[User talk:$1|$1]] bolo poslané na $2. Je možné ho zmeniť na stránke ''[[Special:ChangePassword|zmena hesla]]'' po prihlásení.",
        "newarticle": "(Nový)",
        "newarticletext": "Nasledovali ste odkaz, vedúci na stránku, ktorá zatiaľ neexistuje.\nStránku vytvoríte tak, že začnete písať do políčka nižšie (viac informácií nájdete na stránkach [$1 nápovedy]).\nAk ste sa sem dostali nechtiac, kliknite na tlačidlo <strong>späť</strong> vo svojom prehliadači.",
-       "anontalkpagetext": "----\n<em>Toto je diskusná stránka anonymného používateľa, ktorý nemá vytvorené svoje konto alebo ho nepoužíva.</em>\nPreto musíme na jeho identifikáciu použiť numerickú IP adresu. Je možné, že takúto IP adresu používajú viacerí používatelia.\nAk ste anonymný používateľ a máte pocit, že vám boli adresované irelevantné diskusné príspevky, [[Special:CreateAccount|vytvorte si konto]] alebo sa [[Special:UserLogin|prihláste]], aby sa zamedzilo budúcim zámenám s inými anonymnými používateľmi.",
+       "anontalkpagetext": "----\n<em>Toto je diskusná stránka anonymného používateľa, ktorý ešte nemá vytvorené svoje konto alebo ho nepoužíva.</em>\nPreto musíme na jeho identifikáciu použiť numerickú IP adresu.\nJe možné, že túto IP adresu používajú viacerí používatelia.\nAk ste anonymný používateľ a máte pocit, že vám boli adresované irelevantné diskusné príspevky, [[Special:CreateAccount|vytvorte si konto]] alebo sa [[Special:UserLogin|prihláste]], aby sa zamedzilo budúcim zámenám s inými anonymnými používateľmi.",
        "noarticletext": "Na tejto stránke sa momentálne nenachádza žiadny text.\nMôžete [[Special:Search/{{PAGENAME}}|vyhľadávať názov tejto stránky]] v obsahu iných stránok,\n<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} vyhľadávať v súvisiacich záznamoch] alebo [{{fullurl:{{FULLPAGENAME}}|action=edit}} vytvoriť túto stránku]</span>.",
        "noarticletext-nopermission": "Táto stránka momentálne neobsahuje žiadny text.\nMôžete [[Special:Search/{{PAGENAME}}|hľadať názov tejto stránky]] v texte iných stránok\nalebo <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} hľadať v súvisiacich záznamoch]</span>, ale nemáte oprávnenie túto stránku vytvoriť.",
        "missing-revision": "Revízia #$1 stránky s názvom „{{FULLPAGENAME}}“ neexistuje.\n\nPravdepodobne ste nasledovali zastaraný odkaz na historickú verziu stránky, ktorá bola medzičasom odstránená.\nPodrobnosti nájdete v [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} zázname zmazaní].",
        "page_first": "prvá",
        "page_last": "posledná",
        "histlegend": "Porovnanie zmien: označte výberové políčka revízií, ktoré sa majú porovnať a kliknite na tlačidlo dolu.<br />\nLegenda: (aktuálna) = rozdiel oproti aktuálnej verzii,\n(posledná) = rozdiel oproti predchádzajúcej verzii, D = drobná úprava",
-       "history-fieldset-title": "Prechádzať históriou",
+       "history-fieldset-title": "Filtrovať revízie",
        "history-show-deleted": "Iba zmazané",
        "histfirst": "najstaršie",
        "histlast": "najnovšie",
        "filehist-comment": "komentár",
        "imagelinks": "Použitie súboru",
        "linkstoimage": "Na tento súbor {{PLURAL:$1|odkazuje nasledujúca stránka|odkazujú nasledujúce $1 stránky|odkazuje nasledujúcich $1 stránok}}:",
-       "linkstoimage-more": "Viac ako $1 {{PLURAL:$1|stránka odkazuje|stránky odkazujú|stránok odkazuje}} na tento súbor.\nNasledovný zoznam zobrazuje {{PLURAL:$1|prvú stránku odkazujúcu|prvé $1 stránky odkazujúce|prvých $1 stránok odkazujúcich}} iba na tento súbor.\nMôžete si pozrieť [[Special:WhatLinksHere/$2|úplný zoznam]].",
+       "linkstoimage-more": "Viac ako $1 {{PLURAL:$1|stránka odkazuje|stránky odkazujú|stránok odkazuje}} na tento súbor.\nNasledovný zoznam zobrazuje {{PLURAL:$1|prvú stránku odkazujúcu|prvé $1 stránky odkazujúce|prvých $1 stránok odkazujúcich}} iba na tento súbor.\nK dispozícii je aj [[Special:WhatLinksHere/$2|úplný zoznam]].",
        "nolinkstoimage": "Žiadne stránky neobsahujú odkazy na tento súbor.",
        "morelinkstoimage": "Zobraziť [[Special:WhatLinksHere/$1|ďalšie odkazy]] na tento súbor.",
        "linkstoimage-redirect": "$1 (presmerovanie súboru) $2",
index add1976..fc0d936 100644 (file)
@@ -85,6 +85,7 @@
        "tog-norollbackdiff": "Mos trego ndrysh pas kryerjes së një rikthkimi",
        "tog-useeditwarning": "Më paralajmëro kur lë një redaktim faqeje me ndryshime të paruajtura",
        "tog-prefershttps": "Gjithmonë përdorni një lidhje të sigurt kur të kyçur",
+       "tog-showrollbackconfirmation": "Shfaq një komandë konfirmimi kur shtyp mbi butonin e rikthimit të përgjithshëm.",
        "underline-always": "Gjithmonë",
        "underline-never": "Asnjëherë",
        "underline-default": "Parapërcaktuar nga shfletuesi",
        "returnto": "Kthehu tek $1",
        "tagline": "Nga {{SITENAME}}",
        "help": "Ndihmë",
+       "help-mediawiki": "Ndihmë rreth MediaWiki-t",
        "search": "Kërko",
        "searchbutton": "Kërko",
        "go": "Shko",
        "history": "Historiku i faqes",
        "history_short": "Historiku",
        "history_small": "historiku",
-       "updatedmarker": "përditësuar që nga vizita ime e fundit",
+       "updatedmarker": "përditësuar që nga vizita e fundit",
        "printableversion": "Versioni i printueshëm",
        "permalink": "Lidhje e përhershme",
        "print": "Printo",
        "badarticleerror": "Ky veprim nuk mund të bëhet në këtë faqe.",
        "cannotdelete": "Faqja ose skeda $1 nuk mund të fshihej.\nMund të jetë fshirë nga dikush tjetër.",
        "cannotdelete-title": "Faqja \"$1\" nuk mund të fshihet",
+       "delete-scheduled": "Faqja \"$1\" është përcaktuar për fshirje.\n\nJu lutemi, kini durim.",
        "delete-hook-aborted": "Fshirja u anulua nga togëza.\nNuk jipet shpjegim.",
        "no-null-revision": "I pamundur krijimi rishikimi  i ri për faqen bosh \"$ 1\"",
        "badtitle": "Titull i pasaktë",
        "cascadeprotected": "Kjo faqe është mbrojtur nga redaktimi pasi që është përfshirë në {{PLURAL:$1|faqen|faqet}} e mëposhtme që {{PLURAL:$1|është|janë}} mbrojtur sipas metodës \"cascading\":\n$2",
        "namespaceprotected": "Nuk ju lejohet redaktimi i faqeve në hapsirën '''$1'''.",
        "customcssprotected": "Ju nuk keni leje për të redaktuar këtë faqe CSS, sepse ai përmban cilësimet personale tjetër user's.",
+       "customjsonprotected": "Ju nuk keni leje për ta redaktuar këtë faqe JSON sepse përmban të dhënat personale të një përdoruesi tjetër.",
        "customjsprotected": "Ju nuk keni leje për të redaktuar këtë faqe JavaScript, sepse ai përmban cilësimet personale tjetër user's.",
+       "sitecssprotected": "Ju nuk keni leje ta redaktoni këtë faqe CSS sepse ndryshimi mund të prekë të gjithë vizitorët.",
+       "sitejsonprotected": "Ju nuk keni leje ta redaktoni këtë faqe JSON sepse ndryshimi mund të prekë të gjithë vizitorët.",
+       "sitejsprotected": "Ju nuk keni leje ta redaktoni këtë faqe JavaScript sepse ndryshimi mund të prekë të gjithë vizitorët.",
        "mycustomcssprotected": "Ju nuk keni leje për të redaktuar këtë faqe CSS.",
+       "mycustomjsonprotected": "Ju nuk keni leje ta redaktoni këtë faqe JSON.",
        "mycustomjsprotected": "Ju nuk keni leje për të redaktuar këtë   faqe JavaScript .",
        "myprivateinfoprotected": "Ti nuk ke leje për të redaktuar të dhënat e tua private.",
        "mypreferencesprotected": "Ti nuk ke leje për të ndryshuar preferencat e tua.",
        "ns-specialprotected": "Faqet speciale nuk mund të redaktohen.",
        "titleprotected": "Ky titull është mbrojtur nga [[User:$1|$1]] dhe nuk mund të krijohet.\nArsyeja e dhënë është <em>$2</em>.",
        "filereadonlyerror": "Nuk është në gjendje që të ndryshojë skedarin \"$1\" sepse depoja e skedarit \"$2\" është në formën vetëm-lexim.\n\nAdministratori sistemit i cili e mbylli atë e dha këtë shpjegim: \"$3\".",
+       "invalidtitle": "Titull i pavlefshëm",
        "invalidtitle-knownnamespace": "Titull jo i vlefshëm me hapësirën \"$2\" dhe teksti \"$3\"",
        "invalidtitle-unknownnamespace": "Titull jo i vlefshëm me numrin e panjohur të hapësirës së emrit $1 dhe tekstit \"$2\"",
        "exception-nologin": "I paqasur",
        "userpage-userdoesnotexist": "Llogaria e përdoruesit \"<nowiki>$1</nowiki>\" nuk është e regjistruar. \nJu lutem kontrolloni nëse dëshironi të krijoni/redaktoni këtë faqe.",
        "userpage-userdoesnotexist-view": "Llogaria i përdoruesit \"$1\" nuk është e regjistruar.",
        "blocked-notice-logextract": "Ky përdorues është  aktualisht i bllokuar.\nMë poshtë mund t'i referoheni shënimit të regjistruar për bllokimin e fundit:",
-       "clearyourcache": "<strong>Shënim:</strong> Pas ruajtjes, juve mund t'iu duhet të anashkaloni \"cache-in\" e shfletuesit tuaj për të parë ndryshimet. \n* <strong>Firefox/Safari:</strong> Mbaj të shtypur <em>Shift</em> ndërkohë që klikon <em>Reload</em>, ose shtyp <em>Ctrl-F5</strong> ose <em>Ctrl-R</em> (<em>⌘-R</em> në Mac)\n* <strong>Google Chrome:</strong> Shtyp <em>Ctrl-Shift-R</em> (<strong>'⌘-R</strong>' në Mac)\n* <strong>Internet Explorer:</strong> Mbaj të shtypur <em>Ctrl</em>  ndërkohë që klikon <em>Refresh</em>, ose shtyp <em>Ctrl-F5</em> \n* <strong>Opera:</strong> Shkoni në <em>Menu → Settings</em> (<em>Opera → Preferences</em> në Mac) dhe pastaj në <em>Privacy & security → Clear browsing data → Cached images and files</em>.",
+       "clearyourcache": "<strong>Shënim:</strong> Pas ruajtjes, mund t'iu duhet të pastroni kashenë e shfletuesit tuaj për të parë ndryshimet. \n* <strong>Firefox/Safari:</strong> Mbaj të shtypur <em>Shift</em> ndërkohë që shtyp <em>Reload</em>, ose shtyp <em>Ctrl-F5</strong> ose <em>Ctrl-R</em> (<em>⌘-R</em> në Mac).\n* <strong>Google Chrome:</strong> Shtyp <em>Ctrl-Shift-R</em> (<strong>'⌘-R</strong>' në Mac).\n* <strong>Internet Explorer:</strong> Mbaj të shtypur <em>Ctrl</em>  ndërkohë që shtyp <em>Refresh</em>, ose shtyp <em>Ctrl-F5</em>. \n* <strong>Opera:</strong> Shkoni në <em>Menu → Settings</em> (<em>Opera → Preferences</em> në Mac) dhe pastaj në <em>Privacy & security → Clear browsing data → Cached images and files</em>.",
        "usercssyoucanpreview": "'''Këshillë:''' Përdorni butonin '{{int:showpreview}}' për të testuar CSS-në e re para se të ruani ndryshimet e kryera.",
        "userjsyoucanpreview": "'''Këshillë:''' Përdorni butonin '{{int:showpreview}}' për të testuar JavaScripting e ri para se të ruani ndryshimet e kryera.",
        "usercsspreview": "<strong>Vini re! Ju jeni duke inspektuar CSS-në si përdorues!\nNuk është ruajtur ende!</strong>",
index 1b8aa35..6872a0f 100644 (file)
        "history": "Историја странице",
        "history_short": "Историја",
        "history_small": "историја",
-       "updatedmarker": "ажÑ\83Ñ\80иÑ\80ано Ð¾Ð´ Ð¼Ð¾Ñ\98е последње посете",
+       "updatedmarker": "ажÑ\83Ñ\80иÑ\80ано Ð¾Ð´ Ð²Ð°Ñ\88е последње посете",
        "printableversion": "Верзија за штампање",
        "permalink": "Трајна веза",
        "print": "Штампање",
        "userlogout": "Одјава",
        "notloggedin": "Нисте пријављени",
        "userlogin-noaccount": "Немате налог?",
-       "userlogin-joinproject": "Придружите се пројекту {{SITENAME}}",
+       "userlogin-joinproject": "Придружите се пројекту",
        "createaccount": "Отварање налога",
        "userlogin-resetpassword-link": "Заборавили сте лозинку?",
        "userlogin-helplink2": "Помоћ при пријављивању",
index f2532a2..af4bd1e 100644 (file)
        "restrictionsfield-help": "En IP-adress eller CIDR-intervall per rad. För att aktivera allting, använd<br /><code>0.0.0.0/0</code><br /><code>::/0</code>",
        "edit-error-short": "Fel: $1",
        "edit-error-long": "Fel:\n\n$1",
+       "specialmute": "Tyst",
+       "specialmute-success": "Dina tystnadsinställningar har uppdateras. Se alla tystade användare i [[Special:Preferences|inställningarna]].",
+       "specialmute-submit": "Bekräfta",
+       "specialmute-label-mute-email": "Tysta e-post från denna användare",
+       "specialmute-header": "Välj dina tystnadsinställningar för {{BIDI:[[User:$1]]}}.",
+       "specialmute-error-invalid-user": "Det begärda användarnamnet kunde inte hittas.",
+       "specialmute-error-email-blacklist-disabled": "Att förhindra användare från att skicka e-post till dig har inte aktiverats.",
+       "specialmute-error-email-preferences": "Du måste bekräfta din e-postadress innan du kan tysta en användare. Du kan göra det i [[Special:Preferences|inställningarna]].",
+       "specialmute-email-footer": "[$1 Hantera e-postinställningar för {{BIDI:$2}}.]",
+       "specialmute-login-required": "Logga in för att ändra dina tystnadsinställningar.",
        "revid": "sidversion $1",
        "pageid": "sid-ID $1",
        "interfaceadmin-info": "$1\n\nBehörigheter för att redigera CSS/JS/JSON-filer för hela webbplatsen separerades nyligen från rättigheten <code>editinterface</code>. Om du inte förstår varför du får detta felmeddelande, se [[mw:MediaWiki_1.32/interface-admin]].",
index 6a68b4a..b89b619 100644 (file)
        "blocklist-userblocks": "Hesap engellemelerini gizle",
        "blocklist-tempblocks": "Geçici engellemeleri gizle",
        "blocklist-addressblocks": "Tek IP engellemelerini gizle",
+       "blocklist-type": "Tür:",
+       "blocklist-type-opt-all": "Hepsi",
+       "blocklist-type-opt-sitewide": "Site genelinde",
+       "blocklist-type-opt-partial": "Kısmi",
        "blocklist-rangeblocks": "Dizi bloklarını gizle",
        "blocklist-timestamp": "Tarih",
        "blocklist-target": "Hedef",
index 27e1acf..1b0da39 100644 (file)
        "accmailtext": "「[[User talk:$1|$1]]」嘅隨機產生密碼已經寄咗去 $2。\n\n呢個密碼可以響簽到咗之後嘅<em>[[Special:ChangePassword|改密碼]]</em> 版度改佢。",
        "newarticle": "(新)",
        "newarticletext": "你連連過嚟嘅頁面重未存在。\n要起版新嘅,請你喺下面嗰格度輸入。(睇睇[$1 自助版]拎多啲資料。)\n如果你係唔覺意嚟到呢度,撳一次你個瀏覽器'''返轉頭'''個掣。",
-       "anontalkpagetext": "----\n<em>呢度係匿名用戶嘅討論頁,佢可能係重未開戶口,或者佢重唔識開戶口。</em>\n我哋會用數字表示嘅IP地址嚟代表佢。\n一個IP地址係可以由幾個用戶夾來用。\n如果你係匿名用戶,同覺得呢啲留言係同你冇關係嘅話,唔該去[[Special:CreateAccount|開一個新戶口]]或[[Special:UserLogin|登入]],避免喺以後嘅留言會同埋其它用戶混淆。",
+       "anontalkpagetext": "----\n<em>呢度係位匿名用戶嘅討論頁,佢可能係重未開戶口,或者佢唔想開戶口。</em>\n因此,我哋會用數字表示嘅IP地址嚟代表佢。\n一個IP地址係可以由幾個用戶夾來用。\n如果你係匿名用戶,同覺得呢啲留言係同你冇關係嘅話,唔該去[[Special:CreateAccount|開一個新戶口]]或[[Special:UserLogin|登入]],避免喺以後嘅留言會同埋其它用戶混淆。",
        "noarticletext": "喺呢一頁而家並冇任何嘅文字,你可以喺其它嘅頁面中[[Special:Search/{{PAGENAME}}|搵呢一頁嘅標題]],\n<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} 搵有關嘅日誌],\n或者[{{fullurl:{{FULLPAGENAME}}|action=edit}} 編輯呢一版]</span>。",
        "noarticletext-nopermission": "呢一頁而家冇任何文字,你可以喺其它嘅頁面中[[Special:Search/{{PAGENAME}}|搵呢一頁嘅標題]],或者<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} 搵有關嘅日誌]</span>。",
        "missing-revision": "The revision #$1 of the page named \"{{FULLPAGENAME}}\" does not exist.\n\nThis is usually caused by following an outdated history link to a page that has been deleted.\nDetails can be found in the [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} deletion log].\n\n《{{FULLPAGENAME}}》嘅編輯#$1唔存在。\n\n恁通常係因為一條過徂時嘅鏈接帶徂閣下去一個已經刪除徂嘅版。\n詳情請查閱[{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} 刪文紀錄]。",
index c5f1879..c872c4b 100644 (file)
                        "94rain",
                        "Viztor",
                        "Ps2049",
-                       "Suchichi02"
+                       "Suchichi02",
+                       "神樂坂秀吉"
                ]
        },
        "tog-underline": "链接下划线:",
        "history": "页面历史",
        "history_short": "历史",
        "history_small": "历史",
-       "updatedmarker": "æ\9b´æ\96°äº\8eæ\88\91上次访问后",
+       "updatedmarker": "æ\9b´æ\96°äº\8eæ\82¨上次访问后",
        "printableversion": "可打印版本",
        "permalink": "固定链接",
        "print": "打印",
        "autoblockedtext": "您的IP地址因曾被一位被$1封禁的用户使用而被自动封禁。封禁原因:\n\n:<em>$2</em>\n\n* 开始时间:$8\n* 到期时间:$6\n* 目标用户:$7\n\n您可以联系$1或其他[[{{MediaWiki:Grouppage-sysop}}|管理员]]申诉该封禁。\n\n请注意,只有当您已在[[Special:Preferences|系统设置]]确认了电子邮件地址且未被禁止使用“{{int:emailuser}}”功能时,才能发送电子邮件联系管理员。\n\n您当前的IP地址为$3,该封禁ID为#$5。请在您做出的任何查询中包含所有上述详情。",
        "systemblockedtext": "您的用户名或IP地址已被MediaWiki自动封禁。封禁原因:\n\n:<em>$2</em>\n\n* 开始时间:$8\n* 到期时间:$6\n* 目标用户:$7\n\n您当前的IP地址是$3。请在您做出的任何查询中包含所有上述详情。",
        "blockednoreason": "未给出原因",
+       "blockedtext-composite": "您的用户名或IP地址已被封禁。封禁原因:\n\n:<em>$2</em>\n\n* 开始时间:$8\n* 到期时间:$6\n\n您当前的IP地址是$3。请在您做出的任何查询中包含所有上述详情。",
        "whitelistedittext": "请$1以编辑页面。",
        "confirmedittext": "您必须确认您的电子邮件地址才能编辑页面。请通过[[Special:Preferences|系统设置]]设置并确认您的电子邮件地址。",
        "nosuchsectiontitle": "没有这个段落",
        "restrictionsfield-help": "每行一个IP地址或CIDR段。要启用任何地址或地址段,可使用:<pre>0.0.0.0/0\n::/0</pre>",
        "edit-error-short": "错误:$1",
        "edit-error-long": "错误:\n\n$1",
+       "specialmute": "屏蔽",
+       "specialmute-submit": "确认",
        "revid": "修订版本$1",
        "pageid": "页面ID$1",
        "interfaceadmin-info": "$1\n\n编辑全站CSS/JS/JSON文件的权限刚刚从<code>editinterface</code>权限中拆分。如果您不知道为何收到此错误,请参见[[mw:MediaWiki_1.32/interface-admin]]。",
index a902397..6d5dda1 100644 (file)
@@ -82,7 +82,7 @@ class FindHooks extends Maintenance {
                        "$IP/",
                ];
                $extraFiles = [
-                       "$IP/tests/phpunit/MediaWikiTestCase.php",
+                       "$IP/tests/phpunit/MediaWikiIntegrationTestCase.php",
                ];
 
                foreach ( $recurseDirs as $dir ) {
index eaed7ed..b37fec1 100644 (file)
@@ -281,7 +281,7 @@ TEXT
                $this->finalOptionCheck();
 
                // we only want this so we know how to close a stream :-P
-               $this->xmlwriterobj = new XmlDumpWriter();
+               $this->xmlwriterobj = new XmlDumpWriter( XmlDumpWriter::WRITE_CONTENT, $this->schemaVersion );
 
                $input = fopen( $this->input, "rt" );
                $this->readDump( $input );
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644 (file)
index 0000000..e160f3b
--- /dev/null
@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit bootstrap="tests/phpunit/bootstrap.php"
+                xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+                xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.8/phpunit.xsd"
+
+                colors="true"
+                backupGlobals="false"
+                convertErrorsToExceptions="true"
+                convertNoticesToExceptions="true"
+                convertWarningsToExceptions="true"
+                forceCoversAnnotation="true"
+                stopOnFailure="false"
+                timeoutForSmallTests="10"
+                timeoutForMediumTests="30"
+                timeoutForLargeTests="60"
+                beStrictAboutTestsThatDoNotTestAnything="true"
+                beStrictAboutOutputDuringTests="true"
+                beStrictAboutTestSize="true"
+                verbose="false">
+       <php>
+               <ini name="memory_limit" value="512M" />
+       </php>
+       <testsuites>
+               <testsuite name="unit">
+                       <directory>tests/phpunit/unit</directory>
+               </testsuite>
+               <testsuite name="integration">
+                       <directory>tests/phpunit/integration</directory>
+               </testsuite>
+       </testsuites>
+       <groups>
+               <exclude>
+                       <group>Broken</group>
+               </exclude>
+       </groups>
+       <filter>
+               <whitelist addUncoveredFilesFromWhitelist="true">
+                       <directory suffix=".php">includes</directory>
+                       <directory suffix=".php">languages</directory>
+                       <directory suffix=".php">maintenance</directory>
+                       <exclude>
+                               <directory suffix=".php">languages/messages</directory>
+                               <file>languages/data/normalize-ar.php</file>
+                               <file>languages/data/normalize-ml.php</file>
+                       </exclude>
+               </whitelist>
+       </filter>
+       <listeners>
+               <listener class="JohnKary\PHPUnit\Listener\SpeedTrapListener">
+                       <arguments>
+                               <array>
+                                       <element key="slowThreshold">
+                                               <integer>50</integer>
+                                       </element>
+                                       <element key="reportLength">
+                                               <integer>50</integer>
+                                       </element>
+                               </array>
+                       </arguments>
+               </listener>
+       </listeners>
+</phpunit>
index c1b83fd..d34b06c 100644 (file)
                                }
                                $thead.append( this );
                        } );
-                       $table.find( ' > tbody:first' ).before( $thead );
+                       $table.find( ' > tbody' ).first().before( $thead );
                }
                if ( !$table.get( 0 ).tFoot ) {
                        $tfoot = $( '<tfoot>' );
                        headerIndex,
                        exploded,
                        $tableHeaders = $( [] ),
-                       $tableRows = $( 'thead:eq(0) > tr', table );
+                       $tableRows = $( table ).find( 'thead' ).eq( 0 ).find( '> tr' );
 
                if ( $tableRows.length <= 1 ) {
                        $tableHeaders = $tableRows.children( 'th' );
        }
 
        function sortText( a, b ) {
-               return ( ( a < b ) ? -1 : ( ( a > b ) ? 1 : 0 ) );
+               return ts.collator.compare( a, b );
        }
 
-       function sortTextDesc( a, b ) {
-               return ( ( b < a ) ? -1 : ( ( b > a ) ? 1 : 0 ) );
+       function sortNumeric( a, b ) {
+               return ( ( a < b ) ? -1 : ( ( a > b ) ? 1 : 0 ) );
        }
 
        function multisort( table, sortList, cache ) {
                var i,
-                       sortFn = [];
+                       sortFn = [],
+                       parsers = $( table ).data( 'tablesorter' ).config.parsers;
 
                for ( i = 0; i < sortList.length; i++ ) {
-                       sortFn[ i ] = ( sortList[ i ][ 1 ] ) ? sortTextDesc : sortText;
+                       // Android doesn't support Intl.Collator
+                       if ( window.Intl && Intl.Collator && parsers[ sortList[ i ][ 0 ] ].type === 'text' ) {
+                               sortFn[ i ] = sortText;
+                       } else {
+                               sortFn[ i ] = sortNumeric;
+                       }
                }
                cache.normalized.sort( function ( array1, array2 ) {
                        var i, col, ret;
                        for ( i = 0; i < sortList.length; i++ ) {
                                col = sortList[ i ][ 0 ];
-                               ret = sortFn[ i ].call( this, array1[ col ], array2[ col ] );
+                               if ( sortList[ i ][ 1 ] ) {
+                                       // descending
+                                       ret = sortFn[ i ].call( this, array2[ col ], array1[ col ] );
+                               } else {
+                                       // ascending
+                                       ret = sortFn[ i ].call( this, array1[ col ], array2[ col ] );
+                               }
                                if ( ret !== 0 ) {
                                        return ret;
                                }
                }
        }
 
-       function buildCollationTable() {
+       function buildCollation() {
                var key, keys = [];
                ts.collationTable = mw.config.get( 'tableSorterCollation' );
                ts.collationRegex = null;
                                ts.collationRegex = new RegExp( keys.join( '|' ), 'ig' );
                        }
                }
+               if ( window.Intl && Intl.Collator ) {
+                       ts.collator = new Intl.Collator( [
+                               mw.config.get( 'wgPageContentLanguage' ),
+                               mw.config.get( 'wgUserLanguage' )
+                       ], {
+                               numeric: true
+                       } );
+               }
        }
 
        function cacheRegexs() {
                                        // may customize tableSorterCollation but load after $.ready(), other
                                        // scripts may call .tablesorter() before they have done the
                                        // tableSorterCollation customizations.
-                                       buildCollationTable();
+                                       buildCollation();
 
                                        // Legacy fix of .sortbottoms
                                        // Wrap them inside a tfoot (because that's what they actually want to be)
                        buildTransformTable();
                        buildDateTable();
                        cacheRegexs();
-                       buildCollationTable();
+                       buildCollation();
 
                        return getParserById( id );
                },
                },
                format: function ( s ) {
                        var tsc;
-                       s = s.toLowerCase().trim();
+                       s = s.trim();
                        if ( ts.collationRegex ) {
                                tsc = ts.collationTable;
                                s = s.replace( ts.collationRegex, function ( match ) {
-                                       var r = tsc[ match ] ? tsc[ match ] : tsc[ match.toUpperCase() ];
-                                       return r.toLowerCase();
+                                       var r,
+                                               upper = match.toUpperCase(),
+                                               lower = match.toLowerCase();
+                                       if ( upper === match && !lower === match ) {
+                                               r = tsc[ lower ] ? tsc[ lower ] : tsc[ upper ];
+                                               r = r.toUpperCase();
+                                       } else {
+                                               r = tsc[ match.toLowerCase() ];
+                                       }
+                                       return r;
                                } );
                        }
                        return s;
index 3e4081a..b9c13f0 100644 (file)
                                                }
                                        } else {
                                                // The toggle-link will be in one of the cells (td or th) of the first row
-                                               $firstItem = $collapsible.find( 'tr:first th, tr:first td' );
+                                               $firstItem = $collapsible.find( 'tr' ).first().find( 'th, td' );
                                                $toggle = $firstItem.find( '> .mw-collapsible-toggle' );
 
                                                // If theres no toggle link, add it to the last cell
                                        $collapsible.before( $toggle );
                                } else if ( $collapsible.is( 'ul' ) || $collapsible.is( 'ol' ) ) {
                                        // The toggle-link will be in the first list-item
-                                       $firstItem = $collapsible.find( 'li:first' );
+                                       $firstItem = $collapsible.find( 'li' ).first();
                                        $toggle = $firstItem.find( '> .mw-collapsible-toggle' );
 
                                        // If theres no toggle link, add it
index 3083b0f..5111295 100644 (file)
                if ( !result.get || selected.get( 0 ) !== result.get( 0 ) ) {
                        if ( result === 'prev' ) {
                                if ( selected.hasClass( 'suggestions-special' ) ) {
-                                       result = context.data.$container.find( '.suggestions-result:last' );
+                                       result = context.data.$container.find( '.suggestions-result' ).last();
                                } else {
                                        result = selected.prev();
                                        if ( !( result.length && result.hasClass( 'suggestions-result' ) ) ) {
                                                if ( context.data.$container.find( '.suggestions-special' ).html() !== '' ) {
                                                        result = context.data.$container.find( '.suggestions-special' );
                                                } else {
-                                                       result = context.data.$container.find( '.suggestions-results .suggestions-result:last' );
+                                                       result = context.data.$container.find( '.suggestions-results .suggestions-result' ).last();
                                                }
                                        }
                                }
                        } else if ( result === 'next' ) {
                                if ( selected.length === 0 ) {
                                        // No item selected, go to the first one
-                                       result = context.data.$container.find( '.suggestions-results .suggestions-result:first' );
+                                       result = context.data.$container.find( '.suggestions-results .suggestions-result' ).first();
                                        if ( result.length === 0 && context.data.$container.find( '.suggestions-special' ).html() !== '' ) {
                                                // No suggestion exists, go to the special one directly
                                                result = context.data.$container.find( '.suggestions-special' );
index 259febc..3084e12 100644 (file)
@@ -609,7 +609,7 @@ Title.newFromFileName = function ( uncleanName ) {
 /**
  * Get the file title from an image element
  *
- *     var title = mw.Title.newFromImg( $( 'img:first' ) );
+ *     var title = mw.Title.newFromImg( imageNode );
  *
  * @static
  * @param {HTMLElement|jQuery} img The image to use as a base
index af4b897..3af8222 100644 (file)
                // can change where they are output).
 
                if ( !document.getElementById( 'p-lang' ) && document.getElementById( 'p-tb' ) && mw.config.get( 'skin' ) === 'vector' ) {
-                       $( '.portal:last' ).after(
+                       $( '.portal' ).last().after(
                                $( '<div>' ).attr( {
                                        class: 'portal',
                                        id: 'p-lang',
index 6988576..1c4824f 100644 (file)
                getExpiryInputs().on( 'input change', updateExpiry );
                getLevelSelectors().on( 'change', updateLevels );
 
-               $( '#mwProtectSet > tbody > tr:first' ).after( $row );
+               $( '#mwProtectSet > tbody > tr' ).first().after( $row );
 
                // If there is only one protection type, there is nothing to chain
                if ( $( '[id ^= mw-protect-table-]' ).length > 1 ) {
index 0ffc867..7d098e6 100644 (file)
@@ -45,8 +45,8 @@
                                // Note that if we do have a real image, using this method will generally
                                // give the same answer, but can be different in the case of a very
                                // narrow image where extra padding is added.
-                               imgHeight = $this.children().children( 'div:first' ).height();
-                               imgWidth = $this.children().children( 'div:first' ).width();
+                               imgHeight = $this.children().children( 'div' ).first().height();
+                               imgWidth = $this.children().children( 'div' ).first().width();
                        }
 
                        // Hack to make an edge case work ok
index fff2d4e..2469381 100644 (file)
@@ -9,7 +9,7 @@
                        originalText = $emailLabel.text(),
                        requiredText = mw.message( 'createacct-emailrequired' ).text(),
                        $createByMailCheckbox = $( '#wpCreateaccountMail' ),
-                       $beforePwds = $( '.mw-row-password:first' ).prev(),
+                       $beforePwds = $( '.mw-row-password' ).first().prev(),
                        $pwds;
 
                function updateForCheckbox() {
index e574568..c1066f2 100644 (file)
@@ -13,7 +13,7 @@
 
                        // Hide/show the table of contents element
                        function toggleToc() {
-                               if ( $tocList.is( ':hidden' ) ) {
+                               if ( $this.hasClass( 'tochidden' ) ) {
                                        // FIXME: Use CSS transitions
                                        // eslint-disable-next-line no-jquery/no-slide
                                        $tocList.slideDown( 'fast' );
index b53b58f..0121f37 100644 (file)
                                                                e.keyCode === OO.ui.Keys.UP ? -1 : 1, 'wrap' )
                                                );
                                        }
-                                       if ( $field.is( ':input' ) ) {
+                                       if ( $field.is( 'input' ) ) {
                                                $field.trigger( 'select' );
                                        }
                                        return false;
                        if ( this.getValueAsDate() === null ) {
                                this.setValue( this.formatter.getDefaultDate() );
                        }
-                       if ( $field.is( ':input' ) ) {
+                       if ( $field.is( 'input' ) ) {
                                $field.trigger( 'select' );
                        }
 
index 8b6c6d5..e1dde22 100644 (file)
@@ -59,8 +59,9 @@ $wgAutoloadClasses += [
        'MediaWikiPHPUnitCommand' => "$testDir/phpunit/MediaWikiPHPUnitCommand.php",
        'MediaWikiPHPUnitResultPrinter' => "$testDir/phpunit/MediaWikiPHPUnitResultPrinter.php",
        'MediaWikiPHPUnitTestListener' => "$testDir/phpunit/MediaWikiPHPUnitTestListener.php",
-       'MediaWikiTestCase' => "$testDir/phpunit/MediaWikiTestCase.php",
+       'MediaWikiTestCase' => "$testDir/phpunit/MediaWikiIntegrationTestCase.php",
        'MediaWikiUnitTestCase' => "$testDir/phpunit/MediaWikiUnitTestCase.php",
+       'MediaWikiIntegrationTestCase' => "$testDir/phpunit/MediaWikiIntegrationTestCase.php",
        'MediaWikiTestResult' => "$testDir/phpunit/MediaWikiTestResult.php",
        'MediaWikiTestRunner' => "$testDir/phpunit/MediaWikiTestRunner.php",
        'PHPUnit4And6Compat' => "$testDir/phpunit/PHPUnit4And6Compat.php",
diff --git a/tests/phpunit/MediaWikiIntegrationTestCase.php b/tests/phpunit/MediaWikiIntegrationTestCase.php
new file mode 100644 (file)
index 0000000..999ba47
--- /dev/null
@@ -0,0 +1,2546 @@
+<?php
+
+use MediaWiki\Logger\LegacySpi;
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\Logger\MonologSpi;
+use MediaWiki\Logger\LogCapturingSpi;
+use MediaWiki\MediaWikiServices;
+use Psr\Log\LoggerInterface;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\IMaintainableDatabase;
+use Wikimedia\Rdbms\Database;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @since 1.18
+ *
+ * Extend this class if you are testing classes which access global variables, methods, services
+ * or a storage backend.
+ *
+ * Consider using MediaWikiUnitTestCase and mocking dependencies if your code uses dependency
+ * injection and does not access any globals.
+ */
+abstract class MediaWikiIntegrationTestCase extends PHPUnit\Framework\TestCase {
+
+       use MediaWikiCoversValidator;
+       use PHPUnit4And6Compat;
+
+       /**
+        * The original service locator. This is overridden during setUp().
+        *
+        * @var MediaWikiServices|null
+        */
+       private static $originalServices;
+
+       /**
+        * The local service locator, created during setUp().
+        * @var MediaWikiServices
+        */
+       private $localServices;
+
+       /**
+        * $called tracks whether the setUp and tearDown method has been called.
+        * class extending MediaWikiTestCase usually override setUp and tearDown
+        * but forget to call the parent.
+        *
+        * The array format takes a method name as key and anything as a value.
+        * By asserting the key exist, we know the child class has called the
+        * parent.
+        *
+        * This property must be private, we do not want child to override it,
+        * they should call the appropriate parent method instead.
+        */
+       private $called = [];
+
+       /**
+        * @var TestUser[]
+        * @since 1.20
+        */
+       public static $users;
+
+       /**
+        * Primary database
+        *
+        * @var Database
+        * @since 1.18
+        */
+       protected $db;
+
+       /**
+        * @var array
+        * @since 1.19
+        */
+       protected $tablesUsed = []; // tables with data
+
+       private static $useTemporaryTables = true;
+       private static $reuseDB = false;
+       private static $dbSetup = false;
+       private static $oldTablePrefix = '';
+
+       /**
+        * Original value of PHP's error_reporting setting.
+        *
+        * @var int
+        */
+       private $phpErrorLevel;
+
+       /**
+        * Holds the paths of temporary files/directories created through getNewTempFile,
+        * and getNewTempDirectory
+        *
+        * @var array
+        */
+       private $tmpFiles = [];
+
+       /**
+        * Holds original values of MediaWiki configuration settings
+        * to be restored in tearDown().
+        * See also setMwGlobals().
+        * @var array
+        */
+       private $mwGlobals = [];
+
+       /**
+        * Holds list of MediaWiki configuration settings to be unset in tearDown().
+        * See also setMwGlobals().
+        * @var array
+        */
+       private $mwGlobalsToUnset = [];
+
+       /**
+        * Holds original values of ini settings to be restored
+        * in tearDown().
+        * @see setIniSettings()
+        * @var array
+        */
+       private $iniSettings = [];
+
+       /**
+        * Holds original loggers which have been replaced by setLogger()
+        * @var LoggerInterface[]
+        */
+       private $loggers = [];
+
+       /**
+        * The CLI arguments passed through from phpunit.php
+        * @var array
+        */
+       private $cliArgs = [];
+
+       /**
+        * Holds a list of services that were overridden with setService().  Used for printing an error
+        * if overrideMwServices() overrides a service that was previously set.
+        * @var string[]
+        */
+       private $overriddenServices = [];
+
+       /**
+        * Table name prefixes. Oracle likes it shorter.
+        */
+       const DB_PREFIX = 'unittest_';
+       const ORA_DB_PREFIX = 'ut_';
+
+       /**
+        * @var array
+        * @since 1.18
+        */
+       protected $supportedDBs = [
+               'mysql',
+               'sqlite',
+               'postgres',
+               'oracle'
+       ];
+
+       public function __construct( $name = null, array $data = [], $dataName = '' ) {
+               parent::__construct( $name, $data, $dataName );
+
+               $this->backupGlobals = false;
+               $this->backupStaticAttributes = false;
+       }
+
+       public function __destruct() {
+               // Complain if self::setUp() was called, but not self::tearDown()
+               // $this->called['setUp'] will be checked by self::testMediaWikiTestCaseParentSetupCalled()
+               if ( isset( $this->called['setUp'] ) && !isset( $this->called['tearDown'] ) ) {
+                       throw new MWException( static::class . "::tearDown() must call parent::tearDown()" );
+               }
+       }
+
+       private static function initializeForStandardPhpunitEntrypointIfNeeded() {
+               if ( function_exists( 'wfRequireOnceInGlobalScope' ) ) {
+                       $IP = realpath( __DIR__ . '/../..' );
+                       wfRequireOnceInGlobalScope( "$IP/includes/Defines.php" );
+                       wfRequireOnceInGlobalScope( "$IP/includes/DefaultSettings.php" );
+                       wfRequireOnceInGlobalScope( "$IP/includes/GlobalFunctions.php" );
+                       wfRequireOnceInGlobalScope( "$IP/includes/Setup.php" );
+                       wfRequireOnceInGlobalScope( "$IP/tests/common/TestsAutoLoader.php" );
+                       TestSetup::applyInitialConfig();
+               }
+       }
+
+       public static function setUpBeforeClass() {
+               parent::setUpBeforeClass();
+               \PHPUnit\Framework\Assert::assertFileExists( 'LocalSettings.php' );
+               self::initializeForStandardPhpunitEntrypointIfNeeded();
+
+               // Get the original service locator
+               if ( !self::$originalServices ) {
+                       self::$originalServices = MediaWikiServices::getInstance();
+               }
+       }
+
+       /**
+        * Convenience method for getting an immutable test user
+        *
+        * @since 1.28
+        *
+        * @param string|string[] $groups Groups the test user should be in.
+        * @return TestUser
+        */
+       public static function getTestUser( $groups = [] ) {
+               return TestUserRegistry::getImmutableTestUser( $groups );
+       }
+
+       /**
+        * Convenience method for getting a mutable test user
+        *
+        * @since 1.28
+        *
+        * @param string|string[] $groups Groups the test user should be added in.
+        * @return TestUser
+        */
+       public static function getMutableTestUser( $groups = [] ) {
+               return TestUserRegistry::getMutableTestUser( __CLASS__, $groups );
+       }
+
+       /**
+        * Convenience method for getting an immutable admin test user
+        *
+        * @since 1.28
+        *
+        * @param string[] $groups Groups the test user should be added to.
+        * @return TestUser
+        */
+       public static function getTestSysop() {
+               return self::getTestUser( [ 'sysop', 'bureaucrat' ] );
+       }
+
+       /**
+        * Returns a WikiPage representing an existing page.
+        *
+        * @since 1.32
+        *
+        * @param Title|string|null $title
+        * @return WikiPage
+        * @throws MWException If this test cases's needsDB() method doesn't return true.
+        *         Test cases can use "@group Database" to enable database test support,
+        *         or list the tables under testing in $this->tablesUsed, or override the
+        *         needsDB() method.
+        */
+       protected function getExistingTestPage( $title = null ) {
+               if ( !$this->needsDB() ) {
+                       throw new MWException( 'When testing which pages, the test cases\'s needsDB()' .
+                               ' method should return true. Use @group Database or $this->tablesUsed.' );
+               }
+
+               $title = ( $title === null ) ? 'UTPage' : $title;
+               $title = is_string( $title ) ? Title::newFromText( $title ) : $title;
+               $page = WikiPage::factory( $title );
+
+               if ( !$page->exists() ) {
+                       $user = self::getTestSysop()->getUser();
+                       $page->doEditContent(
+                               new WikitextContent( 'UTContent' ),
+                               'UTPageSummary',
+                               EDIT_NEW | EDIT_SUPPRESS_RC,
+                               false,
+                               $user
+                       );
+               }
+
+               return $page;
+       }
+
+       /**
+        * Returns a WikiPage representing a non-existing page.
+        *
+        * @since 1.32
+        *
+        * @param Title|string|null $title
+        * @return WikiPage
+        * @throws MWException If this test cases's needsDB() method doesn't return true.
+        *         Test cases can use "@group Database" to enable database test support,
+        *         or list the tables under testing in $this->tablesUsed, or override the
+        *         needsDB() method.
+        */
+       protected function getNonexistingTestPage( $title = null ) {
+               if ( !$this->needsDB() ) {
+                       throw new MWException( 'When testing which pages, the test cases\'s needsDB()' .
+                               ' method should return true. Use @group Database or $this->tablesUsed.' );
+               }
+
+               $title = ( $title === null ) ? 'UTPage-' . rand( 0, 100000 ) : $title;
+               $title = is_string( $title ) ? Title::newFromText( $title ) : $title;
+               $page = WikiPage::factory( $title );
+
+               if ( $page->exists() ) {
+                       $page->doDeleteArticle( 'Testing' );
+               }
+
+               return $page;
+       }
+
+       /**
+        * @deprecated since 1.32
+        */
+       public static function prepareServices( Config $bootstrapConfig ) {
+       }
+
+       /**
+        * Create a config suitable for testing, based on a base config, default overrides,
+        * and custom overrides.
+        *
+        * @param Config|null $baseConfig
+        * @param Config|null $customOverrides
+        *
+        * @return Config
+        */
+       private static function makeTestConfig(
+               Config $baseConfig = null,
+               Config $customOverrides = null
+       ) {
+               $defaultOverrides = new HashConfig();
+
+               if ( !$baseConfig ) {
+                       $baseConfig = self::$originalServices->getBootstrapConfig();
+               }
+
+               /* Some functions require some kind of caching, and will end up using the db,
+                * which we can't allow, as that would open a new connection for mysql.
+                * Replace with a HashBag. They would not be going to persist anyway.
+                */
+               $hashCache = [ 'class' => HashBagOStuff::class, 'reportDupes' => false ];
+               $objectCaches = [
+                               CACHE_DB => $hashCache,
+                               CACHE_ACCEL => $hashCache,
+                               CACHE_MEMCACHED => $hashCache,
+                               'apc' => $hashCache,
+                               'apcu' => $hashCache,
+                               'wincache' => $hashCache,
+                       ] + $baseConfig->get( 'ObjectCaches' );
+
+               $defaultOverrides->set( 'ObjectCaches', $objectCaches );
+               $defaultOverrides->set( 'MainCacheType', CACHE_NONE );
+               $defaultOverrides->set( 'JobTypeConf', [ 'default' => [ 'class' => JobQueueMemory::class ] ] );
+
+               // Use a fast hash algorithm to hash passwords.
+               $defaultOverrides->set( 'PasswordDefault', 'A' );
+
+               $testConfig = $customOverrides
+                       ? new MultiConfig( [ $customOverrides, $defaultOverrides, $baseConfig ] )
+                       : new MultiConfig( [ $defaultOverrides, $baseConfig ] );
+
+               return $testConfig;
+       }
+
+       /**
+        * @param ConfigFactory $oldFactory
+        * @param Config[] $configurations
+        *
+        * @return Closure
+        */
+       private static function makeTestConfigFactoryInstantiator(
+               ConfigFactory $oldFactory,
+               array $configurations
+       ) {
+               return function ( MediaWikiServices $services ) use ( $oldFactory, $configurations ) {
+                       $factory = new ConfigFactory();
+
+                       // clone configurations from $oldFactory that are not overwritten by $configurations
+                       $namesToClone = array_diff(
+                               $oldFactory->getConfigNames(),
+                               array_keys( $configurations )
+                       );
+
+                       foreach ( $namesToClone as $name ) {
+                               $factory->register( $name, $oldFactory->makeConfig( $name ) );
+                       }
+
+                       foreach ( $configurations as $name => $config ) {
+                               $factory->register( $name, $config );
+                       }
+
+                       return $factory;
+               };
+       }
+
+       /**
+        * Resets some non-service singleton instances and other static caches. It's not necessary to
+        * reset services here.
+        */
+       public static function resetNonServiceCaches() {
+               global $wgRequest, $wgJobClasses;
+
+               User::resetGetDefaultOptionsForTestsOnly();
+               foreach ( $wgJobClasses as $type => $class ) {
+                       JobQueueGroup::singleton()->get( $type )->delete();
+               }
+               JobQueueGroup::destroySingletons();
+
+               ObjectCache::clear();
+               FileBackendGroup::destroySingleton();
+               DeferredUpdates::clearPendingUpdates();
+
+               // TODO: move global state into MediaWikiServices
+               RequestContext::resetMain();
+               if ( session_id() !== '' ) {
+                       session_write_close();
+                       session_id( '' );
+               }
+
+               $wgRequest = new FauxRequest();
+               MediaWiki\Session\SessionManager::resetCache();
+       }
+
+       public function run( PHPUnit_Framework_TestResult $result = null ) {
+               if ( $result instanceof MediaWikiTestResult ) {
+                       $this->cliArgs = $result->getMediaWikiCliArgs();
+               }
+               $this->overrideMwServices();
+
+               if ( $this->needsDB() && !$this->isTestInDatabaseGroup() ) {
+                       throw new Exception(
+                               get_class( $this ) . ' apparently needsDB but is not in the Database group'
+                       );
+               }
+
+               $needsResetDB = false;
+               if ( !self::$dbSetup || $this->needsDB() ) {
+                       // set up a DB connection for this test to use
+
+                       self::$useTemporaryTables = !$this->getCliArg( 'use-normal-tables' );
+                       self::$reuseDB = $this->getCliArg( 'reuse-db' );
+
+                       $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
+                       $this->db = $lb->getConnection( DB_MASTER );
+
+                       $this->checkDbIsSupported();
+
+                       if ( !self::$dbSetup ) {
+                               $this->setupAllTestDBs();
+                               $this->addCoreDBData();
+                       }
+
+                       // TODO: the DB setup should be done in setUpBeforeClass(), so the test DB
+                       // is available in subclass's setUpBeforeClass() and setUp() methods.
+                       // This would also remove the need for the HACK that is oncePerClass().
+                       if ( $this->oncePerClass() ) {
+                               $this->setUpSchema( $this->db );
+                               $this->resetDB( $this->db, $this->tablesUsed );
+                               $this->addDBDataOnce();
+                       }
+
+                       $this->addDBData();
+                       $needsResetDB = true;
+               }
+
+               parent::run( $result );
+
+               // We don't mind if we override already-overridden services during cleanup
+               $this->overriddenServices = [];
+
+               if ( $needsResetDB ) {
+                       $this->resetDB( $this->db, $this->tablesUsed );
+               }
+
+               self::restoreMwServices();
+               $this->localServices = null;
+       }
+
+       /**
+        * @return bool
+        */
+       private function oncePerClass() {
+               // Remember current test class in the database connection,
+               // so we know when we need to run addData.
+
+               $class = static::class;
+
+               $first = !isset( $this->db->_hasDataForTestClass )
+                       || $this->db->_hasDataForTestClass !== $class;
+
+               $this->db->_hasDataForTestClass = $class;
+               return $first;
+       }
+
+       /**
+        * @since 1.21
+        *
+        * @return bool
+        */
+       public function usesTemporaryTables() {
+               return self::$useTemporaryTables;
+       }
+
+       /**
+        * Obtains a new temporary file name
+        *
+        * The obtained filename is enlisted to be removed upon tearDown
+        *
+        * @since 1.20
+        *
+        * @return string Absolute name of the temporary file
+        */
+       protected function getNewTempFile() {
+               $fileName = tempnam(
+                       wfTempDir(),
+                       // Avoid backslashes here as they result in inconsistent results
+                       // between Windows and other OS, as well as between functions
+                       // that try to normalise these in one or both directions.
+                       // For example, tempnam rejects directory separators in the prefix which
+                       // means it rejects any namespaced class on Windows.
+                       // And then there is, wfMkdirParents which normalises paths always
+                       // whereas most other PHP and MW functions do not.
+                       'MW_PHPUnit_' . strtr( static::class, [ '\\' => '_' ] ) . '_'
+               );
+               $this->tmpFiles[] = $fileName;
+
+               return $fileName;
+       }
+
+       /**
+        * obtains a new temporary directory
+        *
+        * The obtained directory is enlisted to be removed (recursively with all its contained
+        * files) upon tearDown.
+        *
+        * @since 1.20
+        *
+        * @return string Absolute name of the temporary directory
+        */
+       protected function getNewTempDirectory() {
+               // Starting of with a temporary *file*.
+               $fileName = $this->getNewTempFile();
+
+               // Converting the temporary file to a *directory*.
+               // The following is not atomic, but at least we now have a single place,
+               // where temporary directory creation is bundled and can be improved.
+               unlink( $fileName );
+               // If this fails for some reason, PHP will warn and fail the test.
+               mkdir( $fileName, 0777, /* recursive = */ true );
+
+               return $fileName;
+       }
+
+       protected function setUp() {
+               parent::setUp();
+               $this->called['setUp'] = true;
+
+               $this->phpErrorLevel = intval( ini_get( 'error_reporting' ) );
+
+               $this->overriddenServices = [];
+
+               // Cleaning up temporary files
+               foreach ( $this->tmpFiles as $fileName ) {
+                       if ( is_file( $fileName ) || ( is_link( $fileName ) ) ) {
+                               unlink( $fileName );
+                       } elseif ( is_dir( $fileName ) ) {
+                               wfRecursiveRemoveDir( $fileName );
+                       }
+               }
+
+               if ( $this->needsDB() && $this->db ) {
+                       // Clean up open transactions
+                       while ( $this->db->trxLevel() > 0 ) {
+                               $this->db->rollback( __METHOD__, 'flush' );
+                       }
+                       // Check for unsafe queries
+                       if ( $this->db->getType() === 'mysql' ) {
+                               $this->db->query( "SET sql_mode = 'STRICT_ALL_TABLES'", __METHOD__ );
+                       }
+               }
+
+               // Reset all caches between tests.
+               self::resetNonServiceCaches();
+
+               // XXX: reset maintenance triggers
+               // Hook into period lag checks which often happen in long-running scripts
+               $lbFactory = $this->localServices->getDBLoadBalancerFactory();
+               Maintenance::setLBFactoryTriggers( $lbFactory, $this->localServices->getMainConfig() );
+
+               ob_start( 'MediaWikiTestCase::wfResetOutputBuffersBarrier' );
+       }
+
+       protected function addTmpFiles( $files ) {
+               $this->tmpFiles = array_merge( $this->tmpFiles, (array)$files );
+       }
+
+       // @todo Make const when we no longer support HHVM (T192166)
+       private static $namespaceAffectingSettings = [
+               'wgAllowImageMoving',
+               'wgCanonicalNamespaceNames',
+               'wgCapitalLinkOverrides',
+               'wgCapitalLinks',
+               'wgContentNamespaces',
+               'wgExtensionMessagesFiles',
+               'wgExtensionNamespaces',
+               'wgExtraNamespaces',
+               'wgExtraSignatureNamespaces',
+               'wgNamespaceContentModels',
+               'wgNamespaceProtection',
+               'wgNamespacesWithSubpages',
+               'wgNonincludableNamespaces',
+               'wgRestrictionLevels',
+       ];
+
+       protected function tearDown() {
+               global $wgRequest, $wgSQLMode;
+
+               $status = ob_get_status();
+               if ( isset( $status['name'] ) &&
+                       $status['name'] === 'MediaWikiTestCase::wfResetOutputBuffersBarrier'
+               ) {
+                       ob_end_flush();
+               }
+
+               $this->called['tearDown'] = true;
+               // Cleaning up temporary files
+               foreach ( $this->tmpFiles as $fileName ) {
+                       if ( is_file( $fileName ) || ( is_link( $fileName ) ) ) {
+                               unlink( $fileName );
+                       } elseif ( is_dir( $fileName ) ) {
+                               wfRecursiveRemoveDir( $fileName );
+                       }
+               }
+
+               if ( $this->needsDB() && $this->db ) {
+                       // Clean up open transactions
+                       while ( $this->db->trxLevel() > 0 ) {
+                               $this->db->rollback( __METHOD__, 'flush' );
+                       }
+                       if ( $this->db->getType() === 'mysql' ) {
+                               $this->db->query( "SET sql_mode = " . $this->db->addQuotes( $wgSQLMode ),
+                                       __METHOD__ );
+                       }
+               }
+
+               // Re-enable any disabled deprecation warnings
+               MWDebug::clearLog();
+               // Restore mw globals
+               foreach ( $this->mwGlobals as $key => $value ) {
+                       $GLOBALS[$key] = $value;
+               }
+               foreach ( $this->mwGlobalsToUnset as $value ) {
+                       unset( $GLOBALS[$value] );
+               }
+               foreach ( $this->iniSettings as $name => $value ) {
+                       ini_set( $name, $value );
+               }
+               if (
+                       array_intersect( self::$namespaceAffectingSettings, array_keys( $this->mwGlobals ) ) ||
+                       array_intersect( self::$namespaceAffectingSettings, $this->mwGlobalsToUnset )
+               ) {
+                       $this->resetNamespaces();
+               }
+               $this->mwGlobals = [];
+               $this->mwGlobalsToUnset = [];
+               $this->restoreLoggers();
+
+               // TODO: move global state into MediaWikiServices
+               RequestContext::resetMain();
+               if ( session_id() !== '' ) {
+                       session_write_close();
+                       session_id( '' );
+               }
+               $wgRequest = new FauxRequest();
+               MediaWiki\Session\SessionManager::resetCache();
+               MediaWiki\Auth\AuthManager::resetCache();
+
+               $phpErrorLevel = intval( ini_get( 'error_reporting' ) );
+
+               if ( $phpErrorLevel !== $this->phpErrorLevel ) {
+                       ini_set( 'error_reporting', $this->phpErrorLevel );
+
+                       $oldHex = strtoupper( dechex( $this->phpErrorLevel ) );
+                       $newHex = strtoupper( dechex( $phpErrorLevel ) );
+                       $message = "PHP error_reporting setting was left dirty: "
+                               . "was 0x$oldHex before test, 0x$newHex after test!";
+
+                       $this->fail( $message );
+               }
+
+               parent::tearDown();
+       }
+
+       /**
+        * Make sure MediaWikiTestCase extending classes have called their
+        * parent setUp method
+        *
+        * With strict coverage activated in PHP_CodeCoverage, this test would be
+        * marked as risky without the following annotation (T152923).
+        * @coversNothing
+        */
+       final public function testMediaWikiTestCaseParentSetupCalled() {
+               $this->assertArrayHasKey( 'setUp', $this->called,
+                       static::class . '::setUp() must call parent::setUp()'
+               );
+       }
+
+       /**
+        * Sets a service, maintaining a stashed version of the previous service to be
+        * restored in tearDown.
+        *
+        * @note Tests must not call overrideMwServices() after calling setService(), since that would
+        *       lose the new service instance. Since 1.34, resetServices() can be used instead, which
+        *       would reset other services, but retain any services set using setService().
+        *       This means that once a service is set using this method, it cannot be reverted to
+        *       the original service within the same test method. The original service is restored
+        *       in tearDown after the test method has terminated.
+        *
+        * @param string $name
+        * @param object $service The service instance, or a callable that returns the service instance.
+        *
+        * @since 1.27
+        *
+        */
+       protected function setService( $name, $service ) {
+               if ( !$this->localServices ) {
+                       throw new Exception( __METHOD__ . ' must be called after MediaWikiTestCase::run()' );
+               }
+
+               if ( $this->localServices !== MediaWikiServices::getInstance() ) {
+                       throw new Exception( __METHOD__ . ' will not work because the global MediaWikiServices '
+                               . 'instance has been replaced by test code.' );
+               }
+
+               if ( is_callable( $service ) ) {
+                       $instantiator = $service;
+               } else {
+                       $instantiator = function () use ( $service ) {
+                               return $service;
+                       };
+               }
+
+               $this->overriddenServices[] = $name;
+
+               $this->localServices->disableService( $name );
+               $this->localServices->redefineService(
+                       $name,
+                       $instantiator
+               );
+
+               if ( $name === 'ContentLanguage' ) {
+                       $this->doSetMwGlobals( [ 'wgContLang' => $this->localServices->getContentLanguage() ] );
+               }
+       }
+
+       /**
+        * Sets a global, maintaining a stashed version of the previous global to be
+        * restored in tearDown
+        *
+        * The key is added to the array of globals that will be reset afterwards
+        * in the tearDown().
+        *
+        * It may be necessary to call resetServices() to allow any changed configuration variables
+        * to take effect on services that get initialized based on these variables.
+        *
+        * @par Example
+        * @code
+        *     protected function setUp() {
+        *         $this->setMwGlobals( 'wgRestrictStuff', true );
+        *     }
+        *
+        *     function testFoo() {}
+        *
+        *     function testBar() {}
+        *         $this->assertTrue( self::getX()->doStuff() );
+        *
+        *         $this->setMwGlobals( 'wgRestrictStuff', false );
+        *         $this->assertTrue( self::getX()->doStuff() );
+        *     }
+        *
+        *     function testQuux() {}
+        * @endcode
+        *
+        * @param array|string $pairs Key to the global variable, or an array
+        *  of key/value pairs.
+        * @param mixed|null $value Value to set the global to (ignored
+        *  if an array is given as first argument).
+        *
+        * @note To allow changes to global variables to take effect on global service instances,
+        *       call resetServices().
+        *
+        * @since 1.21
+        */
+       protected function setMwGlobals( $pairs, $value = null ) {
+               if ( is_string( $pairs ) ) {
+                       $pairs = [ $pairs => $value ];
+               }
+
+               if ( isset( $pairs['wgContLang'] ) ) {
+                       throw new MWException(
+                               'No setting $wgContLang, use setContentLang() or setService( \'ContentLanguage\' )'
+                       );
+               }
+
+               $this->doSetMwGlobals( $pairs, $value );
+       }
+
+       /**
+        * An internal method that allows setService() to set globals that tests are not supposed to
+        * touch.
+        */
+       private function doSetMwGlobals( $pairs, $value = null ) {
+               $this->doStashMwGlobals( array_keys( $pairs ) );
+
+               foreach ( $pairs as $key => $value ) {
+                       $GLOBALS[$key] = $value;
+               }
+
+               if ( array_intersect( self::$namespaceAffectingSettings, array_keys( $pairs ) ) ) {
+                       $this->resetNamespaces();
+               }
+       }
+
+       /**
+        * Set an ini setting for the duration of the test
+        * @param string $name Name of the setting
+        * @param string $value Value to set
+        * @since 1.32
+        */
+       protected function setIniSetting( $name, $value ) {
+               $original = ini_get( $name );
+               $this->iniSettings[$name] = $original;
+               ini_set( $name, $value );
+       }
+
+       /**
+        * Must be called whenever namespaces are changed, e.g., $wgExtraNamespaces is altered.
+        * Otherwise old namespace data will lurk and cause bugs.
+        */
+       private function resetNamespaces() {
+               if ( !$this->localServices ) {
+                       throw new Exception( __METHOD__ . ' must be called after MediaWikiTestCase::run()' );
+               }
+
+               if ( $this->localServices !== MediaWikiServices::getInstance() ) {
+                       throw new Exception( __METHOD__ . ' will not work because the global MediaWikiServices '
+                               . 'instance has been replaced by test code.' );
+               }
+
+               Language::clearCaches();
+       }
+
+       /**
+        * Check if we can back up a value by performing a shallow copy.
+        * Values which fail this test are copied recursively.
+        *
+        * @param mixed $value
+        * @return bool True if a shallow copy will do; false if a deep copy
+        *  is required.
+        */
+       private static function canShallowCopy( $value ) {
+               if ( is_scalar( $value ) || $value === null ) {
+                       return true;
+               }
+               if ( is_array( $value ) ) {
+                       foreach ( $value as $subValue ) {
+                               if ( !is_scalar( $subValue ) && $subValue !== null ) {
+                                       return false;
+                               }
+                       }
+                       return true;
+               }
+               return false;
+       }
+
+       private function doStashMwGlobals( $globalKeys ) {
+               if ( is_string( $globalKeys ) ) {
+                       $globalKeys = [ $globalKeys ];
+               }
+
+               foreach ( $globalKeys as $globalKey ) {
+                       // NOTE: make sure we only save the global once or a second call to
+                       // setMwGlobals() on the same global would override the original
+                       // value.
+                       if (
+                               !array_key_exists( $globalKey, $this->mwGlobals ) &&
+                               !array_key_exists( $globalKey, $this->mwGlobalsToUnset )
+                       ) {
+                               if ( !array_key_exists( $globalKey, $GLOBALS ) ) {
+                                       $this->mwGlobalsToUnset[$globalKey] = $globalKey;
+                                       continue;
+                               }
+                               // NOTE: we serialize then unserialize the value in case it is an object
+                               // this stops any objects being passed by reference. We could use clone
+                               // and if is_object but this does account for objects within objects!
+                               if ( self::canShallowCopy( $GLOBALS[$globalKey] ) ) {
+                                       $this->mwGlobals[$globalKey] = $GLOBALS[$globalKey];
+                               } elseif (
+                                       // Many MediaWiki types are safe to clone. These are the
+                                       // ones that are most commonly stashed.
+                                       $GLOBALS[$globalKey] instanceof Language ||
+                                       $GLOBALS[$globalKey] instanceof User ||
+                                       $GLOBALS[$globalKey] instanceof FauxRequest
+                               ) {
+                                       $this->mwGlobals[$globalKey] = clone $GLOBALS[$globalKey];
+                               } elseif ( $this->containsClosure( $GLOBALS[$globalKey] ) ) {
+                                       // Serializing Closure only gives a warning on HHVM while
+                                       // it throws an Exception on Zend.
+                                       // Workaround for https://github.com/facebook/hhvm/issues/6206
+                                       $this->mwGlobals[$globalKey] = $GLOBALS[$globalKey];
+                               } else {
+                                       try {
+                                               $this->mwGlobals[$globalKey] = unserialize( serialize( $GLOBALS[$globalKey] ) );
+                                       } catch ( Exception $e ) {
+                                               $this->mwGlobals[$globalKey] = $GLOBALS[$globalKey];
+                                       }
+                               }
+                       }
+               }
+       }
+
+       /**
+        * @param mixed $var
+        * @param int $maxDepth
+        *
+        * @return bool
+        */
+       private function containsClosure( $var, $maxDepth = 15 ) {
+               if ( $var instanceof Closure ) {
+                       return true;
+               }
+               if ( !is_array( $var ) || $maxDepth === 0 ) {
+                       return false;
+               }
+
+               foreach ( $var as $value ) {
+                       if ( $this->containsClosure( $value, $maxDepth - 1 ) ) {
+                               return true;
+                       }
+               }
+               return false;
+       }
+
+       /**
+        * Merges the given values into a MW global array variable.
+        * Useful for setting some entries in a configuration array, instead of
+        * setting the entire array.
+        *
+        * It may be necessary to call resetServices() to allow any changed configuration variables
+        * to take effect on services that get initialized based on these variables.
+        *
+        * @param string $name The name of the global, as in wgFooBar
+        * @param array $values The array containing the entries to set in that global
+        *
+        * @throws MWException If the designated global is not an array.
+        *
+        * @note To allow changes to global variables to take effect on global service instances,
+        *       call resetServices().
+        *
+        * @since 1.21
+        */
+       protected function mergeMwGlobalArrayValue( $name, $values ) {
+               if ( !isset( $GLOBALS[$name] ) ) {
+                       $merged = $values;
+               } else {
+                       if ( !is_array( $GLOBALS[$name] ) ) {
+                               throw new MWException( "MW global $name is not an array." );
+                       }
+
+                       // NOTE: do not use array_merge, it screws up for numeric keys.
+                       $merged = $GLOBALS[$name];
+                       foreach ( $values as $k => $v ) {
+                               $merged[$k] = $v;
+                       }
+               }
+
+               $this->setMwGlobals( $name, $merged );
+       }
+
+       /**
+        * Resets service instances in the global instance of MediaWikiServices.
+        *
+        * In contrast to overrideMwServices(), this does not create a new MediaWikiServices instance,
+        * and it preserves any service instances set via setService().
+        *
+        * The primary use case for this method is to allow changes to global configuration variables
+        * to take effect on services that get initialized based on these global configuration
+        * variables. Similarly, it may be necessary to call resetServices() after calling setService(),
+        * so the newly set service gets picked up by any other service definitions that may use it.
+        *
+        * @see MediaWikiServices::resetServiceForTesting.
+        *
+        * @since 1.34
+        */
+       protected function resetServices() {
+               // Reset but don't destroy service instances supplied via setService().
+               foreach ( $this->overriddenServices as $name ) {
+                       $this->localServices->resetServiceForTesting( $name, false );
+               }
+
+               // Reset all services with the destroy flag set.
+               // This will not have any effect on services that had already been reset above.
+               foreach ( $this->localServices->getServiceNames() as $name ) {
+                       $this->localServices->resetServiceForTesting( $name, true );
+               }
+
+               self::resetGlobalParser();
+       }
+
+       /**
+        * Installs a new global instance of MediaWikiServices, allowing test cases to override
+        * settings and services.
+        *
+        * This method can be used to set up specific services or configuration as a fixture.
+        * It should not be used to reset services in between stages of a test - instead, the test
+        * should either be split, or resetServices() should be used.
+        *
+        * If called with no parameters, this method restores all services to their default state.
+        * This is done automatically before each test to isolate tests from any modification
+        * to settings and services that may have been applied by previous tests.
+        * That means that the effect of calling overrideMwServices() is undone before the next
+        * call to a test method.
+        *
+        * @note Calling this after having called setService() in the same test method (or the
+        *       associated setUp) will result in an MWException.
+        *       Tests should use either overrideMwServices() or setService(), but not mix both.
+        *       Since 1.34, resetServices() is available as an alternative compatible with setService().
+        *
+        * @since 1.27
+        *
+        * @param Config|null $configOverrides Configuration overrides for the new MediaWikiServices
+        *        instance.
+        * @param callable[] $services An associative array of services to re-define. Keys are service
+        *        names, values are callables.
+        *
+        * @return MediaWikiServices
+        * @throws MWException
+        */
+       protected function overrideMwServices(
+               Config $configOverrides = null, array $services = []
+       ) {
+               if ( $this->overriddenServices ) {
+                       throw new MWException(
+                               'The following services were set and are now being unset by overrideMwServices: ' .
+                                       implode( ', ', $this->overriddenServices )
+                       );
+               }
+               $newInstance = self::installMockMwServices( $configOverrides );
+
+               if ( $this->localServices ) {
+                       $this->localServices->destroy();
+               }
+
+               $this->localServices = $newInstance;
+
+               foreach ( $services as $name => $callback ) {
+                       $newInstance->redefineService( $name, $callback );
+               }
+
+               self::resetGlobalParser();
+
+               return $newInstance;
+       }
+
+       /**
+        * Creates a new "mock" MediaWikiServices instance, and installs it.
+        * This effectively resets all cached states in services, with the exception of
+        * the ConfigFactory and the DBLoadBalancerFactory service, which are inherited from
+        * the original MediaWikiServices.
+        *
+        * @note The new original MediaWikiServices instance can later be restored by calling
+        * restoreMwServices(). That original is determined by the first call to this method, or
+        * by setUpBeforeClass, whichever is called first. The caller is responsible for managing
+        * and, when appropriate, destroying any other MediaWikiServices instances that may get
+        * replaced when calling this method.
+        *
+        * @param Config|null $configOverrides Configuration overrides for the new MediaWikiServices
+        *        instance.
+        *
+        * @return MediaWikiServices the new mock service locator.
+        */
+       public static function installMockMwServices( Config $configOverrides = null ) {
+               // Make sure we have the original service locator
+               if ( !self::$originalServices ) {
+                       self::$originalServices = MediaWikiServices::getInstance();
+               }
+
+               if ( !$configOverrides ) {
+                       $configOverrides = new HashConfig();
+               }
+
+               $oldConfigFactory = self::$originalServices->getConfigFactory();
+               $oldLoadBalancerFactory = self::$originalServices->getDBLoadBalancerFactory();
+
+               $testConfig = self::makeTestConfig( null, $configOverrides );
+               $newServices = new MediaWikiServices( $testConfig );
+
+               // Load the default wiring from the specified files.
+               // NOTE: this logic mirrors the logic in MediaWikiServices::newInstance.
+               $wiringFiles = $testConfig->get( 'ServiceWiringFiles' );
+               $newServices->loadWiringFiles( $wiringFiles );
+
+               // Provide a traditional hook point to allow extensions to configure services.
+               Hooks::run( 'MediaWikiServices', [ $newServices ] );
+
+               // Use bootstrap config for all configuration.
+               // This allows config overrides via global variables to take effect.
+               $bootstrapConfig = $newServices->getBootstrapConfig();
+               $newServices->resetServiceForTesting( 'ConfigFactory' );
+               $newServices->redefineService(
+                       'ConfigFactory',
+                       self::makeTestConfigFactoryInstantiator(
+                               $oldConfigFactory,
+                               [ 'main' => $bootstrapConfig ]
+                       )
+               );
+               $newServices->resetServiceForTesting( 'DBLoadBalancerFactory' );
+               $newServices->redefineService(
+                       'DBLoadBalancerFactory',
+                       function ( MediaWikiServices $services ) use ( $oldLoadBalancerFactory ) {
+                               return $oldLoadBalancerFactory;
+                       }
+               );
+
+               MediaWikiServices::forceGlobalInstance( $newServices );
+
+               self::resetGlobalParser();
+
+               return $newServices;
+       }
+
+       /**
+        * Restores the original, non-mock MediaWikiServices instance.
+        * The previously active MediaWikiServices instance is destroyed,
+        * if it is different from the original that is to be restored.
+        *
+        * @note this if for internal use by test framework code. It should never be
+        * called from inside a test case, a data provider, or a setUp or tearDown method.
+        *
+        * @return bool true if the original service locator was restored,
+        *         false if there was nothing  too do.
+        */
+       public static function restoreMwServices() {
+               if ( !self::$originalServices ) {
+                       return false;
+               }
+
+               $currentServices = MediaWikiServices::getInstance();
+
+               if ( self::$originalServices === $currentServices ) {
+                       return false;
+               }
+
+               MediaWikiServices::forceGlobalInstance( self::$originalServices );
+               $currentServices->destroy();
+
+               self::resetGlobalParser();
+
+               return true;
+       }
+
+       /**
+        * If $wgParser has been unstubbed, replace it with a fresh one so it picks up any config
+        * changes. $wgParser is deprecated, but we still support it for now.
+        */
+       private static function resetGlobalParser() {
+               // phpcs:ignore MediaWiki.Usage.DeprecatedGlobalVariables.Deprecated$wgParser
+               global $wgParser;
+               if ( $wgParser instanceof StubObject ) {
+                       return;
+               }
+               $wgParser = new StubObject( 'wgParser', function () {
+                       return MediaWikiServices::getInstance()->getParser();
+               } );
+       }
+
+       /**
+        * @since 1.27
+        * @param string|Language $lang
+        */
+       public function setUserLang( $lang ) {
+               RequestContext::getMain()->setLanguage( $lang );
+               $this->setMwGlobals( 'wgLang', RequestContext::getMain()->getLanguage() );
+       }
+
+       /**
+        * @since 1.27
+        * @param string|Language $lang
+        */
+       public function setContentLang( $lang ) {
+               if ( $lang instanceof Language ) {
+                       $this->setMwGlobals( 'wgLanguageCode', $lang->getCode() );
+                       // Set to the exact object requested
+                       $this->setService( 'ContentLanguage', $lang );
+               } else {
+                       $this->setMwGlobals( 'wgLanguageCode', $lang );
+                       // Let the service handler make up the object.  Avoid calling setService(), because if
+                       // we do, overrideMwServices() will complain if it's called later on.
+                       $services = MediaWikiServices::getInstance();
+                       $services->resetServiceForTesting( 'ContentLanguage' );
+                       $this->doSetMwGlobals( [ 'wgContLang' => $services->getContentLanguage() ] );
+               }
+       }
+
+       /**
+        * Alters $wgGroupPermissions for the duration of the test.  Can be called
+        * with an array, like
+        *   [ '*' => [ 'read' => false ], 'user' => [ 'read' => false ] ]
+        * or three values to set a single permission, like
+        *   $this->setGroupPermissions( '*', 'read', false );
+        *
+        * @since 1.31
+        * @param array|string $newPerms Either an array of permissions to change,
+        *   in which case the next two parameters are ignored; or a single string
+        *   identifying a group, to use with the next two parameters.
+        * @param string|null $newKey
+        * @param mixed|null $newValue
+        */
+       public function setGroupPermissions( $newPerms, $newKey = null, $newValue = null ) {
+               global $wgGroupPermissions;
+
+               if ( is_string( $newPerms ) ) {
+                       $newPerms = [ $newPerms => [ $newKey => $newValue ] ];
+               }
+
+               $newPermissions = $wgGroupPermissions;
+               foreach ( $newPerms as $group => $permissions ) {
+                       foreach ( $permissions as $key => $value ) {
+                               $newPermissions[$group][$key] = $value;
+                       }
+               }
+
+               $this->setMwGlobals( 'wgGroupPermissions', $newPermissions );
+
+               // Reset services so they pick up the new permissions.
+               // Resetting just PermissionManager is not sufficient, since other services may
+               // have the old instance of PermissionManager injected.
+               $this->resetServices();
+       }
+
+       /**
+        *
+        * @since 1.34
+        * Sets the logger for a specified channel, for the duration of the test.
+        * @since 1.27
+        * @param string $channel
+        * @param LoggerInterface $logger
+        */
+       protected function setLogger( $channel, LoggerInterface $logger ) {
+               // TODO: Once loggers are managed by MediaWikiServices, use
+               //       resetServiceForTesting() to set loggers.
+
+               $provider = LoggerFactory::getProvider();
+               $wrappedProvider = TestingAccessWrapper::newFromObject( $provider );
+               $singletons = $wrappedProvider->singletons;
+               if ( $provider instanceof MonologSpi ) {
+                       if ( !isset( $this->loggers[$channel] ) ) {
+                               $this->loggers[$channel] = $singletons['loggers'][$channel] ?? null;
+                       }
+                       $singletons['loggers'][$channel] = $logger;
+               } elseif ( $provider instanceof LegacySpi || $provider instanceof LogCapturingSpi ) {
+                       if ( !isset( $this->loggers[$channel] ) ) {
+                               $this->loggers[$channel] = $singletons[$channel] ?? null;
+                       }
+                       $singletons[$channel] = $logger;
+               } else {
+                       throw new LogicException( __METHOD__ . ': setting a logger for ' . get_class( $provider )
+                               . ' is not implemented' );
+               }
+               $wrappedProvider->singletons = $singletons;
+       }
+
+       /**
+        * Restores loggers replaced by setLogger().
+        * @since 1.27
+        */
+       private function restoreLoggers() {
+               $provider = LoggerFactory::getProvider();
+               $wrappedProvider = TestingAccessWrapper::newFromObject( $provider );
+               $singletons = $wrappedProvider->singletons;
+               foreach ( $this->loggers as $channel => $logger ) {
+                       if ( $provider instanceof MonologSpi ) {
+                               if ( $logger === null ) {
+                                       unset( $singletons['loggers'][$channel] );
+                               } else {
+                                       $singletons['loggers'][$channel] = $logger;
+                               }
+                       } elseif ( $provider instanceof LegacySpi || $provider instanceof LogCapturingSpi ) {
+                               if ( $logger === null ) {
+                                       unset( $singletons[$channel] );
+                               } else {
+                                       $singletons[$channel] = $logger;
+                               }
+                       }
+               }
+               $wrappedProvider->singletons = $singletons;
+               $this->loggers = [];
+       }
+
+       /**
+        * @return string
+        * @since 1.18
+        */
+       public function dbPrefix() {
+               return self::getTestPrefixFor( $this->db );
+       }
+
+       /**
+        * @param IDatabase $db
+        * @return string
+        * @since 1.32
+        */
+       public static function getTestPrefixFor( IDatabase $db ) {
+               return $db->getType() == 'oracle' ? self::ORA_DB_PREFIX : self::DB_PREFIX;
+       }
+
+       /**
+        * @return bool
+        * @since 1.18
+        */
+       public function needsDB() {
+               // If the test says it uses database tables, it needs the database
+               return $this->tablesUsed || $this->isTestInDatabaseGroup();
+       }
+
+       /**
+        * @return bool
+        * @since 1.32
+        */
+       protected function isTestInDatabaseGroup() {
+               // If the test class says it belongs to the Database group, it needs the database.
+               // NOTE: This ONLY checks for the group in the class level doc comment.
+               $rc = new ReflectionClass( $this );
+               return (bool)preg_match( '/@group +Database/im', $rc->getDocComment() );
+       }
+
+       /**
+        * Insert a new page.
+        *
+        * Should be called from addDBData().
+        *
+        * @since 1.25 ($namespace in 1.28)
+        * @param string|Title $pageName Page name or title
+        * @param string $text Page's content
+        * @param int|null $namespace Namespace id (name cannot already contain namespace)
+        * @param User|null $user If null, static::getTestSysop()->getUser() is used.
+        * @return array Title object and page id
+        * @throws MWException If this test cases's needsDB() method doesn't return true.
+        *         Test cases can use "@group Database" to enable database test support,
+        *         or list the tables under testing in $this->tablesUsed, or override the
+        *         needsDB() method.
+        */
+       protected function insertPage(
+               $pageName,
+               $text = 'Sample page for unit test.',
+               $namespace = null,
+               User $user = null
+       ) {
+               if ( !$this->needsDB() ) {
+                       throw new MWException( 'When testing which pages, the test cases\'s needsDB()' .
+                               ' method should return true. Use @group Database or $this->tablesUsed.' );
+               }
+
+               if ( is_string( $pageName ) ) {
+                       $title = Title::newFromText( $pageName, $namespace );
+               } else {
+                       $title = $pageName;
+               }
+
+               if ( !$user ) {
+                       $user = static::getTestSysop()->getUser();
+               }
+               $comment = __METHOD__ . ': Sample page for unit test.';
+
+               $page = WikiPage::factory( $title );
+               $page->doEditContent( ContentHandler::makeContent( $text, $title ), $comment, 0, false, $user );
+
+               return [
+                       'title' => $title,
+                       'id' => $page->getId(),
+               ];
+       }
+
+       /**
+        * Stub. If a test suite needs to add additional data to the database, it should
+        * implement this method and do so. This method is called once per test suite
+        * (i.e. once per class).
+        *
+        * Note data added by this method may be removed by resetDB() depending on
+        * the contents of $tablesUsed.
+        *
+        * To add additional data between test function runs, override prepareDB().
+        *
+        * @see addDBData()
+        * @see resetDB()
+        *
+        * @since 1.27
+        */
+       public function addDBDataOnce() {
+       }
+
+       /**
+        * Stub. Subclasses may override this to prepare the database.
+        * Called before every test run (test function or data set).
+        *
+        * @see addDBDataOnce()
+        * @see resetDB()
+        *
+        * @since 1.18
+        */
+       public function addDBData() {
+       }
+
+       /**
+        * @since 1.32
+        */
+       protected function addCoreDBData() {
+               if ( $this->db->getType() == 'oracle' ) {
+                       # Insert 0 user to prevent FK violations
+                       # Anonymous user
+                       if ( !$this->db->selectField( 'user', '1', [ 'user_id' => 0 ] ) ) {
+                               $this->db->insert( 'user', [
+                                       'user_id' => 0,
+                                       'user_name' => 'Anonymous' ], __METHOD__, [ 'IGNORE' ] );
+                       }
+
+                       # Insert 0 page to prevent FK violations
+                       # Blank page
+                       if ( !$this->db->selectField( 'page', '1', [ 'page_id' => 0 ] ) ) {
+                               $this->db->insert( 'page', [
+                                       'page_id' => 0,
+                                       'page_namespace' => 0,
+                                       'page_title' => ' ',
+                                       'page_restrictions' => null,
+                                       'page_is_redirect' => 0,
+                                       'page_is_new' => 0,
+                                       'page_random' => 0,
+                                       'page_touched' => $this->db->timestamp(),
+                                       'page_latest' => 0,
+                                       'page_len' => 0 ], __METHOD__, [ 'IGNORE' ] );
+                       }
+               }
+
+               SiteStatsInit::doPlaceholderInit();
+
+               User::resetIdByNameCache();
+
+               // Make sysop user
+               $user = static::getTestSysop()->getUser();
+
+               // Make 1 page with 1 revision
+               $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+               if ( $page->getId() == 0 ) {
+                       $page->doEditContent(
+                               new WikitextContent( 'UTContent' ),
+                               'UTPageSummary',
+                               EDIT_NEW | EDIT_SUPPRESS_RC,
+                               false,
+                               $user
+                       );
+                       // an edit always attempt to purge backlink links such as history
+                       // pages. That is unnecessary.
+                       JobQueueGroup::singleton()->get( 'htmlCacheUpdate' )->delete();
+                       // WikiPages::doEditUpdates randomly adds RC purges
+                       JobQueueGroup::singleton()->get( 'recentChangesUpdate' )->delete();
+
+                       // doEditContent() probably started the session via
+                       // User::loadFromSession(). Close it now.
+                       if ( session_id() !== '' ) {
+                               session_write_close();
+                               session_id( '' );
+                       }
+               }
+       }
+
+       /**
+        * Restores MediaWiki to using the table set (table prefix) it was using before
+        * setupTestDB() was called. Useful if we need to perform database operations
+        * after the test run has finished (such as saving logs or profiling info).
+        *
+        * This is called by phpunit/bootstrap.php after the last test.
+        *
+        * @since 1.21
+        */
+       public static function teardownTestDB() {
+               global $wgJobClasses;
+
+               if ( !self::$dbSetup ) {
+                       return;
+               }
+
+               Hooks::run( 'UnitTestsBeforeDatabaseTeardown' );
+
+               foreach ( $wgJobClasses as $type => $class ) {
+                       // Delete any jobs under the clone DB (or old prefix in other stores)
+                       JobQueueGroup::singleton()->get( $type )->delete();
+               }
+
+               // T219673: close any connections from code that failed to call reuseConnection()
+               // or is still holding onto a DBConnRef instance (e.g. in a singleton).
+               MediaWikiServices::getInstance()->getDBLoadBalancerFactory()->closeAll();
+               CloneDatabase::changePrefix( self::$oldTablePrefix );
+
+               self::$oldTablePrefix = false;
+               self::$dbSetup = false;
+       }
+
+       /**
+        * Setups a database with cloned tables using the given prefix.
+        *
+        * If reuseDB is true and certain conditions apply, it will just change the prefix.
+        * Otherwise, it will clone the tables and change the prefix.
+        *
+        * @param IMaintainableDatabase $db Database to use
+        * @param string|null $prefix Prefix to use for test tables. If not given, the prefix is determined
+        *        automatically for $db.
+        * @return bool True if tables were cloned, false if only the prefix was changed
+        */
+       protected static function setupDatabaseWithTestPrefix(
+               IMaintainableDatabase $db,
+               $prefix = null
+       ) {
+               if ( $prefix === null ) {
+                       $prefix = self::getTestPrefixFor( $db );
+               }
+
+               if ( ( $db->getType() == 'oracle' || !self::$useTemporaryTables ) && self::$reuseDB ) {
+                       $db->tablePrefix( $prefix );
+                       return false;
+               }
+
+               if ( !isset( $db->_originalTablePrefix ) ) {
+                       $oldPrefix = $db->tablePrefix();
+
+                       if ( $oldPrefix === $prefix ) {
+                               // table already has the correct prefix, but presumably no cloned tables
+                               $oldPrefix = self::$oldTablePrefix;
+                       }
+
+                       $db->tablePrefix( $oldPrefix );
+                       $tablesCloned = self::listTables( $db );
+                       $dbClone = new CloneDatabase( $db, $tablesCloned, $prefix, $oldPrefix );
+                       $dbClone->useTemporaryTables( self::$useTemporaryTables );
+
+                       $dbClone->cloneTableStructure();
+
+                       $db->tablePrefix( $prefix );
+                       $db->_originalTablePrefix = $oldPrefix;
+               }
+
+               return true;
+       }
+
+       /**
+        * Set up all test DBs
+        */
+       public function setupAllTestDBs() {
+               global $wgDBprefix;
+
+               self::$oldTablePrefix = $wgDBprefix;
+
+               $testPrefix = $this->dbPrefix();
+
+               // switch to a temporary clone of the database
+               self::setupTestDB( $this->db, $testPrefix );
+
+               if ( self::isUsingExternalStoreDB() ) {
+                       self::setupExternalStoreTestDBs( $testPrefix );
+               }
+
+               // NOTE: Change the prefix in the LBFactory and $wgDBprefix, to prevent
+               // *any* database connections to operate on live data.
+               CloneDatabase::changePrefix( $testPrefix );
+       }
+
+       /**
+        * Creates an empty skeleton of the wiki database by cloning its structure
+        * to equivalent tables using the given $prefix. Then sets MediaWiki to
+        * use the new set of tables (aka schema) instead of the original set.
+        *
+        * This is used to generate a dummy table set, typically consisting of temporary
+        * tables, that will be used by tests instead of the original wiki database tables.
+        *
+        * @since 1.21
+        *
+        * @note the original table prefix is stored in self::$oldTablePrefix. This is used
+        * by teardownTestDB() to return the wiki to using the original table set.
+        *
+        * @note this method only works when first called. Subsequent calls have no effect,
+        * even if using different parameters.
+        *
+        * @param IMaintainableDatabase $db The database connection
+        * @param string $prefix The prefix to use for the new table set (aka schema).
+        *
+        * @throws MWException If the database table prefix is already $prefix
+        */
+       public static function setupTestDB( IMaintainableDatabase $db, $prefix ) {
+               if ( self::$dbSetup ) {
+                       return;
+               }
+
+               if ( $db->tablePrefix() === $prefix ) {
+                       throw new MWException(
+                               'Cannot run unit tests, the database prefix is already "' . $prefix . '"' );
+               }
+
+               // TODO: the below should be re-written as soon as LBFactory, LoadBalancer,
+               // and Database no longer use global state.
+
+               self::$dbSetup = true;
+
+               if ( !self::setupDatabaseWithTestPrefix( $db, $prefix ) ) {
+                       return;
+               }
+
+               // Assuming this isn't needed for External Store database, and not sure if the procedure
+               // would be available there.
+               if ( $db->getType() == 'oracle' ) {
+                       $db->query( 'BEGIN FILL_WIKI_INFO; END;', __METHOD__ );
+               }
+
+               Hooks::run( 'UnitTestsAfterDatabaseSetup', [ $db, $prefix ] );
+       }
+
+       /**
+        * Clones the External Store database(s) for testing
+        *
+        * @param string|null $testPrefix Prefix for test tables. Will be determined automatically
+        *        if not given.
+        */
+       protected static function setupExternalStoreTestDBs( $testPrefix = null ) {
+               $connections = self::getExternalStoreDatabaseConnections();
+               foreach ( $connections as $dbw ) {
+                       self::setupDatabaseWithTestPrefix( $dbw, $testPrefix );
+               }
+       }
+
+       /**
+        * Gets master database connections for all of the ExternalStoreDB
+        * stores configured in $wgDefaultExternalStore.
+        *
+        * @return Database[] Array of Database master connections
+        */
+       protected static function getExternalStoreDatabaseConnections() {
+               global $wgDefaultExternalStore;
+
+               /** @var ExternalStoreDB $externalStoreDB */
+               $externalStoreDB = ExternalStore::getStoreObject( 'DB' );
+               $defaultArray = (array)$wgDefaultExternalStore;
+               $dbws = [];
+               foreach ( $defaultArray as $url ) {
+                       if ( strpos( $url, 'DB://' ) === 0 ) {
+                               list( $proto, $cluster ) = explode( '://', $url, 2 );
+                               // Avoid getMaster() because setupDatabaseWithTestPrefix()
+                               // requires Database instead of plain DBConnRef/IDatabase
+                               $dbws[] = $externalStoreDB->getMaster( $cluster );
+                       }
+               }
+
+               return $dbws;
+       }
+
+       /**
+        * Check whether ExternalStoreDB is being used
+        *
+        * @return bool True if it's being used
+        */
+       protected static function isUsingExternalStoreDB() {
+               global $wgDefaultExternalStore;
+               if ( !$wgDefaultExternalStore ) {
+                       return false;
+               }
+
+               $defaultArray = (array)$wgDefaultExternalStore;
+               foreach ( $defaultArray as $url ) {
+                       if ( strpos( $url, 'DB://' ) === 0 ) {
+                               return true;
+                       }
+               }
+
+               return false;
+       }
+
+       /**
+        * @throws LogicException if the given database connection is not a set up to use
+        * mock tables.
+        *
+        * @since 1.31 this is no longer private.
+        */
+       protected function ensureMockDatabaseConnection( IDatabase $db ) {
+               if ( $db->tablePrefix() !== $this->dbPrefix() ) {
+                       throw new LogicException(
+                               'Trying to delete mock tables, but table prefix does not indicate a mock database.'
+                       );
+               }
+       }
+
+       private static $schemaOverrideDefaults = [
+               'scripts' => [],
+               'create' => [],
+               'drop' => [],
+               'alter' => [],
+       ];
+
+       /**
+        * Stub. If a test suite needs to test against a specific database schema, it should
+        * override this method and return the appropriate information from it.
+        *
+        * 'create', 'drop' and 'alter' in the returned array should list all the tables affected
+        * by the 'scripts', even if the test is only interested in a subset of them, otherwise
+        * the overrides may not be fully cleaned up, leading to errors later.
+        *
+        * @param IMaintainableDatabase $db The DB connection to use for the mock schema.
+        *        May be used to check the current state of the schema, to determine what
+        *        overrides are needed.
+        *
+        * @return array An associative array with the following fields:
+        *  - 'scripts': any SQL scripts to run. If empty or not present, schema overrides are skipped.
+        * - 'create': A list of tables created (may or may not exist in the original schema).
+        * - 'drop': A list of tables dropped (expected to be present in the original schema).
+        * - 'alter': A list of tables altered (expected to be present in the original schema).
+        */
+       protected function getSchemaOverrides( IMaintainableDatabase $db ) {
+               return [];
+       }
+
+       /**
+        * Undoes the specified schema overrides..
+        * Called once per test class, just before addDataOnce().
+        *
+        * @param IMaintainableDatabase $db
+        * @param array $oldOverrides
+        */
+       private function undoSchemaOverrides( IMaintainableDatabase $db, $oldOverrides ) {
+               $this->ensureMockDatabaseConnection( $db );
+
+               $oldOverrides = $oldOverrides + self::$schemaOverrideDefaults;
+               $originalTables = $this->listOriginalTables( $db );
+
+               // Drop tables that need to be restored or removed.
+               $tablesToDrop = array_merge( $oldOverrides['create'], $oldOverrides['alter'] );
+
+               // Restore tables that have been dropped or created or altered,
+               // if they exist in the original schema.
+               $tablesToRestore = array_merge( $tablesToDrop, $oldOverrides['drop'] );
+               $tablesToRestore = array_intersect( $originalTables, $tablesToRestore );
+
+               if ( $tablesToDrop ) {
+                       $this->dropMockTables( $db, $tablesToDrop );
+               }
+
+               if ( $tablesToRestore ) {
+                       $this->recloneMockTables( $db, $tablesToRestore );
+
+                       // Reset the restored tables, mainly for the side effect of
+                       // re-calling $this->addCoreDBData() if necessary.
+                       $this->resetDB( $db, $tablesToRestore );
+               }
+       }
+
+       /**
+        * Applies the schema overrides returned by getSchemaOverrides(),
+        * after undoing any previously applied schema overrides.
+        * Called once per test class, just before addDataOnce().
+        */
+       private function setUpSchema( IMaintainableDatabase $db ) {
+               // Undo any active overrides.
+               $oldOverrides = $db->_schemaOverrides ?? self::$schemaOverrideDefaults;
+
+               if ( $oldOverrides['alter'] || $oldOverrides['create'] || $oldOverrides['drop'] ) {
+                       $this->undoSchemaOverrides( $db, $oldOverrides );
+                       unset( $db->_schemaOverrides );
+               }
+
+               // Determine new overrides.
+               $overrides = $this->getSchemaOverrides( $db ) + self::$schemaOverrideDefaults;
+
+               $extraKeys = array_diff(
+                       array_keys( $overrides ),
+                       array_keys( self::$schemaOverrideDefaults )
+               );
+
+               if ( $extraKeys ) {
+                       throw new InvalidArgumentException(
+                               'Schema override contains extra keys: ' . var_export( $extraKeys, true )
+                       );
+               }
+
+               if ( !$overrides['scripts'] ) {
+                       // no scripts to run
+                       return;
+               }
+
+               if ( !$overrides['create'] && !$overrides['drop'] && !$overrides['alter'] ) {
+                       throw new InvalidArgumentException(
+                               'Schema override scripts given, but no tables are declared to be '
+                               . 'created, dropped or altered.'
+                       );
+               }
+
+               $this->ensureMockDatabaseConnection( $db );
+
+               // Drop the tables that will be created by the schema scripts.
+               $originalTables = $this->listOriginalTables( $db );
+               $tablesToDrop = array_intersect( $originalTables, $overrides['create'] );
+
+               if ( $tablesToDrop ) {
+                       $this->dropMockTables( $db, $tablesToDrop );
+               }
+
+               // Run schema override scripts.
+               foreach ( $overrides['scripts'] as $script ) {
+                       $db->sourceFile(
+                               $script,
+                               null,
+                               null,
+                               __METHOD__,
+                               function ( $cmd ) {
+                                       return $this->mungeSchemaUpdateQuery( $cmd );
+                               }
+                       );
+               }
+
+               $db->_schemaOverrides = $overrides;
+       }
+
+       private function mungeSchemaUpdateQuery( $cmd ) {
+               return self::$useTemporaryTables
+                       ? preg_replace( '/\bCREATE\s+TABLE\b/i', 'CREATE TEMPORARY TABLE', $cmd )
+                       : $cmd;
+       }
+
+       /**
+        * Drops the given mock tables.
+        *
+        * @param IMaintainableDatabase $db
+        * @param array $tables
+        */
+       private function dropMockTables( IMaintainableDatabase $db, array $tables ) {
+               $this->ensureMockDatabaseConnection( $db );
+
+               foreach ( $tables as $tbl ) {
+                       $tbl = $db->tableName( $tbl );
+                       $db->query( "DROP TABLE IF EXISTS $tbl", __METHOD__ );
+               }
+       }
+
+       /**
+        * Lists all tables in the live database schema, without a prefix.
+        *
+        * @param IMaintainableDatabase $db
+        * @return array
+        */
+       private function listOriginalTables( IMaintainableDatabase $db ) {
+               if ( !isset( $db->_originalTablePrefix ) ) {
+                       throw new LogicException( 'No original table prefix know, cannot list tables!' );
+               }
+
+               $originalTables = $db->listTables( $db->_originalTablePrefix, __METHOD__ );
+
+               $unittestPrefixRegex = '/^' . preg_quote( $this->dbPrefix(), '/' ) . '/';
+               $originalPrefixRegex = '/^' . preg_quote( $db->_originalTablePrefix, '/' ) . '/';
+
+               $originalTables = array_filter(
+                       $originalTables,
+                       function ( $pt ) use ( $unittestPrefixRegex ) {
+                               return !preg_match( $unittestPrefixRegex, $pt );
+                       }
+               );
+
+               $originalTables = array_map(
+                       function ( $pt ) use ( $originalPrefixRegex ) {
+                               return preg_replace( $originalPrefixRegex, '', $pt );
+                       },
+                       $originalTables
+               );
+
+               return array_unique( $originalTables );
+       }
+
+       /**
+        * Re-clones the given mock tables to restore them based on the live database schema.
+        * The tables listed in $tables are expected to currently not exist, so dropMockTables()
+        * should be called first.
+        *
+        * @param IMaintainableDatabase $db
+        * @param array $tables
+        */
+       private function recloneMockTables( IMaintainableDatabase $db, array $tables ) {
+               $this->ensureMockDatabaseConnection( $db );
+
+               if ( !isset( $db->_originalTablePrefix ) ) {
+                       throw new LogicException( 'No original table prefix know, cannot restore tables!' );
+               }
+
+               $originalTables = $this->listOriginalTables( $db );
+               $tables = array_intersect( $tables, $originalTables );
+
+               $dbClone = new CloneDatabase( $db, $tables, $db->tablePrefix(), $db->_originalTablePrefix );
+               $dbClone->useTemporaryTables( self::$useTemporaryTables );
+
+               $dbClone->cloneTableStructure();
+       }
+
+       /**
+        * Empty all tables so they can be repopulated for tests
+        *
+        * @param Database $db|null Database to reset
+        * @param array $tablesUsed Tables to reset
+        */
+       private function resetDB( $db, $tablesUsed ) {
+               if ( $db ) {
+                       $userTables = [ 'user', 'user_groups', 'user_properties', 'actor' ];
+                       $pageTables = [
+                               'page', 'revision', 'ip_changes', 'revision_comment_temp', 'comment', 'archive',
+                               'revision_actor_temp', 'slots', 'content', 'content_models', 'slot_roles',
+                       ];
+                       $coreDBDataTables = array_merge( $userTables, $pageTables );
+
+                       // If any of the user or page tables were marked as used, we should clear all of them.
+                       if ( array_intersect( $tablesUsed, $userTables ) ) {
+                               $tablesUsed = array_unique( array_merge( $tablesUsed, $userTables ) );
+                               TestUserRegistry::clear();
+
+                               // Reset $wgUser, which is probably 127.0.0.1, as its loaded data is probably not valid
+                               // @todo Should we start setting $wgUser to something nondeterministic
+                               //  to encourage tests to be updated to not depend on it?
+                               global $wgUser;
+                               $wgUser->clearInstanceCache( $wgUser->mFrom );
+                       }
+                       if ( array_intersect( $tablesUsed, $pageTables ) ) {
+                               $tablesUsed = array_unique( array_merge( $tablesUsed, $pageTables ) );
+                       }
+
+                       // Postgres, Oracle, and MSSQL all use mwuser/pagecontent
+                       // instead of user/text. But Postgres does not remap the
+                       // table name in tableExists(), so we mark the real table
+                       // names as being used.
+                       if ( $db->getType() === 'postgres' ) {
+                               if ( in_array( 'user', $tablesUsed ) ) {
+                                       $tablesUsed[] = 'mwuser';
+                               }
+                               if ( in_array( 'text', $tablesUsed ) ) {
+                                       $tablesUsed[] = 'pagecontent';
+                               }
+                       }
+
+                       foreach ( $tablesUsed as $tbl ) {
+                               $this->truncateTable( $tbl, $db );
+                       }
+
+                       if ( array_intersect( $tablesUsed, $coreDBDataTables ) ) {
+                               // Reset services that may contain information relating to the truncated tables
+                               $this->overrideMwServices();
+                               // Re-add core DB data that was deleted
+                               $this->addCoreDBData();
+                       }
+               }
+       }
+
+       /**
+        * Empties the given table and resets any auto-increment counters.
+        * Will also purge caches associated with some well known tables.
+        * If the table is not know, this method just returns.
+        *
+        * @param string $tableName
+        * @param IDatabase|null $db
+        */
+       protected function truncateTable( $tableName, IDatabase $db = null ) {
+               if ( !$db ) {
+                       $db = $this->db;
+               }
+
+               if ( !$db->tableExists( $tableName ) ) {
+                       return;
+               }
+
+               $truncate = in_array( $db->getType(), [ 'oracle', 'mysql' ] );
+
+               if ( $truncate ) {
+                       $db->query( 'TRUNCATE TABLE ' . $db->tableName( $tableName ), __METHOD__ );
+               } else {
+                       $db->delete( $tableName, '*', __METHOD__ );
+               }
+
+               if ( $db instanceof DatabasePostgres || $db instanceof DatabaseSqlite ) {
+                       // Reset the table's sequence too.
+                       $db->resetSequenceForTable( $tableName, __METHOD__ );
+               }
+
+               // re-initialize site_stats table
+               if ( $tableName === 'site_stats' ) {
+                       SiteStatsInit::doPlaceholderInit();
+               }
+       }
+
+       private static function unprefixTable( &$tableName, $ind, $prefix ) {
+               $tableName = substr( $tableName, strlen( $prefix ) );
+       }
+
+       private static function isNotUnittest( $table ) {
+               return strpos( $table, self::DB_PREFIX ) !== 0;
+       }
+
+       /**
+        * @since 1.18
+        *
+        * @param IMaintainableDatabase $db
+        *
+        * @return array
+        */
+       public static function listTables( IMaintainableDatabase $db ) {
+               $prefix = $db->tablePrefix();
+               $tables = $db->listTables( $prefix, __METHOD__ );
+
+               if ( $db->getType() === 'mysql' ) {
+                       static $viewListCache = null;
+                       if ( $viewListCache === null ) {
+                               $viewListCache = $db->listViews( null, __METHOD__ );
+                       }
+                       // T45571: cannot clone VIEWs under MySQL
+                       $tables = array_diff( $tables, $viewListCache );
+               }
+               array_walk( $tables, [ __CLASS__, 'unprefixTable' ], $prefix );
+
+               // Don't duplicate test tables from the previous fataled run
+               $tables = array_filter( $tables, [ __CLASS__, 'isNotUnittest' ] );
+
+               if ( $db->getType() == 'sqlite' ) {
+                       $tables = array_flip( $tables );
+                       // these are subtables of searchindex and don't need to be duped/dropped separately
+                       unset( $tables['searchindex_content'] );
+                       unset( $tables['searchindex_segdir'] );
+                       unset( $tables['searchindex_segments'] );
+                       $tables = array_flip( $tables );
+               }
+
+               return $tables;
+       }
+
+       /**
+        * Copy test data from one database connection to another.
+        *
+        * This should only be used for small data sets.
+        *
+        * @param IDatabase $source
+        * @param IDatabase $target
+        */
+       public function copyTestData( IDatabase $source, IDatabase $target ) {
+               if ( $this->db->getType() === 'sqlite' ) {
+                       // SQLite uses a non-temporary copy of the searchindex table for testing,
+                       // which gets deleted and re-created when setting up the secondary connection,
+                       // causing "Error 17" when trying to copy the data. See T191863#4130112.
+                       throw new RuntimeException(
+                               'Setting up a secondary database connection with test data is currently not'
+                               . 'with SQLite. You may want to use markTestSkippedIfDbType() to bypass this issue.'
+                       );
+               }
+
+               $tables = self::listOriginalTables( $source );
+
+               foreach ( $tables as $table ) {
+                       $res = $source->select( $table, '*', [], __METHOD__ );
+                       $allRows = [];
+
+                       foreach ( $res as $row ) {
+                               $allRows[] = (array)$row;
+                       }
+
+                       $target->insert( $table, $allRows, __METHOD__, [ 'IGNORE' ] );
+               }
+       }
+
+       /**
+        * @throws MWException
+        * @since 1.18
+        */
+       protected function checkDbIsSupported() {
+               if ( !in_array( $this->db->getType(), $this->supportedDBs ) ) {
+                       throw new MWException( $this->db->getType() . " is not currently supported for unit testing." );
+               }
+       }
+
+       /**
+        * @since 1.18
+        * @param string $offset
+        * @return mixed
+        */
+       public function getCliArg( $offset ) {
+               return $this->cliArgs[$offset] ?? null;
+       }
+
+       /**
+        * @since 1.18
+        * @param string $offset
+        * @param mixed $value
+        */
+       public function setCliArg( $offset, $value ) {
+               $this->cliArgs[$offset] = $value;
+       }
+
+       /**
+        * Don't throw a warning if $function is deprecated and called later
+        *
+        * @since 1.19
+        *
+        * @param string $function
+        */
+       public function hideDeprecated( $function ) {
+               Wikimedia\suppressWarnings();
+               wfDeprecated( $function );
+               Wikimedia\restoreWarnings();
+       }
+
+       /**
+        * Asserts that the given database query yields the rows given by $expectedRows.
+        * The expected rows should be given as indexed (not associative) arrays, with
+        * the values given in the order of the columns in the $fields parameter.
+        * Note that the rows are sorted by the columns given in $fields.
+        *
+        * @since 1.20
+        *
+        * @param string|array $table The table(s) to query
+        * @param string|array $fields The columns to include in the result (and to sort by)
+        * @param string|array $condition "where" condition(s)
+        * @param array $expectedRows An array of arrays giving the expected rows.
+        * @param array $options Options for the query
+        * @param array $join_conds Join conditions for the query
+        *
+        * @throws MWException If this test cases's needsDB() method doesn't return true.
+        *         Test cases can use "@group Database" to enable database test support,
+        *         or list the tables under testing in $this->tablesUsed, or override the
+        *         needsDB() method.
+        */
+       protected function assertSelect(
+               $table, $fields, $condition, array $expectedRows, array $options = [], array $join_conds = []
+       ) {
+               if ( !$this->needsDB() ) {
+                       throw new MWException( 'When testing database state, the test cases\'s needDB()' .
+                               ' method should return true. Use @group Database or $this->tablesUsed.' );
+               }
+
+               $db = wfGetDB( DB_REPLICA );
+
+               $res = $db->select(
+                       $table,
+                       $fields,
+                       $condition,
+                       wfGetCaller(),
+                       $options + [ 'ORDER BY' => $fields ],
+                       $join_conds
+               );
+               $this->assertNotEmpty( $res, "query failed: " . $db->lastError() );
+
+               $i = 0;
+
+               foreach ( $expectedRows as $expected ) {
+                       $r = $res->fetchRow();
+                       self::stripStringKeys( $r );
+
+                       $i += 1;
+                       $this->assertNotEmpty( $r, "row #$i missing" );
+
+                       $this->assertEquals( $expected, $r, "row #$i mismatches" );
+               }
+
+               $r = $res->fetchRow();
+               self::stripStringKeys( $r );
+
+               $this->assertFalse( $r, "found extra row (after #$i)" );
+       }
+
+       /**
+        * Utility method taking an array of elements and wrapping
+        * each element in its own array. Useful for data providers
+        * that only return a single argument.
+        *
+        * @since 1.20
+        *
+        * @param array $elements
+        *
+        * @return array
+        */
+       protected function arrayWrap( array $elements ) {
+               return array_map(
+                       function ( $element ) {
+                               return [ $element ];
+                       },
+                       $elements
+               );
+       }
+
+       /**
+        * Assert that two arrays are equal. By default this means that both arrays need to hold
+        * the same set of values. Using additional arguments, order and associated key can also
+        * be set as relevant.
+        *
+        * @since 1.20
+        *
+        * @param array $expected
+        * @param array $actual
+        * @param bool $ordered If the order of the values should match
+        * @param bool $named If the keys should match
+        */
+       protected function assertArrayEquals( array $expected, array $actual,
+               $ordered = false, $named = false
+       ) {
+               if ( !$ordered ) {
+                       $this->objectAssociativeSort( $expected );
+                       $this->objectAssociativeSort( $actual );
+               }
+
+               if ( !$named ) {
+                       $expected = array_values( $expected );
+                       $actual = array_values( $actual );
+               }
+
+               call_user_func_array(
+                       [ $this, 'assertEquals' ],
+                       array_merge( [ $expected, $actual ], array_slice( func_get_args(), 4 ) )
+               );
+       }
+
+       /**
+        * Put each HTML element on its own line and then equals() the results
+        *
+        * Use for nicely formatting of PHPUnit diff output when comparing very
+        * simple HTML
+        *
+        * @since 1.20
+        *
+        * @param string $expected HTML on oneline
+        * @param string $actual HTML on oneline
+        * @param string $msg Optional message
+        */
+       protected function assertHTMLEquals( $expected, $actual, $msg = '' ) {
+               $expected = str_replace( '>', ">\n", $expected );
+               $actual = str_replace( '>', ">\n", $actual );
+
+               $this->assertEquals( $expected, $actual, $msg );
+       }
+
+       /**
+        * Does an associative sort that works for objects.
+        *
+        * @since 1.20
+        *
+        * @param array &$array
+        */
+       protected function objectAssociativeSort( array &$array ) {
+               uasort(
+                       $array,
+                       function ( $a, $b ) {
+                               return serialize( $a ) <=> serialize( $b );
+                       }
+               );
+       }
+
+       /**
+        * Utility function for eliminating all string keys from an array.
+        * Useful to turn a database result row as returned by fetchRow() into
+        * a pure indexed array.
+        *
+        * @since 1.20
+        *
+        * @param mixed &$r The array to remove string keys from.
+        */
+       protected static function stripStringKeys( &$r ) {
+               if ( !is_array( $r ) ) {
+                       return;
+               }
+
+               foreach ( $r as $k => $v ) {
+                       if ( is_string( $k ) ) {
+                               unset( $r[$k] );
+                       }
+               }
+       }
+
+       /**
+        * Asserts that the provided variable is of the specified
+        * internal type or equals the $value argument. This is useful
+        * for testing return types of functions that return a certain
+        * type or *value* when not set or on error.
+        *
+        * @since 1.20
+        *
+        * @param string $type
+        * @param mixed $actual
+        * @param mixed $value
+        * @param string $message
+        */
+       protected function assertTypeOrValue( $type, $actual, $value = false, $message = '' ) {
+               if ( $actual === $value ) {
+                       $this->assertTrue( true, $message );
+               } else {
+                       $this->assertType( $type, $actual, $message );
+               }
+       }
+
+       /**
+        * Asserts the type of the provided value. This can be either
+        * in internal type such as boolean or integer, or a class or
+        * interface the value extends or implements.
+        *
+        * @since 1.20
+        *
+        * @param string $type
+        * @param mixed $actual
+        * @param string $message
+        */
+       protected function assertType( $type, $actual, $message = '' ) {
+               if ( class_exists( $type ) || interface_exists( $type ) ) {
+                       $this->assertInstanceOf( $type, $actual, $message );
+               } else {
+                       $this->assertInternalType( $type, $actual, $message );
+               }
+       }
+
+       /**
+        * Returns true if the given namespace defaults to Wikitext
+        * according to $wgNamespaceContentModels
+        *
+        * @param int $ns The namespace ID to check
+        *
+        * @return bool
+        * @since 1.21
+        */
+       protected function isWikitextNS( $ns ) {
+               global $wgNamespaceContentModels;
+
+               if ( isset( $wgNamespaceContentModels[$ns] ) ) {
+                       return $wgNamespaceContentModels[$ns] === CONTENT_MODEL_WIKITEXT;
+               }
+
+               return true;
+       }
+
+       /**
+        * Returns the ID of a namespace that defaults to Wikitext.
+        *
+        * @throws MWException If there is none.
+        * @return int The ID of the wikitext Namespace
+        * @since 1.21
+        */
+       protected function getDefaultWikitextNS() {
+               global $wgNamespaceContentModels;
+
+               static $wikitextNS = null; // this is not going to change
+               if ( $wikitextNS !== null ) {
+                       return $wikitextNS;
+               }
+
+               // quickly short out on most common case:
+               if ( !isset( $wgNamespaceContentModels[NS_MAIN] ) ) {
+                       return NS_MAIN;
+               }
+
+               // NOTE: prefer content namespaces
+               $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
+               $namespaces = array_unique( array_merge(
+                       $nsInfo->getContentNamespaces(),
+                       [ NS_MAIN, NS_HELP, NS_PROJECT ], // prefer these
+                       $nsInfo->getValidNamespaces()
+               ) );
+
+               $namespaces = array_diff( $namespaces, [
+                       NS_FILE, NS_CATEGORY, NS_MEDIAWIKI, NS_USER // don't mess with magic namespaces
+               ] );
+
+               $talk = array_filter( $namespaces, function ( $ns ) use ( $nsInfo ) {
+                       return $nsInfo->isTalk( $ns );
+               } );
+
+               // prefer non-talk pages
+               $namespaces = array_diff( $namespaces, $talk );
+               $namespaces = array_merge( $namespaces, $talk );
+
+               // check default content model of each namespace
+               foreach ( $namespaces as $ns ) {
+                       if ( !isset( $wgNamespaceContentModels[$ns] ) ||
+                               $wgNamespaceContentModels[$ns] === CONTENT_MODEL_WIKITEXT
+                       ) {
+                               $wikitextNS = $ns;
+
+                               return $wikitextNS;
+                       }
+               }
+
+               // give up
+               // @todo Inside a test, we could skip the test as incomplete.
+               //        But frequently, this is used in fixture setup.
+               throw new MWException( "No namespace defaults to wikitext!" );
+       }
+
+       /**
+        * Check, if $wgDiff3 is set and ready to merge
+        * Will mark the calling test as skipped, if not ready
+        *
+        * @since 1.21
+        */
+       protected function markTestSkippedIfNoDiff3() {
+               global $wgDiff3;
+
+               # This check may also protect against code injection in
+               # case of broken installations.
+               Wikimedia\suppressWarnings();
+               $haveDiff3 = $wgDiff3 && file_exists( $wgDiff3 );
+               Wikimedia\restoreWarnings();
+
+               if ( !$haveDiff3 ) {
+                       $this->markTestSkipped( "Skip test, since diff3 is not configured" );
+               }
+       }
+
+       /**
+        * Check if $extName is a loaded PHP extension, will skip the
+        * test whenever it is not loaded.
+        *
+        * @since 1.21
+        * @param string $extName
+        * @return bool
+        */
+       protected function checkPHPExtension( $extName ) {
+               $loaded = extension_loaded( $extName );
+               if ( !$loaded ) {
+                       $this->markTestSkipped( "PHP extension '$extName' is not loaded, skipping." );
+               }
+
+               return $loaded;
+       }
+
+       /**
+        * Skip the test if using the specified database type
+        *
+        * @param string $type Database type
+        * @since 1.32
+        */
+       protected function markTestSkippedIfDbType( $type ) {
+               if ( $this->db->getType() === $type ) {
+                       $this->markTestSkipped( "The $type database type isn't supported for this test" );
+               }
+       }
+
+       /**
+        * Used as a marker to prevent wfResetOutputBuffers from breaking PHPUnit.
+        * @param string $buffer
+        * @return string
+        */
+       public static function wfResetOutputBuffersBarrier( $buffer ) {
+               return $buffer;
+       }
+
+       /**
+        * Create a temporary hook handler which will be reset by tearDown.
+        * This replaces other handlers for the same hook.
+        * @param string $hookName Hook name
+        * @param mixed $handler Value suitable for a hook handler
+        * @since 1.28
+        */
+       protected function setTemporaryHook( $hookName, $handler ) {
+               $this->mergeMwGlobalArrayValue( 'wgHooks', [ $hookName => [ $handler ] ] );
+       }
+
+       /**
+        * Check whether file contains given data.
+        * @param string $fileName
+        * @param string $actualData
+        * @param bool $createIfMissing If true, and file does not exist, create it with given data
+        *                              and skip the test.
+        * @param string $msg
+        * @since 1.30
+        */
+       protected function assertFileContains(
+               $fileName,
+               $actualData,
+               $createIfMissing = false,
+               $msg = ''
+       ) {
+               if ( $createIfMissing ) {
+                       if ( !file_exists( $fileName ) ) {
+                               file_put_contents( $fileName, $actualData );
+                               $this->markTestSkipped( "Data file $fileName does not exist" );
+                       }
+               } else {
+                       self::assertFileExists( $fileName );
+               }
+               self::assertEquals( file_get_contents( $fileName ), $actualData, $msg );
+       }
+
+       /**
+        * Edits or creates a page/revision
+        * @param string $pageName Page title
+        * @param string $text Content of the page
+        * @param string $summary Optional summary string for the revision
+        * @param int $defaultNs Optional namespace id
+        * @param User|null $user If null, static::getTestSysop()->getUser() is used.
+        * @return Status Object as returned by WikiPage::doEditContent()
+        * @throws MWException If this test cases's needsDB() method doesn't return true.
+        *         Test cases can use "@group Database" to enable database test support,
+        *         or list the tables under testing in $this->tablesUsed, or override the
+        *         needsDB() method.
+        */
+       protected function editPage(
+               $pageName,
+               $text,
+               $summary = '',
+               $defaultNs = NS_MAIN,
+               User $user = null
+       ) {
+               if ( !$this->needsDB() ) {
+                       throw new MWException( 'When testing which pages, the test cases\'s needsDB()' .
+                               ' method should return true. Use @group Database or $this->tablesUsed.' );
+               }
+
+               $title = Title::newFromText( $pageName, $defaultNs );
+               $page = WikiPage::factory( $title );
+
+               return $page->doEditContent(
+                       ContentHandler::makeContent( $text, $title ),
+                       $summary,
+                       0,
+                       false,
+                       $user
+               );
+       }
+
+       /**
+        * Revision-deletes a revision.
+        *
+        * @param Revision|int $rev Revision to delete
+        * @param array $value Keys are Revision::DELETED_* flags.  Values are 1 to set the bit, 0 to
+        *   clear, -1 to leave alone.  (All other values also clear the bit.)
+        * @param string $comment Deletion comment
+        */
+       protected function revisionDelete(
+               $rev, array $value = [ Revision::DELETED_TEXT => 1 ], $comment = ''
+       ) {
+               if ( is_int( $rev ) ) {
+                       $rev = Revision::newFromId( $rev );
+               }
+               RevisionDeleter::createList(
+                       'revision', RequestContext::getMain(), $rev->getTitle(), [ $rev->getId() ]
+               )->setVisibility( [
+                       'value' => $value,
+                       'comment' => $comment,
+               ] );
+       }
+
+       /**
+        * Returns a PHPUnit constraint that matches anything other than a fixed set of values. This can
+        * be used to whitelist values, e.g.
+        *   $mock->expects( $this->never() )->method( $this->anythingBut( 'foo', 'bar' ) );
+        * which will throw if any unexpected method is called.
+        *
+        * @param mixed ...$values Values that are not matched
+        */
+       protected function anythingBut( ...$values ) {
+               return $this->logicalNot( $this->logicalOr(
+                       ...array_map( [ $this, 'matches' ], $values )
+               ) );
+       }
+}
+
+class_alias( 'MediaWikiIntegrationTestCase', 'MediaWikiTestCase' );
diff --git a/tests/phpunit/MediaWikiTestCase.php b/tests/phpunit/MediaWikiTestCase.php
deleted file mode 100644 (file)
index 6c8b51f..0000000
+++ /dev/null
@@ -1,2453 +0,0 @@
-<?php
-
-use MediaWiki\Logger\LegacySpi;
-use MediaWiki\Logger\LoggerFactory;
-use MediaWiki\Logger\MonologSpi;
-use MediaWiki\Logger\LogCapturingSpi;
-use MediaWiki\MediaWikiServices;
-use Psr\Log\LoggerInterface;
-use Wikimedia\Rdbms\IDatabase;
-use Wikimedia\Rdbms\IMaintainableDatabase;
-use Wikimedia\Rdbms\Database;
-use Wikimedia\TestingAccessWrapper;
-
-/**
- * @since 1.18
- */
-abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
-
-       use MediaWikiCoversValidator;
-       use PHPUnit4And6Compat;
-
-       /**
-        * The original service locator. This is overridden during setUp().
-        *
-        * @var MediaWikiServices|null
-        */
-       private static $originalServices;
-
-       /**
-        * The local service locator, created during setUp().
-        * @var MediaWikiServices
-        */
-       private $localServices;
-
-       /**
-        * $called tracks whether the setUp and tearDown method has been called.
-        * class extending MediaWikiTestCase usually override setUp and tearDown
-        * but forget to call the parent.
-        *
-        * The array format takes a method name as key and anything as a value.
-        * By asserting the key exist, we know the child class has called the
-        * parent.
-        *
-        * This property must be private, we do not want child to override it,
-        * they should call the appropriate parent method instead.
-        */
-       private $called = [];
-
-       /**
-        * @var TestUser[]
-        * @since 1.20
-        */
-       public static $users;
-
-       /**
-        * Primary database
-        *
-        * @var Database
-        * @since 1.18
-        */
-       protected $db;
-
-       /**
-        * @var array
-        * @since 1.19
-        */
-       protected $tablesUsed = []; // tables with data
-
-       private static $useTemporaryTables = true;
-       private static $reuseDB = false;
-       private static $dbSetup = false;
-       private static $oldTablePrefix = '';
-
-       /**
-        * Original value of PHP's error_reporting setting.
-        *
-        * @var int
-        */
-       private $phpErrorLevel;
-
-       /**
-        * Holds the paths of temporary files/directories created through getNewTempFile,
-        * and getNewTempDirectory
-        *
-        * @var array
-        */
-       private $tmpFiles = [];
-
-       /**
-        * Holds original values of MediaWiki configuration settings
-        * to be restored in tearDown().
-        * See also setMwGlobals().
-        * @var array
-        */
-       private $mwGlobals = [];
-
-       /**
-        * Holds list of MediaWiki configuration settings to be unset in tearDown().
-        * See also setMwGlobals().
-        * @var array
-        */
-       private $mwGlobalsToUnset = [];
-
-       /**
-        * Holds original values of ini settings to be restored
-        * in tearDown().
-        * @see setIniSettings()
-        * @var array
-        */
-       private $iniSettings = [];
-
-       /**
-        * Holds original loggers which have been replaced by setLogger()
-        * @var LoggerInterface[]
-        */
-       private $loggers = [];
-
-       /**
-        * The CLI arguments passed through from phpunit.php
-        * @var array
-        */
-       private $cliArgs = [];
-
-       /**
-        * Holds a list of services that were overridden with setService().  Used for printing an error
-        * if overrideMwServices() overrides a service that was previously set.
-        * @var string[]
-        */
-       private $overriddenServices = [];
-
-       /**
-        * Table name prefixes. Oracle likes it shorter.
-        */
-       const DB_PREFIX = 'unittest_';
-       const ORA_DB_PREFIX = 'ut_';
-
-       /**
-        * @var array
-        * @since 1.18
-        */
-       protected $supportedDBs = [
-               'mysql',
-               'sqlite',
-               'postgres',
-               'oracle'
-       ];
-
-       public function __construct( $name = null, array $data = [], $dataName = '' ) {
-               parent::__construct( $name, $data, $dataName );
-
-               $this->backupGlobals = false;
-               $this->backupStaticAttributes = false;
-       }
-
-       public function __destruct() {
-               // Complain if self::setUp() was called, but not self::tearDown()
-               // $this->called['setUp'] will be checked by self::testMediaWikiTestCaseParentSetupCalled()
-               if ( isset( $this->called['setUp'] ) && !isset( $this->called['tearDown'] ) ) {
-                       throw new MWException( static::class . "::tearDown() must call parent::tearDown()" );
-               }
-       }
-
-       public static function setUpBeforeClass() {
-               parent::setUpBeforeClass();
-
-               // Get the original service locator
-               if ( !self::$originalServices ) {
-                       self::$originalServices = MediaWikiServices::getInstance();
-               }
-       }
-
-       /**
-        * Convenience method for getting an immutable test user
-        *
-        * @since 1.28
-        *
-        * @param string|string[] $groups Groups the test user should be in.
-        * @return TestUser
-        */
-       public static function getTestUser( $groups = [] ) {
-               return TestUserRegistry::getImmutableTestUser( $groups );
-       }
-
-       /**
-        * Convenience method for getting a mutable test user
-        *
-        * @since 1.28
-        *
-        * @param string|string[] $groups Groups the test user should be added in.
-        * @return TestUser
-        */
-       public static function getMutableTestUser( $groups = [] ) {
-               return TestUserRegistry::getMutableTestUser( __CLASS__, $groups );
-       }
-
-       /**
-        * Convenience method for getting an immutable admin test user
-        *
-        * @since 1.28
-        *
-        * @param string[] $groups Groups the test user should be added to.
-        * @return TestUser
-        */
-       public static function getTestSysop() {
-               return self::getTestUser( [ 'sysop', 'bureaucrat' ] );
-       }
-
-       /**
-        * Returns a WikiPage representing an existing page.
-        *
-        * @since 1.32
-        *
-        * @param Title|string|null $title
-        * @return WikiPage
-        * @throws MWException If this test cases's needsDB() method doesn't return true.
-        *         Test cases can use "@group Database" to enable database test support,
-        *         or list the tables under testing in $this->tablesUsed, or override the
-        *         needsDB() method.
-        */
-       protected function getExistingTestPage( $title = null ) {
-               if ( !$this->needsDB() ) {
-                       throw new MWException( 'When testing which pages, the test cases\'s needsDB()' .
-                               ' method should return true. Use @group Database or $this->tablesUsed.' );
-               }
-
-               $title = ( $title === null ) ? 'UTPage' : $title;
-               $title = is_string( $title ) ? Title::newFromText( $title ) : $title;
-               $page = WikiPage::factory( $title );
-
-               if ( !$page->exists() ) {
-                       $user = self::getTestSysop()->getUser();
-                       $page->doEditContent(
-                               new WikitextContent( 'UTContent' ),
-                               'UTPageSummary',
-                               EDIT_NEW | EDIT_SUPPRESS_RC,
-                               false,
-                               $user
-                       );
-               }
-
-               return $page;
-       }
-
-       /**
-        * Returns a WikiPage representing a non-existing page.
-        *
-        * @since 1.32
-        *
-        * @param Title|string|null $title
-        * @return WikiPage
-        * @throws MWException If this test cases's needsDB() method doesn't return true.
-        *         Test cases can use "@group Database" to enable database test support,
-        *         or list the tables under testing in $this->tablesUsed, or override the
-        *         needsDB() method.
-        */
-       protected function getNonexistingTestPage( $title = null ) {
-               if ( !$this->needsDB() ) {
-                       throw new MWException( 'When testing which pages, the test cases\'s needsDB()' .
-                               ' method should return true. Use @group Database or $this->tablesUsed.' );
-               }
-
-               $title = ( $title === null ) ? 'UTPage-' . rand( 0, 100000 ) : $title;
-               $title = is_string( $title ) ? Title::newFromText( $title ) : $title;
-               $page = WikiPage::factory( $title );
-
-               if ( $page->exists() ) {
-                       $page->doDeleteArticle( 'Testing' );
-               }
-
-               return $page;
-       }
-
-       /**
-        * @deprecated since 1.32
-        */
-       public static function prepareServices( Config $bootstrapConfig ) {
-       }
-
-       /**
-        * Create a config suitable for testing, based on a base config, default overrides,
-        * and custom overrides.
-        *
-        * @param Config|null $baseConfig
-        * @param Config|null $customOverrides
-        *
-        * @return Config
-        */
-       private static function makeTestConfig(
-               Config $baseConfig = null,
-               Config $customOverrides = null
-       ) {
-               $defaultOverrides = new HashConfig();
-
-               if ( !$baseConfig ) {
-                       $baseConfig = self::$originalServices->getBootstrapConfig();
-               }
-
-               /* Some functions require some kind of caching, and will end up using the db,
-                * which we can't allow, as that would open a new connection for mysql.
-                * Replace with a HashBag. They would not be going to persist anyway.
-                */
-               $hashCache = [ 'class' => HashBagOStuff::class, 'reportDupes' => false ];
-               $objectCaches = [
-                               CACHE_DB => $hashCache,
-                               CACHE_ACCEL => $hashCache,
-                               CACHE_MEMCACHED => $hashCache,
-                               'apc' => $hashCache,
-                               'apcu' => $hashCache,
-                               'wincache' => $hashCache,
-                       ] + $baseConfig->get( 'ObjectCaches' );
-
-               $defaultOverrides->set( 'ObjectCaches', $objectCaches );
-               $defaultOverrides->set( 'MainCacheType', CACHE_NONE );
-               $defaultOverrides->set( 'JobTypeConf', [ 'default' => [ 'class' => JobQueueMemory::class ] ] );
-
-               // Use a fast hash algorithm to hash passwords.
-               $defaultOverrides->set( 'PasswordDefault', 'A' );
-
-               $testConfig = $customOverrides
-                       ? new MultiConfig( [ $customOverrides, $defaultOverrides, $baseConfig ] )
-                       : new MultiConfig( [ $defaultOverrides, $baseConfig ] );
-
-               return $testConfig;
-       }
-
-       /**
-        * @param ConfigFactory $oldFactory
-        * @param Config[] $configurations
-        *
-        * @return Closure
-        */
-       private static function makeTestConfigFactoryInstantiator(
-               ConfigFactory $oldFactory,
-               array $configurations
-       ) {
-               return function ( MediaWikiServices $services ) use ( $oldFactory, $configurations ) {
-                       $factory = new ConfigFactory();
-
-                       // clone configurations from $oldFactory that are not overwritten by $configurations
-                       $namesToClone = array_diff(
-                               $oldFactory->getConfigNames(),
-                               array_keys( $configurations )
-                       );
-
-                       foreach ( $namesToClone as $name ) {
-                               $factory->register( $name, $oldFactory->makeConfig( $name ) );
-                       }
-
-                       foreach ( $configurations as $name => $config ) {
-                               $factory->register( $name, $config );
-                       }
-
-                       return $factory;
-               };
-       }
-
-       /**
-        * Resets some non-service singleton instances and other static caches. It's not necessary to
-        * reset services here.
-        */
-       public static function resetNonServiceCaches() {
-               global $wgRequest, $wgJobClasses;
-
-               User::resetGetDefaultOptionsForTestsOnly();
-               foreach ( $wgJobClasses as $type => $class ) {
-                       JobQueueGroup::singleton()->get( $type )->delete();
-               }
-               JobQueueGroup::destroySingletons();
-
-               ObjectCache::clear();
-               FileBackendGroup::destroySingleton();
-               DeferredUpdates::clearPendingUpdates();
-
-               // TODO: move global state into MediaWikiServices
-               RequestContext::resetMain();
-               if ( session_id() !== '' ) {
-                       session_write_close();
-                       session_id( '' );
-               }
-
-               $wgRequest = new FauxRequest();
-               MediaWiki\Session\SessionManager::resetCache();
-       }
-
-       public function run( PHPUnit_Framework_TestResult $result = null ) {
-               if ( $result instanceof MediaWikiTestResult ) {
-                       $this->cliArgs = $result->getMediaWikiCliArgs();
-               }
-               $this->overrideMwServices();
-
-               if ( $this->needsDB() && !$this->isTestInDatabaseGroup() ) {
-                       throw new Exception(
-                               get_class( $this ) . ' apparently needsDB but is not in the Database group'
-                       );
-               }
-
-               $needsResetDB = false;
-               if ( !self::$dbSetup || $this->needsDB() ) {
-                       // set up a DB connection for this test to use
-
-                       self::$useTemporaryTables = !$this->getCliArg( 'use-normal-tables' );
-                       self::$reuseDB = $this->getCliArg( 'reuse-db' );
-
-                       $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
-                       $this->db = $lb->getConnection( DB_MASTER );
-
-                       $this->checkDbIsSupported();
-
-                       if ( !self::$dbSetup ) {
-                               $this->setupAllTestDBs();
-                               $this->addCoreDBData();
-                       }
-
-                       // TODO: the DB setup should be done in setUpBeforeClass(), so the test DB
-                       // is available in subclass's setUpBeforeClass() and setUp() methods.
-                       // This would also remove the need for the HACK that is oncePerClass().
-                       if ( $this->oncePerClass() ) {
-                               $this->setUpSchema( $this->db );
-                               $this->resetDB( $this->db, $this->tablesUsed );
-                               $this->addDBDataOnce();
-                       }
-
-                       $this->addDBData();
-                       $needsResetDB = true;
-               }
-
-               parent::run( $result );
-
-               // We don't mind if we override already-overridden services during cleanup
-               $this->overriddenServices = [];
-
-               if ( $needsResetDB ) {
-                       $this->resetDB( $this->db, $this->tablesUsed );
-               }
-
-               self::restoreMwServices();
-               $this->localServices = null;
-       }
-
-       /**
-        * @return bool
-        */
-       private function oncePerClass() {
-               // Remember current test class in the database connection,
-               // so we know when we need to run addData.
-
-               $class = static::class;
-
-               $first = !isset( $this->db->_hasDataForTestClass )
-                       || $this->db->_hasDataForTestClass !== $class;
-
-               $this->db->_hasDataForTestClass = $class;
-               return $first;
-       }
-
-       /**
-        * @since 1.21
-        *
-        * @return bool
-        */
-       public function usesTemporaryTables() {
-               return self::$useTemporaryTables;
-       }
-
-       /**
-        * Obtains a new temporary file name
-        *
-        * The obtained filename is enlisted to be removed upon tearDown
-        *
-        * @since 1.20
-        *
-        * @return string Absolute name of the temporary file
-        */
-       protected function getNewTempFile() {
-               $fileName = tempnam(
-                       wfTempDir(),
-                       // Avoid backslashes here as they result in inconsistent results
-                       // between Windows and other OS, as well as between functions
-                       // that try to normalise these in one or both directions.
-                       // For example, tempnam rejects directory separators in the prefix which
-                       // means it rejects any namespaced class on Windows.
-                       // And then there is, wfMkdirParents which normalises paths always
-                       // whereas most other PHP and MW functions do not.
-                       'MW_PHPUnit_' . strtr( static::class, [ '\\' => '_' ] ) . '_'
-               );
-               $this->tmpFiles[] = $fileName;
-
-               return $fileName;
-       }
-
-       /**
-        * obtains a new temporary directory
-        *
-        * The obtained directory is enlisted to be removed (recursively with all its contained
-        * files) upon tearDown.
-        *
-        * @since 1.20
-        *
-        * @return string Absolute name of the temporary directory
-        */
-       protected function getNewTempDirectory() {
-               // Starting of with a temporary *file*.
-               $fileName = $this->getNewTempFile();
-
-               // Converting the temporary file to a *directory*.
-               // The following is not atomic, but at least we now have a single place,
-               // where temporary directory creation is bundled and can be improved.
-               unlink( $fileName );
-               // If this fails for some reason, PHP will warn and fail the test.
-               mkdir( $fileName, 0777, /* recursive = */ true );
-
-               return $fileName;
-       }
-
-       protected function setUp() {
-               parent::setUp();
-               $this->called['setUp'] = true;
-
-               $this->phpErrorLevel = intval( ini_get( 'error_reporting' ) );
-
-               $this->overriddenServices = [];
-
-               // Cleaning up temporary files
-               foreach ( $this->tmpFiles as $fileName ) {
-                       if ( is_file( $fileName ) || ( is_link( $fileName ) ) ) {
-                               unlink( $fileName );
-                       } elseif ( is_dir( $fileName ) ) {
-                               wfRecursiveRemoveDir( $fileName );
-                       }
-               }
-
-               if ( $this->needsDB() && $this->db ) {
-                       // Clean up open transactions
-                       while ( $this->db->trxLevel() > 0 ) {
-                               $this->db->rollback( __METHOD__, 'flush' );
-                       }
-                       // Check for unsafe queries
-                       if ( $this->db->getType() === 'mysql' ) {
-                               $this->db->query( "SET sql_mode = 'STRICT_ALL_TABLES'", __METHOD__ );
-                       }
-               }
-
-               // Reset all caches between tests.
-               self::resetNonServiceCaches();
-
-               // XXX: reset maintenance triggers
-               // Hook into period lag checks which often happen in long-running scripts
-               $lbFactory = $this->localServices->getDBLoadBalancerFactory();
-               Maintenance::setLBFactoryTriggers( $lbFactory, $this->localServices->getMainConfig() );
-
-               ob_start( 'MediaWikiTestCase::wfResetOutputBuffersBarrier' );
-       }
-
-       protected function addTmpFiles( $files ) {
-               $this->tmpFiles = array_merge( $this->tmpFiles, (array)$files );
-       }
-
-       // @todo Make const when we no longer support HHVM (T192166)
-       private static $namespaceAffectingSettings = [
-               'wgAllowImageMoving',
-               'wgCanonicalNamespaceNames',
-               'wgCapitalLinkOverrides',
-               'wgCapitalLinks',
-               'wgContentNamespaces',
-               'wgExtensionMessagesFiles',
-               'wgExtensionNamespaces',
-               'wgExtraNamespaces',
-               'wgExtraSignatureNamespaces',
-               'wgNamespaceContentModels',
-               'wgNamespaceProtection',
-               'wgNamespacesWithSubpages',
-               'wgNonincludableNamespaces',
-               'wgRestrictionLevels',
-       ];
-
-       protected function tearDown() {
-               global $wgRequest, $wgSQLMode;
-
-               $status = ob_get_status();
-               if ( isset( $status['name'] ) &&
-                       $status['name'] === 'MediaWikiTestCase::wfResetOutputBuffersBarrier'
-               ) {
-                       ob_end_flush();
-               }
-
-               $this->called['tearDown'] = true;
-               // Cleaning up temporary files
-               foreach ( $this->tmpFiles as $fileName ) {
-                       if ( is_file( $fileName ) || ( is_link( $fileName ) ) ) {
-                               unlink( $fileName );
-                       } elseif ( is_dir( $fileName ) ) {
-                               wfRecursiveRemoveDir( $fileName );
-                       }
-               }
-
-               if ( $this->needsDB() && $this->db ) {
-                       // Clean up open transactions
-                       while ( $this->db->trxLevel() > 0 ) {
-                               $this->db->rollback( __METHOD__, 'flush' );
-                       }
-                       if ( $this->db->getType() === 'mysql' ) {
-                               $this->db->query( "SET sql_mode = " . $this->db->addQuotes( $wgSQLMode ),
-                                       __METHOD__ );
-                       }
-               }
-
-               // Re-enable any disabled deprecation warnings
-               MWDebug::clearLog();
-               // Restore mw globals
-               foreach ( $this->mwGlobals as $key => $value ) {
-                       $GLOBALS[$key] = $value;
-               }
-               foreach ( $this->mwGlobalsToUnset as $value ) {
-                       unset( $GLOBALS[$value] );
-               }
-               foreach ( $this->iniSettings as $name => $value ) {
-                       ini_set( $name, $value );
-               }
-               if (
-                       array_intersect( self::$namespaceAffectingSettings, array_keys( $this->mwGlobals ) ) ||
-                       array_intersect( self::$namespaceAffectingSettings, $this->mwGlobalsToUnset )
-               ) {
-                       $this->resetNamespaces();
-               }
-               $this->mwGlobals = [];
-               $this->mwGlobalsToUnset = [];
-               $this->restoreLoggers();
-
-               // TODO: move global state into MediaWikiServices
-               RequestContext::resetMain();
-               if ( session_id() !== '' ) {
-                       session_write_close();
-                       session_id( '' );
-               }
-               $wgRequest = new FauxRequest();
-               MediaWiki\Session\SessionManager::resetCache();
-               MediaWiki\Auth\AuthManager::resetCache();
-
-               $phpErrorLevel = intval( ini_get( 'error_reporting' ) );
-
-               if ( $phpErrorLevel !== $this->phpErrorLevel ) {
-                       ini_set( 'error_reporting', $this->phpErrorLevel );
-
-                       $oldHex = strtoupper( dechex( $this->phpErrorLevel ) );
-                       $newHex = strtoupper( dechex( $phpErrorLevel ) );
-                       $message = "PHP error_reporting setting was left dirty: "
-                               . "was 0x$oldHex before test, 0x$newHex after test!";
-
-                       $this->fail( $message );
-               }
-
-               parent::tearDown();
-       }
-
-       /**
-        * Make sure MediaWikiTestCase extending classes have called their
-        * parent setUp method
-        *
-        * With strict coverage activated in PHP_CodeCoverage, this test would be
-        * marked as risky without the following annotation (T152923).
-        * @coversNothing
-        */
-       final public function testMediaWikiTestCaseParentSetupCalled() {
-               $this->assertArrayHasKey( 'setUp', $this->called,
-                       static::class . '::setUp() must call parent::setUp()'
-               );
-       }
-
-       /**
-        * Sets a service, maintaining a stashed version of the previous service to be
-        * restored in tearDown
-        *
-        * @since 1.27
-        *
-        * @param string $name
-        * @param object $object
-        */
-       protected function setService( $name, $object ) {
-               if ( !$this->localServices ) {
-                       throw new Exception( __METHOD__ . ' must be called after MediaWikiTestCase::run()' );
-               }
-
-               if ( $this->localServices !== MediaWikiServices::getInstance() ) {
-                       throw new Exception( __METHOD__ . ' will not work because the global MediaWikiServices '
-                               . 'instance has been replaced by test code.' );
-               }
-
-               $this->overriddenServices[] = $name;
-
-               $this->localServices->disableService( $name );
-               $this->localServices->redefineService(
-                       $name,
-                       function () use ( $object ) {
-                               return $object;
-                       }
-               );
-
-               if ( $name === 'ContentLanguage' ) {
-                       $this->doSetMwGlobals( [ 'wgContLang' => $object ] );
-               }
-       }
-
-       /**
-        * Sets a global, maintaining a stashed version of the previous global to be
-        * restored in tearDown
-        *
-        * The key is added to the array of globals that will be reset afterwards
-        * in the tearDown().
-        *
-        * @par Example
-        * @code
-        *     protected function setUp() {
-        *         $this->setMwGlobals( 'wgRestrictStuff', true );
-        *     }
-        *
-        *     function testFoo() {}
-        *
-        *     function testBar() {}
-        *         $this->assertTrue( self::getX()->doStuff() );
-        *
-        *         $this->setMwGlobals( 'wgRestrictStuff', false );
-        *         $this->assertTrue( self::getX()->doStuff() );
-        *     }
-        *
-        *     function testQuux() {}
-        * @endcode
-        *
-        * @param array|string $pairs Key to the global variable, or an array
-        *  of key/value pairs.
-        * @param mixed|null $value Value to set the global to (ignored
-        *  if an array is given as first argument).
-        *
-        * @note To allow changes to global variables to take effect on global service instances,
-        *       call overrideMwServices().
-        *
-        * @since 1.21
-        */
-       protected function setMwGlobals( $pairs, $value = null ) {
-               if ( is_string( $pairs ) ) {
-                       $pairs = [ $pairs => $value ];
-               }
-
-               if ( isset( $pairs['wgContLang'] ) ) {
-                       throw new MWException(
-                               'No setting $wgContLang, use setContentLang() or setService( \'ContentLanguage\' )'
-                       );
-               }
-
-               $this->doSetMwGlobals( $pairs, $value );
-       }
-
-       /**
-        * An internal method that allows setService() to set globals that tests are not supposed to
-        * touch.
-        */
-       private function doSetMwGlobals( $pairs, $value = null ) {
-               $this->doStashMwGlobals( array_keys( $pairs ) );
-
-               foreach ( $pairs as $key => $value ) {
-                       $GLOBALS[$key] = $value;
-               }
-
-               if ( array_intersect( self::$namespaceAffectingSettings, array_keys( $pairs ) ) ) {
-                       $this->resetNamespaces();
-               }
-       }
-
-       /**
-        * Set an ini setting for the duration of the test
-        * @param string $name Name of the setting
-        * @param string $value Value to set
-        * @since 1.32
-        */
-       protected function setIniSetting( $name, $value ) {
-               $original = ini_get( $name );
-               $this->iniSettings[$name] = $original;
-               ini_set( $name, $value );
-       }
-
-       /**
-        * Must be called whenever namespaces are changed, e.g., $wgExtraNamespaces is altered.
-        * Otherwise old namespace data will lurk and cause bugs.
-        */
-       private function resetNamespaces() {
-               if ( !$this->localServices ) {
-                       throw new Exception( __METHOD__ . ' must be called after MediaWikiTestCase::run()' );
-               }
-
-               if ( $this->localServices !== MediaWikiServices::getInstance() ) {
-                       throw new Exception( __METHOD__ . ' will not work because the global MediaWikiServices '
-                               . 'instance has been replaced by test code.' );
-               }
-
-               Language::clearCaches();
-       }
-
-       /**
-        * Check if we can back up a value by performing a shallow copy.
-        * Values which fail this test are copied recursively.
-        *
-        * @param mixed $value
-        * @return bool True if a shallow copy will do; false if a deep copy
-        *  is required.
-        */
-       private static function canShallowCopy( $value ) {
-               if ( is_scalar( $value ) || $value === null ) {
-                       return true;
-               }
-               if ( is_array( $value ) ) {
-                       foreach ( $value as $subValue ) {
-                               if ( !is_scalar( $subValue ) && $subValue !== null ) {
-                                       return false;
-                               }
-                       }
-                       return true;
-               }
-               return false;
-       }
-
-       private function doStashMwGlobals( $globalKeys ) {
-               if ( is_string( $globalKeys ) ) {
-                       $globalKeys = [ $globalKeys ];
-               }
-
-               foreach ( $globalKeys as $globalKey ) {
-                       // NOTE: make sure we only save the global once or a second call to
-                       // setMwGlobals() on the same global would override the original
-                       // value.
-                       if (
-                               !array_key_exists( $globalKey, $this->mwGlobals ) &&
-                               !array_key_exists( $globalKey, $this->mwGlobalsToUnset )
-                       ) {
-                               if ( !array_key_exists( $globalKey, $GLOBALS ) ) {
-                                       $this->mwGlobalsToUnset[$globalKey] = $globalKey;
-                                       continue;
-                               }
-                               // NOTE: we serialize then unserialize the value in case it is an object
-                               // this stops any objects being passed by reference. We could use clone
-                               // and if is_object but this does account for objects within objects!
-                               if ( self::canShallowCopy( $GLOBALS[$globalKey] ) ) {
-                                       $this->mwGlobals[$globalKey] = $GLOBALS[$globalKey];
-                               } elseif (
-                                       // Many MediaWiki types are safe to clone. These are the
-                                       // ones that are most commonly stashed.
-                                       $GLOBALS[$globalKey] instanceof Language ||
-                                       $GLOBALS[$globalKey] instanceof User ||
-                                       $GLOBALS[$globalKey] instanceof FauxRequest
-                               ) {
-                                       $this->mwGlobals[$globalKey] = clone $GLOBALS[$globalKey];
-                               } elseif ( $this->containsClosure( $GLOBALS[$globalKey] ) ) {
-                                       // Serializing Closure only gives a warning on HHVM while
-                                       // it throws an Exception on Zend.
-                                       // Workaround for https://github.com/facebook/hhvm/issues/6206
-                                       $this->mwGlobals[$globalKey] = $GLOBALS[$globalKey];
-                               } else {
-                                       try {
-                                               $this->mwGlobals[$globalKey] = unserialize( serialize( $GLOBALS[$globalKey] ) );
-                                       } catch ( Exception $e ) {
-                                               $this->mwGlobals[$globalKey] = $GLOBALS[$globalKey];
-                                       }
-                               }
-                       }
-               }
-       }
-
-       /**
-        * @param mixed $var
-        * @param int $maxDepth
-        *
-        * @return bool
-        */
-       private function containsClosure( $var, $maxDepth = 15 ) {
-               if ( $var instanceof Closure ) {
-                       return true;
-               }
-               if ( !is_array( $var ) || $maxDepth === 0 ) {
-                       return false;
-               }
-
-               foreach ( $var as $value ) {
-                       if ( $this->containsClosure( $value, $maxDepth - 1 ) ) {
-                               return true;
-                       }
-               }
-               return false;
-       }
-
-       /**
-        * Merges the given values into a MW global array variable.
-        * Useful for setting some entries in a configuration array, instead of
-        * setting the entire array.
-        *
-        * @param string $name The name of the global, as in wgFooBar
-        * @param array $values The array containing the entries to set in that global
-        *
-        * @throws MWException If the designated global is not an array.
-        *
-        * @note To allow changes to global variables to take effect on global service instances,
-        *       call overrideMwServices().
-        *
-        * @since 1.21
-        */
-       protected function mergeMwGlobalArrayValue( $name, $values ) {
-               if ( !isset( $GLOBALS[$name] ) ) {
-                       $merged = $values;
-               } else {
-                       if ( !is_array( $GLOBALS[$name] ) ) {
-                               throw new MWException( "MW global $name is not an array." );
-                       }
-
-                       // NOTE: do not use array_merge, it screws up for numeric keys.
-                       $merged = $GLOBALS[$name];
-                       foreach ( $values as $k => $v ) {
-                               $merged[$k] = $v;
-                       }
-               }
-
-               $this->setMwGlobals( $name, $merged );
-       }
-
-       /**
-        * Stashes the global instance of MediaWikiServices, and installs a new one,
-        * allowing test cases to override settings and services.
-        * The previous instance of MediaWikiServices will be restored on tearDown.
-        *
-        * @since 1.27
-        *
-        * @param Config|null $configOverrides Configuration overrides for the new MediaWikiServices
-        *        instance.
-        * @param callable[] $services An associative array of services to re-define. Keys are service
-        *        names, values are callables.
-        *
-        * @return MediaWikiServices
-        * @throws MWException
-        */
-       protected function overrideMwServices(
-               Config $configOverrides = null, array $services = []
-       ) {
-               if ( $this->overriddenServices ) {
-                       throw new MWException(
-                               'The following services were set and are now being unset by overrideMwServices: ' .
-                                       implode( ', ', $this->overriddenServices )
-                       );
-               }
-               $newInstance = self::installMockMwServices( $configOverrides );
-
-               if ( $this->localServices ) {
-                       $this->localServices->destroy();
-               }
-
-               $this->localServices = $newInstance;
-
-               foreach ( $services as $name => $callback ) {
-                       $newInstance->redefineService( $name, $callback );
-               }
-
-               self::resetGlobalParser();
-
-               return $newInstance;
-       }
-
-       /**
-        * Creates a new "mock" MediaWikiServices instance, and installs it.
-        * This effectively resets all cached states in services, with the exception of
-        * the ConfigFactory and the DBLoadBalancerFactory service, which are inherited from
-        * the original MediaWikiServices.
-        *
-        * @note The new original MediaWikiServices instance can later be restored by calling
-        * restoreMwServices(). That original is determined by the first call to this method, or
-        * by setUpBeforeClass, whichever is called first. The caller is responsible for managing
-        * and, when appropriate, destroying any other MediaWikiServices instances that may get
-        * replaced when calling this method.
-        *
-        * @param Config|null $configOverrides Configuration overrides for the new MediaWikiServices
-        *        instance.
-        *
-        * @return MediaWikiServices the new mock service locator.
-        */
-       public static function installMockMwServices( Config $configOverrides = null ) {
-               // Make sure we have the original service locator
-               if ( !self::$originalServices ) {
-                       self::$originalServices = MediaWikiServices::getInstance();
-               }
-
-               if ( !$configOverrides ) {
-                       $configOverrides = new HashConfig();
-               }
-
-               $oldConfigFactory = self::$originalServices->getConfigFactory();
-               $oldLoadBalancerFactory = self::$originalServices->getDBLoadBalancerFactory();
-
-               $testConfig = self::makeTestConfig( null, $configOverrides );
-               $newServices = new MediaWikiServices( $testConfig );
-
-               // Load the default wiring from the specified files.
-               // NOTE: this logic mirrors the logic in MediaWikiServices::newInstance.
-               $wiringFiles = $testConfig->get( 'ServiceWiringFiles' );
-               $newServices->loadWiringFiles( $wiringFiles );
-
-               // Provide a traditional hook point to allow extensions to configure services.
-               Hooks::run( 'MediaWikiServices', [ $newServices ] );
-
-               // Use bootstrap config for all configuration.
-               // This allows config overrides via global variables to take effect.
-               $bootstrapConfig = $newServices->getBootstrapConfig();
-               $newServices->resetServiceForTesting( 'ConfigFactory' );
-               $newServices->redefineService(
-                       'ConfigFactory',
-                       self::makeTestConfigFactoryInstantiator(
-                               $oldConfigFactory,
-                               [ 'main' => $bootstrapConfig ]
-                       )
-               );
-               $newServices->resetServiceForTesting( 'DBLoadBalancerFactory' );
-               $newServices->redefineService(
-                       'DBLoadBalancerFactory',
-                       function ( MediaWikiServices $services ) use ( $oldLoadBalancerFactory ) {
-                               return $oldLoadBalancerFactory;
-                       }
-               );
-
-               MediaWikiServices::forceGlobalInstance( $newServices );
-
-               self::resetGlobalParser();
-
-               return $newServices;
-       }
-
-       /**
-        * Restores the original, non-mock MediaWikiServices instance.
-        * The previously active MediaWikiServices instance is destroyed,
-        * if it is different from the original that is to be restored.
-        *
-        * @note this if for internal use by test framework code. It should never be
-        * called from inside a test case, a data provider, or a setUp or tearDown method.
-        *
-        * @return bool true if the original service locator was restored,
-        *         false if there was nothing  too do.
-        */
-       public static function restoreMwServices() {
-               if ( !self::$originalServices ) {
-                       return false;
-               }
-
-               $currentServices = MediaWikiServices::getInstance();
-
-               if ( self::$originalServices === $currentServices ) {
-                       return false;
-               }
-
-               MediaWikiServices::forceGlobalInstance( self::$originalServices );
-               $currentServices->destroy();
-
-               self::resetGlobalParser();
-
-               return true;
-       }
-
-       /**
-        * If $wgParser has been unstubbed, replace it with a fresh one so it picks up any config
-        * changes. $wgParser is deprecated, but we still support it for now.
-        */
-       private static function resetGlobalParser() {
-               // phpcs:ignore MediaWiki.Usage.DeprecatedGlobalVariables.Deprecated$wgParser
-               global $wgParser;
-               if ( $wgParser instanceof StubObject ) {
-                       return;
-               }
-               $wgParser = new StubObject( 'wgParser', function () {
-                       return MediaWikiServices::getInstance()->getParser();
-               } );
-       }
-
-       /**
-        * @since 1.27
-        * @param string|Language $lang
-        */
-       public function setUserLang( $lang ) {
-               RequestContext::getMain()->setLanguage( $lang );
-               $this->setMwGlobals( 'wgLang', RequestContext::getMain()->getLanguage() );
-       }
-
-       /**
-        * @since 1.27
-        * @param string|Language $lang
-        */
-       public function setContentLang( $lang ) {
-               if ( $lang instanceof Language ) {
-                       $this->setMwGlobals( 'wgLanguageCode', $lang->getCode() );
-                       // Set to the exact object requested
-                       $this->setService( 'ContentLanguage', $lang );
-               } else {
-                       $this->setMwGlobals( 'wgLanguageCode', $lang );
-                       // Let the service handler make up the object.  Avoid calling setService(), because if
-                       // we do, overrideMwServices() will complain if it's called later on.
-                       $services = MediaWikiServices::getInstance();
-                       $services->resetServiceForTesting( 'ContentLanguage' );
-                       $this->doSetMwGlobals( [ 'wgContLang' => $services->getContentLanguage() ] );
-               }
-       }
-
-       /**
-        * Alters $wgGroupPermissions for the duration of the test.  Can be called
-        * with an array, like
-        *   [ '*' => [ 'read' => false ], 'user' => [ 'read' => false ] ]
-        * or three values to set a single permission, like
-        *   $this->setGroupPermissions( '*', 'read', false );
-        *
-        * @since 1.31
-        * @param array|string $newPerms Either an array of permissions to change,
-        *   in which case the next two parameters are ignored; or a single string
-        *   identifying a group, to use with the next two parameters.
-        * @param string|null $newKey
-        * @param mixed|null $newValue
-        */
-       public function setGroupPermissions( $newPerms, $newKey = null, $newValue = null ) {
-               global $wgGroupPermissions;
-
-               if ( is_string( $newPerms ) ) {
-                       $newPerms = [ $newPerms => [ $newKey => $newValue ] ];
-               }
-
-               $newPermissions = $wgGroupPermissions;
-               foreach ( $newPerms as $group => $permissions ) {
-                       foreach ( $permissions as $key => $value ) {
-                               $newPermissions[$group][$key] = $value;
-                       }
-               }
-
-               $this->setMwGlobals( 'wgGroupPermissions', $newPermissions );
-       }
-
-       /**
-        * Sets the logger for a specified channel, for the duration of the test.
-        * @since 1.27
-        * @param string $channel
-        * @param LoggerInterface $logger
-        */
-       protected function setLogger( $channel, LoggerInterface $logger ) {
-               // TODO: Once loggers are managed by MediaWikiServices, use
-               //       overrideMwServices() to set loggers.
-
-               $provider = LoggerFactory::getProvider();
-               $wrappedProvider = TestingAccessWrapper::newFromObject( $provider );
-               $singletons = $wrappedProvider->singletons;
-               if ( $provider instanceof MonologSpi ) {
-                       if ( !isset( $this->loggers[$channel] ) ) {
-                               $this->loggers[$channel] = $singletons['loggers'][$channel] ?? null;
-                       }
-                       $singletons['loggers'][$channel] = $logger;
-               } elseif ( $provider instanceof LegacySpi || $provider instanceof LogCapturingSpi ) {
-                       if ( !isset( $this->loggers[$channel] ) ) {
-                               $this->loggers[$channel] = $singletons[$channel] ?? null;
-                       }
-                       $singletons[$channel] = $logger;
-               } else {
-                       throw new LogicException( __METHOD__ . ': setting a logger for ' . get_class( $provider )
-                               . ' is not implemented' );
-               }
-               $wrappedProvider->singletons = $singletons;
-       }
-
-       /**
-        * Restores loggers replaced by setLogger().
-        * @since 1.27
-        */
-       private function restoreLoggers() {
-               $provider = LoggerFactory::getProvider();
-               $wrappedProvider = TestingAccessWrapper::newFromObject( $provider );
-               $singletons = $wrappedProvider->singletons;
-               foreach ( $this->loggers as $channel => $logger ) {
-                       if ( $provider instanceof MonologSpi ) {
-                               if ( $logger === null ) {
-                                       unset( $singletons['loggers'][$channel] );
-                               } else {
-                                       $singletons['loggers'][$channel] = $logger;
-                               }
-                       } elseif ( $provider instanceof LegacySpi || $provider instanceof LogCapturingSpi ) {
-                               if ( $logger === null ) {
-                                       unset( $singletons[$channel] );
-                               } else {
-                                       $singletons[$channel] = $logger;
-                               }
-                       }
-               }
-               $wrappedProvider->singletons = $singletons;
-               $this->loggers = [];
-       }
-
-       /**
-        * @return string
-        * @since 1.18
-        */
-       public function dbPrefix() {
-               return self::getTestPrefixFor( $this->db );
-       }
-
-       /**
-        * @param IDatabase $db
-        * @return string
-        * @since 1.32
-        */
-       public static function getTestPrefixFor( IDatabase $db ) {
-               return $db->getType() == 'oracle' ? self::ORA_DB_PREFIX : self::DB_PREFIX;
-       }
-
-       /**
-        * @return bool
-        * @since 1.18
-        */
-       public function needsDB() {
-               // If the test says it uses database tables, it needs the database
-               return $this->tablesUsed || $this->isTestInDatabaseGroup();
-       }
-
-       /**
-        * @return bool
-        * @since 1.32
-        */
-       protected function isTestInDatabaseGroup() {
-               // If the test class says it belongs to the Database group, it needs the database.
-               // NOTE: This ONLY checks for the group in the class level doc comment.
-               $rc = new ReflectionClass( $this );
-               return (bool)preg_match( '/@group +Database/im', $rc->getDocComment() );
-       }
-
-       /**
-        * Insert a new page.
-        *
-        * Should be called from addDBData().
-        *
-        * @since 1.25 ($namespace in 1.28)
-        * @param string|Title $pageName Page name or title
-        * @param string $text Page's content
-        * @param int|null $namespace Namespace id (name cannot already contain namespace)
-        * @param User|null $user If null, static::getTestSysop()->getUser() is used.
-        * @return array Title object and page id
-        * @throws MWException If this test cases's needsDB() method doesn't return true.
-        *         Test cases can use "@group Database" to enable database test support,
-        *         or list the tables under testing in $this->tablesUsed, or override the
-        *         needsDB() method.
-        */
-       protected function insertPage(
-               $pageName,
-               $text = 'Sample page for unit test.',
-               $namespace = null,
-               User $user = null
-       ) {
-               if ( !$this->needsDB() ) {
-                       throw new MWException( 'When testing which pages, the test cases\'s needsDB()' .
-                               ' method should return true. Use @group Database or $this->tablesUsed.' );
-               }
-
-               if ( is_string( $pageName ) ) {
-                       $title = Title::newFromText( $pageName, $namespace );
-               } else {
-                       $title = $pageName;
-               }
-
-               if ( !$user ) {
-                       $user = static::getTestSysop()->getUser();
-               }
-               $comment = __METHOD__ . ': Sample page for unit test.';
-
-               $page = WikiPage::factory( $title );
-               $page->doEditContent( ContentHandler::makeContent( $text, $title ), $comment, 0, false, $user );
-
-               return [
-                       'title' => $title,
-                       'id' => $page->getId(),
-               ];
-       }
-
-       /**
-        * Stub. If a test suite needs to add additional data to the database, it should
-        * implement this method and do so. This method is called once per test suite
-        * (i.e. once per class).
-        *
-        * Note data added by this method may be removed by resetDB() depending on
-        * the contents of $tablesUsed.
-        *
-        * To add additional data between test function runs, override prepareDB().
-        *
-        * @see addDBData()
-        * @see resetDB()
-        *
-        * @since 1.27
-        */
-       public function addDBDataOnce() {
-       }
-
-       /**
-        * Stub. Subclasses may override this to prepare the database.
-        * Called before every test run (test function or data set).
-        *
-        * @see addDBDataOnce()
-        * @see resetDB()
-        *
-        * @since 1.18
-        */
-       public function addDBData() {
-       }
-
-       /**
-        * @since 1.32
-        */
-       protected function addCoreDBData() {
-               if ( $this->db->getType() == 'oracle' ) {
-                       # Insert 0 user to prevent FK violations
-                       # Anonymous user
-                       if ( !$this->db->selectField( 'user', '1', [ 'user_id' => 0 ] ) ) {
-                               $this->db->insert( 'user', [
-                                       'user_id' => 0,
-                                       'user_name' => 'Anonymous' ], __METHOD__, [ 'IGNORE' ] );
-                       }
-
-                       # Insert 0 page to prevent FK violations
-                       # Blank page
-                       if ( !$this->db->selectField( 'page', '1', [ 'page_id' => 0 ] ) ) {
-                               $this->db->insert( 'page', [
-                                       'page_id' => 0,
-                                       'page_namespace' => 0,
-                                       'page_title' => ' ',
-                                       'page_restrictions' => null,
-                                       'page_is_redirect' => 0,
-                                       'page_is_new' => 0,
-                                       'page_random' => 0,
-                                       'page_touched' => $this->db->timestamp(),
-                                       'page_latest' => 0,
-                                       'page_len' => 0 ], __METHOD__, [ 'IGNORE' ] );
-                       }
-               }
-
-               SiteStatsInit::doPlaceholderInit();
-
-               User::resetIdByNameCache();
-
-               // Make sysop user
-               $user = static::getTestSysop()->getUser();
-
-               // Make 1 page with 1 revision
-               $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
-               if ( $page->getId() == 0 ) {
-                       $page->doEditContent(
-                               new WikitextContent( 'UTContent' ),
-                               'UTPageSummary',
-                               EDIT_NEW | EDIT_SUPPRESS_RC,
-                               false,
-                               $user
-                       );
-                       // an edit always attempt to purge backlink links such as history
-                       // pages. That is unnecessary.
-                       JobQueueGroup::singleton()->get( 'htmlCacheUpdate' )->delete();
-                       // WikiPages::doEditUpdates randomly adds RC purges
-                       JobQueueGroup::singleton()->get( 'recentChangesUpdate' )->delete();
-
-                       // doEditContent() probably started the session via
-                       // User::loadFromSession(). Close it now.
-                       if ( session_id() !== '' ) {
-                               session_write_close();
-                               session_id( '' );
-                       }
-               }
-       }
-
-       /**
-        * Restores MediaWiki to using the table set (table prefix) it was using before
-        * setupTestDB() was called. Useful if we need to perform database operations
-        * after the test run has finished (such as saving logs or profiling info).
-        *
-        * This is called by phpunit/bootstrap.php after the last test.
-        *
-        * @since 1.21
-        */
-       public static function teardownTestDB() {
-               global $wgJobClasses;
-
-               if ( !self::$dbSetup ) {
-                       return;
-               }
-
-               Hooks::run( 'UnitTestsBeforeDatabaseTeardown' );
-
-               foreach ( $wgJobClasses as $type => $class ) {
-                       // Delete any jobs under the clone DB (or old prefix in other stores)
-                       JobQueueGroup::singleton()->get( $type )->delete();
-               }
-
-               // T219673: close any connections from code that failed to call reuseConnection()
-               // or is still holding onto a DBConnRef instance (e.g. in a singleton).
-               MediaWikiServices::getInstance()->getDBLoadBalancerFactory()->closeAll();
-               CloneDatabase::changePrefix( self::$oldTablePrefix );
-
-               self::$oldTablePrefix = false;
-               self::$dbSetup = false;
-       }
-
-       /**
-        * Setups a database with cloned tables using the given prefix.
-        *
-        * If reuseDB is true and certain conditions apply, it will just change the prefix.
-        * Otherwise, it will clone the tables and change the prefix.
-        *
-        * @param IMaintainableDatabase $db Database to use
-        * @param string|null $prefix Prefix to use for test tables. If not given, the prefix is determined
-        *        automatically for $db.
-        * @return bool True if tables were cloned, false if only the prefix was changed
-        */
-       protected static function setupDatabaseWithTestPrefix(
-               IMaintainableDatabase $db,
-               $prefix = null
-       ) {
-               if ( $prefix === null ) {
-                       $prefix = self::getTestPrefixFor( $db );
-               }
-
-               if ( ( $db->getType() == 'oracle' || !self::$useTemporaryTables ) && self::$reuseDB ) {
-                       $db->tablePrefix( $prefix );
-                       return false;
-               }
-
-               if ( !isset( $db->_originalTablePrefix ) ) {
-                       $oldPrefix = $db->tablePrefix();
-
-                       if ( $oldPrefix === $prefix ) {
-                               // table already has the correct prefix, but presumably no cloned tables
-                               $oldPrefix = self::$oldTablePrefix;
-                       }
-
-                       $db->tablePrefix( $oldPrefix );
-                       $tablesCloned = self::listTables( $db );
-                       $dbClone = new CloneDatabase( $db, $tablesCloned, $prefix, $oldPrefix );
-                       $dbClone->useTemporaryTables( self::$useTemporaryTables );
-
-                       $dbClone->cloneTableStructure();
-
-                       $db->tablePrefix( $prefix );
-                       $db->_originalTablePrefix = $oldPrefix;
-               }
-
-               return true;
-       }
-
-       /**
-        * Set up all test DBs
-        */
-       public function setupAllTestDBs() {
-               global $wgDBprefix;
-
-               self::$oldTablePrefix = $wgDBprefix;
-
-               $testPrefix = $this->dbPrefix();
-
-               // switch to a temporary clone of the database
-               self::setupTestDB( $this->db, $testPrefix );
-
-               if ( self::isUsingExternalStoreDB() ) {
-                       self::setupExternalStoreTestDBs( $testPrefix );
-               }
-
-               // NOTE: Change the prefix in the LBFactory and $wgDBprefix, to prevent
-               // *any* database connections to operate on live data.
-               CloneDatabase::changePrefix( $testPrefix );
-       }
-
-       /**
-        * Creates an empty skeleton of the wiki database by cloning its structure
-        * to equivalent tables using the given $prefix. Then sets MediaWiki to
-        * use the new set of tables (aka schema) instead of the original set.
-        *
-        * This is used to generate a dummy table set, typically consisting of temporary
-        * tables, that will be used by tests instead of the original wiki database tables.
-        *
-        * @since 1.21
-        *
-        * @note the original table prefix is stored in self::$oldTablePrefix. This is used
-        * by teardownTestDB() to return the wiki to using the original table set.
-        *
-        * @note this method only works when first called. Subsequent calls have no effect,
-        * even if using different parameters.
-        *
-        * @param IMaintainableDatabase $db The database connection
-        * @param string $prefix The prefix to use for the new table set (aka schema).
-        *
-        * @throws MWException If the database table prefix is already $prefix
-        */
-       public static function setupTestDB( IMaintainableDatabase $db, $prefix ) {
-               if ( self::$dbSetup ) {
-                       return;
-               }
-
-               if ( $db->tablePrefix() === $prefix ) {
-                       throw new MWException(
-                               'Cannot run unit tests, the database prefix is already "' . $prefix . '"' );
-               }
-
-               // TODO: the below should be re-written as soon as LBFactory, LoadBalancer,
-               // and Database no longer use global state.
-
-               self::$dbSetup = true;
-
-               if ( !self::setupDatabaseWithTestPrefix( $db, $prefix ) ) {
-                       return;
-               }
-
-               // Assuming this isn't needed for External Store database, and not sure if the procedure
-               // would be available there.
-               if ( $db->getType() == 'oracle' ) {
-                       $db->query( 'BEGIN FILL_WIKI_INFO; END;', __METHOD__ );
-               }
-
-               Hooks::run( 'UnitTestsAfterDatabaseSetup', [ $db, $prefix ] );
-       }
-
-       /**
-        * Clones the External Store database(s) for testing
-        *
-        * @param string|null $testPrefix Prefix for test tables. Will be determined automatically
-        *        if not given.
-        */
-       protected static function setupExternalStoreTestDBs( $testPrefix = null ) {
-               $connections = self::getExternalStoreDatabaseConnections();
-               foreach ( $connections as $dbw ) {
-                       self::setupDatabaseWithTestPrefix( $dbw, $testPrefix );
-               }
-       }
-
-       /**
-        * Gets master database connections for all of the ExternalStoreDB
-        * stores configured in $wgDefaultExternalStore.
-        *
-        * @return Database[] Array of Database master connections
-        */
-       protected static function getExternalStoreDatabaseConnections() {
-               global $wgDefaultExternalStore;
-
-               /** @var ExternalStoreDB $externalStoreDB */
-               $externalStoreDB = ExternalStore::getStoreObject( 'DB' );
-               $defaultArray = (array)$wgDefaultExternalStore;
-               $dbws = [];
-               foreach ( $defaultArray as $url ) {
-                       if ( strpos( $url, 'DB://' ) === 0 ) {
-                               list( $proto, $cluster ) = explode( '://', $url, 2 );
-                               // Avoid getMaster() because setupDatabaseWithTestPrefix()
-                               // requires Database instead of plain DBConnRef/IDatabase
-                               $dbws[] = $externalStoreDB->getMaster( $cluster );
-                       }
-               }
-
-               return $dbws;
-       }
-
-       /**
-        * Check whether ExternalStoreDB is being used
-        *
-        * @return bool True if it's being used
-        */
-       protected static function isUsingExternalStoreDB() {
-               global $wgDefaultExternalStore;
-               if ( !$wgDefaultExternalStore ) {
-                       return false;
-               }
-
-               $defaultArray = (array)$wgDefaultExternalStore;
-               foreach ( $defaultArray as $url ) {
-                       if ( strpos( $url, 'DB://' ) === 0 ) {
-                               return true;
-                       }
-               }
-
-               return false;
-       }
-
-       /**
-        * @throws LogicException if the given database connection is not a set up to use
-        * mock tables.
-        *
-        * @since 1.31 this is no longer private.
-        */
-       protected function ensureMockDatabaseConnection( IDatabase $db ) {
-               if ( $db->tablePrefix() !== $this->dbPrefix() ) {
-                       throw new LogicException(
-                               'Trying to delete mock tables, but table prefix does not indicate a mock database.'
-                       );
-               }
-       }
-
-       private static $schemaOverrideDefaults = [
-               'scripts' => [],
-               'create' => [],
-               'drop' => [],
-               'alter' => [],
-       ];
-
-       /**
-        * Stub. If a test suite needs to test against a specific database schema, it should
-        * override this method and return the appropriate information from it.
-        *
-        * 'create', 'drop' and 'alter' in the returned array should list all the tables affected
-        * by the 'scripts', even if the test is only interested in a subset of them, otherwise
-        * the overrides may not be fully cleaned up, leading to errors later.
-        *
-        * @param IMaintainableDatabase $db The DB connection to use for the mock schema.
-        *        May be used to check the current state of the schema, to determine what
-        *        overrides are needed.
-        *
-        * @return array An associative array with the following fields:
-        *  - 'scripts': any SQL scripts to run. If empty or not present, schema overrides are skipped.
-        * - 'create': A list of tables created (may or may not exist in the original schema).
-        * - 'drop': A list of tables dropped (expected to be present in the original schema).
-        * - 'alter': A list of tables altered (expected to be present in the original schema).
-        */
-       protected function getSchemaOverrides( IMaintainableDatabase $db ) {
-               return [];
-       }
-
-       /**
-        * Undoes the specified schema overrides..
-        * Called once per test class, just before addDataOnce().
-        *
-        * @param IMaintainableDatabase $db
-        * @param array $oldOverrides
-        */
-       private function undoSchemaOverrides( IMaintainableDatabase $db, $oldOverrides ) {
-               $this->ensureMockDatabaseConnection( $db );
-
-               $oldOverrides = $oldOverrides + self::$schemaOverrideDefaults;
-               $originalTables = $this->listOriginalTables( $db );
-
-               // Drop tables that need to be restored or removed.
-               $tablesToDrop = array_merge( $oldOverrides['create'], $oldOverrides['alter'] );
-
-               // Restore tables that have been dropped or created or altered,
-               // if they exist in the original schema.
-               $tablesToRestore = array_merge( $tablesToDrop, $oldOverrides['drop'] );
-               $tablesToRestore = array_intersect( $originalTables, $tablesToRestore );
-
-               if ( $tablesToDrop ) {
-                       $this->dropMockTables( $db, $tablesToDrop );
-               }
-
-               if ( $tablesToRestore ) {
-                       $this->recloneMockTables( $db, $tablesToRestore );
-
-                       // Reset the restored tables, mainly for the side effect of
-                       // re-calling $this->addCoreDBData() if necessary.
-                       $this->resetDB( $db, $tablesToRestore );
-               }
-       }
-
-       /**
-        * Applies the schema overrides returned by getSchemaOverrides(),
-        * after undoing any previously applied schema overrides.
-        * Called once per test class, just before addDataOnce().
-        */
-       private function setUpSchema( IMaintainableDatabase $db ) {
-               // Undo any active overrides.
-               $oldOverrides = $db->_schemaOverrides ?? self::$schemaOverrideDefaults;
-
-               if ( $oldOverrides['alter'] || $oldOverrides['create'] || $oldOverrides['drop'] ) {
-                       $this->undoSchemaOverrides( $db, $oldOverrides );
-                       unset( $db->_schemaOverrides );
-               }
-
-               // Determine new overrides.
-               $overrides = $this->getSchemaOverrides( $db ) + self::$schemaOverrideDefaults;
-
-               $extraKeys = array_diff(
-                       array_keys( $overrides ),
-                       array_keys( self::$schemaOverrideDefaults )
-               );
-
-               if ( $extraKeys ) {
-                       throw new InvalidArgumentException(
-                               'Schema override contains extra keys: ' . var_export( $extraKeys, true )
-                       );
-               }
-
-               if ( !$overrides['scripts'] ) {
-                       // no scripts to run
-                       return;
-               }
-
-               if ( !$overrides['create'] && !$overrides['drop'] && !$overrides['alter'] ) {
-                       throw new InvalidArgumentException(
-                               'Schema override scripts given, but no tables are declared to be '
-                               . 'created, dropped or altered.'
-                       );
-               }
-
-               $this->ensureMockDatabaseConnection( $db );
-
-               // Drop the tables that will be created by the schema scripts.
-               $originalTables = $this->listOriginalTables( $db );
-               $tablesToDrop = array_intersect( $originalTables, $overrides['create'] );
-
-               if ( $tablesToDrop ) {
-                       $this->dropMockTables( $db, $tablesToDrop );
-               }
-
-               // Run schema override scripts.
-               foreach ( $overrides['scripts'] as $script ) {
-                       $db->sourceFile(
-                               $script,
-                               null,
-                               null,
-                               __METHOD__,
-                               function ( $cmd ) {
-                                       return $this->mungeSchemaUpdateQuery( $cmd );
-                               }
-                       );
-               }
-
-               $db->_schemaOverrides = $overrides;
-       }
-
-       private function mungeSchemaUpdateQuery( $cmd ) {
-               return self::$useTemporaryTables
-                       ? preg_replace( '/\bCREATE\s+TABLE\b/i', 'CREATE TEMPORARY TABLE', $cmd )
-                       : $cmd;
-       }
-
-       /**
-        * Drops the given mock tables.
-        *
-        * @param IMaintainableDatabase $db
-        * @param array $tables
-        */
-       private function dropMockTables( IMaintainableDatabase $db, array $tables ) {
-               $this->ensureMockDatabaseConnection( $db );
-
-               foreach ( $tables as $tbl ) {
-                       $tbl = $db->tableName( $tbl );
-                       $db->query( "DROP TABLE IF EXISTS $tbl", __METHOD__ );
-               }
-       }
-
-       /**
-        * Lists all tables in the live database schema, without a prefix.
-        *
-        * @param IMaintainableDatabase $db
-        * @return array
-        */
-       private function listOriginalTables( IMaintainableDatabase $db ) {
-               if ( !isset( $db->_originalTablePrefix ) ) {
-                       throw new LogicException( 'No original table prefix know, cannot list tables!' );
-               }
-
-               $originalTables = $db->listTables( $db->_originalTablePrefix, __METHOD__ );
-
-               $unittestPrefixRegex = '/^' . preg_quote( $this->dbPrefix(), '/' ) . '/';
-               $originalPrefixRegex = '/^' . preg_quote( $db->_originalTablePrefix, '/' ) . '/';
-
-               $originalTables = array_filter(
-                       $originalTables,
-                       function ( $pt ) use ( $unittestPrefixRegex ) {
-                               return !preg_match( $unittestPrefixRegex, $pt );
-                       }
-               );
-
-               $originalTables = array_map(
-                       function ( $pt ) use ( $originalPrefixRegex ) {
-                               return preg_replace( $originalPrefixRegex, '', $pt );
-                       },
-                       $originalTables
-               );
-
-               return array_unique( $originalTables );
-       }
-
-       /**
-        * Re-clones the given mock tables to restore them based on the live database schema.
-        * The tables listed in $tables are expected to currently not exist, so dropMockTables()
-        * should be called first.
-        *
-        * @param IMaintainableDatabase $db
-        * @param array $tables
-        */
-       private function recloneMockTables( IMaintainableDatabase $db, array $tables ) {
-               $this->ensureMockDatabaseConnection( $db );
-
-               if ( !isset( $db->_originalTablePrefix ) ) {
-                       throw new LogicException( 'No original table prefix know, cannot restore tables!' );
-               }
-
-               $originalTables = $this->listOriginalTables( $db );
-               $tables = array_intersect( $tables, $originalTables );
-
-               $dbClone = new CloneDatabase( $db, $tables, $db->tablePrefix(), $db->_originalTablePrefix );
-               $dbClone->useTemporaryTables( self::$useTemporaryTables );
-
-               $dbClone->cloneTableStructure();
-       }
-
-       /**
-        * Empty all tables so they can be repopulated for tests
-        *
-        * @param Database $db|null Database to reset
-        * @param array $tablesUsed Tables to reset
-        */
-       private function resetDB( $db, $tablesUsed ) {
-               if ( $db ) {
-                       $userTables = [ 'user', 'user_groups', 'user_properties', 'actor' ];
-                       $pageTables = [
-                               'page', 'revision', 'ip_changes', 'revision_comment_temp', 'comment', 'archive',
-                               'revision_actor_temp', 'slots', 'content', 'content_models', 'slot_roles',
-                       ];
-                       $coreDBDataTables = array_merge( $userTables, $pageTables );
-
-                       // If any of the user or page tables were marked as used, we should clear all of them.
-                       if ( array_intersect( $tablesUsed, $userTables ) ) {
-                               $tablesUsed = array_unique( array_merge( $tablesUsed, $userTables ) );
-                               TestUserRegistry::clear();
-
-                               // Reset $wgUser, which is probably 127.0.0.1, as its loaded data is probably not valid
-                               // @todo Should we start setting $wgUser to something nondeterministic
-                               //  to encourage tests to be updated to not depend on it?
-                               global $wgUser;
-                               $wgUser->clearInstanceCache( $wgUser->mFrom );
-                       }
-                       if ( array_intersect( $tablesUsed, $pageTables ) ) {
-                               $tablesUsed = array_unique( array_merge( $tablesUsed, $pageTables ) );
-                       }
-
-                       // Postgres, Oracle, and MSSQL all use mwuser/pagecontent
-                       // instead of user/text. But Postgres does not remap the
-                       // table name in tableExists(), so we mark the real table
-                       // names as being used.
-                       if ( $db->getType() === 'postgres' ) {
-                               if ( in_array( 'user', $tablesUsed ) ) {
-                                       $tablesUsed[] = 'mwuser';
-                               }
-                               if ( in_array( 'text', $tablesUsed ) ) {
-                                       $tablesUsed[] = 'pagecontent';
-                               }
-                       }
-
-                       foreach ( $tablesUsed as $tbl ) {
-                               $this->truncateTable( $tbl, $db );
-                       }
-
-                       if ( array_intersect( $tablesUsed, $coreDBDataTables ) ) {
-                               // Reset services that may contain information relating to the truncated tables
-                               $this->overrideMwServices();
-                               // Re-add core DB data that was deleted
-                               $this->addCoreDBData();
-                       }
-               }
-       }
-
-       /**
-        * Empties the given table and resets any auto-increment counters.
-        * Will also purge caches associated with some well known tables.
-        * If the table is not know, this method just returns.
-        *
-        * @param string $tableName
-        * @param IDatabase|null $db
-        */
-       protected function truncateTable( $tableName, IDatabase $db = null ) {
-               if ( !$db ) {
-                       $db = $this->db;
-               }
-
-               if ( !$db->tableExists( $tableName ) ) {
-                       return;
-               }
-
-               $truncate = in_array( $db->getType(), [ 'oracle', 'mysql' ] );
-
-               if ( $truncate ) {
-                       $db->query( 'TRUNCATE TABLE ' . $db->tableName( $tableName ), __METHOD__ );
-               } else {
-                       $db->delete( $tableName, '*', __METHOD__ );
-               }
-
-               if ( $db instanceof DatabasePostgres || $db instanceof DatabaseSqlite ) {
-                       // Reset the table's sequence too.
-                       $db->resetSequenceForTable( $tableName, __METHOD__ );
-               }
-
-               // re-initialize site_stats table
-               if ( $tableName === 'site_stats' ) {
-                       SiteStatsInit::doPlaceholderInit();
-               }
-       }
-
-       private static function unprefixTable( &$tableName, $ind, $prefix ) {
-               $tableName = substr( $tableName, strlen( $prefix ) );
-       }
-
-       private static function isNotUnittest( $table ) {
-               return strpos( $table, self::DB_PREFIX ) !== 0;
-       }
-
-       /**
-        * @since 1.18
-        *
-        * @param IMaintainableDatabase $db
-        *
-        * @return array
-        */
-       public static function listTables( IMaintainableDatabase $db ) {
-               $prefix = $db->tablePrefix();
-               $tables = $db->listTables( $prefix, __METHOD__ );
-
-               if ( $db->getType() === 'mysql' ) {
-                       static $viewListCache = null;
-                       if ( $viewListCache === null ) {
-                               $viewListCache = $db->listViews( null, __METHOD__ );
-                       }
-                       // T45571: cannot clone VIEWs under MySQL
-                       $tables = array_diff( $tables, $viewListCache );
-               }
-               array_walk( $tables, [ __CLASS__, 'unprefixTable' ], $prefix );
-
-               // Don't duplicate test tables from the previous fataled run
-               $tables = array_filter( $tables, [ __CLASS__, 'isNotUnittest' ] );
-
-               if ( $db->getType() == 'sqlite' ) {
-                       $tables = array_flip( $tables );
-                       // these are subtables of searchindex and don't need to be duped/dropped separately
-                       unset( $tables['searchindex_content'] );
-                       unset( $tables['searchindex_segdir'] );
-                       unset( $tables['searchindex_segments'] );
-                       $tables = array_flip( $tables );
-               }
-
-               return $tables;
-       }
-
-       /**
-        * Copy test data from one database connection to another.
-        *
-        * This should only be used for small data sets.
-        *
-        * @param IDatabase $source
-        * @param IDatabase $target
-        */
-       public function copyTestData( IDatabase $source, IDatabase $target ) {
-               if ( $this->db->getType() === 'sqlite' ) {
-                       // SQLite uses a non-temporary copy of the searchindex table for testing,
-                       // which gets deleted and re-created when setting up the secondary connection,
-                       // causing "Error 17" when trying to copy the data. See T191863#4130112.
-                       throw new RuntimeException(
-                               'Setting up a secondary database connection with test data is currently not'
-                               . 'with SQLite. You may want to use markTestSkippedIfDbType() to bypass this issue.'
-                       );
-               }
-
-               $tables = self::listOriginalTables( $source );
-
-               foreach ( $tables as $table ) {
-                       $res = $source->select( $table, '*', [], __METHOD__ );
-                       $allRows = [];
-
-                       foreach ( $res as $row ) {
-                               $allRows[] = (array)$row;
-                       }
-
-                       $target->insert( $table, $allRows, __METHOD__, [ 'IGNORE' ] );
-               }
-       }
-
-       /**
-        * @throws MWException
-        * @since 1.18
-        */
-       protected function checkDbIsSupported() {
-               if ( !in_array( $this->db->getType(), $this->supportedDBs ) ) {
-                       throw new MWException( $this->db->getType() . " is not currently supported for unit testing." );
-               }
-       }
-
-       /**
-        * @since 1.18
-        * @param string $offset
-        * @return mixed
-        */
-       public function getCliArg( $offset ) {
-               return $this->cliArgs[$offset] ?? null;
-       }
-
-       /**
-        * @since 1.18
-        * @param string $offset
-        * @param mixed $value
-        */
-       public function setCliArg( $offset, $value ) {
-               $this->cliArgs[$offset] = $value;
-       }
-
-       /**
-        * Don't throw a warning if $function is deprecated and called later
-        *
-        * @since 1.19
-        *
-        * @param string $function
-        */
-       public function hideDeprecated( $function ) {
-               Wikimedia\suppressWarnings();
-               wfDeprecated( $function );
-               Wikimedia\restoreWarnings();
-       }
-
-       /**
-        * Asserts that the given database query yields the rows given by $expectedRows.
-        * The expected rows should be given as indexed (not associative) arrays, with
-        * the values given in the order of the columns in the $fields parameter.
-        * Note that the rows are sorted by the columns given in $fields.
-        *
-        * @since 1.20
-        *
-        * @param string|array $table The table(s) to query
-        * @param string|array $fields The columns to include in the result (and to sort by)
-        * @param string|array $condition "where" condition(s)
-        * @param array $expectedRows An array of arrays giving the expected rows.
-        * @param array $options Options for the query
-        * @param array $join_conds Join conditions for the query
-        *
-        * @throws MWException If this test cases's needsDB() method doesn't return true.
-        *         Test cases can use "@group Database" to enable database test support,
-        *         or list the tables under testing in $this->tablesUsed, or override the
-        *         needsDB() method.
-        */
-       protected function assertSelect(
-               $table, $fields, $condition, array $expectedRows, array $options = [], array $join_conds = []
-       ) {
-               if ( !$this->needsDB() ) {
-                       throw new MWException( 'When testing database state, the test cases\'s needDB()' .
-                               ' method should return true. Use @group Database or $this->tablesUsed.' );
-               }
-
-               $db = wfGetDB( DB_REPLICA );
-
-               $res = $db->select(
-                       $table,
-                       $fields,
-                       $condition,
-                       wfGetCaller(),
-                       $options + [ 'ORDER BY' => $fields ],
-                       $join_conds
-               );
-               $this->assertNotEmpty( $res, "query failed: " . $db->lastError() );
-
-               $i = 0;
-
-               foreach ( $expectedRows as $expected ) {
-                       $r = $res->fetchRow();
-                       self::stripStringKeys( $r );
-
-                       $i += 1;
-                       $this->assertNotEmpty( $r, "row #$i missing" );
-
-                       $this->assertEquals( $expected, $r, "row #$i mismatches" );
-               }
-
-               $r = $res->fetchRow();
-               self::stripStringKeys( $r );
-
-               $this->assertFalse( $r, "found extra row (after #$i)" );
-       }
-
-       /**
-        * Utility method taking an array of elements and wrapping
-        * each element in its own array. Useful for data providers
-        * that only return a single argument.
-        *
-        * @since 1.20
-        *
-        * @param array $elements
-        *
-        * @return array
-        */
-       protected function arrayWrap( array $elements ) {
-               return array_map(
-                       function ( $element ) {
-                               return [ $element ];
-                       },
-                       $elements
-               );
-       }
-
-       /**
-        * Assert that two arrays are equal. By default this means that both arrays need to hold
-        * the same set of values. Using additional arguments, order and associated key can also
-        * be set as relevant.
-        *
-        * @since 1.20
-        *
-        * @param array $expected
-        * @param array $actual
-        * @param bool $ordered If the order of the values should match
-        * @param bool $named If the keys should match
-        */
-       protected function assertArrayEquals( array $expected, array $actual,
-               $ordered = false, $named = false
-       ) {
-               if ( !$ordered ) {
-                       $this->objectAssociativeSort( $expected );
-                       $this->objectAssociativeSort( $actual );
-               }
-
-               if ( !$named ) {
-                       $expected = array_values( $expected );
-                       $actual = array_values( $actual );
-               }
-
-               call_user_func_array(
-                       [ $this, 'assertEquals' ],
-                       array_merge( [ $expected, $actual ], array_slice( func_get_args(), 4 ) )
-               );
-       }
-
-       /**
-        * Put each HTML element on its own line and then equals() the results
-        *
-        * Use for nicely formatting of PHPUnit diff output when comparing very
-        * simple HTML
-        *
-        * @since 1.20
-        *
-        * @param string $expected HTML on oneline
-        * @param string $actual HTML on oneline
-        * @param string $msg Optional message
-        */
-       protected function assertHTMLEquals( $expected, $actual, $msg = '' ) {
-               $expected = str_replace( '>', ">\n", $expected );
-               $actual = str_replace( '>', ">\n", $actual );
-
-               $this->assertEquals( $expected, $actual, $msg );
-       }
-
-       /**
-        * Does an associative sort that works for objects.
-        *
-        * @since 1.20
-        *
-        * @param array &$array
-        */
-       protected function objectAssociativeSort( array &$array ) {
-               uasort(
-                       $array,
-                       function ( $a, $b ) {
-                               return serialize( $a ) <=> serialize( $b );
-                       }
-               );
-       }
-
-       /**
-        * Utility function for eliminating all string keys from an array.
-        * Useful to turn a database result row as returned by fetchRow() into
-        * a pure indexed array.
-        *
-        * @since 1.20
-        *
-        * @param mixed &$r The array to remove string keys from.
-        */
-       protected static function stripStringKeys( &$r ) {
-               if ( !is_array( $r ) ) {
-                       return;
-               }
-
-               foreach ( $r as $k => $v ) {
-                       if ( is_string( $k ) ) {
-                               unset( $r[$k] );
-                       }
-               }
-       }
-
-       /**
-        * Asserts that the provided variable is of the specified
-        * internal type or equals the $value argument. This is useful
-        * for testing return types of functions that return a certain
-        * type or *value* when not set or on error.
-        *
-        * @since 1.20
-        *
-        * @param string $type
-        * @param mixed $actual
-        * @param mixed $value
-        * @param string $message
-        */
-       protected function assertTypeOrValue( $type, $actual, $value = false, $message = '' ) {
-               if ( $actual === $value ) {
-                       $this->assertTrue( true, $message );
-               } else {
-                       $this->assertType( $type, $actual, $message );
-               }
-       }
-
-       /**
-        * Asserts the type of the provided value. This can be either
-        * in internal type such as boolean or integer, or a class or
-        * interface the value extends or implements.
-        *
-        * @since 1.20
-        *
-        * @param string $type
-        * @param mixed $actual
-        * @param string $message
-        */
-       protected function assertType( $type, $actual, $message = '' ) {
-               if ( class_exists( $type ) || interface_exists( $type ) ) {
-                       $this->assertInstanceOf( $type, $actual, $message );
-               } else {
-                       $this->assertInternalType( $type, $actual, $message );
-               }
-       }
-
-       /**
-        * Returns true if the given namespace defaults to Wikitext
-        * according to $wgNamespaceContentModels
-        *
-        * @param int $ns The namespace ID to check
-        *
-        * @return bool
-        * @since 1.21
-        */
-       protected function isWikitextNS( $ns ) {
-               global $wgNamespaceContentModels;
-
-               if ( isset( $wgNamespaceContentModels[$ns] ) ) {
-                       return $wgNamespaceContentModels[$ns] === CONTENT_MODEL_WIKITEXT;
-               }
-
-               return true;
-       }
-
-       /**
-        * Returns the ID of a namespace that defaults to Wikitext.
-        *
-        * @throws MWException If there is none.
-        * @return int The ID of the wikitext Namespace
-        * @since 1.21
-        */
-       protected function getDefaultWikitextNS() {
-               global $wgNamespaceContentModels;
-
-               static $wikitextNS = null; // this is not going to change
-               if ( $wikitextNS !== null ) {
-                       return $wikitextNS;
-               }
-
-               // quickly short out on most common case:
-               if ( !isset( $wgNamespaceContentModels[NS_MAIN] ) ) {
-                       return NS_MAIN;
-               }
-
-               // NOTE: prefer content namespaces
-               $nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
-               $namespaces = array_unique( array_merge(
-                       $nsInfo->getContentNamespaces(),
-                       [ NS_MAIN, NS_HELP, NS_PROJECT ], // prefer these
-                       $nsInfo->getValidNamespaces()
-               ) );
-
-               $namespaces = array_diff( $namespaces, [
-                       NS_FILE, NS_CATEGORY, NS_MEDIAWIKI, NS_USER // don't mess with magic namespaces
-               ] );
-
-               $talk = array_filter( $namespaces, function ( $ns ) use ( $nsInfo ) {
-                       return $nsInfo->isTalk( $ns );
-               } );
-
-               // prefer non-talk pages
-               $namespaces = array_diff( $namespaces, $talk );
-               $namespaces = array_merge( $namespaces, $talk );
-
-               // check default content model of each namespace
-               foreach ( $namespaces as $ns ) {
-                       if ( !isset( $wgNamespaceContentModels[$ns] ) ||
-                               $wgNamespaceContentModels[$ns] === CONTENT_MODEL_WIKITEXT
-                       ) {
-                               $wikitextNS = $ns;
-
-                               return $wikitextNS;
-                       }
-               }
-
-               // give up
-               // @todo Inside a test, we could skip the test as incomplete.
-               //        But frequently, this is used in fixture setup.
-               throw new MWException( "No namespace defaults to wikitext!" );
-       }
-
-       /**
-        * Check, if $wgDiff3 is set and ready to merge
-        * Will mark the calling test as skipped, if not ready
-        *
-        * @since 1.21
-        */
-       protected function markTestSkippedIfNoDiff3() {
-               global $wgDiff3;
-
-               # This check may also protect against code injection in
-               # case of broken installations.
-               Wikimedia\suppressWarnings();
-               $haveDiff3 = $wgDiff3 && file_exists( $wgDiff3 );
-               Wikimedia\restoreWarnings();
-
-               if ( !$haveDiff3 ) {
-                       $this->markTestSkipped( "Skip test, since diff3 is not configured" );
-               }
-       }
-
-       /**
-        * Check if $extName is a loaded PHP extension, will skip the
-        * test whenever it is not loaded.
-        *
-        * @since 1.21
-        * @param string $extName
-        * @return bool
-        */
-       protected function checkPHPExtension( $extName ) {
-               $loaded = extension_loaded( $extName );
-               if ( !$loaded ) {
-                       $this->markTestSkipped( "PHP extension '$extName' is not loaded, skipping." );
-               }
-
-               return $loaded;
-       }
-
-       /**
-        * Skip the test if using the specified database type
-        *
-        * @param string $type Database type
-        * @since 1.32
-        */
-       protected function markTestSkippedIfDbType( $type ) {
-               if ( $this->db->getType() === $type ) {
-                       $this->markTestSkipped( "The $type database type isn't supported for this test" );
-               }
-       }
-
-       /**
-        * Used as a marker to prevent wfResetOutputBuffers from breaking PHPUnit.
-        * @param string $buffer
-        * @return string
-        */
-       public static function wfResetOutputBuffersBarrier( $buffer ) {
-               return $buffer;
-       }
-
-       /**
-        * Create a temporary hook handler which will be reset by tearDown.
-        * This replaces other handlers for the same hook.
-        * @param string $hookName Hook name
-        * @param mixed $handler Value suitable for a hook handler
-        * @since 1.28
-        */
-       protected function setTemporaryHook( $hookName, $handler ) {
-               $this->mergeMwGlobalArrayValue( 'wgHooks', [ $hookName => [ $handler ] ] );
-       }
-
-       /**
-        * Check whether file contains given data.
-        * @param string $fileName
-        * @param string $actualData
-        * @param bool $createIfMissing If true, and file does not exist, create it with given data
-        *                              and skip the test.
-        * @param string $msg
-        * @since 1.30
-        */
-       protected function assertFileContains(
-               $fileName,
-               $actualData,
-               $createIfMissing = false,
-               $msg = ''
-       ) {
-               if ( $createIfMissing ) {
-                       if ( !file_exists( $fileName ) ) {
-                               file_put_contents( $fileName, $actualData );
-                               $this->markTestSkipped( "Data file $fileName does not exist" );
-                       }
-               } else {
-                       self::assertFileExists( $fileName );
-               }
-               self::assertEquals( file_get_contents( $fileName ), $actualData, $msg );
-       }
-
-       /**
-        * Edits or creates a page/revision
-        * @param string $pageName Page title
-        * @param string $text Content of the page
-        * @param string $summary Optional summary string for the revision
-        * @param int $defaultNs Optional namespace id
-        * @param User|null $user If null, static::getTestSysop()->getUser() is used.
-        * @return Status Object as returned by WikiPage::doEditContent()
-        * @throws MWException If this test cases's needsDB() method doesn't return true.
-        *         Test cases can use "@group Database" to enable database test support,
-        *         or list the tables under testing in $this->tablesUsed, or override the
-        *         needsDB() method.
-        */
-       protected function editPage(
-               $pageName,
-               $text,
-               $summary = '',
-               $defaultNs = NS_MAIN,
-               User $user = null
-       ) {
-               if ( !$this->needsDB() ) {
-                       throw new MWException( 'When testing which pages, the test cases\'s needsDB()' .
-                               ' method should return true. Use @group Database or $this->tablesUsed.' );
-               }
-
-               $title = Title::newFromText( $pageName, $defaultNs );
-               $page = WikiPage::factory( $title );
-
-               return $page->doEditContent(
-                       ContentHandler::makeContent( $text, $title ),
-                       $summary,
-                       0,
-                       false,
-                       $user
-               );
-       }
-
-       /**
-        * Revision-deletes a revision.
-        *
-        * @param Revision|int $rev Revision to delete
-        * @param array $value Keys are Revision::DELETED_* flags.  Values are 1 to set the bit, 0 to
-        *   clear, -1 to leave alone.  (All other values also clear the bit.)
-        * @param string $comment Deletion comment
-        */
-       protected function revisionDelete(
-               $rev, array $value = [ Revision::DELETED_TEXT => 1 ], $comment = ''
-       ) {
-               if ( is_int( $rev ) ) {
-                       $rev = Revision::newFromId( $rev );
-               }
-               RevisionDeleter::createList(
-                       'revision', RequestContext::getMain(), $rev->getTitle(), [ $rev->getId() ]
-               )->setVisibility( [
-                       'value' => $value,
-                       'comment' => $comment,
-               ] );
-       }
-
-       /**
-        * Returns a PHPUnit constraint that matches anything other than a fixed set of values. This can
-        * be used to whitelist values, e.g.
-        *   $mock->expects( $this->never() )->method( $this->anythingBut( 'foo', 'bar' ) );
-        * which will throw if any unexpected method is called.
-        *
-        * @param mixed ...$values Values that are not matched
-        */
-       protected function anythingBut( ...$values ) {
-               return $this->logicalNot( $this->logicalOr(
-                       ...array_map( [ $this, 'matches' ], $values )
-               ) );
-       }
-}
index 407be20..06f0c9c 100644 (file)
@@ -1,7 +1,5 @@
 <?php
 /**
- * Base class for MediaWiki unit tests.
- *
  * 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
 
 use PHPUnit\Framework\TestCase;
 
+/**
+ * Base class for unit tests.
+ *
+ * Extend this class if you are testing classes which use dependency injection and do not access
+ * global functions, variables, services or a storage backend.
+ */
 abstract class MediaWikiUnitTestCase extends TestCase {
        use PHPUnit4And6Compat;
        use MediaWikiCoversValidator;
diff --git a/tests/phpunit/bootstrap.maintenance.php b/tests/phpunit/bootstrap.maintenance.php
new file mode 100644 (file)
index 0000000..6c9440c
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Bootstrapping for MediaWiki PHPUnit tests when called via the maintenance class tests runner.
+ * This file is included by phpunit and is NOT in the global scope.
+ *
+ * @file
+ */
+
+if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+       echo <<<EOF
+You are running these tests directly from phpunit. You may not have all globals correctly set.
+Running phpunit.php instead is recommended.
+EOF;
+       require_once __DIR__ . "/phpunit.php";
+}
+
+// The PHPUnit_TextUI_TestRunner class will run each test suite and may call
+// exit() with an exit status code. As such, we cannot run code "after the last test"
+// by adding statements to PHPUnitMaintClass::execute or MediaWikiPHPUnitCommand::run.
+// Instead, we work around it by registering a shutdown callback from the bootstrap
+// file, which runs before PHPUnit starts.
+// @todo Once we use PHPUnit 8 or higher, use the 'AfterLastTestHook' feature.
+// https://phpunit.readthedocs.io/en/8.0/extending-phpunit.html#available-hook-interfaces
+register_shutdown_function( function () {
+       // This will:
+       // - clear the temporary job queue.
+       // - allow extensions to delete any temporary tables they created.
+       // - restore ability to connect to the real database,
+       //   (for logging profiling data).
+       MediaWikiTestCase::teardownTestDB();
+
+       // Log profiling data, e.g. in the database or UDP
+       wfLogProfilingData();
+} );
index 79cb5be..258c822 100644 (file)
@@ -1,34 +1,67 @@
 <?php
+
 /**
- * Bootstrapping for MediaWiki PHPUnit tests
- * This file is included by phpunit and is NOT in the global scope.
+ * PHPUnit bootstrap file.
+ *
+ * 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 Testing
+ */
+
+if ( PHP_SAPI !== 'cli' ) {
+       die( 'This file is only meant to be executed indirectly by PHPUnit\'s bootstrap process!' );
+}
+
+/**
+ * PHPUnit includes the bootstrap file inside a method body, while most MediaWiki startup files
+ * assume to be included in the global scope.
+ * This utility provides a way to include these files: it makes all globals available in the
+ * inclusion scope before including the file, then exports all new or changed globals.
+ *
+ * @param string $fileName the file to include
  */
+function wfRequireOnceInGlobalScope( $fileName ) {
+       // phpcs:disable MediaWiki.Usage.ForbiddenFunctions.extract
+       extract( $GLOBALS, EXTR_REFS | EXTR_SKIP );
+       // phpcs:enable
 
-if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
-       echo <<<EOF
-You are running these tests directly from phpunit. You may not have all globals correctly set.
-Running phpunit.php instead is recommended.
-EOF;
-       require_once __DIR__ . "/phpunit.php";
+       require_once $fileName;
+
+       foreach ( get_defined_vars() as $varName => $value ) {
+               $GLOBALS[$varName] = $value;
+       }
 }
 
-// The PHPUnit_TextUI_TestRunner class will run each test suite and may call
-// exit() with an exit status code. As such, we cannot run code "after the last test"
-// by adding statements to PHPUnitMaintClass::execute or MediaWikiPHPUnitCommand::run.
-// Instead, we work around it by registering a shutdown callback from the bootstrap
-// file, which runs before PHPUnit starts.
-// @todo Once we use PHPUnit 8 or higher, use the 'AfterLastTestHook' feature.
-// https://phpunit.readthedocs.io/en/8.0/extending-phpunit.html#available-hook-interfaces
-register_shutdown_function( function () {
-       // This will:
-       // - clear the temporary job queue.
-       // - allow extensions to delete any temporary tables they created.
-       // - restore ability to connect to the real database,
-       //   (for logging profiling data).
-       MediaWikiTestCase::teardownTestDB();
-
-       // Log profiling data, e.g. in the database or UDP
-       wfLogProfilingData();
-} );
+define( 'MEDIAWIKI', true );
+define( 'MW_PHPUNIT_TEST', true );
+
+// We don't use a settings file here but some code still assumes that one exists
+define( 'MW_CONFIG_FILE', 'LocalSettings.php' );
+
+$IP = realpath( __DIR__ . '/../../' );
+
+// these variables must be defined before setup runs
+$GLOBALS['IP'] = $IP;
+// Faking for Setup.php
+$GLOBALS['wgScopeTest'] = 'MediaWiki Setup.php scope test';
+$GLOBALS['wgCommandLineMode'] = true;
+$GLOBALS['wgAutoloadClasses'] = [];
+
+require_once "$IP/tests/common/TestSetup.php";
+
+wfRequireOnceInGlobalScope( "$IP/includes/AutoLoader.php" );
+wfRequireOnceInGlobalScope( "$IP/tests/common/TestsAutoLoader.php" );
index fed47f0..43a698a 100644 (file)
@@ -144,4 +144,14 @@ class McrReadNewRevisionStoreDbTest extends RevisionStoreDbTestBase {
                ];
        }
 
+       /**
+        * Conditions to use together with getSlotsQueryInfo() when selecting slot rows for a given
+        * revision.
+        *
+        * @return array
+        */
+       protected function getSlotRevisionConditions( $revId ) {
+               return [ 'slot_revision_id' => $revId ];
+       }
+
 }
index 0aa220c..7d301a9 100644 (file)
@@ -187,4 +187,14 @@ class McrRevisionStoreDbTest extends RevisionStoreDbTestBase {
                $this->assertRevisionRecordsEqual( $return, $loaded );
        }
 
+       /**
+        * Conditions to use together with getSlotsQueryInfo() when selecting slot rows for a given
+        * revision.
+        *
+        * @return array
+        */
+       protected function getSlotRevisionConditions( $revId ) {
+               return [ 'slot_revision_id' => $revId ];
+       }
+
 }
index 856c343..8c0960b 100644 (file)
@@ -183,4 +183,14 @@ class McrWriteBothRevisionStoreDbTest extends RevisionStoreDbTestBase {
                $this->assertRevisionExistsInDatabase( $return );
        }
 
+       /**
+        * Conditions to use together with getSlotsQueryInfo() when selecting slot rows for a given
+        * revision.
+        *
+        * @return array
+        */
+       protected function getSlotRevisionConditions( $revId ) {
+               return [ 'rev_id' => $revId ];
+       }
+
 }
index 1250a6b..51cfc63 100644 (file)
@@ -189,4 +189,14 @@ class NoContentModelRevisionStoreDbTest extends RevisionStoreDbTestBase {
                ];
        }
 
+       /**
+        * Conditions to use together with getSlotsQueryInfo() when selecting slot rows for a given
+        * revision.
+        *
+        * @return array
+        */
+       protected function getSlotRevisionConditions( $revId ) {
+               return [ 'rev_id' => $revId ];
+       }
+
 }
index 011c79e..468ab60 100644 (file)
@@ -89,4 +89,14 @@ class PreMcrRevisionStoreDbTest extends RevisionStoreDbTestBase {
                ];
        }
 
+       /**
+        * Conditions to use together with getSlotsQueryInfo() when selecting slot rows for a given
+        * revision.
+        *
+        * @return array
+        */
+       protected function getSlotRevisionConditions( $revId ) {
+               return [ 'rev_id' => $revId ];
+       }
+
 }
index 35bc917..57619c5 100644 (file)
@@ -731,13 +731,13 @@ class RevisionQueryInfoTest extends MediaWikiTestCase {
                        [],
                        [
                                'tables' => [
-                                       'slots' => 'revision',
+                                       'revision',
                                ],
                                'fields' => array_merge(
                                        [
-                                               'slot_revision_id' => 'slots.rev_id',
+                                               'slot_revision_id' => 'rev_id',
                                                'slot_content_id' => 'NULL',
-                                               'slot_origin' => 'slots.rev_id',
+                                               'slot_origin' => 'rev_id',
                                                'role_name' => $db->addQuotes( SlotRecord::MAIN ),
                                        ]
                                ),
@@ -752,19 +752,20 @@ class RevisionQueryInfoTest extends MediaWikiTestCase {
                        [ 'content' ],
                        [
                                'tables' => [
-                                       'slots' => 'revision',
+                                       'revision',
                                ],
                                'fields' => array_merge(
                                        [
-                                               'slot_revision_id' => 'slots.rev_id',
+                                               'slot_revision_id' => 'rev_id',
                                                'slot_content_id' => 'NULL',
-                                               'slot_origin' => 'slots.rev_id',
+                                               'slot_origin' => 'rev_id',
                                                'role_name' => $db->addQuotes( SlotRecord::MAIN ),
-                                               'content_size' => 'slots.rev_len',
-                                               'content_sha1' => 'slots.rev_sha1',
+                                               'content_size' => 'rev_len',
+                                               'content_sha1' => 'rev_sha1',
                                                'content_address' => $db->buildConcat( [
-                                                       $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ),
-                                               'model_name' => 'slots.rev_content_model',
+                                                       $db->addQuotes( 'tt:' ), 'rev_text_id' ] ),
+                                               'rev_text_id' => 'rev_text_id',
+                                               'model_name' => 'rev_content_model',
                                        ]
                                ),
                                'joins' => [],
@@ -778,19 +779,20 @@ class RevisionQueryInfoTest extends MediaWikiTestCase {
                        [ 'content', 'model', 'role' ],
                        [
                                'tables' => [
-                                       'slots' => 'revision',
+                                       'revision',
                                ],
                                'fields' => array_merge(
                                        [
-                                               'slot_revision_id' => 'slots.rev_id',
+                                               'slot_revision_id' => 'rev_id',
                                                'slot_content_id' => 'NULL',
-                                               'slot_origin' => 'slots.rev_id',
+                                               'slot_origin' => 'rev_id',
                                                'role_name' => $db->addQuotes( SlotRecord::MAIN ),
-                                               'content_size' => 'slots.rev_len',
-                                               'content_sha1' => 'slots.rev_sha1',
+                                               'content_size' => 'rev_len',
+                                               'content_sha1' => 'rev_sha1',
                                                'content_address' => $db->buildConcat( [
-                                                       $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ),
-                                               'model_name' => 'slots.rev_content_model',
+                                                       $db->addQuotes( 'tt:' ), 'rev_text_id' ] ),
+                                               'rev_text_id' => 'rev_text_id',
+                                               'model_name' => 'rev_content_model',
                                        ]
                                ),
                                'joins' => [],
@@ -804,13 +806,13 @@ class RevisionQueryInfoTest extends MediaWikiTestCase {
                        [],
                        [
                                'tables' => [
-                                       'slots' => 'revision',
+                                       'revision',
                                ],
                                'fields' => array_merge(
                                        [
-                                               'slot_revision_id' => 'slots.rev_id',
+                                               'slot_revision_id' => 'rev_id',
                                                'slot_content_id' => 'NULL',
-                                               'slot_origin' => 'slots.rev_id',
+                                               'slot_origin' => 'rev_id',
                                                'role_name' => $db->addQuotes( SlotRecord::MAIN ),
                                        ]
                                ),
@@ -825,19 +827,20 @@ class RevisionQueryInfoTest extends MediaWikiTestCase {
                        [ 'content' ],
                        [
                                'tables' => [
-                                       'slots' => 'revision',
+                                       'revision',
                                ],
                                'fields' => array_merge(
                                        [
-                                               'slot_revision_id' => 'slots.rev_id',
+                                               'slot_revision_id' => 'rev_id',
                                                'slot_content_id' => 'NULL',
-                                               'slot_origin' => 'slots.rev_id',
+                                               'slot_origin' => 'rev_id',
                                                'role_name' => $db->addQuotes( SlotRecord::MAIN ),
-                                               'content_size' => 'slots.rev_len',
-                                               'content_sha1' => 'slots.rev_sha1',
+                                               'content_size' => 'rev_len',
+                                               'content_sha1' => 'rev_sha1',
                                                'content_address' =>
-                                                       $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'slots.rev_text_id' ] ),
-                                               'model_name' => 'slots.rev_content_model',
+                                                       $db->buildConcat( [ $db->addQuotes( 'tt:' ), 'rev_text_id' ] ),
+                                               'rev_text_id' => 'rev_text_id',
+                                               'model_name' => 'rev_content_model',
                                        ]
                                ),
                                'joins' => [],
index 3467153..7b017ab 100644 (file)
@@ -897,12 +897,71 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase {
                }
 
                if ( $revMain->hasContentId() ) {
-                       $this->assertSame( $revMain->getContentId(), $recMain->getContentId(), 'getContentId' );
+                       // XXX: the content ID value is ill-defined when SCHEMA_COMPAT_WRITE_BOTH and
+                       //      SCHEMA_COMPAT_READ_OLD is set, since revision insertion will report the
+                       //      content ID used with the new schema, while loading the revision from the
+                       //      old schema will report an emulated ID.
+                       if ( $this->getMcrMigrationStage() & SCHEMA_COMPAT_READ_NEW ) {
+                               $this->assertSame( $revMain->getContentId(), $recMain->getContentId(), 'getContentId' );
+                       }
                }
        }
 
+       /**
+        * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRowAndSlots
+        * @covers \MediaWiki\Revision\RevisionStore::getQueryInfo
+        */
+       public function testNewRevisionFromRowAndSlot_getQueryInfo() {
+               $page = $this->getTestPage();
+               $text = __METHOD__ . 'o-ö';
+               /** @var Revision $rev */
+               $rev = $page->doEditContent(
+                       new WikitextContent( $text ),
+                       __METHOD__ . 'a'
+               )->value['revision'];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $info = $store->getQueryInfo();
+               $row = $this->db->selectRow(
+                       $info['tables'],
+                       $info['fields'],
+                       [ 'rev_id' => $rev->getId() ],
+                       __METHOD__,
+                       [],
+                       $info['joins']
+               );
+
+               $info = $store->getSlotsQueryInfo( [ 'content' ] );
+               $slotRows = $this->db->select(
+                       $info['tables'],
+                       $info['fields'],
+                       $this->getSlotRevisionConditions( $rev->getId() ),
+                       __METHOD__,
+                       [],
+                       $info['joins']
+               );
+
+               $record = $store->newRevisionFromRowAndSlots(
+                       $row,
+                       iterator_to_array( $slotRows ),
+                       [],
+                       $page->getTitle()
+               );
+               $this->assertRevisionRecordMatchesRevision( $rev, $record );
+               $this->assertSame( $text, $rev->getContent()->serialize() );
+       }
+
+       /**
+        * Conditions to use together with getSlotsQueryInfo() when selecting slot rows for a given
+        * revision.
+        *
+        * @return array
+        */
+       abstract protected function getSlotRevisionConditions( $revId );
+
        /**
         * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow
+        * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRowAndSlots
         * @covers \MediaWiki\Revision\RevisionStore::getQueryInfo
         */
        public function testNewRevisionFromRow_getQueryInfo() {
@@ -935,6 +994,7 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase {
 
        /**
         * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow
+        * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRowAndSlots
         */
        public function testNewRevisionFromRow_anonEdit() {
                $page = $this->getTestPage();
@@ -957,6 +1017,7 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase {
 
        /**
         * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow
+        * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRowAndSlots
         */
        public function testNewRevisionFromRow_anonEdit_legacyEncoding() {
                $this->setMwGlobals( 'wgLegacyEncoding', 'windows-1252' );
@@ -981,6 +1042,7 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase {
 
        /**
         * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow
+        * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRowAndSlots
         */
        public function testNewRevisionFromRow_userEdit() {
                $page = $this->getTestPage();
@@ -1105,6 +1167,7 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase {
 
        /**
         * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRow
+        * @covers \MediaWiki\Revision\RevisionStore::newRevisionFromRowAndSlots
         */
        public function testNewRevisionFromRow_no_user() {
                $store = MediaWikiServices::getInstance()->getRevisionStore();
diff --git a/tests/phpunit/includes/db/DatabaseSqliteTest.php b/tests/phpunit/includes/db/DatabaseSqliteTest.php
deleted file mode 100644 (file)
index 0f5c1f2..0000000
+++ /dev/null
@@ -1,553 +0,0 @@
-<?php
-
-use Psr\Log\NullLogger;
-use Wikimedia\Rdbms\Blob;
-use Wikimedia\Rdbms\Database;
-use Wikimedia\Rdbms\DatabaseSqlite;
-use Wikimedia\Rdbms\ResultWrapper;
-use Wikimedia\Rdbms\TransactionProfiler;
-use Wikimedia\TestingAccessWrapper;
-
-/**
- * @group sqlite
- * @group Database
- * @group medium
- */
-class DatabaseSqliteTest extends MediaWikiTestCase {
-       /** @var DatabaseSqlite */
-       protected $db;
-
-       protected function setUp() {
-               parent::setUp();
-
-               if ( !Sqlite::isPresent() ) {
-                       $this->markTestSkipped( 'No SQLite support detected' );
-               }
-               $this->db = $this->getMockBuilder( DatabaseSqlite::class )
-                       ->setConstructorArgs( [ [
-                               'dbFilePath' => ':memory:',
-                               'schema' => false,
-                               'host' => false,
-                               'user' => false,
-                               'password' => false,
-                               'tablePrefix' => '',
-                               'cliMode' => true,
-                               'agent' => 'unit-tests',
-                               'flags' => DBO_DEFAULT,
-                               'variables' => [],
-                               'profiler' => null,
-                               'trxProfiler' => new TransactionProfiler(),
-                               'connLogger' => new NullLogger(),
-                               'queryLogger' => new NullLogger(),
-                               'errorLogger' => null,
-                               'deprecationLogger' => null,
-                       ] ] )->setMethods( [ 'query' ] )
-                       ->getMock();
-               $this->db->initConnection();
-               $this->db->method( 'query' )->willReturn( true );
-               if ( version_compare( $this->db->getServerVersion(), '3.6.0', '<' ) ) {
-                       $this->markTestSkipped( "SQLite at least 3.6 required, {$this->db->getServerVersion()} found" );
-               }
-       }
-
-       /**
-        * @param $sql
-        * @return string|string[]|null
-        */
-       private function replaceVars( $sql ) {
-               $wrapper = TestingAccessWrapper::newFromObject( $this->db );
-               // normalize spacing to hide implementation details
-               return preg_replace( '/\s+/', ' ', $wrapper->replaceVars( $sql ) );
-       }
-
-       private function assertResultIs( $expected, $res ) {
-               $this->assertNotNull( $res );
-               $i = 0;
-               foreach ( $res as $row ) {
-                       foreach ( $expected[$i] as $key => $value ) {
-                               $this->assertTrue( isset( $row->$key ) );
-                               $this->assertEquals( $value, $row->$key );
-                       }
-                       $i++;
-               }
-               $this->assertEquals( count( $expected ), $i, 'Unexpected number of rows' );
-       }
-
-       public static function provideAddQuotes() {
-               return [
-                       [ // #0: empty
-                               '', "''"
-                       ],
-                       [ // #1: simple
-                               'foo bar', "'foo bar'"
-                       ],
-                       [ // #2: including quote
-                               'foo\'bar', "'foo''bar'"
-                       ],
-                       // #3: including \0 (must be represented as hex, per https://bugs.php.net/bug.php?id=63419)
-                       [
-                               "x\0y",
-                               "x'780079'",
-                       ],
-                       [ // #4: blob object (must be represented as hex)
-                               new Blob( "hello" ),
-                               "x'68656c6c6f'",
-                       ],
-                       [ // #5: null
-                               null,
-                               "''",
-                       ],
-               ];
-       }
-
-       /**
-        * @dataProvider provideAddQuotes()
-        * @covers DatabaseSqlite::addQuotes
-        */
-       public function testAddQuotes( $value, $expected ) {
-               // check quoting
-               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
-               $this->assertEquals( $expected, $db->addQuotes( $value ), 'string not quoted as expected' );
-
-               // ok, quoting works as expected, now try a round trip.
-               $re = $db->query( 'select ' . $db->addQuotes( $value ) );
-
-               $this->assertTrue( $re !== false, 'query failed' );
-
-               $row = $re->fetchRow();
-               if ( $row ) {
-                       if ( $value instanceof Blob ) {
-                               $value = $value->fetch();
-                       }
-
-                       $this->assertEquals( $value, $row[0], 'string mangled by the database' );
-               } else {
-                       $this->fail( 'query returned no result' );
-               }
-       }
-
-       /**
-        * @covers DatabaseSqlite::replaceVars
-        */
-       public function testReplaceVars() {
-               $this->assertEquals( 'foo', $this->replaceVars( 'foo' ), "Don't break anything accidentally" );
-
-               $this->assertEquals(
-                       "CREATE TABLE /**/foo (foo_key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "
-                       . "foo_bar TEXT, foo_name TEXT NOT NULL DEFAULT '', foo_int INTEGER, foo_int2 INTEGER );",
-                       $this->replaceVars(
-                               "CREATE TABLE /**/foo (foo_key int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT, "
-                               . "foo_bar char(13), foo_name varchar(255) binary NOT NULL DEFAULT '', "
-                               . "foo_int tinyint ( 8 ), foo_int2 int(16) ) ENGINE=MyISAM;"
-                       )
-               );
-
-               $this->assertEquals(
-                       "CREATE TABLE foo ( foo1 REAL, foo2 REAL, foo3 REAL );",
-                       $this->replaceVars(
-                               "CREATE TABLE foo ( foo1 FLOAT, foo2 DOUBLE( 1,10), foo3 DOUBLE PRECISION );"
-                       )
-               );
-
-               $this->assertEquals( "CREATE TABLE foo ( foo_binary1 BLOB, foo_binary2 BLOB );",
-                       $this->replaceVars( "CREATE TABLE foo ( foo_binary1 binary(16), foo_binary2 varbinary(32) );" )
-               );
-
-               $this->assertEquals( "CREATE TABLE text ( text_foo TEXT );",
-                       $this->replaceVars( "CREATE TABLE text ( text_foo tinytext );" ),
-                       'Table name changed'
-               );
-
-               $this->assertEquals( "CREATE TABLE foo ( foobar INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL );",
-                       $this->replaceVars( "CREATE TABLE foo ( foobar INT PRIMARY KEY NOT NULL AUTO_INCREMENT );" )
-               );
-               $this->assertEquals( "CREATE TABLE foo ( foobar INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL );",
-                       $this->replaceVars( "CREATE TABLE foo ( foobar INT PRIMARY KEY AUTO_INCREMENT NOT NULL );" )
-               );
-
-               $this->assertEquals( "CREATE TABLE enums( enum1 TEXT, myenum TEXT)",
-                       $this->replaceVars( "CREATE TABLE enums( enum1 ENUM('A', 'B'), myenum ENUM ('X', 'Y'))" )
-               );
-
-               $this->assertEquals( "ALTER TABLE foo ADD COLUMN foo_bar INTEGER DEFAULT 42",
-                       $this->replaceVars( "ALTER TABLE foo\nADD COLUMN foo_bar int(10) unsigned DEFAULT 42" )
-               );
-
-               $this->assertEquals( "DROP INDEX foo",
-                       $this->replaceVars( "DROP INDEX /*i*/foo ON /*_*/bar" )
-               );
-
-               $this->assertEquals( "DROP INDEX foo -- dropping index",
-                       $this->replaceVars( "DROP INDEX /*i*/foo ON /*_*/bar -- dropping index" )
-               );
-               $this->assertEquals( "INSERT OR IGNORE INTO foo VALUES ('bar')",
-                       $this->replaceVars( "INSERT OR IGNORE INTO foo VALUES ('bar')" )
-               );
-       }
-
-       /**
-        * @covers DatabaseSqlite::tableName
-        */
-       public function testTableName() {
-               // @todo Moar!
-               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
-               $this->assertEquals( 'foo', $db->tableName( 'foo' ) );
-               $this->assertEquals( 'sqlite_master', $db->tableName( 'sqlite_master' ) );
-               $db->tablePrefix( 'foo_' );
-               $this->assertEquals( 'sqlite_master', $db->tableName( 'sqlite_master' ) );
-               $this->assertEquals( 'foo_bar', $db->tableName( 'bar' ) );
-       }
-
-       /**
-        * @covers DatabaseSqlite::duplicateTableStructure
-        */
-       public function testDuplicateTableStructure() {
-               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
-               $db->query( 'CREATE TABLE foo(foo, barfoo)' );
-               $db->query( 'CREATE INDEX index1 ON foo(foo)' );
-               $db->query( 'CREATE UNIQUE INDEX index2 ON foo(barfoo)' );
-
-               $db->duplicateTableStructure( 'foo', 'bar' );
-               $this->assertEquals( 'CREATE TABLE "bar"(foo, barfoo)',
-                       $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'bar' ] ),
-                       'Normal table duplication'
-               );
-               $indexList = $db->query( 'PRAGMA INDEX_LIST("bar")' );
-               $index = $indexList->next();
-               $this->assertEquals( 'bar_index1', $index->name );
-               $this->assertEquals( '0', $index->unique );
-               $index = $indexList->next();
-               $this->assertEquals( 'bar_index2', $index->name );
-               $this->assertEquals( '1', $index->unique );
-
-               $db->duplicateTableStructure( 'foo', 'baz', true );
-               $this->assertEquals( 'CREATE TABLE "baz"(foo, barfoo)',
-                       $db->selectField( 'sqlite_temp_master', 'sql', [ 'name' => 'baz' ] ),
-                       'Creation of temporary duplicate'
-               );
-               $indexList = $db->query( 'PRAGMA INDEX_LIST("baz")' );
-               $index = $indexList->next();
-               $this->assertEquals( 'baz_index1', $index->name );
-               $this->assertEquals( '0', $index->unique );
-               $index = $indexList->next();
-               $this->assertEquals( 'baz_index2', $index->name );
-               $this->assertEquals( '1', $index->unique );
-               $this->assertEquals( 0,
-                       $db->selectField( 'sqlite_master', 'COUNT(*)', [ 'name' => 'baz' ] ),
-                       'Create a temporary duplicate only'
-               );
-       }
-
-       /**
-        * @covers DatabaseSqlite::duplicateTableStructure
-        */
-       public function testDuplicateTableStructureVirtual() {
-               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
-               if ( $db->getFulltextSearchModule() != 'FTS3' ) {
-                       $this->markTestSkipped( 'FTS3 not supported, cannot create virtual tables' );
-               }
-               $db->query( 'CREATE VIRTUAL TABLE "foo" USING FTS3(foobar)' );
-
-               $db->duplicateTableStructure( 'foo', 'bar' );
-               $this->assertEquals( 'CREATE VIRTUAL TABLE "bar" USING FTS3(foobar)',
-                       $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'bar' ] ),
-                       'Duplication of virtual tables'
-               );
-
-               $db->duplicateTableStructure( 'foo', 'baz', true );
-               $this->assertEquals( 'CREATE VIRTUAL TABLE "baz" USING FTS3(foobar)',
-                       $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'baz' ] ),
-                       "Can't create temporary virtual tables, should fall back to non-temporary duplication"
-               );
-       }
-
-       /**
-        * @covers DatabaseSqlite::deleteJoin
-        */
-       public function testDeleteJoin() {
-               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
-               $db->query( 'CREATE TABLE a (a_1)', __METHOD__ );
-               $db->query( 'CREATE TABLE b (b_1, b_2)', __METHOD__ );
-               $db->insert( 'a', [
-                       [ 'a_1' => 1 ],
-                       [ 'a_1' => 2 ],
-                       [ 'a_1' => 3 ],
-               ],
-                       __METHOD__
-               );
-               $db->insert( 'b', [
-                       [ 'b_1' => 2, 'b_2' => 'a' ],
-                       [ 'b_1' => 3, 'b_2' => 'b' ],
-               ],
-                       __METHOD__
-               );
-               $db->deleteJoin( 'a', 'b', 'a_1', 'b_1', [ 'b_2' => 'a' ], __METHOD__ );
-               $res = $db->query( "SELECT * FROM a", __METHOD__ );
-               $this->assertResultIs( [
-                       [ 'a_1' => 1 ],
-                       [ 'a_1' => 3 ],
-               ],
-                       $res
-               );
-       }
-
-       /**
-        * @coversNothing
-        */
-       public function testEntireSchema() {
-               global $IP;
-
-               $result = Sqlite::checkSqlSyntax( "$IP/maintenance/tables.sql" );
-               if ( $result !== true ) {
-                       $this->fail( $result );
-               }
-               $this->assertTrue( true ); // avoid test being marked as incomplete due to lack of assertions
-       }
-
-       /**
-        * Runs upgrades of older databases and compares results with current schema
-        * @todo Currently only checks list of tables
-        * @coversNothing
-        */
-       public function testUpgrades() {
-               global $IP, $wgVersion, $wgProfiler;
-
-               // Versions tested
-               $versions = [
-                       // '1.13', disabled for now, was totally screwed up
-                       // SQLite wasn't included in 1.14
-                       '1.15',
-                       '1.16',
-                       '1.17',
-                       '1.18',
-                       '1.19',
-                       '1.20',
-                       '1.21',
-                       '1.22',
-                       '1.23',
-               ];
-
-               // Mismatches for these columns we can safely ignore
-               $ignoredColumns = [
-                       'user_newtalk.user_last_timestamp', // r84185
-               ];
-
-               $currentDB = DatabaseSqlite::newStandaloneInstance( ':memory:' );
-               $currentDB->sourceFile( "$IP/maintenance/tables.sql" );
-
-               $profileToDb = false;
-               if ( isset( $wgProfiler['output'] ) ) {
-                       $out = $wgProfiler['output'];
-                       if ( $out === 'db' ) {
-                               $profileToDb = true;
-                       } elseif ( is_array( $out ) && in_array( 'db', $out ) ) {
-                               $profileToDb = true;
-                       }
-               }
-
-               if ( $profileToDb ) {
-                       $currentDB->sourceFile( "$IP/maintenance/sqlite/archives/patch-profiling.sql" );
-               }
-               $currentTables = $this->getTables( $currentDB );
-               sort( $currentTables );
-
-               foreach ( $versions as $version ) {
-                       $versions = "upgrading from $version to $wgVersion";
-                       $db = $this->prepareTestDB( $version );
-                       $tables = $this->getTables( $db );
-                       $this->assertEquals( $currentTables, $tables, "Different tables $versions" );
-                       foreach ( $tables as $table ) {
-                               $currentCols = $this->getColumns( $currentDB, $table );
-                               $cols = $this->getColumns( $db, $table );
-                               $this->assertEquals(
-                                       array_keys( $currentCols ),
-                                       array_keys( $cols ),
-                                       "Mismatching columns for table \"$table\" $versions"
-                               );
-                               foreach ( $currentCols as $name => $column ) {
-                                       $fullName = "$table.$name";
-                                       $this->assertEquals(
-                                               (bool)$column->pk,
-                                               (bool)$cols[$name]->pk,
-                                               "PRIMARY KEY status does not match for column $fullName $versions"
-                                       );
-                                       if ( !in_array( $fullName, $ignoredColumns ) ) {
-                                               $this->assertEquals(
-                                                       (bool)$column->notnull,
-                                                       (bool)$cols[$name]->notnull,
-                                                       "NOT NULL status does not match for column $fullName $versions"
-                                               );
-                                               $this->assertEquals(
-                                                       $column->dflt_value,
-                                                       $cols[$name]->dflt_value,
-                                                       "Default values does not match for column $fullName $versions"
-                                               );
-                                       }
-                               }
-                               $currentIndexes = $this->getIndexes( $currentDB, $table );
-                               $indexes = $this->getIndexes( $db, $table );
-                               $this->assertEquals(
-                                       array_keys( $currentIndexes ),
-                                       array_keys( $indexes ),
-                                       "mismatching indexes for table \"$table\" $versions"
-                               );
-                       }
-                       $db->close();
-               }
-       }
-
-       /**
-        * @covers DatabaseSqlite::insertId
-        */
-       public function testInsertIdType() {
-               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
-
-               $databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ );
-               $this->assertInstanceOf( ResultWrapper::class, $databaseCreation, "Database creation" );
-
-               $insertion = $db->insert( 'a', [ 'a_1' => 10 ], __METHOD__ );
-               $this->assertTrue( $insertion, "Insertion worked" );
-
-               $this->assertInternalType( 'integer', $db->insertId(), "Actual typecheck" );
-               $this->assertTrue( $db->close(), "closing database" );
-       }
-
-       /**
-        * @covers DatabaseSqlite::insert
-        */
-       public function testInsertAffectedRows() {
-               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
-               $db->query( 'CREATE TABLE testInsertAffectedRows ( foo )', __METHOD__ );
-
-               $insertion = $db->insert(
-                       'testInsertAffectedRows',
-                       [
-                               [ 'foo' => 10 ],
-                               [ 'foo' => 12 ],
-                               [ 'foo' => 1555 ],
-                       ],
-                       __METHOD__
-               );
-               $this->assertTrue( $insertion, "Insertion worked" );
-
-               $this->assertSame( 3, $db->affectedRows() );
-               $this->assertTrue( $db->close(), "closing database" );
-       }
-
-       private function prepareTestDB( $version ) {
-               static $maint = null;
-               if ( $maint === null ) {
-                       $maint = new FakeMaintenance();
-                       $maint->loadParamsAndArgs( null, [ 'quiet' => 1 ] );
-               }
-
-               global $IP;
-               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
-               $db->sourceFile( "$IP/tests/phpunit/data/db/sqlite/tables-$version.sql" );
-               $updater = DatabaseUpdater::newForDB( $db, false, $maint );
-               $updater->doUpdates( [ 'core' ] );
-
-               return $db;
-       }
-
-       private function getTables( $db ) {
-               $list = array_flip( $db->listTables() );
-               $excluded = [
-                       'external_user', // removed from core in 1.22
-                       'math', // moved out of core in 1.18
-                       'trackbacks', // removed from core in 1.19
-                       'searchindex',
-                       'searchindex_content',
-                       'searchindex_segments',
-                       'searchindex_segdir',
-                       // FTS4 ready!!1
-                       'searchindex_docsize',
-                       'searchindex_stat',
-               ];
-               foreach ( $excluded as $t ) {
-                       unset( $list[$t] );
-               }
-               $list = array_flip( $list );
-               sort( $list );
-
-               return $list;
-       }
-
-       private function getColumns( $db, $table ) {
-               $cols = [];
-               $res = $db->query( "PRAGMA table_info($table)" );
-               $this->assertNotNull( $res );
-               foreach ( $res as $col ) {
-                       $cols[$col->name] = $col;
-               }
-               ksort( $cols );
-
-               return $cols;
-       }
-
-       private function getIndexes( $db, $table ) {
-               $indexes = [];
-               $res = $db->query( "PRAGMA index_list($table)" );
-               $this->assertNotNull( $res );
-               foreach ( $res as $index ) {
-                       $res2 = $db->query( "PRAGMA index_info({$index->name})" );
-                       $this->assertNotNull( $res2 );
-                       $index->columns = [];
-                       foreach ( $res2 as $col ) {
-                               $index->columns[] = $col;
-                       }
-                       $indexes[$index->name] = $index;
-               }
-               ksort( $indexes );
-
-               return $indexes;
-       }
-
-       /**
-        * @coversNothing
-        */
-       public function testCaseInsensitiveLike() {
-               // TODO: Test this for all databases
-               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
-               $res = $db->query( 'SELECT "a" LIKE "A" AS a' );
-               $row = $res->fetchRow();
-               $this->assertFalse( (bool)$row['a'] );
-       }
-
-       /**
-        * @covers DatabaseSqlite::numFields
-        */
-       public function testNumFields() {
-               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
-
-               $databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ );
-               $this->assertInstanceOf( ResultWrapper::class, $databaseCreation, "Failed to create table a" );
-               $res = $db->select( 'a', '*' );
-               $this->assertEquals( 0, $db->numFields( $res ), "expects to get 0 fields for an empty table" );
-               $insertion = $db->insert( 'a', [ 'a_1' => 10 ], __METHOD__ );
-               $this->assertTrue( $insertion, "Insertion failed" );
-               $res = $db->select( 'a', '*' );
-               $this->assertEquals( 1, $db->numFields( $res ), "wrong number of fields" );
-
-               $this->assertTrue( $db->close(), "closing database" );
-       }
-
-       /**
-        * @covers \Wikimedia\Rdbms\DatabaseSqlite::__toString
-        */
-       public function testToString() {
-               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
-
-               $toString = (string)$db;
-
-               $this->assertContains( 'sqlite object', $toString );
-       }
-
-       /**
-        * @covers \Wikimedia\Rdbms\DatabaseSqlite::getAttributes()
-        */
-       public function testsAttributes() {
-               $attributes = Database::attributesFromType( 'sqlite' );
-               $this->assertTrue( $attributes[Database::ATTR_DB_LEVEL_LOCKING] );
-       }
-}
index 0e133d8..3d79afe 100644 (file)
@@ -1488,7 +1488,8 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
                $triggerMap = [
                        '-' => '-',
                        IDatabase::TRIGGER_COMMIT => 'tCommit',
-                       IDatabase::TRIGGER_ROLLBACK => 'tRollback'
+                       IDatabase::TRIGGER_ROLLBACK => 'tRollback',
+                       IDatabase::TRIGGER_CANCEL => 'tCancel',
                ];
                $pcCallback = function ( IDatabase $db ) use ( $fname ) {
                        $this->database->query( "SELECT 0", $fname );
@@ -1518,6 +1519,11 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
                $this->database->cancelAtomic( __METHOD__ );
                $this->assertLastSql( 'BEGIN; ROLLBACK; SELECT 1, tRollback AS t' );
 
+               $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+               $this->database->onAtomicSectionCancel( $callback1, __METHOD__ );
+               $this->database->cancelAtomic( __METHOD__ );
+               $this->assertLastSql( 'BEGIN; ROLLBACK; SELECT 1, tRollback AS t' );
+
                $this->database->startAtomic( __METHOD__ . '_outer' );
                $this->database->onTransactionPreCommitOrIdle( $pcCallback, __METHOD__ );
                $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
@@ -1567,6 +1573,21 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
                        'SELECT 3, tCommit AS t'
                ] ) );
 
+               $this->database->startAtomic( __METHOD__ . '_outer' );
+               $this->database->onAtomicSectionCancel( $callback1, __METHOD__ );
+               $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+               $this->database->onAtomicSectionCancel( $callback2, __METHOD__ );
+               $this->database->cancelAtomic( __METHOD__ );
+               $this->database->onAtomicSectionCancel( $callback3, __METHOD__ );
+               $this->database->endAtomic( __METHOD__ . '_outer' );
+               $this->assertLastSql( implode( "; ", [
+                       'BEGIN',
+                       'SAVEPOINT wikimedia_rdbms_atomic1',
+                       'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1',
+                       'SELECT 2, tCancel AS t',
+                       'COMMIT',
+               ] ) );
+
                $makeCallback = function ( $id ) use ( $fname, $triggerMap ) {
                        return function ( $trigger = '-' ) use ( $id, $fname, $triggerMap ) {
                                $this->database->query( "SELECT $id, {$triggerMap[$trigger]} AS t", $fname );
@@ -1609,6 +1630,29 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
                        'SELECT 3, tRollback AS t',
                        'SELECT 4, tCommit AS t'
                ] ) );
+
+               $this->database->startAtomic( __METHOD__ . '_level1', IDatabase::ATOMIC_CANCELABLE );
+               $this->database->onAtomicSectionCancel( $makeCallback( 1 ), __METHOD__ );
+               $this->database->startAtomic( __METHOD__ . '_level2' );
+               $this->database->startAtomic( __METHOD__ . '_level3', IDatabase::ATOMIC_CANCELABLE );
+               $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+               $this->database->onAtomicSectionCancel( $makeCallback( 2 ), __METHOD__ );
+               $this->database->endAtomic( __METHOD__ );
+               $this->database->onAtomicSectionCancel( $makeCallback( 3 ), __METHOD__ );
+               $this->database->cancelAtomic( __METHOD__ . '_level3' );
+               $this->database->endAtomic( __METHOD__ . '_level2' );
+               $this->database->onAtomicSectionCancel( $makeCallback( 4 ), __METHOD__ );
+               $this->database->endAtomic( __METHOD__ . '_level1' );
+               $this->assertLastSql( implode( "; ", [
+                       'BEGIN',
+                       'SAVEPOINT wikimedia_rdbms_atomic1',
+                       'SAVEPOINT wikimedia_rdbms_atomic2',
+                       'RELEASE SAVEPOINT wikimedia_rdbms_atomic2',
+                       'ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1',
+                       'SELECT 2, tCancel AS t',
+                       'SELECT 3, tCancel AS t',
+                       'COMMIT',
+               ] ) );
        }
 
        /**
@@ -1692,6 +1736,16 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
                        $callback3Called = $trigger;
                        $this->database->query( "SELECT 3", $fname );
                };
+               $callback4Called = 0;
+               $callback4 = function () use ( $fname, &$callback4Called ) {
+                       $callback4Called++;
+                       $this->database->query( "SELECT 4", $fname );
+               };
+               $callback5Called = 0;
+               $callback5 = function () use ( $fname, &$callback5Called ) {
+                       $callback5Called++;
+                       $this->database->query( "SELECT 5", $fname );
+               };
 
                $this->database->startAtomic( __METHOD__ . '_outer' );
                $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
@@ -1699,57 +1753,67 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
                $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ );
                $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ );
                $this->database->onTransactionResolution( $callback3, __METHOD__ );
+               $this->database->onAtomicSectionCancel( $callback4, __METHOD__ );
                $this->database->endAtomic( __METHOD__ . '_inner' );
                $this->database->cancelAtomic( __METHOD__ );
                $this->database->endAtomic( __METHOD__ . '_outer' );
                $this->assertNull( $callback1Called );
                $this->assertNull( $callback2Called );
                $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called );
+               $this->assertEquals( 1, $callback4Called );
                // phpcs:ignore Generic.Files.LineLength
-               $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT; SELECT 3' );
+               $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; SELECT 4; COMMIT; SELECT 3' );
 
                $callback1Called = null;
                $callback2Called = null;
                $callback3Called = null;
+               $callback4Called = 0;
                $this->database->startAtomic( __METHOD__ . '_outer' );
                $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
                $this->database->startAtomic( __METHOD__ . '_inner', IDatabase::ATOMIC_CANCELABLE );
                $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ );
                $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ );
                $this->database->onTransactionResolution( $callback3, __METHOD__ );
+               $this->database->onAtomicSectionCancel( $callback4, __METHOD__ );
                $this->database->endAtomic( __METHOD__ . '_inner' );
                $this->database->cancelAtomic( __METHOD__ );
                $this->database->endAtomic( __METHOD__ . '_outer' );
                $this->assertNull( $callback1Called );
                $this->assertNull( $callback2Called );
                $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called );
+               $this->assertEquals( 1, $callback4Called );
                // phpcs:ignore Generic.Files.LineLength
-               $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SAVEPOINT wikimedia_rdbms_atomic2; RELEASE SAVEPOINT wikimedia_rdbms_atomic2; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT; SELECT 3' );
+               $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SAVEPOINT wikimedia_rdbms_atomic2; RELEASE SAVEPOINT wikimedia_rdbms_atomic2; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; SELECT 4; COMMIT; SELECT 3' );
 
                $callback1Called = null;
                $callback2Called = null;
                $callback3Called = null;
+               $callback4Called = 0;
                $this->database->startAtomic( __METHOD__ . '_outer' );
                $atomicId = $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
                $this->database->startAtomic( __METHOD__ . '_inner' );
                $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ );
                $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ );
                $this->database->onTransactionResolution( $callback3, __METHOD__ );
+               $this->database->onAtomicSectionCancel( $callback4, __METHOD__ );
                $this->database->cancelAtomic( __METHOD__, $atomicId );
                $this->database->endAtomic( __METHOD__ . '_outer' );
                $this->assertNull( $callback1Called );
                $this->assertNull( $callback2Called );
                $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called );
+               $this->assertEquals( 1, $callback4Called );
 
                $callback1Called = null;
                $callback2Called = null;
                $callback3Called = null;
+               $callback4Called = 0;
                $this->database->startAtomic( __METHOD__ . '_outer' );
                $atomicId = $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
                $this->database->startAtomic( __METHOD__ . '_inner' );
                $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ );
                $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ );
                $this->database->onTransactionResolution( $callback3, __METHOD__ );
+               $this->database->onAtomicSectionCancel( $callback4, __METHOD__ );
                try {
                        $this->database->cancelAtomic( __METHOD__ . '_X', $atomicId );
                } catch ( DBUnexpectedError $e ) {
@@ -1764,30 +1828,65 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
                $this->assertNull( $callback1Called );
                $this->assertNull( $callback2Called );
                $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called );
+               $this->assertEquals( 1, $callback4Called );
 
+               $callback4Called = 0;
+               $callback5Called = 0;
+               $this->database->getLastSqls(); // flush
                $this->database->startAtomic( __METHOD__ . '_outer' );
                $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
-               $this->database->startAtomic( __METHOD__ . '_inner' );
-               $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ );
-               $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ );
-               $this->database->onTransactionResolution( $callback3, __METHOD__ );
+               $this->database->onAtomicSectionCancel( $callback5, __METHOD__ );
+               $this->database->startAtomic( __METHOD__ . '_inner', IDatabase::ATOMIC_CANCELABLE );
+               $this->database->onAtomicSectionCancel( $callback4, __METHOD__ );
                $this->database->cancelAtomic( __METHOD__ . '_inner' );
                $this->database->cancelAtomic( __METHOD__ );
                $this->database->endAtomic( __METHOD__ . '_outer' );
-               $this->assertNull( $callback1Called );
-               $this->assertNull( $callback2Called );
-               $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called );
+               // phpcs:ignore Generic.Files.LineLength
+               $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SAVEPOINT wikimedia_rdbms_atomic2; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic2; SELECT 4; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; SELECT 5; COMMIT' );
+               $this->assertEquals( 1, $callback4Called );
+               $this->assertEquals( 1, $callback5Called );
+
+               $callback4Called = 0;
+               $callback5Called = 0;
+               $this->database->startAtomic( __METHOD__ . '_outer' );
+               $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+               $this->database->onAtomicSectionCancel( $callback5, __METHOD__ );
+               $this->database->startAtomic( __METHOD__ . '_inner', IDatabase::ATOMIC_CANCELABLE );
+               $this->database->onAtomicSectionCancel( $callback4, __METHOD__ );
+               $this->database->endAtomic( __METHOD__ . '_inner' );
+               $this->database->cancelAtomic( __METHOD__ );
+               $this->database->endAtomic( __METHOD__ . '_outer' );
+               // phpcs:ignore Generic.Files.LineLength
+               $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SAVEPOINT wikimedia_rdbms_atomic2; RELEASE SAVEPOINT wikimedia_rdbms_atomic2; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; SELECT 5; SELECT 4; COMMIT' );
+               $this->assertEquals( 1, $callback4Called );
+               $this->assertEquals( 1, $callback5Called );
+
+               $callback4Called = 0;
+               $callback5Called = 0;
+               $this->database->startAtomic( __METHOD__ . '_outer' );
+               $sectionId = $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+               $this->database->onAtomicSectionCancel( $callback5, __METHOD__ );
+               $this->database->startAtomic( __METHOD__ . '_inner', IDatabase::ATOMIC_CANCELABLE );
+               $this->database->onAtomicSectionCancel( $callback4, __METHOD__ );
+               $this->database->cancelAtomic( __METHOD__, $sectionId );
+               $this->database->endAtomic( __METHOD__ . '_outer' );
+               // phpcs:ignore Generic.Files.LineLength
+               $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; SAVEPOINT wikimedia_rdbms_atomic2; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; SELECT 5; SELECT 4; COMMIT' );
+               $this->assertEquals( 1, $callback4Called );
+               $this->assertEquals( 1, $callback5Called );
 
                $wrapper = TestingAccessWrapper::newFromObject( $this->database );
                $callback1Called = null;
                $callback2Called = null;
                $callback3Called = null;
+               $callback4Called = 0;
                $this->database->startAtomic( __METHOD__ . '_outer' );
                $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
                $this->database->startAtomic( __METHOD__ . '_inner' );
                $this->database->onTransactionCommitOrIdle( $callback1, __METHOD__ );
                $this->database->onTransactionPreCommitOrIdle( $callback2, __METHOD__ );
                $this->database->onTransactionResolution( $callback3, __METHOD__ );
+               $this->database->onAtomicSectionCancel( $callback4, __METHOD__ );
                $wrapper->trxStatus = Database::STATUS_TRX_ERROR;
                $this->database->cancelAtomic( __METHOD__ . '_inner' );
                $this->database->cancelAtomic( __METHOD__ );
@@ -1795,6 +1894,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
                $this->assertNull( $callback1Called );
                $this->assertNull( $callback2Called );
                $this->assertEquals( IDatabase::TRIGGER_ROLLBACK, $callback3Called );
+               $this->assertEquals( 1, $callback4Called );
        }
 
        /**
@@ -1876,6 +1976,22 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
                }
        }
 
+       /**
+        * @covers \Wikimedia\Rdbms\Database::onAtomicSectionCancel
+        */
+       public function testNoAtomicSectionForCallback() {
+               try {
+                       $this->database->onAtomicSectionCancel( function () {
+                       }, __METHOD__ );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( DBUnexpectedError $ex ) {
+                       $this->assertSame(
+                               'No atomic section is open (got ' . __METHOD__ . ').',
+                               $ex->getMessage()
+                       );
+               }
+       }
+
        /**
         * @expectedException \Wikimedia\Rdbms\DBTransactionStateError
         * @covers \Wikimedia\Rdbms\Database::assertQueryIsCurrentlyAllowed
@@ -2091,6 +2207,9 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
                        $this->database->onTransactionCommitOrIdle( function () use ( $fname ) {
                                $this->database->query( 'SELECT 1', $fname );
                        } );
+                       $this->database->onAtomicSectionCancel( function () use ( $fname ) {
+                               $this->database->query( 'SELECT 2', $fname );
+                       } );
                        $this->database->delete( 'x', [ 'field' => 3 ], __METHOD__ );
                        $this->database->close();
                        $this->fail( 'Expected exception not thrown' );
@@ -2103,7 +2222,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
                }
 
                $this->assertFalse( $this->database->isOpen() );
-               $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; ROLLBACK' );
+               $this->assertLastSql( 'BEGIN; DELETE FROM x WHERE field = \'3\'; ROLLBACK; SELECT 2' );
                $this->assertEquals( 0, $this->database->trxLevel() );
        }
 
diff --git a/tests/phpunit/integration/includes/db/DatabaseSqliteTest.php b/tests/phpunit/integration/includes/db/DatabaseSqliteTest.php
new file mode 100644 (file)
index 0000000..6fa911b
--- /dev/null
@@ -0,0 +1,553 @@
+<?php
+
+use Psr\Log\NullLogger;
+use Wikimedia\Rdbms\Blob;
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\DatabaseSqlite;
+use Wikimedia\Rdbms\ResultWrapper;
+use Wikimedia\Rdbms\TransactionProfiler;
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group sqlite
+ * @group Database
+ * @group medium
+ */
+class DatabaseSqliteTest extends \MediaWikiIntegrationTestCase {
+       /** @var DatabaseSqlite */
+       protected $db;
+
+       protected function setUp() {
+               parent::setUp();
+
+               if ( !Sqlite::isPresent() ) {
+                       $this->markTestSkipped( 'No SQLite support detected' );
+               }
+               $this->db = $this->getMockBuilder( DatabaseSqlite::class )
+                       ->setConstructorArgs( [ [
+                               'dbFilePath' => ':memory:',
+                               'schema' => false,
+                               'host' => false,
+                               'user' => false,
+                               'password' => false,
+                               'tablePrefix' => '',
+                               'cliMode' => true,
+                               'agent' => 'unit-tests',
+                               'flags' => DBO_DEFAULT,
+                               'variables' => [],
+                               'profiler' => null,
+                               'trxProfiler' => new TransactionProfiler(),
+                               'connLogger' => new NullLogger(),
+                               'queryLogger' => new NullLogger(),
+                               'errorLogger' => null,
+                               'deprecationLogger' => null,
+                       ] ] )->setMethods( [ 'query' ] )
+                       ->getMock();
+               $this->db->initConnection();
+               $this->db->method( 'query' )->willReturn( true );
+               if ( version_compare( $this->db->getServerVersion(), '3.6.0', '<' ) ) {
+                       $this->markTestSkipped( "SQLite at least 3.6 required, {$this->db->getServerVersion()} found" );
+               }
+       }
+
+       /**
+        * @param $sql
+        * @return string|string[]|null
+        */
+       private function replaceVars( $sql ) {
+               $wrapper = TestingAccessWrapper::newFromObject( $this->db );
+               // normalize spacing to hide implementation details
+               return preg_replace( '/\s+/', ' ', $wrapper->replaceVars( $sql ) );
+       }
+
+       private function assertResultIs( $expected, $res ) {
+               $this->assertNotNull( $res );
+               $i = 0;
+               foreach ( $res as $row ) {
+                       foreach ( $expected[$i] as $key => $value ) {
+                               $this->assertTrue( isset( $row->$key ) );
+                               $this->assertEquals( $value, $row->$key );
+                       }
+                       $i++;
+               }
+               $this->assertEquals( count( $expected ), $i, 'Unexpected number of rows' );
+       }
+
+       public static function provideAddQuotes() {
+               return [
+                       [ // #0: empty
+                               '', "''"
+                       ],
+                       [ // #1: simple
+                               'foo bar', "'foo bar'"
+                       ],
+                       [ // #2: including quote
+                               'foo\'bar', "'foo''bar'"
+                       ],
+                       // #3: including \0 (must be represented as hex, per https://bugs.php.net/bug.php?id=63419)
+                       [
+                               "x\0y",
+                               "x'780079'",
+                       ],
+                       [ // #4: blob object (must be represented as hex)
+                               new Blob( "hello" ),
+                               "x'68656c6c6f'",
+                       ],
+                       [ // #5: null
+                               null,
+                               "''",
+                       ],
+               ];
+       }
+
+       /**
+        * @dataProvider provideAddQuotes()
+        * @covers DatabaseSqlite::addQuotes
+        */
+       public function testAddQuotes( $value, $expected ) {
+               // check quoting
+               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+               $this->assertEquals( $expected, $db->addQuotes( $value ), 'string not quoted as expected' );
+
+               // ok, quoting works as expected, now try a round trip.
+               $re = $db->query( 'select ' . $db->addQuotes( $value ) );
+
+               $this->assertTrue( $re !== false, 'query failed' );
+
+               $row = $re->fetchRow();
+               if ( $row ) {
+                       if ( $value instanceof Blob ) {
+                               $value = $value->fetch();
+                       }
+
+                       $this->assertEquals( $value, $row[0], 'string mangled by the database' );
+               } else {
+                       $this->fail( 'query returned no result' );
+               }
+       }
+
+       /**
+        * @covers DatabaseSqlite::replaceVars
+        */
+       public function testReplaceVars() {
+               $this->assertEquals( 'foo', $this->replaceVars( 'foo' ), "Don't break anything accidentally" );
+
+               $this->assertEquals(
+                       "CREATE TABLE /**/foo (foo_key INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "
+                       . "foo_bar TEXT, foo_name TEXT NOT NULL DEFAULT '', foo_int INTEGER, foo_int2 INTEGER );",
+                       $this->replaceVars(
+                               "CREATE TABLE /**/foo (foo_key int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT, "
+                               . "foo_bar char(13), foo_name varchar(255) binary NOT NULL DEFAULT '', "
+                               . "foo_int tinyint ( 8 ), foo_int2 int(16) ) ENGINE=MyISAM;"
+                       )
+               );
+
+               $this->assertEquals(
+                       "CREATE TABLE foo ( foo1 REAL, foo2 REAL, foo3 REAL );",
+                       $this->replaceVars(
+                               "CREATE TABLE foo ( foo1 FLOAT, foo2 DOUBLE( 1,10), foo3 DOUBLE PRECISION );"
+                       )
+               );
+
+               $this->assertEquals( "CREATE TABLE foo ( foo_binary1 BLOB, foo_binary2 BLOB );",
+                       $this->replaceVars( "CREATE TABLE foo ( foo_binary1 binary(16), foo_binary2 varbinary(32) );" )
+               );
+
+               $this->assertEquals( "CREATE TABLE text ( text_foo TEXT );",
+                       $this->replaceVars( "CREATE TABLE text ( text_foo tinytext );" ),
+                       'Table name changed'
+               );
+
+               $this->assertEquals( "CREATE TABLE foo ( foobar INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL );",
+                       $this->replaceVars( "CREATE TABLE foo ( foobar INT PRIMARY KEY NOT NULL AUTO_INCREMENT );" )
+               );
+               $this->assertEquals( "CREATE TABLE foo ( foobar INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL );",
+                       $this->replaceVars( "CREATE TABLE foo ( foobar INT PRIMARY KEY AUTO_INCREMENT NOT NULL );" )
+               );
+
+               $this->assertEquals( "CREATE TABLE enums( enum1 TEXT, myenum TEXT)",
+                       $this->replaceVars( "CREATE TABLE enums( enum1 ENUM('A', 'B'), myenum ENUM ('X', 'Y'))" )
+               );
+
+               $this->assertEquals( "ALTER TABLE foo ADD COLUMN foo_bar INTEGER DEFAULT 42",
+                       $this->replaceVars( "ALTER TABLE foo\nADD COLUMN foo_bar int(10) unsigned DEFAULT 42" )
+               );
+
+               $this->assertEquals( "DROP INDEX foo",
+                       $this->replaceVars( "DROP INDEX /*i*/foo ON /*_*/bar" )
+               );
+
+               $this->assertEquals( "DROP INDEX foo -- dropping index",
+                       $this->replaceVars( "DROP INDEX /*i*/foo ON /*_*/bar -- dropping index" )
+               );
+               $this->assertEquals( "INSERT OR IGNORE INTO foo VALUES ('bar')",
+                       $this->replaceVars( "INSERT OR IGNORE INTO foo VALUES ('bar')" )
+               );
+       }
+
+       /**
+        * @covers DatabaseSqlite::tableName
+        */
+       public function testTableName() {
+               // @todo Moar!
+               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+               $this->assertEquals( 'foo', $db->tableName( 'foo' ) );
+               $this->assertEquals( 'sqlite_master', $db->tableName( 'sqlite_master' ) );
+               $db->tablePrefix( 'foo_' );
+               $this->assertEquals( 'sqlite_master', $db->tableName( 'sqlite_master' ) );
+               $this->assertEquals( 'foo_bar', $db->tableName( 'bar' ) );
+       }
+
+       /**
+        * @covers DatabaseSqlite::duplicateTableStructure
+        */
+       public function testDuplicateTableStructure() {
+               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+               $db->query( 'CREATE TABLE foo(foo, barfoo)' );
+               $db->query( 'CREATE INDEX index1 ON foo(foo)' );
+               $db->query( 'CREATE UNIQUE INDEX index2 ON foo(barfoo)' );
+
+               $db->duplicateTableStructure( 'foo', 'bar' );
+               $this->assertEquals( 'CREATE TABLE "bar"(foo, barfoo)',
+                       $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'bar' ] ),
+                       'Normal table duplication'
+               );
+               $indexList = $db->query( 'PRAGMA INDEX_LIST("bar")' );
+               $index = $indexList->next();
+               $this->assertEquals( 'bar_index1', $index->name );
+               $this->assertEquals( '0', $index->unique );
+               $index = $indexList->next();
+               $this->assertEquals( 'bar_index2', $index->name );
+               $this->assertEquals( '1', $index->unique );
+
+               $db->duplicateTableStructure( 'foo', 'baz', true );
+               $this->assertEquals( 'CREATE TABLE "baz"(foo, barfoo)',
+                       $db->selectField( 'sqlite_temp_master', 'sql', [ 'name' => 'baz' ] ),
+                       'Creation of temporary duplicate'
+               );
+               $indexList = $db->query( 'PRAGMA INDEX_LIST("baz")' );
+               $index = $indexList->next();
+               $this->assertEquals( 'baz_index1', $index->name );
+               $this->assertEquals( '0', $index->unique );
+               $index = $indexList->next();
+               $this->assertEquals( 'baz_index2', $index->name );
+               $this->assertEquals( '1', $index->unique );
+               $this->assertEquals( 0,
+                       $db->selectField( 'sqlite_master', 'COUNT(*)', [ 'name' => 'baz' ] ),
+                       'Create a temporary duplicate only'
+               );
+       }
+
+       /**
+        * @covers DatabaseSqlite::duplicateTableStructure
+        */
+       public function testDuplicateTableStructureVirtual() {
+               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+               if ( $db->getFulltextSearchModule() != 'FTS3' ) {
+                       $this->markTestSkipped( 'FTS3 not supported, cannot create virtual tables' );
+               }
+               $db->query( 'CREATE VIRTUAL TABLE "foo" USING FTS3(foobar)' );
+
+               $db->duplicateTableStructure( 'foo', 'bar' );
+               $this->assertEquals( 'CREATE VIRTUAL TABLE "bar" USING FTS3(foobar)',
+                       $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'bar' ] ),
+                       'Duplication of virtual tables'
+               );
+
+               $db->duplicateTableStructure( 'foo', 'baz', true );
+               $this->assertEquals( 'CREATE VIRTUAL TABLE "baz" USING FTS3(foobar)',
+                       $db->selectField( 'sqlite_master', 'sql', [ 'name' => 'baz' ] ),
+                       "Can't create temporary virtual tables, should fall back to non-temporary duplication"
+               );
+       }
+
+       /**
+        * @covers DatabaseSqlite::deleteJoin
+        */
+       public function testDeleteJoin() {
+               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+               $db->query( 'CREATE TABLE a (a_1)', __METHOD__ );
+               $db->query( 'CREATE TABLE b (b_1, b_2)', __METHOD__ );
+               $db->insert( 'a', [
+                       [ 'a_1' => 1 ],
+                       [ 'a_1' => 2 ],
+                       [ 'a_1' => 3 ],
+               ],
+                       __METHOD__
+               );
+               $db->insert( 'b', [
+                       [ 'b_1' => 2, 'b_2' => 'a' ],
+                       [ 'b_1' => 3, 'b_2' => 'b' ],
+               ],
+                       __METHOD__
+               );
+               $db->deleteJoin( 'a', 'b', 'a_1', 'b_1', [ 'b_2' => 'a' ], __METHOD__ );
+               $res = $db->query( "SELECT * FROM a", __METHOD__ );
+               $this->assertResultIs( [
+                       [ 'a_1' => 1 ],
+                       [ 'a_1' => 3 ],
+               ],
+                       $res
+               );
+       }
+
+       /**
+        * @coversNothing
+        */
+       public function testEntireSchema() {
+               global $IP;
+
+               $result = Sqlite::checkSqlSyntax( "$IP/maintenance/tables.sql" );
+               if ( $result !== true ) {
+                       $this->fail( $result );
+               }
+               $this->assertTrue( true ); // avoid test being marked as incomplete due to lack of assertions
+       }
+
+       /**
+        * Runs upgrades of older databases and compares results with current schema
+        * @todo Currently only checks list of tables
+        * @coversNothing
+        */
+       public function testUpgrades() {
+               global $IP, $wgVersion, $wgProfiler;
+
+               // Versions tested
+               $versions = [
+                       // '1.13', disabled for now, was totally screwed up
+                       // SQLite wasn't included in 1.14
+                       '1.15',
+                       '1.16',
+                       '1.17',
+                       '1.18',
+                       '1.19',
+                       '1.20',
+                       '1.21',
+                       '1.22',
+                       '1.23',
+               ];
+
+               // Mismatches for these columns we can safely ignore
+               $ignoredColumns = [
+                       'user_newtalk.user_last_timestamp', // r84185
+               ];
+
+               $currentDB = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+               $currentDB->sourceFile( "$IP/maintenance/tables.sql" );
+
+               $profileToDb = false;
+               if ( isset( $wgProfiler['output'] ) ) {
+                       $out = $wgProfiler['output'];
+                       if ( $out === 'db' ) {
+                               $profileToDb = true;
+                       } elseif ( is_array( $out ) && in_array( 'db', $out ) ) {
+                               $profileToDb = true;
+                       }
+               }
+
+               if ( $profileToDb ) {
+                       $currentDB->sourceFile( "$IP/maintenance/sqlite/archives/patch-profiling.sql" );
+               }
+               $currentTables = $this->getTables( $currentDB );
+               sort( $currentTables );
+
+               foreach ( $versions as $version ) {
+                       $versions = "upgrading from $version to $wgVersion";
+                       $db = $this->prepareTestDB( $version );
+                       $tables = $this->getTables( $db );
+                       $this->assertEquals( $currentTables, $tables, "Different tables $versions" );
+                       foreach ( $tables as $table ) {
+                               $currentCols = $this->getColumns( $currentDB, $table );
+                               $cols = $this->getColumns( $db, $table );
+                               $this->assertEquals(
+                                       array_keys( $currentCols ),
+                                       array_keys( $cols ),
+                                       "Mismatching columns for table \"$table\" $versions"
+                               );
+                               foreach ( $currentCols as $name => $column ) {
+                                       $fullName = "$table.$name";
+                                       $this->assertEquals(
+                                               (bool)$column->pk,
+                                               (bool)$cols[$name]->pk,
+                                               "PRIMARY KEY status does not match for column $fullName $versions"
+                                       );
+                                       if ( !in_array( $fullName, $ignoredColumns ) ) {
+                                               $this->assertEquals(
+                                                       (bool)$column->notnull,
+                                                       (bool)$cols[$name]->notnull,
+                                                       "NOT NULL status does not match for column $fullName $versions"
+                                               );
+                                               $this->assertEquals(
+                                                       $column->dflt_value,
+                                                       $cols[$name]->dflt_value,
+                                                       "Default values does not match for column $fullName $versions"
+                                               );
+                                       }
+                               }
+                               $currentIndexes = $this->getIndexes( $currentDB, $table );
+                               $indexes = $this->getIndexes( $db, $table );
+                               $this->assertEquals(
+                                       array_keys( $currentIndexes ),
+                                       array_keys( $indexes ),
+                                       "mismatching indexes for table \"$table\" $versions"
+                               );
+                       }
+                       $db->close();
+               }
+       }
+
+       /**
+        * @covers DatabaseSqlite::insertId
+        */
+       public function testInsertIdType() {
+               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+
+               $databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ );
+               $this->assertInstanceOf( ResultWrapper::class, $databaseCreation, "Database creation" );
+
+               $insertion = $db->insert( 'a', [ 'a_1' => 10 ], __METHOD__ );
+               $this->assertTrue( $insertion, "Insertion worked" );
+
+               $this->assertInternalType( 'integer', $db->insertId(), "Actual typecheck" );
+               $this->assertTrue( $db->close(), "closing database" );
+       }
+
+       /**
+        * @covers DatabaseSqlite::insert
+        */
+       public function testInsertAffectedRows() {
+               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+               $db->query( 'CREATE TABLE testInsertAffectedRows ( foo )', __METHOD__ );
+
+               $insertion = $db->insert(
+                       'testInsertAffectedRows',
+                       [
+                               [ 'foo' => 10 ],
+                               [ 'foo' => 12 ],
+                               [ 'foo' => 1555 ],
+                       ],
+                       __METHOD__
+               );
+               $this->assertTrue( $insertion, "Insertion worked" );
+
+               $this->assertSame( 3, $db->affectedRows() );
+               $this->assertTrue( $db->close(), "closing database" );
+       }
+
+       private function prepareTestDB( $version ) {
+               static $maint = null;
+               if ( $maint === null ) {
+                       $maint = new FakeMaintenance();
+                       $maint->loadParamsAndArgs( null, [ 'quiet' => 1 ] );
+               }
+
+               global $IP;
+               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+               $db->sourceFile( "$IP/tests/phpunit/data/db/sqlite/tables-$version.sql" );
+               $updater = DatabaseUpdater::newForDB( $db, false, $maint );
+               $updater->doUpdates( [ 'core' ] );
+
+               return $db;
+       }
+
+       private function getTables( $db ) {
+               $list = array_flip( $db->listTables() );
+               $excluded = [
+                       'external_user', // removed from core in 1.22
+                       'math', // moved out of core in 1.18
+                       'trackbacks', // removed from core in 1.19
+                       'searchindex',
+                       'searchindex_content',
+                       'searchindex_segments',
+                       'searchindex_segdir',
+                       // FTS4 ready!!1
+                       'searchindex_docsize',
+                       'searchindex_stat',
+               ];
+               foreach ( $excluded as $t ) {
+                       unset( $list[$t] );
+               }
+               $list = array_flip( $list );
+               sort( $list );
+
+               return $list;
+       }
+
+       private function getColumns( $db, $table ) {
+               $cols = [];
+               $res = $db->query( "PRAGMA table_info($table)" );
+               $this->assertNotNull( $res );
+               foreach ( $res as $col ) {
+                       $cols[$col->name] = $col;
+               }
+               ksort( $cols );
+
+               return $cols;
+       }
+
+       private function getIndexes( $db, $table ) {
+               $indexes = [];
+               $res = $db->query( "PRAGMA index_list($table)" );
+               $this->assertNotNull( $res );
+               foreach ( $res as $index ) {
+                       $res2 = $db->query( "PRAGMA index_info({$index->name})" );
+                       $this->assertNotNull( $res2 );
+                       $index->columns = [];
+                       foreach ( $res2 as $col ) {
+                               $index->columns[] = $col;
+                       }
+                       $indexes[$index->name] = $index;
+               }
+               ksort( $indexes );
+
+               return $indexes;
+       }
+
+       /**
+        * @coversNothing
+        */
+       public function testCaseInsensitiveLike() {
+               // TODO: Test this for all databases
+               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+               $res = $db->query( 'SELECT "a" LIKE "A" AS a' );
+               $row = $res->fetchRow();
+               $this->assertFalse( (bool)$row['a'] );
+       }
+
+       /**
+        * @covers DatabaseSqlite::numFields
+        */
+       public function testNumFields() {
+               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+
+               $databaseCreation = $db->query( 'CREATE TABLE a ( a_1 )', __METHOD__ );
+               $this->assertInstanceOf( ResultWrapper::class, $databaseCreation, "Failed to create table a" );
+               $res = $db->select( 'a', '*' );
+               $this->assertEquals( 0, $db->numFields( $res ), "expects to get 0 fields for an empty table" );
+               $insertion = $db->insert( 'a', [ 'a_1' => 10 ], __METHOD__ );
+               $this->assertTrue( $insertion, "Insertion failed" );
+               $res = $db->select( 'a', '*' );
+               $this->assertEquals( 1, $db->numFields( $res ), "wrong number of fields" );
+
+               $this->assertTrue( $db->close(), "closing database" );
+       }
+
+       /**
+        * @covers \Wikimedia\Rdbms\DatabaseSqlite::__toString
+        */
+       public function testToString() {
+               $db = DatabaseSqlite::newStandaloneInstance( ':memory:' );
+
+               $toString = (string)$db;
+
+               $this->assertContains( 'sqlite object', $toString );
+       }
+
+       /**
+        * @covers \Wikimedia\Rdbms\DatabaseSqlite::getAttributes()
+        */
+       public function testsAttributes() {
+               $attributes = Database::attributesFromType( 'sqlite' );
+               $this->assertTrue( $attributes[Database::ATTR_DB_LEVEL_LOCKING] );
+       }
+}
index ad33f6e..e8c1cd6 100644 (file)
@@ -137,6 +137,34 @@ class DumpAsserter {
                }
        }
 
+       /**
+        * Asserts that the xml reader is at an element of given name, and that element
+        * is an empty tag.
+        *
+        * @param string $name The name of the element to check for
+        *   (e.g.: "text" for <text/>)
+        * @param bool $skip (optional) if true, skip past the found element
+        * @param bool $skip_ws (optional) if true, also skip past white spaces that trail the
+        *   closing element.
+        */
+       public function assertEmptyNode( $name, $skip = true, $skip_ws = true ) {
+               $this->assertNodeStart( $name, false );
+               Assert::assertFalse( $this->xml->hasValue, "$name tag has content" );
+
+               if ( $skip ) {
+                       Assert::assertTrue( $this->xml->read(), "Skipping $name tag" );
+                       if ( ( $this->xml->nodeType == XMLReader::END_ELEMENT )
+                               && ( $this->xml->name == $name )
+                       ) {
+                               $this->xml->read();
+                       }
+
+                       if ( $skip_ws ) {
+                               $this->skipWhitespace();
+                       }
+               }
+       }
+
        /**
         * Asserts that the xml reader is at an closing element of given name, and optionally
         * skips past it.
@@ -246,6 +274,11 @@ class DumpAsserter {
                $this->assertTextNode( "comment", $summary );
                $this->skipWhitespace();
 
+               if ( $this->schemaVersion >= XML_DUMP_SCHEMA_VERSION_11 ) {
+                       $this->assertTextNode( "origin", false );
+                       $this->skipWhitespace();
+               }
+
                $this->assertTextNode( "model", $model );
                $this->skipWhitespace();
 
@@ -258,9 +291,16 @@ class DumpAsserter {
                        $this->assertText( $id, $text_id, $text_bytes, $text );
                } else {
                        $text_found = false;
+                       if ( $this->schemaVersion >= XML_DUMP_SCHEMA_VERSION_11 ) {
+                               Assert::fail( 'Missing text node' );
+                       }
                }
 
-               $this->assertTextNode( "sha1", $text_sha1 );
+               if ( $text_sha1 ) {
+                       $this->assertTextNode( "sha1", $text_sha1 );
+               } else {
+                       $this->assertEmptyNode( "sha1" );
+               }
 
                if ( !$text_found ) {
                        $this->assertText( $id, $text_id, $text_bytes, $text );
@@ -278,17 +318,9 @@ class DumpAsserter {
                }
 
                if ( $text === false ) {
-                       // Testing for a stub
                        Assert::assertEquals( $this->xml->getAttribute( "id" ), $text_id,
                                "Text id of revision " . $id );
-                       Assert::assertFalse( $this->xml->hasValue, "Revision has text" );
-                       Assert::assertTrue( $this->xml->read(), "Skipping text start tag" );
-                       if ( ( $this->xml->nodeType == XMLReader::END_ELEMENT )
-                               && ( $this->xml->name == "text" )
-                       ) {
-                               $this->xml->read();
-                       }
-                       $this->skipWhitespace();
+                       $this->assertEmptyNode( "text" );
                } else {
                        // Testing for a real dump
                        Assert::assertTrue( $this->xml->read(), "Skipping text start tag" );
index 17c8757..7a78e52 100644 (file)
@@ -5,8 +5,11 @@ namespace MediaWiki\Tests\Maintenance;
 use DumpBackup;
 use Exception;
 use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RevisionRecord;
 use MediaWikiTestCase;
 use MWException;
+use RequestContext;
+use RevisionDeleter;
 use Title;
 use WikiExporter;
 use Wikimedia\Rdbms\IDatabase;
@@ -77,6 +80,17 @@ class BackupDumperPageTest extends DumpTestCase {
                                "BackupDumperTestP2Summary4 extra " );
                        $this->pageId2 = $page->getId();
 
+                       $revDel = RevisionDeleter::createList(
+                               'revision',
+                               RequestContext::getMain(),
+                               $this->pageTitle2,
+                               [ $this->revId2_2 ]
+                       );
+                       $revDel->setVisibility( [
+                               'value' => [ RevisionRecord::DELETED_TEXT => 1 ],
+                               'comment' => 'testing!'
+                       ] );
+
                        $this->pageTitle3 = Title::newFromText( 'BackupDumperTestP3', $this->namespace );
                        $page = WikiPage::factory( $this->pageTitle3 );
                        list( $this->revId3_1, $this->textId3_1 ) = $this->addRevision( $page,
@@ -232,10 +246,10 @@ class BackupDumperPageTest extends DumpTestCase {
                $asserter->assertRevision(
                        $this->revId2_2,
                        "BackupDumperTestP2Summary2",
-                       $this->textId2_2,
-                       23,
-                       "b7vj5ks32po5m1z1t1br4o7scdwwy95",
-                       "BackupDumperTestP2Text2",
+                       null, // deleted!
+                       false, // deleted!
+                       null, // deleted!
+                       false, // deleted!
                        $this->revId2_1
                );
                $asserter->assertRevision(
@@ -346,10 +360,10 @@ class BackupDumperPageTest extends DumpTestCase {
                $asserter->assertRevision(
                        $this->revId2_2,
                        "BackupDumperTestP2Summary2",
-                       $this->textId2_2,
-                       23,
-                       "b7vj5ks32po5m1z1t1br4o7scdwwy95",
-                       false,
+                       null, // deleted!
+                       false, // deleted!
+                       null, // deleted!
+                       false, // deleted!
                        $this->revId2_1
                );
                $asserter->assertRevision(
@@ -622,10 +636,10 @@ class BackupDumperPageTest extends DumpTestCase {
                $asserter->assertRevision(
                        $this->revId2_2,
                        "BackupDumperTestP2Summary2",
-                       $this->textId2_2,
-                       23,
-                       "b7vj5ks32po5m1z1t1br4o7scdwwy95",
-                       false,
+                       null, // deleted!
+                       false, // deleted!
+                       null, // deleted!
+                       false, // deleted!
                        $this->revId2_1
                );
                $asserter->assertRevision(
index cc6ac31..d7d3c61 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<phpunit bootstrap="./bootstrap.php"
+<phpunit bootstrap="./bootstrap.maintenance.php"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.8/phpunit.xsd"
 
index 9641802..88fc93b 100644 (file)
@@ -184,4 +184,43 @@ class MediaWikiTestCaseTest extends MediaWikiTestCase {
                $this->assertSame( 'TEST', $value, 'Copied Data' );
        }
 
+       public function testResetServices() {
+               $services = MediaWikiServices::getInstance();
+
+               // override a service instance
+               $myReadOnlyMode = $this->getMockBuilder( ReadOnlyMode::class )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+               $this->setService( 'ReadOnlyMode', $myReadOnlyMode );
+
+               // sanity check
+               $this->assertSame( $myReadOnlyMode, $services->getService( 'ReadOnlyMode' ) );
+
+               // define a custom service
+               $services->defineService(
+                       '_TEST_ResetService_Dummy',
+                       function ( MediaWikiServices $services ) {
+                               $conf = $services->getMainConfig();
+                               return (object)[ 'lang' => $conf->get( 'LanguageCode' ) ];
+                       }
+               );
+
+               // sanity check
+               $lang = $services->getMainConfig()->get( 'LanguageCode' );
+               $dummy = $services->getService( '_TEST_ResetService_Dummy' );
+               $this->assertSame( $lang, $dummy->lang );
+
+               // the actual test: change config, reset services.
+               $this->setMwGlobals( 'wgLanguageCode', 'qqx' );
+               $this->resetServices();
+
+               // the overridden service instance should still be there
+               $this->assertSame( $myReadOnlyMode, $services->getService( 'ReadOnlyMode' ) );
+
+               // our custom service should have been re-created with the new language code
+               $dummy2 = $services->getService( '_TEST_ResetService_Dummy' );
+               $this->assertNotSame( $dummy2, $dummy );
+               $this->assertSame( 'qqx', $dummy2->lang );
+       }
+
 }
diff --git a/tests/phpunit/unit-tests.xml b/tests/phpunit/unit-tests.xml
deleted file mode 100644 (file)
index cd4118c..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<phpunit bootstrap="unit/initUnitTests.php"
-                xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-                xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.8/phpunit.xsd"
-
-                colors="true"
-                backupGlobals="false"
-                convertErrorsToExceptions="true"
-                convertNoticesToExceptions="true"
-                convertWarningsToExceptions="true"
-                forceCoversAnnotation="true"
-                stopOnFailure="false"
-                timeoutForSmallTests="10"
-                timeoutForMediumTests="30"
-                timeoutForLargeTests="60"
-                beStrictAboutTestsThatDoNotTestAnything="true"
-                beStrictAboutOutputDuringTests="true"
-                beStrictAboutTestSize="true"
-                verbose="false">
-       <testsuites>
-               <testsuite name="tests">
-                       <directory>unit</directory>
-               </testsuite>
-       </testsuites>
-       <groups>
-               <exclude>
-                       <group>Broken</group>
-               </exclude>
-       </groups>
-       <filter>
-               <whitelist addUncoveredFilesFromWhitelist="true">
-                       <directory suffix=".php">../../includes</directory>
-                       <directory suffix=".php">../../languages</directory>
-                       <directory suffix=".php">../../maintenance</directory>
-                       <exclude>
-                               <directory suffix=".php">../../languages/messages</directory>
-                               <file>../../languages/data/normalize-ar.php</file>
-                               <file>../../languages/data/normalize-ml.php</file>
-                       </exclude>
-               </whitelist>
-       </filter>
-</phpunit>
diff --git a/tests/phpunit/unit/initUnitTests.php b/tests/phpunit/unit/initUnitTests.php
deleted file mode 100644 (file)
index 2121877..0000000
+++ /dev/null
@@ -1,69 +0,0 @@
-<?php
-/**
- * PHPUnit bootstrap file for the unit test suite.
- *
- * 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 Testing
- */
-
-if ( PHP_SAPI !== 'cli' ) {
-       die( 'This file is only meant to be executed indirectly by PHPUnit\'s bootstrap process!' );
-}
-
-/**
- * PHPUnit includes the bootstrap file inside a method body, while most MediaWiki startup files
- * assume to be included in the global scope.
- * This utility provides a way to include these files: it makes all globals available in the
- * inclusion scope before including the file, then exports all new or changed globals.
- *
- * @param string $fileName the file to include
- */
-function wfRequireOnceInGlobalScope( $fileName ) {
-       // phpcs:disable MediaWiki.Usage.ForbiddenFunctions.extract
-       extract( $GLOBALS, EXTR_REFS | EXTR_SKIP );
-       // phpcs:enable
-
-       require_once $fileName;
-
-       foreach ( get_defined_vars() as $varName => $value ) {
-               $GLOBALS[$varName] = $value;
-       }
-}
-
-define( 'MEDIAWIKI', true );
-define( 'MW_PHPUNIT_TEST', true );
-
-// We don't use a settings file here but some code still assumes that one exists
-define( 'MW_CONFIG_FILE', 'LocalSettings.php' );
-
-$IP = realpath( __DIR__ . '/../../..' );
-
-// these variables must be defined before setup runs
-$GLOBALS['IP'] = $IP;
-$GLOBALS['wgCommandLineMode'] = true;
-
-require_once "$IP/tests/common/TestSetup.php";
-
-wfRequireOnceInGlobalScope( "$IP/includes/AutoLoader.php" );
-wfRequireOnceInGlobalScope( "$IP/includes/Defines.php" );
-wfRequireOnceInGlobalScope( "$IP/includes/DefaultSettings.php" );
-wfRequireOnceInGlobalScope( "$IP/includes/GlobalFunctions.php" );
-
-require_once "$IP/tests/common/TestsAutoLoader.php";
-
-TestSetup::applyInitialConfig();
index 0b3e809..e67af10 100644 (file)
 
                // On collapse...
                $collapsible.on( 'beforeCollapse.mw-collapsible', function () {
-                       assert.assertTrue( $content.is( ':visible' ), 'first beforeCollapseExpand: content is visible' );
+                       assert.assertTrue( $content.css( 'display' ) !== 'none', 'first beforeCollapseExpand: content is visible' );
                } );
                $collapsible.on( 'afterCollapse.mw-collapsible', function () {
-                       assert.assertTrue( $content.is( ':hidden' ), 'first afterCollapseExpand: content is hidden' );
+                       assert.assertTrue( $content.css( 'display' ) === 'none', 'first afterCollapseExpand: content is hidden' );
 
                        // On expand...
                        $collapsible.on( 'beforeExpand.mw-collapsible', function () {
-                               assert.assertTrue( $content.is( ':hidden' ), 'second beforeCollapseExpand: content is hidden' );
+                               assert.assertTrue( $content.css( 'display' ) === 'none', 'second beforeCollapseExpand: content is hidden' );
                        } );
                        $collapsible.on( 'afterExpand.mw-collapsible', function () {
-                               assert.assertTrue( $content.is( ':visible' ), 'second afterCollapseExpand: content is visible' );
+                               assert.assertTrue( $content.css( 'display' ) !== 'none', 'second afterCollapseExpand: content is visible' );
                        } );
 
                        // ...expanding happens here
                assert.strictEqual( $content.length, 1, 'content is present' );
                assert.strictEqual( $content.find( $toggle ).length, 0, 'toggle is not a descendant of content' );
 
-               assert.assertTrue( $content.is( ':visible' ), 'content is visible' );
+               assert.assertTrue( $content.css( 'display' ) !== 'none', 'content is visible' );
 
                $collapsible.on( 'afterCollapse.mw-collapsible', function () {
-                       assert.assertTrue( $content.is( ':hidden' ), 'after collapsing: content is hidden' );
+                       assert.assertTrue( $content.css( 'display' ) === 'none', 'after collapsing: content is hidden' );
 
                        $collapsible.on( 'afterExpand.mw-collapsible', function () {
-                               assert.assertTrue( $content.is( ':visible' ), 'after expanding: content is visible' );
+                               assert.assertTrue( $content.css( 'display' ) !== 'none', 'after expanding: content is visible' );
                        } );
 
                        $toggle.trigger( 'click' );
                                        '<tr><td>' + loremIpsum + '</td><td>' + loremIpsum + '</td></tr>' +
                                '</table>'
                        ),
-                       $headerRow = $collapsible.find( 'tr:first' ),
-                       $contentRow = $collapsible.find( 'tr:last' ),
-                       $toggle = $headerRow.find( 'td:last .mw-collapsible-toggle' );
+                       $headerRow = $collapsible.find( 'tr' ).first(),
+                       $contentRow = $collapsible.find( 'tr' ).last(),
+                       $toggle = $headerRow.find( 'td' ).last().find( '.mw-collapsible-toggle' );
 
                assert.strictEqual( $toggle.length, 1, 'toggle is added to last cell of first row' );
 
-               assert.assertTrue( $headerRow.is( ':visible' ), 'headerRow is visible' );
-               assert.assertTrue( $contentRow.is( ':visible' ), 'contentRow is visible' );
+               assert.assertTrue( $headerRow.css( 'display' ) !== 'none', 'headerRow is visible' );
+               assert.assertTrue( $contentRow.css( 'display' ) !== 'none', 'contentRow is visible' );
 
                $collapsible.on( 'afterCollapse.mw-collapsible', function () {
-                       assert.assertTrue( $headerRow.is( ':visible' ), 'after collapsing: headerRow is still visible' );
-                       assert.assertTrue( $contentRow.is( ':hidden' ), 'after collapsing: contentRow is hidden' );
+                       assert.assertTrue( $headerRow.css( 'display' ) !== 'none', 'after collapsing: headerRow is still visible' );
+                       assert.assertTrue( $contentRow.css( 'display' ) === 'none', 'after collapsing: contentRow is hidden' );
 
                        $collapsible.on( 'afterExpand.mw-collapsible', function () {
-                               assert.assertTrue( $headerRow.is( ':visible' ), 'after expanding: headerRow is still visible' );
-                               assert.assertTrue( $contentRow.is( ':visible' ), 'after expanding: contentRow is visible' );
+                               assert.assertTrue( $headerRow.css( 'display' ) !== 'none', 'after expanding: headerRow is still visible' );
+                               assert.assertTrue( $contentRow.css( 'display' ) !== 'none', 'after expanding: contentRow is visible' );
                        } );
 
                        $toggle.trigger( 'click' );
 
        function tableWithCaptionTest( $collapsible, test, assert ) {
                var $caption = $collapsible.find( 'caption' ),
-                       $headerRow = $collapsible.find( 'tr:first' ),
-                       $contentRow = $collapsible.find( 'tr:last' ),
+                       $headerRow = $collapsible.find( 'tr' ).first(),
+                       $contentRow = $collapsible.find( 'tr' ).last(),
                        $toggle = $caption.find( '.mw-collapsible-toggle' );
 
                assert.strictEqual( $toggle.length, 1, 'toggle is added to the end of the caption' );
 
-               assert.assertTrue( $caption.is( ':visible' ), 'caption is visible' );
-               assert.assertTrue( $headerRow.is( ':visible' ), 'headerRow is visible' );
-               assert.assertTrue( $contentRow.is( ':visible' ), 'contentRow is visible' );
+               assert.assertTrue( $caption.css( 'display' ) !== 'none', 'caption is visible' );
+               assert.assertTrue( $headerRow.css( 'display' ) !== 'none', 'headerRow is visible' );
+               assert.assertTrue( $contentRow.css( 'display' ) !== 'none', 'contentRow is visible' );
 
                $collapsible.on( 'afterCollapse.mw-collapsible', function () {
-                       assert.assertTrue( $caption.is( ':visible' ), 'after collapsing: caption is still visible' );
-                       assert.assertTrue( $headerRow.is( ':hidden' ), 'after collapsing: headerRow is hidden' );
-                       assert.assertTrue( $contentRow.is( ':hidden' ), 'after collapsing: contentRow is hidden' );
+                       assert.assertTrue( $caption.css( 'display' ) !== 'none', 'after collapsing: caption is still visible' );
+                       assert.assertTrue( $headerRow.css( 'display' ) === 'none', 'after collapsing: headerRow is hidden' );
+                       assert.assertTrue( $contentRow.css( 'display' ) === 'none', 'after collapsing: contentRow is hidden' );
 
                        $collapsible.on( 'afterExpand.mw-collapsible', function () {
-                               assert.assertTrue( $caption.is( ':visible' ), 'after expanding: caption is still visible' );
-                               assert.assertTrue( $headerRow.is( ':visible' ), 'after expanding: headerRow is visible' );
-                               assert.assertTrue( $contentRow.is( ':visible' ), 'after expanding: contentRow is visible' );
+                               assert.assertTrue( $caption.css( 'display' ) !== 'none', 'after expanding: caption is still visible' );
+                               assert.assertTrue( $headerRow.css( 'display' ) !== 'none', 'after expanding: headerRow is visible' );
+                               assert.assertTrue( $contentRow.css( 'display' ) !== 'none', 'after expanding: contentRow is visible' );
                        } );
 
                        $toggle.trigger( 'click' );
                                '</' + listType + '>'
                        ),
                        $toggleItem = $collapsible.find( 'li.mw-collapsible-toggle-li:first-child' ),
-                       $contentItem = $collapsible.find( 'li:last' ),
+                       $contentItem = $collapsible.find( 'li' ).last(),
                        $toggle = $toggleItem.find( '.mw-collapsible-toggle' );
 
                assert.strictEqual( $toggle.length, 1, 'toggle is present, added inside new zeroth list item' );
 
-               assert.assertTrue( $toggleItem.is( ':visible' ), 'toggleItem is visible' );
-               assert.assertTrue( $contentItem.is( ':visible' ), 'contentItem is visible' );
+               assert.assertTrue( $toggleItem.css( 'display' ) !== 'none', 'toggleItem is visible' );
+               assert.assertTrue( $contentItem.css( 'display' ) !== 'none', 'contentItem is visible' );
 
                $collapsible.on( 'afterCollapse.mw-collapsible', function () {
-                       assert.assertTrue( $toggleItem.is( ':visible' ), 'after collapsing: toggleItem is still visible' );
-                       assert.assertTrue( $contentItem.is( ':hidden' ), 'after collapsing: contentItem is hidden' );
+                       assert.assertTrue( $toggleItem.css( 'display' ) !== 'none', 'after collapsing: toggleItem is still visible' );
+                       assert.assertTrue( $contentItem.css( 'display' ) === 'none', 'after collapsing: contentItem is hidden' );
 
                        $collapsible.on( 'afterExpand.mw-collapsible', function () {
-                               assert.assertTrue( $toggleItem.is( ':visible' ), 'after expanding: toggleItem is still visible' );
-                               assert.assertTrue( $contentItem.is( ':visible' ), 'after expanding: contentItem is visible' );
+                               assert.assertTrue( $toggleItem.css( 'display' ) !== 'none', 'after expanding: toggleItem is still visible' );
+                               assert.assertTrue( $contentItem.css( 'display' ) !== 'none', 'after expanding: contentItem is visible' );
                        } );
 
                        $toggle.trigger( 'click' );
                        ),
                        $content = $collapsible.find( '.mw-collapsible-content' );
 
-               assert.assertTrue( $content.is( ':visible' ), 'content is visible' );
+               assert.assertTrue( $content.css( 'display' ) !== 'none', 'content is visible' );
 
                $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' );
 
-               assert.assertTrue( $content.is( ':hidden' ), 'after collapsing: content is hidden' );
+               assert.assertTrue( $content.css( 'display' ) === 'none', 'after collapsing: content is hidden' );
        } );
 
        QUnit.test( 'mw-made-collapsible data added', function ( assert ) {
                        $content = $collapsible.find( '.mw-collapsible-content' );
 
                // Synchronous - mw-collapsed should cause instantHide: true to be used on initial collapsing
-               assert.assertTrue( $content.is( ':hidden' ), 'content is hidden' );
+               assert.assertTrue( $content.css( 'display' ) === 'none', 'content is hidden' );
 
                $collapsible.on( 'afterExpand.mw-collapsible', function () {
-                       assert.assertTrue( $content.is( ':visible' ), 'after expanding: content is visible' );
+                       assert.assertTrue( $content.css( 'display' ) !== 'none', 'after expanding: content is visible' );
                } );
 
                $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' );
                        $content = $collapsible.find( '.mw-collapsible-content' );
 
                // Synchronous - collapsed: true should cause instantHide: true to be used on initial collapsing
-               assert.assertTrue( $content.is( ':hidden' ), 'content is hidden' );
+               assert.assertTrue( $content.css( 'display' ) === 'none', 'content is hidden' );
 
                $collapsible.on( 'afterExpand.mw-collapsible', function () {
-                       assert.assertTrue( $content.is( ':visible' ), 'after expanding: content is visible' );
+                       assert.assertTrue( $content.css( 'display' ) !== 'none', 'after expanding: content is visible' );
                } );
 
                $collapsible.find( '.mw-collapsible-toggle' ).trigger( 'click' );
                        $content = $collapsible.find( '.mw-collapsible-content' );
 
                $collapsible.find( '.mw-collapsible-toggle a' ).trigger( 'click' );
-               assert.assertTrue( $content.is( ':visible' ), 'click event on link inside toggle passes through (content not toggled)' );
+               assert.assertTrue( $content.css( 'display' ) !== 'none', 'click event on link inside toggle passes through (content not toggled)' );
 
                $collapsible.find( '.mw-collapsible-toggle b' ).trigger( 'click' );
-               assert.assertTrue( $content.is( ':hidden' ), 'click event on non-link inside toggle toggles content' );
+               assert.assertTrue( $content.css( 'display' ) === 'none', 'click event on non-link inside toggle toggles content' );
        } );
 
        QUnit.test( 'click on non-link inside toggler counts as trigger', function ( assert ) {
                        $content = $collapsible.find( '.mw-collapsible-content' );
 
                $collapsible.find( '.mw-collapsible-toggle a' ).trigger( 'click' );
-               assert.assertTrue( $content.is( ':hidden' ), 'click event on link (with no href) inside toggle toggles content' );
+               assert.assertTrue( $content.css( 'display' ) === 'none', 'click event on link (with no href) inside toggle toggles content' );
        } );
 
        QUnit.test( 'collapse/expand text (data-collapsetext, data-expandtext)', function ( assert ) {
                                .appendTo( '#qunit-fixture' ).makeCollapsible(),
                        $content = $clone.find( '.mw-collapsible-content' );
 
-               assert.assertTrue( $content.is( ':visible' ), 'content is visible' );
+               assert.assertTrue( $content.css( 'display' ) !== 'none', 'content is visible' );
 
                $clone.on( 'afterCollapse.mw-collapsible', function () {
-                       assert.assertTrue( $content.is( ':hidden' ), 'after collapsing: content is hidden' );
+                       assert.assertTrue( $content.css( 'display' ) === 'none', 'after collapsing: content is hidden' );
                } );
 
                $clone.find( '.mw-collapsible-toggle a' ).trigger( 'click' );
index 4731b32..506b25b 100644 (file)
@@ -74,9 +74,9 @@
        }
 
        text = [
-               [ 'Mars', true, 'mars', 'Simple text' ],
-               [ 'Mẘas', true, 'mẘas', 'Non ascii character' ],
-               [ 'A sentence', true, 'a sentence', 'A sentence with space chars' ]
+               [ 'Mars', true, 'Mars', 'Simple text' ],
+               [ 'Mẘas', true, 'Mẘas', 'Non ascii character' ],
+               [ 'A sentence', true, 'A sentence', 'A sentence with space chars' ]
        ];
        parserTest( 'Textual keys', 'text', text );
 
index bd6ee16..2711ddf 100644 (file)
                        [ 'Günther' ],
                        [ 'Peter' ],
                        [ 'Björn' ],
+                       [ 'ä' ],
+                       [ 'z' ],
                        [ 'Bjorn' ],
+                       [ 'BjÖrn' ],
+                       [ 'apfel' ],
                        [ 'Apfel' ],
                        [ 'Äpfel' ],
                        [ 'Strasse' ],
                        [ 'Sträßschen' ]
                ],
-               umlautWordsSorted = [
+               umlautWordsSortedEn = [
+                       [ 'ä' ],
                        [ 'Äpfel' ],
+                       [ 'apfel' ],
                        [ 'Apfel' ],
                        [ 'Björn' ],
+                       [ 'BjÖrn' ],
                        [ 'Bjorn' ],
                        [ 'Günther' ],
                        [ 'Peter' ],
                        [ 'Sträßschen' ],
-                       [ 'Strasse' ]
+                       [ 'Strasse' ],
+                       [ 'z' ]
+               ],
+               umlautWordsSortedSv = [
+                       [ 'apfel' ],
+                       [ 'Apfel' ],
+                       [ 'Bjorn' ],
+                       [ 'Björn' ],
+                       [ 'BjÖrn' ],
+                       [ 'Günther' ],
+                       [ 'Peter' ],
+                       [ 'Strasse' ],
+                       [ 'Sträßschen' ],
+                       [ 'z' ],
+                       [ 'ä' ], // ä sorts after z in Swedish
+                       [ 'Äpfel' ]
                ],
 
                // Data set "digraph"
                planetsAscName,
                function ( $table ) {
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
                }
        );
        tableTest(
                planetsAscName,
                function ( $table ) {
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
                }
        );
        tableTest(
                planetsAscName,
                function ( $table ) {
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
-                       $table.find( '.headerSort:eq(1)' ).trigger( 'click' );
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
                }
        );
        tableTest(
                reversed( planetsAscName ),
                function ( $table ) {
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ).trigger( 'click' );
                }
        );
        tableTest(
                planetsAscRadius,
                function ( $table ) {
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(1)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' );
                }
        );
        tableTest(
                reversed( planetsAscRadius ),
                function ( $table ) {
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(1)' ).trigger( 'click' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' ).trigger( 'click' );
                }
        );
        tableTest(
                        $table.tablesorter(
                                { sortList: [ { 0: 'asc' }, { 1: 'asc' } ] }
                        );
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
                }
        );
        tableTest(
                        $table.tablesorter(
                                { sortList: [ { 0: 'desc' }, { 1: 'desc' } ] }
                        );
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
 
                        // Pretend to click while pressing the multi-sort key
                        event = $.Event( 'click' );
                        event[ $table.data( 'tablesorter' ).config.sortMultiSortKey ] = true;
-                       $table.find( '.headerSort:eq(1)' ).trigger( event );
+                       $table.find( '.headerSort' ).eq( 1 ).trigger( event );
                }
        );
        QUnit.test( 'Reset sorting making table appear unsorted', function ( assert ) {
                [ aaa1, aab5, abc3, bbc2, caa4 ],
                function ( $table ) {
                        // Make colspanned header for test
-                       $table.find( 'tr:eq(0) th:eq(1), tr:eq(0) th:eq(2)' ).remove();
-                       $table.find( 'tr:eq(0) th:eq(0)' ).attr( 'colspan', '3' );
+                       $table.find( 'tr th' ).eq( 1 ).remove();
+                       $table.find( 'tr th' ).eq( 1 ).remove();
+                       $table.find( 'tr th' ).eq( 0 ).attr( 'colspan', '3' );
 
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
                }
        );
        tableTest( 'Sorting with colspanned headers: sort spanned column twice',
                [ caa4, bbc2, abc3, aab5, aaa1 ],
                function ( $table ) {
                        // Make colspanned header for test
-                       $table.find( 'tr:eq(0) th:eq(1), tr:eq(0) th:eq(2)' ).remove();
-                       $table.find( 'tr:eq(0) th:eq(0)' ).attr( 'colspan', '3' );
+                       $table.find( 'tr th' ).eq( 1 ).remove();
+                       $table.find( 'tr th' ).eq( 1 ).remove();
+                       $table.find( 'tr' ).eq( 0 ).find( 'th' ).eq( 0 ).attr( 'colspan', '3' );
 
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
                }
        );
        tableTest( 'Sorting with colspanned headers: subsequent column',
                [ aaa1, bbc2, abc3, caa4, aab5 ],
                function ( $table ) {
                        // Make colspanned header for test
-                       $table.find( 'tr:eq(0) th:eq(1), tr:eq(0) th:eq(2)' ).remove();
-                       $table.find( 'tr:eq(0) th:eq(0)' ).attr( 'colspan', '3' );
+                       $table.find( 'tr th' ).eq( 1 ).remove();
+                       $table.find( 'tr th' ).eq( 1 ).remove();
+                       $table.find( 'tr th' ).eq( 0 ).attr( 'colspan', '3' );
 
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(1)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' );
                }
        );
        tableTest( 'Sorting with colspanned headers: sort subsequent column twice',
                [ aab5, caa4, abc3, bbc2, aaa1 ],
                function ( $table ) {
                        // Make colspanned header for test
-                       $table.find( 'tr:eq(0) th:eq(1), tr:eq(0) th:eq(2)' ).remove();
-                       $table.find( 'tr:eq(0) th:eq(0)' ).attr( 'colspan', '3' );
+                       $table.find( 'tr th' ).eq( 1 ).remove();
+                       $table.find( 'tr th' ).eq( 1 ).remove();
+                       $table.find( 'tr th' ).eq( 0 ).attr( 'colspan', '3' );
 
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(1)' ).trigger( 'click' );
-                       $table.find( '.headerSort:eq(1)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' );
                }
        );
 
        QUnit.test( 'Basic planet table: one unsortable column', function ( assert ) {
                var $table = tableCreate( header, planets ),
                        $cell;
-               $table.find( 'tr:eq(0) > th:eq(0)' ).addClass( 'unsortable' );
+               $table.find( 'tr > th' ).eq( 0 ).addClass( 'unsortable' );
 
                $table.tablesorter();
-               $table.find( 'tr:eq(0) > th:eq(0)' ).trigger( 'click' );
+               $table.find( 'tr > th' ).eq( 0 ).trigger( 'click' );
 
                assert.deepEqual(
                        tableExtract( $table ),
                        'table not sorted'
                );
 
-               $cell = $table.find( 'tr:eq(0) > th:eq(0)' );
-               $table.find( 'tr:eq(0) > th:eq(1)' ).trigger( 'click' );
+               $cell = $table.find( 'tr > th' ).eq( 0 );
+               $table.find( 'tr > th' ).eq( 1 ).trigger( 'click' );
 
                assert.strictEqual(
                        $cell.hasClass( 'headerSortUp' ) || $cell.hasClass( 'headerSortDown' ),
                        mw.config.set( 'wgPageContentLanguage', 'de' );
 
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
                }
        );
 
                        mw.config.set( 'wgDefaultDateFormat', 'mdy' );
 
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
                }
        );
 
                ipv4Sorted,
                function ( $table ) {
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
                }
        );
        tableTest(
                reversed( ipv4Sorted ),
                function ( $table ) {
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ).trigger( 'click' );
                }
        );
 
                'Accented Characters with custom collation',
                [ 'Name' ],
                umlautWords,
-               umlautWordsSorted,
+               umlautWordsSortedEn,
                function ( $table ) {
                        mw.config.set( 'tableSorterCollation', {
                                ä: 'ae',
                                ü: 'ue'
                        } );
 
+                       $table.tablesorter();
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
+               }
+       );
+
+       tableTest(
+               'Accented Characters Swedish locale',
+               [ 'Name' ],
+               umlautWords,
+               umlautWordsSortedSv,
+               function ( $table ) {
+                       mw.config.set( 'wgPageContentLanguage', 'sv' );
+
                        $table.tablesorter();
                        $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
                }
                        } );
 
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
                }
        );
 
 
                // Modify the table to have a multiple-row-spanning cell:
                // - Remove 2nd cell of 4th row, and, 2nd cell or 5th row.
-               $table.find( 'tr:eq(3) td:eq(1), tr:eq(4) td:eq(1)' ).remove();
+               $table.find( 'tr' ).eq( 3 ).find( 'td' ).eq( 1 ).remove();
+               $table.find( 'tr' ).eq( 4 ).find( 'td' ).eq( 1 ).remove();
                // - Set rowspan for 2nd cell of 3rd row to 3.
                //   This covers the removed cell in the 4th and 5th row.
-               $table.find( 'tr:eq(2) td:eq(1)' ).attr( 'rowspan', '3' );
+               $table.find( 'tr' ).eq( 2 ).find( 'td' ).eq( 1 ).attr( 'rowspan', '3' );
 
                $table.tablesorter();
 
                assert.strictEqual(
-                       $table.find( 'tr:eq(2) td:eq(1)' ).prop( 'rowSpan' ),
+                       $table.find( 'tr' ).eq( 2 ).find( 'td' ).eq( 1 ).prop( 'rowSpan' ),
                        3,
                        'Rowspan not exploded'
                );
                function ( $table ) {
                        // Modify the table to have a multiple-row-spanning cell:
                        // - Remove 2nd cell of 4th row, and, 2nd cell or 5th row.
-                       $table.find( 'tr:eq(3) td:eq(1), tr:eq(4) td:eq(1)' ).remove();
+                       $table.find( 'tr' ).eq( 3 ).find( 'td' ).eq( 1 ).remove();
+                       $table.find( 'tr' ).eq( 4 ).find( 'td' ).eq( 1 ).remove();
                        // - Set rowspan for 2nd cell of 3rd row to 3.
                        //   This covers the removed cell in the 4th and 5th row.
-                       $table.find( 'tr:eq(2) td:eq(1)' ).attr( 'rowspan', '3' );
+                       $table.find( 'tr' ).eq( 2 ).find( 'td' ).eq( 1 ).attr( 'rowspan', '3' );
 
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
                }
        );
        tableTest(
                function ( $table ) {
                        // Modify the table to have a multiple-row-spanning cell:
                        // - Remove 2nd cell of 4th row, and, 2nd cell or 5th row.
-                       $table.find( 'tr:eq(3) td:eq(1), tr:eq(4) td:eq(1)' ).remove();
+                       $table.find( 'tr' ).eq( 3 ).find( 'td' ).eq( 1 ).remove();
+                       $table.find( 'tr' ).eq( 4 ).find( 'td' ).eq( 1 ).remove();
                        // - Set rowspan for 2nd cell of 3rd row to 3.
                        //   This covers the removed cell in the 4th and 5th row.
-                       $table.find( 'tr:eq(2) td:eq(1)' ).attr( 'rowspan', '3' );
+                       $table.find( 'tr' ).eq( 2 ).find( 'td' ).eq( 1 ).attr( 'rowspan', '3' );
 
                        $table.tablesorter( { sortList: [
                                { 0: 'asc' }
                function ( $table ) {
                        // Modify the table to have a multiple-row-spanning cell:
                        // - Remove 1st cell of 4th row, and, 1st cell or 5th row.
-                       $table.find( 'tr:eq(3) td:eq(0), tr:eq(4) td:eq(0)' ).remove();
+                       $table.find( 'tr' ).eq( 3 ).find( 'td' ).eq( 0 ).remove();
+                       $table.find( 'tr' ).eq( 4 ).find( 'td' ).eq( 0 ).remove();
                        // - Set rowspan for 1st cell of 3rd row to 3.
                        //   This covers the removed cell in the 4th and 5th row.
-                       $table.find( 'tr:eq(2) td:eq(0)' ).attr( 'rowspan', '3' );
+                       $table.find( 'tr' ).eq( 2 ).find( 'td' ).eq( 0 ).attr( 'rowspan', '3' );
 
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
                }
        );
 
                        mw.config.set( 'wgDefaultDateFormat', 'mdy' );
 
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
                }
        );
 
                currencySorted,
                function ( $table ) {
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
                }
        );
 
                planets,
                planetsAscNameLegacy,
                function ( $table ) {
-                       $table.find( 'tr:last' ).addClass( 'sortbottom' );
+                       $table.find( 'tr' ).last().addClass( 'sortbottom' );
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
                }
        );
 
                                '</table>'
                );
                $table.tablesorter();
-               $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+               $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
 
                assert.strictEqual(
                        $table.data( 'tablesorter' ).config.parsers[ 0 ].id,
                                '<tr><td data-sort-value="Cherry">Dolphin</td></tr>' +
                                '</tbody></table>'
                );
-               $table.tablesorter().find( '.headerSort:eq(0)' ).trigger( 'click' );
+               $table.tablesorter().find( '.headerSort' ).eq( 0 ).trigger( 'click' );
 
                data = [];
                $table.find( 'tbody > tr' ).each( function ( i, tr ) {
                                '<tr><td><span data-sort-value="D">H</span></td></tr>' +
                                '</tbody></table>'
                );
-               $table.tablesorter().find( '.headerSort:eq(0)' ).trigger( 'click' );
+               $table.tablesorter().find( '.headerSort' ).eq( 0 ).trigger( 'click' );
 
                data = [];
                $table.find( 'tbody > tr' ).each( function ( i, tr ) {
                                '</tbody></table>'
                );
                // initialize table sorter and sort once
-               $table
-                       .tablesorter()
-                       .find( '.headerSort:eq(0)' ).trigger( 'click' );
+               $table.tablesorter().find( '.headerSort' ).eq( 0 ).trigger( 'click' );
 
                // Change the sortValue data properties (T40152)
                // - change data
                $table.find( 'td:contains(G)' ).removeData( 'sortValue' );
 
                // Now sort again (twice, so it is back at Ascending)
-               $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
-               $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+               $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
+               $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
 
                data = [];
                $table.find( 'tbody > tr' ).each( function ( i, tr ) {
                [ 'Numbers' ], numbers, numbersAsc,
                function ( $table ) {
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
                }
        );
 
                [ 'Numbers' ], numbers, reversed( numbersAsc ),
                function ( $table ) {
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' ).trigger( 'click' );
                }
        );
        // TODO add numbers sorting tests for T10115 with a different language
                $table.tablesorter();
 
                assert.strictEqual(
-                       $table.find( '> thead:eq(0) > tr > th.headerSort' ).length,
+                       $table.find( '> thead > tr > th.headerSort' ).length,
                        1,
                        'Child tables inside a headercell should not interfere with sortable headers (T34888)'
                );
                        mw.config.set( 'wgDefaultDateFormat', 'mdy' );
 
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
                }
        );
 
                        mw.config.set( 'wgDefaultDateFormat', 'dmy' );
 
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
                }
        );
 
                        mw.config.set( 'wgDefaultDateFormat', 'dmy' );
 
                        $table.tablesorter();
-                       $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+                       $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
                }
        );
 
                                '<tr><td>1</td></tr>' +
                                '</table>'
                );
-               $table.tablesorter().find( '.headerSort:eq(0)' ).trigger( 'click' );
+               $table.tablesorter().find( '.headerSort' ).eq( 0 ).trigger( 'click' );
 
                assert.strictEqual(
                        $table.find( 'td' ).first().text(),
                                '<tr><td><img alt="A" />C</tr>' +
                                '</table>'
                );
-               $table.tablesorter().find( '.headerSort:eq(0)' ).trigger( 'click' );
+               $table.tablesorter().find( '.headerSort' ).eq( 0 ).trigger( 'click' );
 
                assert.strictEqual(
                        $table.find( 'td' ).text(),
                                '<tr><td>4</td></tr>' +
                                '</table>'
                );
-               $table.tablesorter().find( '.headerSort:eq(0)' ).trigger( 'click' );
+               $table.tablesorter().find( '.headerSort' ).eq( 0 ).trigger( 'click' );
 
                assert.strictEqual(
                        $table.find( 'td' ).text(),
                                '</tbody></table>' );
 
                        $table.tablesorter();
-                       assert.strictEqual( $table.find( 'tr:eq(1) th:eq(1)' ).data( 'headerIndex' ),
+                       assert.strictEqual( $table.find( 'tr' ).eq( 1 ).find( 'th' ).eq( 1 ).data( 'headerIndex' ),
                                2,
                                'Incorrect index of sort header'
                        );
                                '</table>'
                );
                $table.tablesorter();
-               $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+               $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
                // now the first row have 2 columns
-               $table.find( '.headerSort:eq(1)' ).trigger( 'click' );
+               $table.find( '.headerSort' ).eq( 1 ).trigger( 'click' );
 
                parsers = $table.data( 'tablesorter' ).config.parsers;
 
                );
 
                assert.strictEqual(
-                       parsers[ 1 ].format( $table.find( 'tbody > tr > td:eq(1)' ).text() ),
+                       parsers[ 1 ].format( $table.find( 'tbody > tr > td' ).eq( 1 ).text() ),
                        -Infinity,
                        'empty cell is sorted as number -Infinity'
                );
                                '</table>'
                );
                $table.tablesorter();
-               $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+               $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
 
                assert.deepEqual(
                        tableExtract( $table ),
                                '</table>'
                );
                $table.tablesorter();
-               $table.find( '.headerSort:eq(0)' ).trigger( 'click' );
+               $table.find( '.headerSort' ).eq( 0 ).trigger( 'click' );
 
                assert.deepEqual(
                        tableExtract( $table ),
index 74fd743..6dcdb44 100644 (file)
                $( '#qunit-fixture' ).append( $toc );
                mw.hook( 'wikipage.content' ).fire( $( '#qunit-fixture' ) );
 
-               $tocList = $toc.find( 'ul:first' );
+               $tocList = $toc.find( 'ul' ).first();
                $toggleLink = $toc.find( '.togglelink' );
 
                assert.strictEqual( $toggleLink.length, 1, 'Toggle link is added to the table of contents' );
 
-               assert.strictEqual( $tocList.is( ':hidden' ), false, 'The table of contents is now visible' );
+               assert.strictEqual( $toc.hasClass( 'tochidden' ), false, 'The table of contents is now visible' );
 
                $toggleLink.trigger( 'click' );
                return $tocList.promise().then( function () {
-                       assert.strictEqual( $tocList.is( ':hidden' ), true, 'The table of contents is now hidden' );
-
+                       assert.strictEqual( $toc.hasClass( 'tochidden' ), true, 'The table of contents is now hidden' );
                        $toggleLink.trigger( 'click' );
                        return $tocList.promise();
                } );
index 6b316e5..01dea8e 100644 (file)
                        'Default modules', 't-rldm-nonexistent', 'List of all default modules ', 'd', '#t-rl-nonexistent' );
                assert.strictEqual(
                        tbRLDMnonexistentid,
-                       $( '#p-test-tb li:last' )[ 0 ],
+                       $( '#p-test-tb li' ).last()[ 0 ],
                        'Next node as non-matching CSS selector falls back to appending'
                );
 
                        'Default modules', 't-rldm-empty-jquery', 'List of all default modules ', 'd', $( '#t-rl-nonexistent' ) );
                assert.strictEqual(
                        tbRLDMemptyjquery,
-                       $( '#p-test-tb li:last' )[ 0 ],
+                       $( '#p-test-tb li' ).last()[ 0 ],
                        'Next node as empty jQuery object falls back to appending'
                );
        } );