the SQL query. The ActorMigration class may also be used to get feature-flagged
information needed to access actor-related fields during the migration
period.
+* Added Wikimedia\Rdbms\IDatabase::cancelAtomic(), to roll back an atomic
+ section without having to roll back the whole transaction.
+* Wikimedia\Rdbms\IDatabase::doAtomicSection(), non-native ::insertSelect(),
+ and non-MySQL ::replace() and ::upsert() no longer roll back the whole
+ transaction on failure.
=== External library changes in 1.31 ===
'CleanupUsersWithNoId' => __DIR__ . '/maintenance/cleanupUsersWithNoId.php',
'ClearInterwikiCache' => __DIR__ . '/maintenance/clearInterwikiCache.php',
'ClearUserWatchlistJob' => __DIR__ . '/includes/jobqueue/jobs/ClearUserWatchlistJob.php',
+ 'ClearWatchlistNotificationsJob' => __DIR__ . '/includes/jobqueue/jobs/ClearWatchlistNotificationsJob.php',
'CliInstaller' => __DIR__ . '/includes/installer/CliInstaller.php',
'CloneDatabase' => __DIR__ . '/includes/db/CloneDatabase.php',
'CodeCleanerGlobalsPass' => __DIR__ . '/maintenance/CodeCleanerGlobalsPass.inc',
'categoryMembershipChange' => CategoryMembershipChangeJob::class,
'clearUserWatchlist' => ClearUserWatchlistJob::class,
'cdnPurge' => CdnPurgeJob::class,
- 'enqueue' => EnqueueJob::class, // local queue for multi-DC setups
'userGroupExpiry' => UserGroupExpiryJob::class,
+ 'clearWatchlistNotifications' => ClearWatchlistNotificationsJob::class,
+ 'enqueue' => EnqueueJob::class, // local queue for multi-DC setups
'null' => NullJob::class,
];
<?php
namespace MediaWiki;
+use ActorMigration;
use CommentStore;
use Config;
use ConfigFactory;
$userIter = call_user_func( function () use ( $dbSecondary, $sort, $op, $fname ) {
global $wgActorTableSchemaMigrationStage;
- $from = $fromName = false;
+ $fromName = false;
if ( !is_null( $this->params['continue'] ) ) {
$continue = explode( '|', $this->params['continue'] );
$this->dieContinueUsageIf( count( $continue ) != 4 );
$this->dieContinueUsageIf( $continue[0] !== 'name' );
$fromName = $continue[1];
- $from = "$op= " . $dbSecondary->addQuotes( $fromName );
}
$like = $dbSecondary->buildLike( $this->params['userprefix'], $dbSecondary->anyString() );
$limit = 501;
do {
+ $from = $fromName ? "$op= " . $dbSecondary->addQuotes( $fromName ) : false;
+
// For the new schema, pull from the actor table. For the
// old, pull from rev_user. For migration a FULL [OUTER]
// JOIN would be what we want, except MySQL doesn't support
}
$count = 0;
- $from = null;
+ $fromName = false;
foreach ( $res as $row ) {
if ( ++$count >= $limit ) {
- $from = $row->user_name;
+ $fromName = $row->user_name;
break;
}
yield User::newFromRow( $row );
}
- } while ( $from !== null );
+ } while ( $fromName !== false );
} );
// Do the actual sorting client-side, because otherwise
// prepareQuery might try to sort by actor and confuse everything.
/**
* Job for updating user activity like "last viewed" timestamps
*
+ * Job parameters include:
+ * - type: one of (updateWatchlistNotification) [required]
+ * - userid: affected user ID [required]
+ * - notifTime: timestamp to set watchlist entries to [required]
+ * - curTime: UNIX timestamp of the event that triggered this job [required]
+ *
* @ingroup JobQueue
* @since 1.26
*/
function __construct( Title $title, array $params ) {
parent::__construct( 'activityUpdateJob', $title, $params );
- if ( !isset( $params['type'] ) ) {
- throw new InvalidArgumentException( "Missing 'type' parameter." );
+ static $required = [ 'type', 'userid', 'notifTime', 'curTime' ];
+ $missing = implode( ', ', array_diff( $required, array_keys( $this->params ) ) );
+ if ( $missing != '' ) {
+ throw new InvalidArgumentException( "Missing paramter(s) $missing" );
}
$this->removeDuplicates = true;
if ( $this->params['type'] === 'updateWatchlistNotification' ) {
$this->updateWatchlistNotification();
} else {
- throw new InvalidArgumentException(
- "Invalid 'type' parameter '{$this->params['type']}'." );
+ throw new InvalidArgumentException( "Invalid 'type' '{$this->params['type']}'." );
}
return true;
--- /dev/null
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup JobQueue
+ */
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Job for clearing all of the "last viewed" timestamps for a user's watchlist
+ *
+ * Job parameters include:
+ * - userId: affected user ID [required]
+ * - casTime: UNIX timestamp of the event that triggered this job [required]
+ *
+ * @ingroup JobQueue
+ * @since 1.31
+ */
+class ClearWatchlistNotificationsJob extends Job {
+ function __construct( Title $title, array $params ) {
+ parent::__construct( 'clearWatchlistNotifications', $title, $params );
+
+ static $required = [ 'userId', 'casTime' ];
+ $missing = implode( ', ', array_diff( $required, array_keys( $this->params ) ) );
+ if ( $missing != '' ) {
+ throw new InvalidArgumentException( "Missing paramter(s) $missing" );
+ }
+
+ $this->removeDuplicates = true;
+ }
+
+ public function run() {
+ $services = MediaWikiServices::getInstance();
+ $lbFactory = $services->getDBLoadBalancerFactory();
+ $rowsPerQuery = $services->getMainConfig()->get( 'UpdateRowsPerQuery' );
+
+ $dbw = $lbFactory->getMainLB()->getConnection( DB_MASTER );
+ $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ );
+
+ $asOfTimes = array_unique( $dbw->selectFieldValues(
+ 'watchlist',
+ 'wl_notificationtimestamp',
+ [ 'wl_user' => $this->params['userId'], 'wl_notificationtimestamp IS NOT NULL' ],
+ __METHOD__,
+ [ 'ORDER BY' => 'wl_notificationtimestamp DESC' ]
+ ) );
+
+ foreach ( array_chunk( $asOfTimes, $rowsPerQuery ) as $asOfTimeBatch ) {
+ $dbw->update(
+ 'watchlist',
+ [ 'wl_notificationtimestamp' => null ],
+ [
+ 'wl_user' => $this->params['userId'],
+ 'wl_notificationtimestamp' => $asOfTimeBatch,
+ // New notifications since the reset should not be cleared
+ 'wl_notificationtimestamp < ' .
+ $dbw->addQuotes( $dbw->timestamp( $this->params['casTime'] ) )
+ ],
+ __METHOD__
+ );
+ $lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
+ }
+ }
+}
return $this->__call( __FUNCTION__, func_get_args() );
}
- public function fieldInfo( $table, $field ) {
- return $this->__call( __FUNCTION__, func_get_args() );
- }
-
public function affectedRows() {
return $this->__call( __FUNCTION__, func_get_args() );
}
return $this->__call( __FUNCTION__, func_get_args() );
}
- public function reportConnectionError( $error = 'Unknown error' ) {
- return $this->__call( __FUNCTION__, func_get_args() );
- }
-
public function query( $sql, $fname = __METHOD__, $tempIgnore = false ) {
return $this->__call( __FUNCTION__, func_get_args() );
}
- public function reportQueryError( $error, $errno, $sql, $fname, $tempIgnore = false ) {
- return $this->__call( __FUNCTION__, func_get_args() );
- }
-
public function freeResult( $res ) {
return $this->__call( __FUNCTION__, func_get_args() );
}
return $this->__call( __FUNCTION__, func_get_args() );
}
- public function indexUnique( $table, $index ) {
- return $this->__call( __FUNCTION__, func_get_args() );
- }
-
public function insert( $table, $a, $fname = __METHOD__, $options = [] ) {
return $this->__call( __FUNCTION__, func_get_args() );
}
return $this->__call( __FUNCTION__, func_get_args() );
}
- public function startAtomic( $fname = __METHOD__ ) {
+ public function startAtomic(
+ $fname = __METHOD__, $cancelable = IDatabase::ATOMIC_NOT_CANCELABLE
+ ) {
return $this->__call( __FUNCTION__, func_get_args() );
}
return $this->__call( __FUNCTION__, func_get_args() );
}
+ public function cancelAtomic( $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
public function doAtomicSection( $fname, callable $callback ) {
return $this->__call( __FUNCTION__, func_get_args() );
}
return $this->__call( __FUNCTION__, func_get_args() );
}
- public function listTables( $prefix = null, $fname = __METHOD__ ) {
- return $this->__call( __FUNCTION__, func_get_args() );
- }
-
public function timestamp( $ts = 0 ) {
return $this->__call( __FUNCTION__, func_get_args() );
}
* @see Database::trxLevel
*/
private $trxAutomatic = false;
+ /**
+ * Counter for atomic savepoint identifiers. Reset when a new transaction begins.
+ *
+ * @var int
+ */
+ private $trxAtomicCounter = 0;
/**
* Array of levels of atomicity within transactions
*
return $res;
}
- /**
- * Turns on (false) or off (true) the automatic generation and sending
- * of a "we're sorry, but there has been a database error" page on
- * database errors. Default is on (false). When turned off, the
- * code should use lastErrno() and lastError() to handle the
- * situation as appropriate.
- *
- * Do not use this function outside of the Database classes.
- *
- * @param null|bool $ignoreErrors
- * @return bool The previous value of the flag.
- */
- protected function ignoreErrors( $ignoreErrors = null ) {
- $res = $this->getFlag( self::DBO_IGNORE );
- if ( $ignoreErrors !== null ) {
- // setFlag()/clearFlag() do not allow DBO_IGNORE changes for sanity
- if ( $ignoreErrors ) {
- $this->flags |= self::DBO_IGNORE;
- } else {
- $this->flags &= ~self::DBO_IGNORE;
- }
- }
-
- return $res;
- }
-
public function trxLevel() {
return $this->trxLevel;
}
*/
abstract protected function closeConnection();
+ /**
+ * @param string $error Fallback error message, used if none is given by DB
+ * @throws DBConnectionError
+ */
public function reportConnectionError( $error = 'Unknown error' ) {
$myError = $this->lastError();
if ( $myError ) {
*/
private function handleSessionLoss() {
$this->trxLevel = 0;
+ $this->trxAtomicCounter = 0;
$this->trxIdleCallbacks = []; // T67263; transaction already lost
$this->trxPreCommitCallbacks = []; // T67263; transaction already lost
$this->sessionTempTables = [];
return false;
}
+ /**
+ * Report a query error. Log the error, and if neither the object ignore
+ * flag nor the $tempIgnore flag is set, throw a DBQueryError.
+ *
+ * @param string $error
+ * @param int $errno
+ * @param string $sql
+ * @param string $fname
+ * @param bool $tempIgnore
+ * @throws DBQueryError
+ */
public function reportQueryError( $error, $errno, $sql, $fname, $tempIgnore = false ) {
- if ( $this->ignoreErrors() || $tempIgnore ) {
+ if ( $this->getFlag( self::DBO_IGNORE ) || $tempIgnore ) {
$this->queryLogger->debug( "SQL ERROR (ignored): $error\n" );
} else {
$sql1line = mb_substr( str_replace( "\n", "\\n", $sql ), 0, 5 * 1024 );
}
try {
- $this->startAtomic( $fname );
+ $this->startAtomic( $fname, self::ATOMIC_CANCELABLE );
$affectedRowCount = 0;
foreach ( $rows as $row ) {
// Delete rows which collide with this one
$this->endAtomic( $fname );
$this->affectedRowCount = $affectedRowCount;
} catch ( Exception $e ) {
- $this->rollback( $fname, self::FLUSHING_INTERNAL );
+ $this->cancelAtomic( $fname );
throw $e;
}
}
$affectedRowCount = 0;
try {
- $this->startAtomic( $fname );
+ $this->startAtomic( $fname, self::ATOMIC_CANCELABLE );
# Update any existing conflicting row(s)
if ( $where !== false ) {
$ok = $this->update( $table, $set, $where, $fname );
$this->endAtomic( $fname );
$this->affectedRowCount = $affectedRowCount;
} catch ( Exception $e ) {
- $this->rollback( $fname, self::FLUSHING_INTERNAL );
+ $this->cancelAtomic( $fname );
throw $e;
}
try {
$affectedRowCount = 0;
- $this->startAtomic( $fname );
+ $this->startAtomic( $fname, self::ATOMIC_CANCELABLE );
$rows = [];
$ok = true;
foreach ( $res as $row ) {
$this->endAtomic( $fname );
$this->affectedRowCount = $affectedRowCount;
} else {
- $this->rollback( $fname, self::FLUSHING_INTERNAL );
+ $this->cancelAtomic( $fname );
}
return $ok;
} catch ( Exception $e ) {
- $this->rollback( $fname, self::FLUSHING_INTERNAL );
+ $this->cancelAtomic( $fname );
throw $e;
}
}
$this->trxPreCommitCallbacks[] = [ $callback, $fname ];
} else {
// No transaction is active nor will start implicitly, so make one for this callback
- $this->startAtomic( __METHOD__ );
+ $this->startAtomic( __METHOD__, self::ATOMIC_CANCELABLE );
try {
call_user_func( $callback );
$this->endAtomic( __METHOD__ );
} catch ( Exception $e ) {
- $this->rollback( __METHOD__, self::FLUSHING_INTERNAL );
+ $this->cancelAtomic( __METHOD__ );
throw $e;
}
}
}
}
- final public function startAtomic( $fname = __METHOD__ ) {
+ /**
+ * Create a savepoint
+ *
+ * This is used internally to implement atomic sections. It should not be
+ * used otherwise.
+ *
+ * @since 1.31
+ * @param string $identifier Identifier for the savepoint
+ * @param string $fname Calling function name
+ */
+ protected function doSavepoint( $identifier, $fname ) {
+ $this->query( 'SAVEPOINT ' . $this->addIdentifierQuotes( $identifier ), $fname );
+ }
+
+ /**
+ * Release a savepoint
+ *
+ * This is used internally to implement atomic sections. It should not be
+ * used otherwise.
+ *
+ * @since 1.31
+ * @param string $identifier Identifier for the savepoint
+ * @param string $fname Calling function name
+ */
+ protected function doReleaseSavepoint( $identifier, $fname ) {
+ $this->query( 'RELEASE SAVEPOINT ' . $this->addIdentifierQuotes( $identifier ), $fname );
+ }
+
+ /**
+ * Rollback to a savepoint
+ *
+ * This is used internally to implement atomic sections. It should not be
+ * used otherwise.
+ *
+ * @since 1.31
+ * @param string $identifier Identifier for the savepoint
+ * @param string $fname Calling function name
+ */
+ protected function doRollbackToSavepoint( $identifier, $fname ) {
+ $this->query( 'ROLLBACK TO SAVEPOINT ' . $this->addIdentifierQuotes( $identifier ), $fname );
+ }
+
+ final public function startAtomic(
+ $fname = __METHOD__, $cancelable = self::ATOMIC_NOT_CANCELABLE
+ ) {
+ $savepointId = $cancelable === self::ATOMIC_CANCELABLE ? 'n/a' : null;
if ( !$this->trxLevel ) {
$this->begin( $fname, self::TRANSACTION_INTERNAL );
// If DBO_TRX is set, a series of startAtomic/endAtomic pairs will result
if ( !$this->getFlag( self::DBO_TRX ) ) {
$this->trxAutomaticAtomic = true;
}
+ } elseif ( $cancelable === self::ATOMIC_CANCELABLE ) {
+ $savepointId = 'wikimedia_rdbms_atomic' . ++$this->trxAtomicCounter;
+ if ( strlen( $savepointId ) > 30 ) { // 30 == Oracle's identifier length limit (pre 12c)
+ $this->queryLogger->warning(
+ 'There have been an excessively large number of atomic sections in a transaction'
+ . " started by $this->trxFname, reusing IDs (at $fname)",
+ [ 'trace' => ( new RuntimeException() )->getTraceAsString() ]
+ );
+ $this->trxAtomicCounter = 0;
+ $savepointId = 'wikimedia_rdbms_atomic' . ++$this->trxAtomicCounter;
+ }
+ $this->doSavepoint( $savepointId, $fname );
}
- $this->trxAtomicLevels[] = $fname;
+ $this->trxAtomicLevels[] = [ $fname, $savepointId ];
}
final public function endAtomic( $fname = __METHOD__ ) {
if ( !$this->trxLevel ) {
throw new DBUnexpectedError( $this, "No atomic transaction is open (got $fname)." );
}
- if ( !$this->trxAtomicLevels ||
- array_pop( $this->trxAtomicLevels ) !== $fname
- ) {
+
+ list( $savedFname, $savepointId ) = $this->trxAtomicLevels
+ ? array_pop( $this->trxAtomicLevels ) : [ null, null ];
+ if ( $savedFname !== $fname ) {
throw new DBUnexpectedError( $this, "Invalid atomic section ended (got $fname)." );
}
if ( !$this->trxAtomicLevels && $this->trxAutomaticAtomic ) {
$this->commit( $fname, self::FLUSHING_INTERNAL );
+ } elseif ( $savepointId && $savepointId !== 'n/a' ) {
+ $this->doReleaseSavepoint( $savepointId, $fname );
}
}
+ final public function cancelAtomic( $fname = __METHOD__ ) {
+ if ( !$this->trxLevel ) {
+ throw new DBUnexpectedError( $this, "No atomic transaction is open (got $fname)." );
+ }
+
+ list( $savedFname, $savepointId ) = $this->trxAtomicLevels
+ ? array_pop( $this->trxAtomicLevels ) : [ null, null ];
+ if ( $savedFname !== $fname ) {
+ throw new DBUnexpectedError( $this, "Invalid atomic section ended (got $fname)." );
+ }
+ if ( !$savepointId ) {
+ throw new DBUnexpectedError( $this, "Uncancelable atomic section canceled (got $fname)." );
+ }
+
+ if ( !$this->trxAtomicLevels && $this->trxAutomaticAtomic ) {
+ $this->rollback( $fname, self::FLUSHING_INTERNAL );
+ } elseif ( $savepointId !== 'n/a' ) {
+ $this->doRollbackToSavepoint( $savepointId, $fname );
+ }
+
+ $this->affectedRowCount = 0; // for the sake of consistency
+ }
+
final public function doAtomicSection( $fname, callable $callback ) {
- $this->startAtomic( $fname );
+ $this->startAtomic( $fname, self::ATOMIC_CANCELABLE );
try {
$res = call_user_func_array( $callback, [ $this, $fname ] );
} catch ( Exception $e ) {
- $this->rollback( $fname, self::FLUSHING_INTERNAL );
+ $this->cancelAtomic( $fname );
throw $e;
}
$this->endAtomic( $fname );
// Protect against mismatched atomic section, transaction nesting, and snapshot loss
if ( $this->trxLevel ) {
if ( $this->trxAtomicLevels ) {
- $levels = implode( ', ', $this->trxAtomicLevels );
+ $levels = array_reduce( $this->trxAtomicLevels, function ( $accum, $v ) {
+ return $accum === null ? $v[0] : "$accum, " . $v[0];
+ } );
$msg = "$fname: Got explicit BEGIN while atomic section(s) $levels are open.";
throw new DBUnexpectedError( $this, $msg );
} elseif ( !$this->trxAutomatic ) {
$this->assertOpen();
$this->doBegin( $fname );
+ $this->trxAtomicCounter = 0;
$this->trxTimestamp = microtime( true );
$this->trxFname = $fname;
$this->trxDoneWrites = false;
final public function commit( $fname = __METHOD__, $flush = '' ) {
if ( $this->trxLevel && $this->trxAtomicLevels ) {
// There are still atomic sections open. This cannot be ignored
- $levels = implode( ', ', $this->trxAtomicLevels );
+ $levels = array_reduce( $this->trxAtomicLevels, function ( $accum, $v ) {
+ return $accum === null ? $v[0] : "$accum, " . $v[0];
+ } );
throw new DBUnexpectedError(
$this,
"$fname: Got COMMIT while atomic sections $levels are still open."
return false;
}
+ protected function doSavepoint( $identifier, $fname ) {
+ $this->query( 'SAVE TRANSACTION ' . $this->addIdentifierQuotes( $identifier ), $fname );
+ }
+
+ protected function doReleaseSavepoint( $identifier, $fname ) {
+ // Not supported. Also not really needed, a new doSavepoint() for the
+ // same identifier will overwrite the old.
+ }
+
+ protected function doRollbackToSavepoint( $identifier, $fname ) {
+ $this->query( 'ROLLBACK TRANSACTION ' . $this->addIdentifierQuotes( $identifier ), $fname );
+ }
+
/**
* Begin a transaction, committing any previously open transaction
* @param string $fname
/** @var stdClass|null */
private $replicationInfoRow = null;
+ // Cache getServerId() for 24 hours
+ const SERVER_ID_CACHE_TTL = 86400;
+
/**
* Additional $params include:
* - lagDetectionMethod : set to one of (Seconds_Behind_Master,pt-heartbeat).
}
// Wait on the GTID set (MariaDB only)
$gtidArg = $this->addQuotes( implode( ',', $gtidsWait ) );
- $res = $this->doQuery( "SELECT MASTER_GTID_WAIT($gtidArg, $timeout)" );
+ if ( strpos( $gtidArg, ':' ) !== false ) {
+ // MySQL GTIDs, e.g "source_id:transaction_id"
+ $res = $this->doQuery( "SELECT WAIT_FOR_EXECUTED_GTID_SET($gtidArg, $timeout)" );
+ } else {
+ // MariaDB GTIDs, e.g."domain:server:sequence"
+ $res = $this->doQuery( "SELECT MASTER_GTID_WAIT($gtidArg, $timeout)" );
+ }
} else {
// Wait on the binlog coordinates
$encFile = $this->addQuotes( $pos->getLogFile() );
- $encPos = intval( $pos->pos[1] );
+ $encPos = intval( $pos->getLogPosition()[$pos::CORD_EVENT] );
$res = $this->doQuery( "SELECT MASTER_POS_WAIT($encFile, $encPos, $timeout)" );
}
$row = $res ? $this->fetchRow( $res ) : false;
if ( !$row ) {
- throw new DBExpectedError( $this,
- "MASTER_POS_WAIT() or MASTER_GTID_WAIT() failed: {$this->lastError()}" );
+ throw new DBExpectedError( $this, "Replication wait failed: {$this->lastError()}" );
}
// Result can be NULL (error), -1 (timeout), or 0+ per the MySQL manual
* @return MySQLMasterPos|bool
*/
public function getReplicaPos() {
- $now = microtime( true );
-
- if ( $this->useGTIDs ) {
- $res = $this->query( "SELECT @@global.gtid_slave_pos AS Value", __METHOD__ );
- $gtidRow = $this->fetchObject( $res );
- if ( $gtidRow && strlen( $gtidRow->Value ) ) {
- return new MySQLMasterPos( $gtidRow->Value, $now );
+ $now = microtime( true ); // as-of-time *before* fetching GTID variables
+
+ if ( $this->useGTIDs() ) {
+ // Try to use GTIDs, fallbacking to binlog positions if not possible
+ $data = $this->getServerGTIDs( __METHOD__ );
+ // Use gtid_current_pos for MariaDB and gtid_executed for MySQL
+ foreach ( [ 'gtid_current_pos', 'gtid_executed' ] as $name ) {
+ if ( isset( $data[$name] ) && strlen( $data[$name] ) ) {
+ return new MySQLMasterPos( $data[$name], $now );
+ }
}
}
- $res = $this->query( 'SHOW SLAVE STATUS', __METHOD__ );
- $row = $this->fetchObject( $res );
- if ( $row && strlen( $row->Relay_Master_Log_File ) ) {
+ $data = $this->getServerRoleStatus( 'SLAVE', __METHOD__ );
+ if ( $data && strlen( $data['Relay_Master_Log_File'] ) ) {
return new MySQLMasterPos(
- "{$row->Relay_Master_Log_File}/{$row->Exec_Master_Log_Pos}",
+ "{$data['Relay_Master_Log_File']}/{$data['Exec_Master_Log_Pos']}",
$now
);
}
* @return MySQLMasterPos|bool
*/
public function getMasterPos() {
- $now = microtime( true );
+ $now = microtime( true ); // as-of-time *before* fetching GTID variables
+
+ $pos = false;
+ if ( $this->useGTIDs() ) {
+ // Try to use GTIDs, fallbacking to binlog positions if not possible
+ $data = $this->getServerGTIDs( __METHOD__ );
+ // Use gtid_current_pos for MariaDB and gtid_executed for MySQL
+ foreach ( [ 'gtid_current_pos', 'gtid_executed' ] as $name ) {
+ if ( isset( $data[$name] ) && strlen( $data[$name] ) ) {
+ $pos = new MySQLMasterPos( $data[$name], $now );
+ break;
+ }
+ }
+ // Filter domains that are inactive or not relevant to the session
+ if ( $pos ) {
+ $pos->setActiveOriginServerId( $this->getServerId() );
+ $pos->setActiveOriginServerUUID( $this->getServerUUID() );
+ if ( isset( $data['gtid_domain_id'] ) ) {
+ $pos->setActiveDomain( $data['gtid_domain_id'] );
+ }
+ }
+ }
- if ( $this->useGTIDs ) {
- $res = $this->query( "SELECT @@global.gtid_binlog_pos AS Value", __METHOD__ );
- $gtidRow = $this->fetchObject( $res );
- if ( $gtidRow && strlen( $gtidRow->Value ) ) {
- return new MySQLMasterPos( $gtidRow->Value, $now );
+ if ( !$pos ) {
+ $data = $this->getServerRoleStatus( 'MASTER', __METHOD__ );
+ if ( $data && strlen( $data['File'] ) ) {
+ $pos = new MySQLMasterPos( "{$data['File']}/{$data['Position']}", $now );
}
}
- $res = $this->query( 'SHOW MASTER STATUS', __METHOD__ );
- $row = $this->fetchObject( $res );
- if ( $row && strlen( $row->File ) ) {
- return new MySQLMasterPos( "{$row->File}/{$row->Position}", $now );
+ return $pos;
+ }
+
+ /**
+ * @return int
+ * @throws DBQueryError If the variable doesn't exist for some reason
+ */
+ protected function getServerId() {
+ return $this->srvCache->getWithSetCallback(
+ $this->srvCache->makeGlobalKey( 'mysql-server-id', $this->getServer() ),
+ self::SERVER_ID_CACHE_TTL,
+ function () {
+ $res = $this->query( "SELECT @@server_id AS id", __METHOD__ );
+ return intval( $this->fetchObject( $res )->id );
+ }
+ );
+ }
+
+ /**
+ * @return string|null
+ */
+ protected function getServerUUID() {
+ return $this->srvCache->getWithSetCallback(
+ $this->srvCache->makeGlobalKey( 'mysql-server-uuid', $this->getServer() ),
+ self::SERVER_ID_CACHE_TTL,
+ function () {
+ $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'server_uuid'" );
+ $row = $this->fetchObject( $res );
+
+ return $row ? $row->Value : null;
+ }
+ );
+ }
+
+ /**
+ * @param string $fname
+ * @return string[]
+ */
+ protected function getServerGTIDs( $fname = __METHOD__ ) {
+ $map = [];
+ // Get global-only variables like gtid_executed
+ $res = $this->query( "SHOW GLOBAL VARIABLES LIKE 'gtid_%'", $fname );
+ foreach ( $res as $row ) {
+ $map[$row->Variable_name] = $row->Value;
+ }
+ // Get session-specific (e.g. gtid_domain_id since that is were writes will log)
+ $res = $this->query( "SHOW SESSION VARIABLES LIKE 'gtid_%'", $fname );
+ foreach ( $res as $row ) {
+ $map[$row->Variable_name] = $row->Value;
}
- return false;
+ return $map;
+ }
+
+ /**
+ * @param string $role One of "MASTER"/"SLAVE"
+ * @param string $fname
+ * @return string[] Latest available server status row
+ */
+ protected function getServerRoleStatus( $role, $fname = __METHOD__ ) {
+ return $this->query( "SHOW $role STATUS", $fname )->fetchRow() ?: [];
}
public function serverIsReadOnly() {
return 'CAST( ' . $field . ' AS SIGNED )';
}
+ /*
+ * @return bool Whether GTID support is used (mockable for testing)
+ */
+ protected function useGTIDs() {
+ return $this->useGTIDs;
+ }
}
class_alias( DatabaseMysqlBase::class, 'DatabaseMysqlBase' );
/** @var string Transaction is requested internally via DBO_TRX/startAtomic() */
const TRANSACTION_INTERNAL = 'implicit';
+ /** @var string Atomic section is not cancelable */
+ const ATOMIC_NOT_CANCELABLE = '';
+ /** @var string Atomic section is cancelable */
+ const ATOMIC_CANCELABLE = 'cancelable';
+
/** @var string Transaction operation comes from service managing all DBs */
const FLUSHING_ALL_PEERS = 'flush';
/** @var string Transaction operation comes from the database class internally */
* Should return true if unsure.
*
* @return bool
+ * @deprecated Since 1.31; use lastDoneWrites()
*/
public function doneWrites();
*/
public function lastError();
- /**
- * mysql_fetch_field() wrapper
- * Returns false if the field doesn't exist
- *
- * @param string $table Table name
- * @param string $field Field name
- *
- * @return Field
- */
- public function fieldInfo( $table, $field );
-
/**
* Get the number of rows affected by the last write query
* @see https://secure.php.net/mysql_affected_rows
*/
public function close();
- /**
- * @param string $error Fallback error message, used if none is given by DB
- * @throws DBConnectionError
- */
- public function reportConnectionError( $error = 'Unknown error' );
-
/**
* Run an SQL query and return the result. Normally throws a DBQueryError
* on failure. If errors are ignored, returns false instead.
*/
public function query( $sql, $fname = __METHOD__, $tempIgnore = false );
- /**
- * Report a query error. Log the error, and if neither the object ignore
- * flag nor the $tempIgnore flag is set, throw a DBQueryError.
- *
- * @param string $error
- * @param int $errno
- * @param string $sql
- * @param string $fname
- * @param bool $tempIgnore
- * @throws DBQueryError
- */
- public function reportQueryError( $error, $errno, $sql, $fname, $tempIgnore = false );
-
/**
* Free a result object returned by query() or select(). It's usually not
* necessary to call this, just use unset() or let the variable holding
*/
public function tableExists( $table, $fname = __METHOD__ );
- /**
- * Determines if a given index is unique
- *
- * @param string $table
- * @param string $index
- *
- * @return bool
- */
- public function indexUnique( $table, $index );
-
/**
* INSERT wrapper, inserts an array into a table.
*
/**
* Begin an atomic section of statements
*
- * If a transaction has been started already, just keep track of the given
- * section name to make sure the transaction is not committed pre-maturely.
- * This function can be used in layers (with sub-sections), so use a stack
- * to keep track of the different atomic sections. If there is no transaction,
- * start one implicitly.
+ * If a transaction has been started already, (optionally) sets a savepoint
+ * and tracks the given section name to make sure the transaction is not
+ * committed pre-maturely. This function can be used in layers (with
+ * sub-sections), so use a stack to keep track of the different atomic
+ * sections. If there is no transaction, one is started implicitly.
*
* The goal of this function is to create an atomic section of SQL queries
* without having to start a new transaction if it already exists.
*
- * All atomic levels *must* be explicitly closed using IDatabase::endAtomic(),
- * and any database transactions cannot be began or committed until all atomic
- * levels are closed. There is no such thing as implicitly opening or closing
- * an atomic section.
+ * All atomic levels *must* be explicitly closed using IDatabase::endAtomic()
+ * or IDatabase::cancelAtomic(), and any database transactions cannot be
+ * began or committed until all atomic levels are closed. There is no such
+ * thing as implicitly opening or closing an atomic section.
*
* @since 1.23
* @param string $fname
+ * @param string $cancelable Pass self::ATOMIC_CANCELABLE to use a
+ * savepoint and enable self::cancelAtomic() for this section.
* @throws DBError
*/
- public function startAtomic( $fname = __METHOD__ );
+ public function startAtomic( $fname = __METHOD__, $cancelable = self::ATOMIC_NOT_CANCELABLE );
/**
* Ends an atomic section of SQL statements
*/
public function endAtomic( $fname = __METHOD__ );
+ /**
+ * Cancel an atomic section of SQL statements
+ *
+ * This will roll back only the statements executed since the start of the
+ * most recent atomic section, and close that section. If a transaction was
+ * open before the corresponding startAtomic() call, any statements before
+ * that call are *not* rolled back and the transaction remains open. If the
+ * corresponding startAtomic() implicitly started a transaction, that
+ * transaction is rolled back.
+ *
+ * Note that a call to IDatabase::rollback() will also roll back any open
+ * atomic sections.
+ *
+ * @note As a micro-optimization to save a few DB calls, this method may only
+ * be called when startAtomic() was called with the ATOMIC_CANCELABLE flag.
+ * @since 1.31
+ * @see IDatabase::startAtomic
+ * @param string $fname
+ * @throws DBError
+ */
+ public function cancelAtomic( $fname = __METHOD__ );
+
/**
* Run a callback to do an atomic set of updates for this database
*
* - This database object
* - The value of $fname
*
- * If any exception occurs in the callback, then rollback() will be called and the error will
- * be re-thrown. It may also be that the rollback itself fails with an exception before then.
- * In any case, such errors are expected to terminate the request, without any outside caller
- * attempting to catch errors and commit anyway. Note that any rollback undoes all prior
- * atomic section and uncommitted updates, which trashes the current request, requiring an
- * error to be displayed.
+ * If any exception occurs in the callback, then cancelAtomic() will be
+ * called to back out any statements executed by the callback and the error
+ * will be re-thrown. It may also be that the cancel itself fails with an
+ * exception before then. In any case, such errors are expected to
+ * terminate the request, without any outside caller attempting to catch
+ * errors and commit anyway.
*
- * This can be an alternative to explicit startAtomic()/endAtomic() calls.
+ * This can be an alternative to explicit startAtomic()/endAtomic()/cancelAtomic() calls.
*
* @see Database::startAtomic
* @see Database::endAtomic
+ * @see Database::cancelAtomic
*
* @param string $fname Caller name (usually __METHOD__)
* @param callable $callback Callback that issues DB updates
* @throws DBError
* @throws RuntimeException
* @throws UnexpectedValueException
- * @since 1.27
+ * @since 1.27; prior to 1.31 this did a rollback() instead of
+ * cancelAtomic(), and assumed no callers up the stack would ever try to
+ * catch the exception.
*/
public function doAtomicSection( $fname, callable $callback );
*/
public function flushSnapshot( $fname = __METHOD__ );
- /**
- * List all tables on the database
- *
- * @param string $prefix Only show tables with this prefix, e.g. mw_
- * @param string $fname Calling function name
- * @throws DBError
- * @return array
- */
- public function listTables( $prefix = null, $fname = __METHOD__ );
-
/**
* Convert a timestamp in one of the formats accepted by wfTimestamp()
* to the format used for inserting into timestamp fields in this DBMS.
* @since 1.29
*/
public function unlockTables( $method );
+
+ /**
+ * List all tables on the database
+ *
+ * @param string $prefix Only show tables with this prefix, e.g. mw_
+ * @param string $fname Calling function name
+ * @throws DBError
+ * @return array
+ */
+ public function listTables( $prefix = null, $fname = __METHOD__ );
+
+ /**
+ * Determines if a given index is unique
+ *
+ * @param string $table
+ * @param string $index
+ *
+ * @return bool
+ */
+ public function indexUnique( $table, $index );
+
+ /**
+ * mysql_fetch_field() wrapper
+ * Returns false if the field doesn't exist
+ *
+ * @param string $table Table name
+ * @param string $field Field name
+ *
+ * @return Field
+ */
+ public function fieldInfo( $table, $field );
}
class_alias( IMaintainableDatabase::class, 'IMaintainableDatabase' );
public function unlockTables( $method ) {
return $this->__call( __FUNCTION__, func_get_args() );
}
+
+ public function indexUnique( $table, $index ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function listTables( $prefix = null, $fname = __METHOD__ ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
+
+ public function fieldInfo( $table, $field ) {
+ return $this->__call( __FUNCTION__, func_get_args() );
+ }
}
class_alias( MaintainableDBConnRef::class, 'MaintainableDBConnRef' );
* - Binlog-based usage assumes single-source replication and non-hierarchical replication.
* - GTID-based usage allows getting/syncing with multi-source replication. It is assumed
* that GTID sets are complete (e.g. include all domains on the server).
+ *
+ * @see https://mariadb.com/kb/en/library/gtid/
+ * @see https://dev.mysql.com/doc/refman/5.6/en/replication-gtids-concepts.html
*/
class MySQLMasterPos implements DBMasterPos {
- /** @var string|null Binlog file base name */
- public $binlog;
- /** @var int[]|null Binglog file position tuple */
- public $pos;
- /** @var string[] GTID list */
- public $gtids = [];
+ /** @var int One of (BINARY_LOG, GTID_MYSQL, GTID_MARIA) */
+ private $style;
+ /** @var string|null Base name of all Binary Log files */
+ private $binLog;
+ /** @var int[]|null Binary Log position tuple (index number, event number) */
+ private $logPos;
+ /** @var string[] Map of (server_uuid/gtid_domain_id => GTID) */
+ private $gtids = [];
+ /** @var int|null Active GTID domain ID */
+ private $activeDomain;
+ /** @var int|null ID of the server were DB writes originate */
+ private $activeServerId;
+ /** @var string|null UUID of the server were DB writes originate */
+ private $activeServerUUID;
/** @var float UNIX timestamp */
- public $asOfTime = 0.0;
+ private $asOfTime = 0.0;
+
+ const BINARY_LOG = 'binary-log';
+ const GTID_MARIA = 'gtid-maria';
+ const GTID_MYSQL = 'gtid-mysql';
+
+ /** @var int Key name of the binary log index number of a position tuple */
+ const CORD_INDEX = 0;
+ /** @var int Key name of the binary log event number of a position tuple */
+ const CORD_EVENT = 1;
/**
* @param string $position One of (comma separated GTID list, <binlog file>/<integer>)
protected function init( $position, $asOfTime ) {
$m = [];
if ( preg_match( '!^(.+)\.(\d+)/(\d+)$!', $position, $m ) ) {
- $this->binlog = $m[1]; // ideally something like host name
- $this->pos = [ (int)$m[2], (int)$m[3] ];
+ $this->binLog = $m[1]; // ideally something like host name
+ $this->logPos = [ self::CORD_INDEX => (int)$m[2], self::CORD_EVENT => (int)$m[3] ];
+ $this->style = self::BINARY_LOG;
} else {
$gtids = array_filter( array_map( 'trim', explode( ',', $position ) ) );
foreach ( $gtids as $gtid ) {
- if ( !self::parseGTID( $gtid ) ) {
+ $components = self::parseGTID( $gtid );
+ if ( !$components ) {
throw new InvalidArgumentException( "Invalid GTID '$gtid'." );
}
- $this->gtids[] = $gtid;
+
+ list( $domain, $pos ) = $components;
+ if ( isset( $this->gtids[$domain] ) ) {
+ // For MySQL, handle the case where some past issue caused a gap in the
+ // executed GTID set, e.g. [last_purged+1,N-1] and [N+1,N+2+K]. Ignore the
+ // gap by using the GTID with the highest ending sequence number.
+ list( , $otherPos ) = self::parseGTID( $this->gtids[$domain] );
+ if ( $pos > $otherPos ) {
+ $this->gtids[$domain] = $gtid;
+ }
+ } else {
+ $this->gtids[$domain] = $gtid;
+ }
+
+ if ( is_int( $domain ) ) {
+ $this->style = self::GTID_MARIA; // gtid_domain_id
+ } else {
+ $this->style = self::GTID_MYSQL; // server_uuid
+ }
}
if ( !$this->gtids ) {
- throw new InvalidArgumentException( "Got empty GTID set." );
+ throw new InvalidArgumentException( "GTID set cannot be empty." );
}
}
}
// Prefer GTID comparisons, which work with multi-tier replication
- $thisPosByDomain = $this->getGtidCoordinates();
- $thatPosByDomain = $pos->getGtidCoordinates();
+ $thisPosByDomain = $this->getActiveGtidCoordinates();
+ $thatPosByDomain = $pos->getActiveGtidCoordinates();
if ( $thisPosByDomain && $thatPosByDomain ) {
$comparisons = [];
// Check that this has positions reaching those in $pos for all domains in common
}
// Prefer GTID comparisons, which work with multi-tier replication
- $thisPosDomains = array_keys( $this->getGtidCoordinates() );
- $thatPosDomains = array_keys( $pos->getGtidCoordinates() );
+ $thisPosDomains = array_keys( $this->getActiveGtidCoordinates() );
+ $thatPosDomains = array_keys( $pos->getActiveGtidCoordinates() );
if ( $thisPosDomains && $thatPosDomains ) {
// Check that $this has a GTID for at least one domain also in $pos; due to MariaDB
// quirks, prior master switch-overs may result in inactive garbage GTIDs that cannot
}
/**
- * @return string|null
+ * @return string|null Base name of binary log files
+ * @since 1.31
+ */
+ public function getLogName() {
+ return $this->gtids ? null : $this->binLog;
+ }
+
+ /**
+ * @return int[]|null Tuple of (binary log file number, event number)
+ * @since 1.31
+ */
+ public function getLogPosition() {
+ return $this->gtids ? null : $this->logPos;
+ }
+
+ /**
+ * @return string|null Name of the binary log file for this position
+ * @since 1.31
*/
public function getLogFile() {
- return $this->gtids ? null : "{$this->binlog}.{$this->pos[0]}";
+ return $this->gtids ? null : "{$this->binLog}.{$this->logPos[self::CORD_INDEX]}";
}
/**
- * @return string[]
+ * @return string[] Map of (server_uuid/gtid_domain_id => GTID)
+ * @since 1.31
*/
public function getGTIDs() {
return $this->gtids;
}
/**
- * @return string GTID set or <binlog file>/<position> (e.g db1034-bin.000976/843431247)
+ * @param int|null $id @@gtid_domain_id of the active replication stream
+ * @since 1.31
*/
- public function __toString() {
- return $this->gtids
- ? implode( ',', $this->gtids )
- : $this->getLogFile() . "/{$this->pos[1]}";
+ public function setActiveDomain( $id ) {
+ $this->activeDomain = (int)$id;
+ }
+
+ /**
+ * @param int|null $id @@server_id of the server were writes originate
+ * @since 1.31
+ */
+ public function setActiveOriginServerId( $id ) {
+ $this->activeServerId = (int)$id;
+ }
+
+ /**
+ * @param string|null $id @@server_uuid of the server were writes originate
+ * @since 1.31
+ */
+ public function setActiveOriginServerUUID( $id ) {
+ $this->activeServerUUID = $id;
}
/**
* @param MySQLMasterPos $pos
* @param MySQLMasterPos $refPos
* @return string[] List of GTIDs from $pos that have domains in $refPos
+ * @since 1.31
*/
public static function getCommonDomainGTIDs( MySQLMasterPos $pos, MySQLMasterPos $refPos ) {
- $gtidsCommon = [];
-
- $relevantDomains = $refPos->getGtidCoordinates(); // (domain => unused)
- foreach ( $pos->gtids as $gtid ) {
- list( $domain ) = self::parseGTID( $gtid );
- if ( isset( $relevantDomains[$domain] ) ) {
- $gtidsCommon[] = $gtid;
- }
- }
-
- return $gtidsCommon;
+ return array_values(
+ array_intersect_key( $pos->gtids, $refPos->getActiveGtidCoordinates() )
+ );
}
/**
* @see https://mariadb.com/kb/en/mariadb/gtid
* @see https://dev.mysql.com/doc/refman/5.6/en/replication-gtids-concepts.html
- * @return array Map of (domain => integer position); possibly empty
+ * @return array Map of (server_uuid/gtid_domain_id => integer position); possibly empty
*/
- protected function getGtidCoordinates() {
+ protected function getActiveGtidCoordinates() {
$gtidInfos = [];
- foreach ( $this->gtids as $gtid ) {
- list( $domain, $pos ) = self::parseGTID( $gtid );
- $gtidInfos[$domain] = $pos;
+
+ foreach ( $this->gtids as $domain => $gtid ) {
+ list( $domain, $pos, $server ) = self::parseGTID( $gtid );
+
+ $ignore = false;
+ // Filter out GTIDs from non-active replication domains
+ if ( $this->style === self::GTID_MARIA && $this->activeDomain !== null ) {
+ $ignore |= ( $domain !== $this->activeDomain );
+ }
+ // Likewise for GTIDs from non-active replication origin servers
+ if ( $this->style === self::GTID_MARIA && $this->activeServerId !== null ) {
+ $ignore |= ( $server !== $this->activeServerId );
+ } elseif ( $this->style === self::GTID_MYSQL && $this->activeServerUUID !== null ) {
+ $ignore |= ( $server !== $this->activeServerUUID );
+ }
+
+ if ( !$ignore ) {
+ $gtidInfos[$domain] = $pos;
+ }
}
return $gtidInfos;
}
/**
- * @param string $gtid
- * @return array|null [domain, integer position] or null
+ * @param string $id GTID
+ * @return array|null [domain ID or server UUID, sequence number, server ID/UUID] or null
*/
- protected static function parseGTID( $gtid ) {
+ protected static function parseGTID( $id ) {
$m = [];
- if ( preg_match( '!^(\d+)-\d+-(\d+)$!', $gtid, $m ) ) {
+ if ( preg_match( '!^(\d+)-(\d+)-(\d+)$!', $id, $m ) ) {
// MariaDB style: <domain>-<server id>-<sequence number>
- return [ (int)$m[1], (int)$m[2] ];
- } elseif ( preg_match( '!^(\w{8}-\w{4}-\w{4}-\w{4}-\w{12}):(\d+)$!', $gtid, $m ) ) {
- // MySQL style: <UUID domain>:<sequence number>
- return [ $m[1], (int)$m[2] ];
+ return [ (int)$m[1], (int)$m[3], (int)$m[2] ];
+ } elseif ( preg_match( '!^(\w{8}-\w{4}-\w{4}-\w{4}-\w{12}):(?:\d+-|)(\d+)$!', $id, $m ) ) {
+ // MySQL style: <server UUID>:<sequence number>-<sequence number>
+ // Normally, the first number should reflect the point (gtid_purged) where older
+ // binary logs where purged to save space. When doing comparisons, it may as well
+ // be 1 in that case. Assume that this is generally the situation.
+ return [ $m[1], (int)$m[2], $m[1] ];
}
return null;
/**
* @see https://dev.mysql.com/doc/refman/5.7/en/show-master-status.html
* @see https://dev.mysql.com/doc/refman/5.7/en/show-slave-status.html
- * @return array|bool (binlog, (integer file number, integer position)) or false
+ * @return array|bool Map of (binlog:<string>, pos:(<integer>, <integer>)) or false
*/
protected function getBinlogCoordinates() {
- return ( $this->binlog !== null && $this->pos !== null )
- ? [ 'binlog' => $this->binlog, 'pos' => $this->pos ]
+ return ( $this->binLog !== null && $this->logPos !== null )
+ ? [ 'binlog' => $this->binLog, 'pos' => $this->logPos ]
: false;
}
$this->init( $data['position'], $data['asOfTime'] );
}
+
+ /**
+ * @return string GTID set or <binary log file>/<position> (e.g db1034-bin.000976/843431247)
+ */
+ public function __toString() {
+ return $this->gtids
+ ? implode( ',', $this->gtids )
+ : $this->getLogFile() . "/{$this->logPos[self::CORD_EVENT]}";
+ }
}
return [
'digitTransformTable' => $language->digitTransformTable(),
'separatorTransformTable' => $language->separatorTransformTable(),
+ 'minimumGroupingDigits' => $language->minimumGroupingDigits(),
'grammarForms' => $language->getGrammarForms(),
'grammarTransformations' => $language->getGrammarTransformations(),
'pluralRules' => $language->getPluralRules(),
'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
&$query_options, &$join_conds
) {
- $conds[] = 'rc_patrolled = 1';
+ $conds[] = 'rc_patrolled != 0';
},
'cssClassSuffix' => 'unpatrolled',
'isRowApplicableCallable' => function ( $ctx, $rc ) {
$this->mHideName = $block->mHideName;
$this->mAllowUsertalk = !$block->prevents( 'editownusertalk' );
} else {
+ $this->mBlock = null;
$this->mBlockedby = '';
+ $this->mBlockreason = '';
$this->mHideName = 0;
$this->mAllowUsertalk = false;
}
* @param string $oname The option to check
* @param string $defaultOverride A default value returned if the option does not exist
* @param bool $ignoreHidden Whether to ignore the effects of $wgHiddenPrefs
- * @return string|null User's current value for the option
+ * @return string|array|int|null User's current value for the option
* @see getBoolOption()
* @see getIntOption()
*/
return;
}
- $dbw = wfGetDB( DB_MASTER );
- $asOfTimes = array_unique( $dbw->selectFieldValues(
- 'watchlist',
- 'wl_notificationtimestamp',
- [ 'wl_user' => $id, 'wl_notificationtimestamp IS NOT NULL' ],
- __METHOD__,
- [ 'ORDER BY' => 'wl_notificationtimestamp DESC', 'LIMIT' => 500 ]
- ) );
- if ( !$asOfTimes ) {
- return;
- }
- // Immediately update the most recent touched rows, which hopefully covers what
- // the user sees on the watchlist page before pressing "mark all pages visited"....
- $dbw->update(
- 'watchlist',
- [ 'wl_notificationtimestamp' => null ],
- [ 'wl_user' => $id, 'wl_notificationtimestamp' => $asOfTimes ],
- __METHOD__
- );
- // ...and finish the older ones in a post-send update with lag checks...
- DeferredUpdates::addUpdate( new AutoCommitUpdate(
- $dbw,
- __METHOD__,
- function () use ( $dbw, $id ) {
- global $wgUpdateRowsPerQuery;
-
- $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
- $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ );
- $asOfTimes = array_unique( $dbw->selectFieldValues(
- 'watchlist',
- 'wl_notificationtimestamp',
- [ 'wl_user' => $id, 'wl_notificationtimestamp IS NOT NULL' ],
- __METHOD__
- ) );
- foreach ( array_chunk( $asOfTimes, $wgUpdateRowsPerQuery ) as $asOfTimeBatch ) {
- $dbw->update(
- 'watchlist',
- [ 'wl_notificationtimestamp' => null ],
- [ 'wl_user' => $id, 'wl_notificationtimestamp' => $asOfTimeBatch ],
- __METHOD__
- );
- $lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
- }
- }
- ) );
+ $watchedItemStore = MediaWikiServices::getInstance()->getWatchedItemStore();
+ $watchedItemStore->resetAllNotificationTimestampsForUser( $this );
+
// We also need to clear here the "you have new message" notification for the own
// user_talk page; it's cleared one page view later in WikiPage::doViewUpdates().
}
throw new DBReadOnlyError( null, 'The watchlist is currently readonly.' );
}
+ public function resetAllNotificationTimestampsForUser( User $user ) {
+ throw new DBReadOnlyError( null, 'The watchlist is currently readonly.' );
+ }
+
public function resetNotificationTimestamp(
User $user,
Title $title,
* @since 1.27
* @param User $user
* @param LinkTarget $target
- * @return bool
+ * @return WatchedItem|bool
*/
public function loadWatchedItem( User $user, LinkTarget $target ) {
// Only loggedin user can have a watchlist
return $success;
}
+ public function resetAllNotificationTimestampsForUser( User $user ) {
+ // Only loggedin user can have a watchlist
+ if ( $user->isAnon() ) {
+ return;
+ }
+
+ // If the page is watched by the user (or may be watched), update the timestamp
+ $job = new ClearWatchlistNotificationsJob(
+ $user->getUserPage(),
+ [ 'userId' => $user->getId(), 'casTime' => time() ]
+ );
+
+ // Try to run this post-send
+ // Calls DeferredUpdates::addCallableUpdate in normal operation
+ call_user_func(
+ $this->deferredUpdatesAddCallableUpdateCallback,
+ function () use ( $job ) {
+ $job->run();
+ }
+ );
+ }
+
/**
* @since 1.27
* @param User $editor
* @param LinkTarget $target
* @param string|int $timestamp
- * @return int
+ * @return int[]
*/
public function updateNotificationTimestamp( User $editor, LinkTarget $target, $timestamp ) {
$dbw = $this->getConnectionRef( DB_MASTER );
/**
* @since 1.31
*
- * @param User $user The user to set the timestamp for
+ * @param User $user The user to set the timestamps for
* @param string|null $timestamp Set the update timestamp to this value
* @param LinkTarget[] $targets List of targets to update. Default to all targets
*
array $targets = []
);
+ /**
+ * Reset all watchlist notificaton timestamps for a user using the job queue
+ *
+ * @since 1.31
+ *
+ * @param User $user The user to reset the timestamps for
+ */
+ public function resetAllNotificationTimestampsForUser( User $user );
+
/**
* @since 1.31
*
* @param int $oldid The revision id being viewed. If not given or 0, latest revision is
* assumed.
*
- * @return bool success
+ * @return bool success Whether a job was enqueued
*/
public function resetNotificationTimestamp( User $user, Title $title, $force = '', $oldid = 0 );
"right-patrol": "Пазначэньне рэдагаваньняў як «патруляваных»",
"right-autopatrol": "Аўтаматычнае пазначэньне рэдагаваньняў як «патруляваных»",
"right-patrolmarks": "Прагляд пазначэньняў пра патруляваньне ў апошніх зьменах",
- "right-unwatchedpages": "прагляд сьпісу старонак, за якімі ніхто не назірае",
+ "right-unwatchedpages": "Ð\9fрагляд сьпісу старонак, за якімі ніхто не назірае",
"right-mergehistory": "аб’яднаньне гісторыі старонак",
"right-userrights": "рэдагаваньне правоў усіх удзельнікаў",
"right-userrights-interwiki": "рэдагаваньне правоў удзельнікаў у іншых вікі",
"databaseerror-query": "অনুসন্ধান: $1",
"databaseerror-function": "ফাংশন: $1",
"databaseerror-error": "ত্রুটি: $1",
- "transaction-duration-limit-exceeded": "দীর্ঘ পুনঃসৃষ্টি বিলম্ব এড়ানোর জন্য এই ট্রানজাকশনটি বাতিল করা হল, কারণ লিখনের স্থায়িত্ব ($1) $2 সেকেন্ড সীমাটিকে অতিক্রম করে গিয়েছিল। \nযদি আপনি অনেকগুলি আইটেম একসাথে পরিবর্তন করতে চান, তাহলে একাধিক ক্ষুদ্রতর অপারেশন সম্পন্ন করার চেষ্টা করুন।",
+ "transaction-duration-limit-exceeded": "দীর্ঘ পুনঃসৃষ্টি বিলম্ব এড়ানোর জন্য, এই কার্যটি বাতিল করা হল কারণ তা লিখনের স্থায়িত্ব ($1) $2 সেকেন্ড সীমাটিকে অতিক্রম করে গিয়েছিল। \nযদি আপনি অনেকগুলি আইটেম একসাথে পরিবর্তন করছিলেন, তাহলে একাধিক ক্ষুদ্রতর অপারেশন সম্পন্ন করার চেষ্টা করুন।",
"laggedslavemode": "<strong>সতর্ক বার্তা:</strong> পাতাটি সম্ভবত সম্প্রতি হালনাগাদ করা হয়নি।",
"readonly": "ডাটাবেজ অবরুদ্ধ",
"enterlockreason": "অবরুদ্ধ করার কারণ কী তা বলুন, সাথে কখন অবরোধ খুলবেন তার আনুমানিক সময় উল্লেখ করুন",
"rcfilters-group-results-by-page": "חלוקה התוצאות לקבוצות לפי דף",
"rcfilters-activefilters": "מסננים פעילים",
"rcfilters-advancedfilters": "מסננים מתקדמים",
- "rcfilters-limit-title": "×\90×\99×\9c×\95 תוצאות להראות",
+ "rcfilters-limit-title": "×\9b×\9e×\94 תוצאות להראות",
"rcfilters-limit-and-date-label": "{{PLURAL:$1|שינוי אחד|$1 שינויים}}, $2",
"rcfilters-date-popup-title": "משך הזמן לחיפוש",
"rcfilters-days-title": "ימים אחרונים",
"tooltip-n-recentchanges": "רשימת השינויים האחרונים באתר",
"tooltip-n-randompage": "טעינת דף אקראי",
"tooltip-n-help": "המקום למצוא מידע",
- "tooltip-t-whatlinkshere": "רש×\99×\9e×\94 ש×\9c ×\9b×\9c ×\93פ×\99 ×\94×\95×\95×\99ק×\99 ש×\9eקשר×\99×\9d ×\94× ה",
+ "tooltip-t-whatlinkshere": "רש×\99×\9e×\94 ש×\9c ×\9b×\9c ×\93פ×\99 ×\94×\95×\95×\99ק×\99 ש×\9eקשר×\99×\9d ×\9c×\93×£ ×\94×\96ה",
"tooltip-t-recentchangeslinked": "השינויים האחרונים שבוצעו בדפים המקושרים מדף זה",
"tooltip-feed-rss": "הזנת RSS עבור דף זה",
"tooltip-feed-atom": "הזנת Atom עבור דף זה",
"statistics-files": "Adkargita arkivi",
"statistics-edits": "Quanto di redakti pos ke {{SITENAME}} kreesis",
"statistics-edits-average": "Mezavalora quanto di redakti per pagino",
+ "statistics-users": "Enrejistrita [[Special:ListUsers|uzeri]]",
"statistics-users-active": "Aktiva uzeri",
"statistics-users-active-desc": "Uzeri qui facis ula agado dum la lasta {{PLURAL:$1|dio|$1 dii}}",
"pageswithprop": "Pagini kun atributo di pagino",
"booksources": "Fonti di libri",
"booksources-search-legend": "Serchez librala fonti",
"booksources-search": "Serchar",
+ "booksources-text": "Infre vu povas vidar listo di ligili ad altra retsitui qui vendas nova ed uzata libri, ed anke povas havar informi pri la libri quin vu serchabas:\nLa {{SITENAME}} ne mantenas komercala relati kun ta vendeyi mencionata, e la listo ne povas konsideresar rekomendo o vend-anunco.",
"magiclink-tracking-isbn": "Pagini qui uzas ligili ISBN",
"specialloguserlabel": "Agero:",
"speciallogtitlelabel": "Skopo (titulo od {{ns:user}}:uzernomo por uzero):",
"newimages-summary": "Op dees speciaal pazjena waere de meis recènt toegevoogde bestenj weergegaeve.",
"newimages-legend": "Bestandjsnaam",
"newimages-label": "Bestandjsnaam (of deel daarvan):",
+ "newimages-user": "IP-adres of gebroekersnaam",
+ "newimages-newbies": "Tuin allein de biedrage van nuuj gebroekers",
+ "newimages-showbots": "Tuin botuploads",
+ "newimages-hidepatrolled": "Versjtaek gecontroleerde uploads",
+ "newimages-mediatype": "Mediaformaot:",
"noimages": "Niks te zeen.",
+ "gallery-slideshow-toggle": "Sjakel miniature",
"ilsubmit": "Zeuk",
"bydate": "op datum",
"sp-newimages-showfrom": "Tuin nuuj besjtande vanaaf $2, $1",
"confirmemail_body_set": "Emes, waersjienlik doe, met 't IP-adres $1,\nhaet 't e-mailadres geregistreerd veur gebroeker \"$2\" op {{SITENAME}} ingesteld óp dit e-mailadres.\n\nÄöpen de volgende verwiezing in diene webbrowser om te bevestige des toe deze gebroeker bis en om de e-mailmeugelikhejen op {{SITENAME}} opnuuj te activere:\n\n$3\n\nEs se dichzelf *neet* haes aangemeld, volg den de volgende verwiezing om de bevestiging van dien e-mailadres te annulere:\n\n$5\n\nDe bevestigingscode vervilt op $4.",
"confirmemail_invalidated": "De e-mailbevestiging is geannuleerdj",
"invalidateemail": "E-mailbevestiging annulere",
+ "notificationemail_subject_changed": "geregistreerd e-mailadres van {{SITENAME}} is verangerd",
+ "notificationemail_subject_removed": "geregistreerd e-mailadres van {{SITENAME}} is eweggehaold",
"scarytranscludedisabled": "[Interwikitransclusie is oetgesjakeld]",
"scarytranscludefailed": "[Sjabloon $1 kós neet opgehaold waer]",
"scarytranscludetoolong": "[URL is te lank]",
"version-ext-colheader-description": "Besjrieving",
"version-ext-colheader-credits": "Sjrievers",
"version-license-title": "Licentie veur $1",
+ "version-credits-title": "Vermeljinge veur $1",
+ "version-credits-not-found": "Gein gedetailleerde meljinge zint aangetroffe veur dees oetbreijing.",
"version-poweredby-credits": "Deze wiki weurt aangedreve door '''[https://www.mediawiki.org/ MediaWiki]''', copyright © 2001-$1 $2.",
"version-poweredby-others": "anger",
+ "version-poweredby-translators": "translatewiki.net-euverzètters",
"version-license-info": "MediaWiki is vrieje sofware; de kins MediaWiki verspreien en/of aanpassen onger de veurwaerde van de GNU General Public License wie gepubliceerd door de Free Software Foundation; ofwaal versie 2 van de Licentie, of - nao diene wönsj - innig later versie.\n\nMediaWiki weurd verspreid in de haop det 't nuttig is, mer ZONGER INNIG GARANTIE; zonger zelfs de implicitiete garantie van VERKOUPBAARHEID of GESJIKHEID VEUR INNIG DOEL IN 'T BIEZÖNJER. Zuuch de GNU General Public License veur mier informatie.\n\nSame mit dit programma heurs se 'n [{{SERVER}}{{SCRIPTPATH}}/COPYING kopie van de GNU General Public License] te höbben ontvange; zo neet, sjrief den nao de Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA of [//www.gnu.org/licenses/old-licenses/gpl-2.0.html laes de licentie online].",
"version-software": "Geïnstallieërde sofwaer",
"version-software-product": "Perduk",
"tags-active-no": "Nae",
"tags-edit": "bewerking",
"tags-hitcount": "$1 {{PLURAL:$1|wieziging|wieziginge}}",
+ "tags-deactivate-reason": "Raeje:",
+ "tags-deactivate-submit": "Deaktiveer",
"comparepages": "Vergeliek pazjena's",
"compare-page1": "Paasj 1",
"compare-page2": "Paasj 2",
"apisandbox-sending-request": "Wysyłanie zapytania API…",
"apisandbox-loading-results": "Pobieranie wyników API...",
"apisandbox-results-error": "Wystąpił błąd podczas pobierania odpowiedzi na zapytanie API: $1.",
+ "apisandbox-results-login-suppressed": "To żądanie zostało przetworzone jako wylogowany użytkownik, ponieważ można go obejść w zabezpieczeniach przeglądarki Same-Origin. Zauważ, że automatyczna obsługa tokenów API piaskownicy nie działa poprawnie z takimi żądaniami, proszę wypełnić je ręcznie.",
"apisandbox-request-selectformat-label": "Pokaż dane z zapytania jako:",
"apisandbox-request-format-url-label": "zapytanie w adresie URL",
"apisandbox-request-url-label": "URL zapytania:",
"ip_range_invalid": "Niepoprawny zakres adresów IP.",
"ip_range_toolarge": "Zakresy IP większe niż /$1 są niedozwolone.",
"ip_range_exceeded": "Zakres IP przekracza zakres maksymalny. Dozwolony zakres to /$1.",
+ "ip_range_toolow": "Zakresy adresów IP są niedozwolone.",
"proxyblocker": "Blokowanie proxy",
"proxyblockreason": "Twój adres IP został zablokowany, ponieważ jest to adres otwartego proxy.\nO tym poważnym problemie dotyczącym bezpieczeństwa należy poinformować dostawcę Internetu lub pomoc techniczną.",
"sorbsreason": "Twój adres IP znajduje się na liście serwerów open proxy w DNSBL, używanej przez {{GRAMMAR:B.lp|{{SITENAME}}}}.",
"authmanager-create-disabled": "Utworzenie konta jest wyłączone.",
"authmanager-create-from-login": "Aby utworzyć konto, wypełnij odpowiednie pola.",
"authmanager-create-not-in-progress": "Tworzenie konta nie jest wykonywane lub dane sesji zostały utracone. Zacznij od początku.",
+ "authmanager-create-no-primary": "Podanych danych uwierzytelniających nie można użyć do utworzenia konta.",
"authmanager-link-not-in-progress": "Tworzenie konta nie jest wykonywane lub dane sesji zostały utracone. Zacznij od początku.",
"authmanager-authplugin-setpass-failed-title": "Zmiana hasła nie powiodła się",
"authmanager-authplugin-setpass-failed-message": "Wtyczka do uwierzytelniania uniemożliwiła zmianę hasła.",
"undelete-cantedit": "Nie możesz odtworzyć tej strony, ponieważ nie masz uprawnień do edytowania tej strony.",
"undelete-cantcreate": "Nie możesz odtworzyć tej strony, ponieważ nie istnieje strona o tej nazwie, a nie masz uprawnień do jej utworzenia.",
"pagedata-title": "Dane ze strony",
+ "pagedata-text": "Ta strona udostępnia interfejs danych do stron. Podaj tytuł strony w adresie URL, używając składni podstrony.\n* Negocjacja treści obowiązuje w oparciu o nagłówek Accept Twojego klienta. Oznacza to, że dane strony będą dostarczane w formacie preferowanym przez klienta.",
"pagedata-not-acceptable": "Nie znaleziono pasującego formatu. Obsługiwane typy MIME: $1",
"pagedata-bad-title": "Niepoprawny tytuł: $1."
}
"wrongpasswordempty": "A palavra-passe não foi introduzida. \nIntroduza-a, por favor.",
"passwordtooshort": "A palavra-passe deve ter no mínimo $1 {{PLURAL:$1|carácter|caracteres}}.",
"passwordtoolong": "A palavra-passe não pode exceder $1 {{PLURAL:$1|carácter|caracteres}}.",
- "passwordtoopopular": "Não podem ser usadas palavras-passe vulgares. Escolha uma palavra-passe mais original, por favor.",
+ "passwordtoopopular": "Não podem ser usadas palavras-passe vulgares. Escolha uma palavra-passe mais difícil de adivinhar, por favor.",
"password-name-match": "A sua palavra-passe tem de ser diferente do seu nome de utilizador.",
"password-login-forbidden": "Foi proibido o uso deste nome de utilizador e palavra-passe.",
"mailmypassword": "Reiniciar a palavra-passe",
"watchlistedit-normal-done": "{{PLURAL:$1|Foi removida uma página|Foram removidas $1 páginas}} da sua lista de páginas vigiadas:",
"watchlistedit-raw-title": "Editar a lista de páginas vigiadas em forma de texto",
"watchlistedit-raw-legend": "Editar a lista de páginas vigiadas em forma de texto",
- "watchlistedit-raw-explain": "A lista de páginas vigiadas é apresentada abaixo.\nPode adicionar ou remover linhas, para aumentar ou reduzir a lista.\nListe uma só página por linha.\nQuando terminar, clique \"{{int:Watchlistedit-raw-submit}}\".\nTambém pode [[Special:EditWatchlist|editar a lista da maneira convencional]].",
+ "watchlistedit-raw-explain": "A lista das páginas vigiadas é apresentada abaixo.\nPode adicionar ou remover linhas, para aumentar ou reduzir a lista.\nListe uma só página por linha.\nQuando terminar, clique \"{{int:Watchlistedit-raw-submit}}\".\nTambém pode [[Special:EditWatchlist|usar o editor padrão]].",
"watchlistedit-raw-titles": "Páginas:",
"watchlistedit-raw-submit": "Atualizar a lista de páginas vigiadas",
"watchlistedit-raw-done": "A sua lista de páginas vigiadas foi atualizada.",
"activeusers-intro": "นี่คือรายการผู้ใช้ที่มีความเคลื่อนไหวใด ๆ ในช่วง $1 วันหลังสุด",
"activeusers-count": "$1 ปฏิบัติการ{{PLURAL:$1|}} ในช่วง $3 วันหลังสุด",
"activeusers-from": "แสดงผู้ใช้เริ่มจาก:",
+ "activeusers-groups": "แสดงผู้ใช้ที่อยู่ในกลุ่ม:",
+ "activeusers-excludegroups": "ไม่รวมผู้ใช้ที่อยู่ในกลุ่ม:",
"activeusers-noresult": "ไม่พบผู้ใช้",
"activeusers-submit": "แสดงผู้ใช้ที่ยังเคลื่อนไหว",
"listgrouprights": "สิทธิกลุ่มผู้ใช้",
"rollback-success": "ย้อนการแก้ไขโดย $1; \nเปลี่ยนกลับไปรุ่นล่าสุดโดย $2",
"rollback-success-notify": "ย้อนการแก้ไขโดย $1;\nเปลี่ยนกลับไปรุ่นล่าสุดโดย $2 [$3 แสดงการเปลี่ยนแปลง]",
"sessionfailure-title": "ช่วงเวลาสื่อสารล้มเหลว",
- "sessionfailure": "à¸\94ูà¹\80หมืà¸à¸\99มีà¸\9bัà¸\8dหาà¸\81ัà¸\9aà¸\8aà¹\88วà¸\87à¹\80วลาสืà¹\88à¸à¸ªà¸²à¸£à¸¥à¹\87à¸à¸\81à¸à¸´à¸\99à¸\82à¸à¸\87à¸\84ุà¸\93\nà¸\81ารà¸\81ระà¸\97ำà¸\99ีà¹\89à¸\96ูà¸\81ยà¸\81à¹\80ลิà¸\81à¹\80à¸\9bà¹\87à¸\99à¸\81ารà¸\9bà¹\89à¸à¸\87à¸\81ัà¸\99à¸\81ารลัà¸\81ลà¸à¸\9aà¸\8aà¹\88วà¸\87à¹\80วลาสืà¹\88à¸à¸ªà¸²à¸£à¹\84วà¹\89à¸\81à¹\88à¸à¸\99 \nà¸\81ลัà¸\9aà¹\84à¸\9bหà¸\99à¹\89าà¸\97ีà¹\88à¹\81ลà¹\89ว à¹\82หลà¸\94หà¸\99à¹\89าà¹\83หมà¹\88 à¹\81ลà¹\89วลà¸à¸\87อีกครั้ง",
+ "sessionfailure": "à¸\94ูà¹\80หมืà¸à¸\99มีà¸\9bัà¸\8dหาà¸\81ัà¸\9aà¸\8aà¹\88วà¸\87à¹\80วลาสืà¹\88à¸à¸ªà¸²à¸£à¸¥à¹\87à¸à¸\81à¸à¸´à¸\99à¸\82à¸à¸\87à¸\84ุà¸\93\nà¸\81ารà¸\81ระà¸\97ำà¸\99ีà¹\89à¸\96ูà¸\81ยà¸\81à¹\80ลิà¸\81à¹\80à¸\9bà¹\87à¸\99à¸\81ารà¸\9bà¹\89à¸à¸\87à¸\81ัà¸\99à¸\81ารลัà¸\81ลà¸à¸\9aà¸\8aà¹\88วà¸\87à¹\80วลาสืà¹\88à¸à¸ªà¸²à¸£à¹\84วà¹\89à¸\81à¹\88à¸à¸\99 \nà¸\81รุà¸\93าà¸\81รà¸à¸\81à¹\81à¸\9aà¸\9aอีกครั้ง",
"changecontentmodel-title-label": "ชื่อหน้า:",
"changecontentmodel-reason-label": "เหตุผล:",
"changecontentmodel-submit": "ความเปลี่ยนแปลง",
"tooltip-namespace_association": "เลือกกล่องนี้เพื่อรวมเนมสเปซคุยหรือเรื่องที่เกี่ยวข้องกับเนมสเปซที่เลือกด้วย",
"blanknamespace": "(หลัก)",
"contributions": "เรื่องที่{{GENDER:$1|ผู้ใช้}}นี้เขียน",
- "contributions-title": "à¹\80รืà¹\88à¸à¸\87à¸\97ีà¹\88à¹\80à¸\82ียà¸\99โดย $1",
+ "contributions-title": "à¹\80รืà¹\88à¸à¸\87à¸\97ีà¹\88มีสà¹\88วà¸\99รà¹\88วมโดย $1",
"mycontris": "เรื่องที่มีส่วนร่วม",
- "anoncontribs": "à¹\80รืà¹\88à¸à¸\87à¸\97ีà¹\88à¹\80à¸\82ียà¸\99",
+ "anoncontribs": "à¹\80รืà¹\88à¸à¸\87à¸\97ีà¹\88มีสà¹\88วà¸\99รà¹\88วม",
"contribsub2": "สำหรับ {{GENDER:$3|$1}} ($2)",
"contributions-userdoesnotexist": "บัญชีผู้ใช้ \"$1\" ยังไม่ได้ลงทะเบียน",
"nocontribs": "ไม่พบการเปลี่ยนแปลงตรงกับเงื่อนไขเหล่านี้",
"sp-contributions-newbies-sub": "สำหรับบัญชีใหม่",
"sp-contributions-newbies-title": "การเข้ามีส่วนร่วมสำหรับบัญชีใหม่",
"sp-contributions-blocklog": "ปูมการบล็อก",
- "sp-contributions-suppresslog": "ระà¸\87ัà¸\9aà¸\81ารà¹\80à¸\82à¹\89ามีสà¹\88วà¸\99รà¹\88วมà¸\82à¸à¸\87à¸\9cูà¹\89à¹\83à¸\8aà¹\89",
- "sp-contributions-deleted": "à¸\81ารà¹\81à¸\81à¹\89à¹\84à¸\82ของผู้ใช้ที่ถูกลบ",
+ "sp-contributions-suppresslog": "ระงับการมีส่วนร่วมของผู้ใช้",
+ "sp-contributions-deleted": "à¸\81ารมà¹\88ีสà¹\88วà¸\99รà¹\88วมของผู้ใช้ที่ถูกลบ",
"sp-contributions-uploads": "อัปโหลด",
"sp-contributions-logs": "ปูม",
"sp-contributions-talk": "คุย",
"ipb-unblock-addr": "ปลดบล็อก $1",
"ipb-unblock": "ปลดบล็อกผู้ใช้หรือเลขที่อยู่ไอพี",
"ipb-blocklist": "ดูการบล็อกที่มีอยู่",
- "ipb-blocklist-contribs": "à¹\80รืà¹\88à¸à¸\87à¸\97ีà¹\88à¹\80à¸\82ียà¸\99โดย $1",
+ "ipb-blocklist-contribs": "à¹\80รืà¹\88à¸à¸\87à¸\97ีà¹\88มีสà¹\88วà¸\99รà¹\88วมโดย $1",
"ipb-blocklist-duration-left": "เหลือเวลา $1",
"unblockip": "ปลดบล็อกผู้ใช้",
"unblockiptext": "ใช้แบบด้านล่างเพื่อคืนการเข้าถึงการเขียนแก่เลขที่อยู่ไอพี หรือชื่อผู้ใช้ที่เคยถูกบล็อก",
"svg-long-error": "ไฟล์ SVG ไม่ถูกต้อง: $1",
"show-big-image": "ไฟล์ต้นฉบับ",
"show-big-image-preview": "ขนาดของตัวอย่างนี้: $1",
- "show-big-image-preview-differ": "ขนาดขงตัวอย่าง $3 นี้ของไฟล์ $2 นี้: $1",
+ "show-big-image-preview-differ": "à¸\82à¸\99าà¸\94à¸\82à¸à¸\87à¸\95ัวà¸à¸¢à¹\88าà¸\87 $3 à¸\99ีà¹\89à¸\82à¸à¸\87à¹\84à¸\9fลà¹\8c $2 à¸\99ีà¹\89: $1",
"show-big-image-other": "{{PLURAL:$2|ความละเอียด|ความละเอียด}}อื่น: $1",
"show-big-image-size": "$1 × $2 พิกเซล",
"file-info-gif-looped": "วนซ้ำ",
"watchlistedit-clear-titles": "ชื่อเรื่อง:",
"watchlistedit-clear-submit": "ล้างรายการเฝ้าดู (เป็นการถาวร!)",
"watchlistedit-clear-done": "ล้างรายการเฝ้าดูของคุณแล้ว",
+ "watchlistedit-clear-jobqueue": "กำลังล้างรายการเฝ้าดูของคุณ อาจใช้เวลาสักหน่อย!",
"watchlistedit-clear-removed": "ลบ $1 ชื่อเรื่อง:",
"watchlistedit-too-many": "มีหน้าแสดงที่นี่มากเกิน",
"watchlisttools-clear": "ล้างรายการเฝ้าดู",
"signature": "[[{{ns:user}}:$1|$2]] ([[{{ns:user_talk}}:$1|คุย]])",
"timezone-local": "ท้องถิ่น",
"duplicate-defaultsort": "<strong>คำเตือน:</strong> หลักเรียงลำดับปริยาย \"$2\" ได้ลบล้างหลักเรียงลำดับปริยาย \"$1\" ที่มีอยู่ก่อนหน้า",
+ "duplicate-displaytitle": "<strong>คำเตือน:</strong> แสดงชื่อเรื่อง \"$2\" เขียนทับการแสดงชื่อเรื่องก่อนหน้านี้ \"$1\"",
+ "restricted-displaytitle": "<strong>คำเตือน:</strong> ละเลยชื่อเรื่องหน้า \"$1\" เพราะไม่เท่ากับชื่อเรื่องแท้จริงของหน้า",
"version": "รุ่น",
"version-extensions": "ส่วนขยายเพิ่ม (extension) ที่ติดตั้ง",
"version-skins": "หน้าตาที่ติดตั้ง",
"specialpages-group-maintenance": "รายงานการบำรุงรักษา",
"specialpages-group-other": "หน้าพิเศษอื่น ๆ",
"specialpages-group-login": "ล็อกอิน / สร้างบัญชี",
- "specialpages-group-changes": "à¸\9bรัà¸\9aà¸\9bรุงล่าสุดและปูม",
+ "specialpages-group-changes": "à¹\80à¸\9bลีà¹\88ยà¸\99à¹\81à¸\9bลงล่าสุดและปูม",
"specialpages-group-media": "รายงานสื่อและการอัปโหลด",
"specialpages-group-users": "ผู้ใช้และสิทธิ",
"specialpages-group-highuse": "หน้าที่มีการใช้สูง",
"authmanager-provider-password": "การพิสูจน์ตัวจริงที่อาศัยรหัสผ่าน",
"authmanager-provider-password-domain": "การพิสูจน์ตัวจริงที่อาศัยรหัสผ่านและโดเมน",
"authmanager-provider-temporarypassword": "รหัสผ่านชั่วคราว",
+ "credentialsform-account": "ชื่อบัญชี:",
+ "cannotlink-no-provider-title": "ไม่มีบัญชีที่โยงได้",
+ "cannotlink-no-provider": "ไม่มีบัญชีที่โยงได้",
+ "linkaccounts": "โยงบัญชี",
+ "linkaccounts-success-text": "โยงบัญชีแล้ว",
+ "linkaccounts-submit": "โยงบัญชี",
+ "unlinkaccounts": "เลิกโยงบัญชี",
+ "unlinkaccounts-success": "เลิกโยงบัญชีแล้ว",
"edit-error-short": "ข้อผิดพลาด: $1",
"edit-error-long": "ข้อผิดพลาด: $1",
"revid": "รุ่นแก้ไข $1",
'class' => ResourceLoaderOOUIFileModule::class,
'styles' => [
'resources/lib/oojs-ui/wikimedia-ui-base.less', // Providing Wikimedia UI LESS variables to all
- 'resources/src/oojs-ui-local.css', // HACK, see inside the file
],
'themeStyles' => 'core',
'targets' => [ 'desktop', 'mobile' ],
*
* - `digitTransformTable`
* - `separatorTransformTable`
+ * - `minimumGroupingDigits`
* - `grammarForms`
* - `pluralRules`
* - `digitGroupingPattern`
* @private
* @param {number} value the number to be formatted, ignores sign
* @param {string} pattern the number portion of a pattern (e.g. `#,##0.00`)
- * @param {Object} [options] If provided, both option keys must be present:
+ * @param {Object} [options] If provided, all option keys must be present:
* @param {string} options.decimal The decimal separator. Defaults to: `'.'`.
* @param {string} options.group The group separator. Defaults to: `','`.
+ * @param {number|null} options.minimumGroupingDigits
* @return {string}
*/
function commafyNumber( value, pattern, options ) {
}
}
- for ( whole = valueParts[ 0 ]; whole; ) {
- off = groupSize ? whole.length - groupSize : 0;
- pieces.push( ( off > 0 ) ? whole.slice( off ) : whole );
- whole = ( off > 0 ) ? whole.slice( 0, off ) : '';
-
- if ( groupSize2 ) {
- groupSize = groupSize2;
- groupSize2 = null;
+ if (
+ options.minimumGroupingDigits === null ||
+ valueParts[ 0 ].length >= groupSize + options.minimumGroupingDigits
+ ) {
+ for ( whole = valueParts[ 0 ]; whole; ) {
+ off = groupSize ? whole.length - groupSize : 0;
+ pieces.push( ( off > 0 ) ? whole.slice( off ) : whole );
+ whole = ( off > 0 ) ? whole.slice( 0, off ) : '';
+
+ if ( groupSize2 ) {
+ groupSize = groupSize2;
+ groupSize2 = null;
+ }
}
+ valueParts[ 0 ] = pieces.reverse().join( options.group );
}
- valueParts[ 0 ] = pieces.reverse().join( options.group );
return valueParts.join( options.decimal );
}
*/
convertNumber: function ( num, integer ) {
var transformTable, digitTransformTable, separatorTransformTable,
- i, numberString, convertedNumber, pattern;
+ i, numberString, convertedNumber, pattern, minimumGroupingDigits;
// Quick shortcut for plain numbers
if ( integer && parseInt( num, 10 ) === num ) {
// When unformatting, we just use separatorTransformTable.
pattern = mw.language.getData( mw.config.get( 'wgUserLanguage' ),
'digitGroupingPattern' ) || '#,##0.###';
- numberString = mw.language.commafy( num, pattern );
+ minimumGroupingDigits = mw.language.getData( mw.config.get( 'wgUserLanguage' ),
+ 'minimumGroupingDigits' ) || null;
+ numberString = mw.language.commafy( num, pattern, minimumGroupingDigits );
}
if ( transformTable ) {
*
* @param {number} value
* @param {string} pattern Pattern string as described by Unicode TR35
+ * @param {number|null} [minimumGroupingDigits=null]
* @throws {Error} If unable to find a number expression in `pattern`.
* @return {string}
*/
- commafy: function ( value, pattern ) {
+ commafy: function ( value, pattern, minimumGroupingDigits ) {
var numberPattern,
transformTable = mw.language.getSeparatorTransformTable(),
group = transformTable[ ',' ] || ',',
pattern = patternList[ ( value < 0 ) ? 1 : 0 ] || ( '-' + positivePattern );
numberPattern = positivePattern.match( numberPatternRE );
+ minimumGroupingDigits = minimumGroupingDigits !== undefined ? minimumGroupingDigits : null;
if ( !numberPattern ) {
throw new Error( 'unable to find a number expression in pattern: ' + pattern );
}
return pattern.replace( numberPatternRE, commafyNumber( value, numberPattern[ 0 ], {
+ minimumGroupingDigits: minimumGroupingDigits,
decimal: decimal,
group: group
} ) );
// Parent
mw.rcfilters.ui.MarkSeenButtonWidget.parent.call( this, $.extend( {
label: mw.message( 'rcfilters-watchlist-markseen-button' ).text(),
- icon: 'doubleCheck'
+ icon: 'checkAll'
}, config ) );
this.controller = controller;
width: 100%;
border: 1px solid @colorFieldBorder;
border-radius: @borderRadius;
- padding: 0.625em 0.625em 0.546875em;
+ padding: 0.57142857em 0.57142857em 0.5em;
// necessary for smooth transition
box-shadow: inset 0 0 0 0.1em #fff;
font-family: inherit;
font-size: inherit;
- line-height: 1.172em;
+ line-height: 1.07142857em;
vertical-align: middle;
// Normalize & style placeholder text, see T139034
+++ /dev/null
-/* HACK: Set sane font-size for OOUI dialogs (and menus/popups inside the default overlay), in
- the most common case. This should be skin's responsibility, but alas our skins tend to have the
- weirdest font-sizes on body. This shall be removed when we make the MediaWiki skins bundled with
- tarball sane. (T91152) */
-body > .oo-ui-windowManager,
-.oo-ui-defaultOverlay {
- font-size: 0.8rem;
-}
$this->assertEquals( 'Success', $a );
}
+ public function testLoginWithNoSameOriginSecurity() {
+ $this->setTemporaryHook( 'RequestHasSameOriginSecurity',
+ function () {
+ return false;
+ }
+ );
+
+ $result = $this->doApiRequest( [
+ 'action' => 'login',
+ ] )[0]['login'];
+
+ $this->assertSame( [
+ 'result' => 'Aborted',
+ 'reason' => 'Cannot log in when the same-origin policy is not applied.',
+ ], $result );
+ }
}
private static $allcategories = [
[ 'list' => 'allcategories', 'acprefix' => 'AQBT-' ],
[ 'allcategories' => [
- [ '*' => 'AQBT-Cat' ],
+ [ 'category' => 'AQBT-Cat' ],
] ]
];
$this->check( self::$allpages );
$this->check( self::$alllinks );
$this->check( self::$alltransclusions );
- // This test is temporarily disabled until a sqlite bug is fixed
- // Confirmed still broken 15-nov-2013
- // $this->check( self::$allcategories );
+ $this->check( self::$allcategories );
$this->check( self::$backlinks );
$this->check( self::$embeddedin );
$this->check( self::$categorymembers );
$db->listViews( '' ) );
}
+ /**
+ * @covers Wikimedia\Rdbms\MySQLMasterPos
+ */
public function testBinLogName() {
$pos = new MySQLMasterPos( "db1052.2424/4643", 1 );
- $this->assertEquals( "db1052", $pos->binlog );
+ $this->assertEquals( "db1052", $pos->getLogName() );
$this->assertEquals( "db1052.2424", $pos->getLogFile() );
- $this->assertEquals( [ 2424, 4643 ], $pos->pos );
+ $this->assertEquals( [ 2424, 4643 ], $pos->getLogPosition() );
}
/**
],
// MySQL GTID style
[
- new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:23', $now ),
- new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:24', $now ),
+ new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-23', $now ),
+ new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:5-24', $now ),
true,
false
],
[
- new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:99', $now ),
- new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:100', $now ),
+ new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:5-99', $now ),
+ new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-100', $now ),
true,
false
],
[
- new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:99', $now ),
- new MySQLMasterPos( '1E11FA47-71CA-11E1-9E33-C80AA9429562:100', $now ),
+ new MySQLMasterPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-99', $now ),
+ new MySQLMasterPos( '1E11FA47-71CA-11E1-9E33-C80AA9429562:1-100', $now ),
false,
false
],
],
[
new MySQLMasterPos(
- '2E11FA47-71CA-11E1-9E33-C80AA9429562:5,' .
- '3E11FA47-71CA-11E1-9E33-C80AA9429562:99,' .
- '7E11FA47-71CA-11E1-9E33-C80AA9429562:30',
+ '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-5,' .
+ '3E11FA47-71CA-11E1-9E33-C80AA9429562:20-99,' .
+ '7E11FA47-71CA-11E1-9E33-C80AA9429562:1-30',
1
),
new MySQLMasterPos(
- '1E11FA47-71CA-11E1-9E33-C80AA9429562:100,' .
- '3E11FA47-71CA-11E1-9E33-C80AA9429562:66',
+ '1E11FA47-71CA-11E1-9E33-C80AA9429562:30-100,' .
+ '3E11FA47-71CA-11E1-9E33-C80AA9429562:30-66',
1
),
- [ '3E11FA47-71CA-11E1-9E33-C80AA9429562:99' ]
+ [ '3E11FA47-71CA-11E1-9E33-C80AA9429562:20-99' ]
]
];
}
];
}
+ /**
+ * @dataProvider provideGtidData
+ * @covers Wikimedia\Rdbms\MySQLMasterPos
+ * @covers Wikimedia\Rdbms\DatabaseMysqlBase::getReplicaPos
+ * @covers Wikimedia\Rdbms\DatabaseMysqlBase::getMasterPos
+ */
+ public function testServerGtidTable( $gtable, $rBLtable, $mBLtable, $rGTIDs, $mGTIDs ) {
+ $db = $this->getMockBuilder( DatabaseMysqli::class )
+ ->disableOriginalConstructor()
+ ->setMethods( [
+ 'useGTIDs',
+ 'getServerGTIDs',
+ 'getServerRoleStatus',
+ 'getServerId',
+ 'getServerUUID'
+ ] )
+ ->getMock();
+
+ $db->method( 'useGTIDs' )->willReturn( true );
+ $db->method( 'getServerGTIDs' )->willReturn( $gtable );
+ $db->method( 'getServerRoleStatus' )->willReturnCallback(
+ function ( $role ) use ( $rBLtable, $mBLtable ) {
+ if ( $role === 'SLAVE' ) {
+ return $rBLtable;
+ } elseif ( $role === 'MASTER' ) {
+ return $mBLtable;
+ }
+
+ return null;
+ }
+ );
+ $db->method( 'getServerId' )->willReturn( 1 );
+ $db->method( 'getServerUUID' )->willReturn( '2E11FA47-71CA-11E1-9E33-C80AA9429562' );
+
+ if ( is_array( $rGTIDs ) ) {
+ $this->assertEquals( $rGTIDs, $db->getReplicaPos()->getGTIDs() );
+ } else {
+ $this->assertEquals( false, $db->getReplicaPos() );
+ }
+ if ( is_array( $mGTIDs ) ) {
+ $this->assertEquals( $mGTIDs, $db->getMasterPos()->getGTIDs() );
+ } else {
+ $this->assertEquals( false, $db->getMasterPos() );
+ }
+ }
+
+ public static function provideGtidData() {
+ return [
+ // MariaDB
+ [
+ [
+ 'gtid_domain_id' => 100,
+ 'gtid_current_pos' => '100-13-77',
+ 'gtid_binlog_pos' => '100-13-77',
+ 'gtid_slave_pos' => null // master
+ ],
+ [],
+ [
+ 'File' => 'host.1600',
+ 'Pos' => '77'
+ ],
+ [ '100' => '100-13-77' ],
+ [ '100' => '100-13-77' ]
+ ],
+ [
+ [
+ 'gtid_domain_id' => 100,
+ 'gtid_current_pos' => '100-13-77',
+ 'gtid_binlog_pos' => '100-13-77',
+ 'gtid_slave_pos' => '100-13-77' // replica
+ ],
+ [
+ 'Relay_Master_Log_File' => 'host.1600',
+ 'Exec_Master_Log_Pos' => '77'
+ ],
+ [],
+ [ '100' => '100-13-77' ],
+ [ '100' => '100-13-77' ]
+ ],
+ [
+ [
+ 'gtid_current_pos' => '100-13-77',
+ 'gtid_binlog_pos' => '100-13-77',
+ 'gtid_slave_pos' => '100-13-77' // replica
+ ],
+ [
+ 'Relay_Master_Log_File' => 'host.1600',
+ 'Exec_Master_Log_Pos' => '77'
+ ],
+ [],
+ [ '100' => '100-13-77' ],
+ [ '100' => '100-13-77' ]
+ ],
+ // MySQL
+ [
+ [
+ 'gtid_executed' => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77'
+ ],
+ [
+ 'Relay_Master_Log_File' => 'host.1600',
+ 'Exec_Master_Log_Pos' => '77'
+ ],
+ [], // only a replica
+ [ '2E11FA47-71CA-11E1-9E33-C80AA9429562'
+ => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77' ],
+ // replica/master use same var
+ [ '2E11FA47-71CA-11E1-9E33-C80AA9429562'
+ => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77' ],
+ ],
+ [
+ [
+ 'gtid_executed' => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-49,' .
+ '2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77'
+ ],
+ [
+ 'Relay_Master_Log_File' => 'host.1600',
+ 'Exec_Master_Log_Pos' => '77'
+ ],
+ [], // only a replica
+ [ '2E11FA47-71CA-11E1-9E33-C80AA9429562'
+ => '2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77' ],
+ // replica/master use same var
+ [ '2E11FA47-71CA-11E1-9E33-C80AA9429562'
+ => '2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77' ],
+ ],
+ [
+ [
+ 'gtid_executed' => null // not enabled?
+ ],
+ [
+ 'Relay_Master_Log_File' => 'host.1600',
+ 'Exec_Master_Log_Pos' => '77'
+ ],
+ [], // only a replica
+ [], // binlog fallback
+ false
+ ],
+ [
+ [
+ 'gtid_executed' => null // not enabled?
+ ],
+ [], // no replication
+ [], // no replication
+ false,
+ false
+ ]
+ ];
+ }
+
/**
* @covers Wikimedia\Rdbms\MySQLMasterPos
*/
<?php
+use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\LikeMatch;
use Wikimedia\Rdbms\Database;
$this->assertSame( 'CAST( fieldName AS INTEGER )', $output );
}
+ /**
+ * @covers \Wikimedia\Rdbms\Database::doSavepoint
+ * @covers \Wikimedia\Rdbms\Database::doReleaseSavepoint
+ * @covers \Wikimedia\Rdbms\Database::doRollbackToSavepoint
+ * @covers \Wikimedia\Rdbms\Database::startAtomic
+ * @covers \Wikimedia\Rdbms\Database::endAtomic
+ * @covers \Wikimedia\Rdbms\Database::cancelAtomic
+ * @covers \Wikimedia\Rdbms\Database::doAtomicSection
+ */
+ public function testAtomicSections() {
+ $this->database->startAtomic( __METHOD__ );
+ $this->database->endAtomic( __METHOD__ );
+ $this->assertLastSql( 'BEGIN; COMMIT' );
+
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->assertLastSql( 'BEGIN; ROLLBACK' );
+
+ $this->database->begin( __METHOD__ );
+ $this->database->startAtomic( __METHOD__ );
+ $this->database->endAtomic( __METHOD__ );
+ $this->database->commit( __METHOD__ );
+ $this->assertLastSql( 'BEGIN; COMMIT' );
+
+ $this->database->begin( __METHOD__ );
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->endAtomic( __METHOD__ );
+ $this->database->commit( __METHOD__ );
+ // phpcs:ignore Generic.Files.LineLength
+ $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; RELEASE SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' );
+
+ $this->database->begin( __METHOD__ );
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->database->commit( __METHOD__ );
+ // phpcs:ignore Generic.Files.LineLength
+ $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' );
+
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->database->endAtomic( __METHOD__ );
+ // phpcs:ignore Generic.Files.LineLength
+ $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' );
+
+ $this->database->doAtomicSection( __METHOD__, function () {
+ } );
+ $this->assertLastSql( 'BEGIN; COMMIT' );
+
+ $this->database->begin( __METHOD__ );
+ $this->database->doAtomicSection( __METHOD__, function () {
+ } );
+ $this->database->rollback( __METHOD__ );
+ // phpcs:ignore Generic.Files.LineLength
+ $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; RELEASE SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK' );
+
+ $this->database->begin( __METHOD__ );
+ try {
+ $this->database->doAtomicSection( __METHOD__, function () {
+ throw new RuntimeException( 'Test exception' );
+ } );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( RuntimeException $ex ) {
+ $this->assertSame( 'Test exception', $ex->getMessage() );
+ }
+ $this->database->commit( __METHOD__ );
+ // phpcs:ignore Generic.Files.LineLength
+ $this->assertLastSql( 'BEGIN; SAVEPOINT wikimedia_rdbms_atomic1; ROLLBACK TO SAVEPOINT wikimedia_rdbms_atomic1; COMMIT' );
+ }
+
+ public static function provideAtomicSectionMethodsForErrors() {
+ return [
+ [ 'endAtomic' ],
+ [ 'cancelAtomic' ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideAtomicSectionMethodsForErrors
+ * @covers \Wikimedia\Rdbms\Database::endAtomic
+ * @covers \Wikimedia\Rdbms\Database::cancelAtomic
+ */
+ public function testNoAtomicSection( $method ) {
+ try {
+ $this->database->$method( __METHOD__ );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( DBUnexpectedError $ex ) {
+ $this->assertSame(
+ 'No atomic transaction is open (got ' . __METHOD__ . ').',
+ $ex->getMessage()
+ );
+ }
+ }
+
+ /**
+ * @dataProvider provideAtomicSectionMethodsForErrors
+ * @covers \Wikimedia\Rdbms\Database::endAtomic
+ * @covers \Wikimedia\Rdbms\Database::cancelAtomic
+ */
+ public function testInvalidAtomicSectionEnded( $method ) {
+ $this->database->startAtomic( __METHOD__ . 'X' );
+ try {
+ $this->database->$method( __METHOD__ );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( DBUnexpectedError $ex ) {
+ $this->assertSame(
+ 'Invalid atomic section ended (got ' . __METHOD__ . ').',
+ $ex->getMessage()
+ );
+ }
+ }
+
+ /**
+ * @covers \Wikimedia\Rdbms\Database::cancelAtomic
+ */
+ public function testUncancellableAtomicSection() {
+ $this->database->startAtomic( __METHOD__ );
+ try {
+ $this->database->cancelAtomic( __METHOD__ );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( DBUnexpectedError $ex ) {
+ $this->assertSame(
+ 'Uncancelable atomic section canceled (got ' . __METHOD__ . ').',
+ $ex->getMessage()
+ );
+ }
+ }
+
}
$user = $this->getTestSysop()->getUser();
$this->assertConditions(
[ # expected
- "rc_patrolled = 1",
+ "rc_patrolled != 0",
],
[
'hideunpatrolled' => 1,
$user->setOption( 'userjs-someoption', 'test' );
$user->setOption( 'rclimit', 200 );
+ $user->setOption( 'wpwatchlistdays', '0' );
$user->saveSettings();
$user = User::newFromName( $user->getName() );
MediaWikiServices::getInstance()->getMainWANObjectCache()->clearProcessCache();
$this->assertEquals( 'test', $user->getOption( 'userjs-someoption' ) );
$this->assertEquals( 200, $user->getOption( 'rclimit' ) );
+
+ // Check that an option saved as a string '0' is returned as an integer.
+ $user = User::newFromName( $user->getName() );
+ $user->load( User::READ_LATEST );
+ $this->assertSame( 0, $user->getOption( 'wpwatchlistdays' ) );
}
/**
} catch ( InvalidArgumentException $ex ) {
}
}
+
+ /**
+ * @covers User::getBlockedStatus
+ * @covers User::getBlock
+ * @covers User::blockedBy
+ * @covers User::blockedFor
+ * @covers User::isHidden
+ * @covers User::isBlockedFrom
+ */
+ public function testBlockInstanceCache() {
+ // First, check the user isn't blocked
+ $user = $this->getMutableTestUser()->getUser();
+ $ut = Title::makeTitle( NS_USER_TALK, $user->getName() );
+ $this->assertNull( $user->getBlock( false ), 'sanity check' );
+ $this->assertSame( '', $user->blockedBy(), 'sanity check' );
+ $this->assertSame( '', $user->blockedFor(), 'sanity check' );
+ $this->assertFalse( (bool)$user->isHidden(), 'sanity check' );
+ $this->assertFalse( $user->isBlockedFrom( $ut ), 'sanity check' );
+
+ // Block the user
+ $blocker = $this->getTestSysop()->getUser();
+ $block = new Block( [
+ 'hideName' => true,
+ 'allowUsertalk' => false,
+ 'reason' => 'Because',
+ ] );
+ $block->setTarget( $user );
+ $block->setBlocker( $blocker );
+ $res = $block->insert();
+ $this->assertTrue( (bool)$res['id'], 'sanity check: Failed to insert block' );
+
+ // Clear cache and confirm it loaded the block properly
+ $user->clearInstanceCache();
+ $this->assertInstanceOf( Block::class, $user->getBlock( false ) );
+ $this->assertSame( $blocker->getName(), $user->blockedBy() );
+ $this->assertSame( 'Because', $user->blockedFor() );
+ $this->assertTrue( (bool)$user->isHidden() );
+ $this->assertTrue( $user->isBlockedFrom( $ut ) );
+
+ // Unblock
+ $block->delete();
+
+ // Clear cache and confirm it loaded the not-blocked properly
+ $user->clearInstanceCache();
+ $this->assertNull( $user->getBlock( false ) );
+ $this->assertSame( '', $user->blockedBy() );
+ $this->assertSame( '', $user->blockedFor() );
+ $this->assertFalse( (bool)$user->isHidden() );
+ $this->assertFalse( $user->isBlockedFrom( $ut ) );
+ }
+
}
mw.language.setData( 'en', 'digitGroupingPattern', null );
mw.language.setData( 'en', 'digitTransformTable', null );
mw.language.setData( 'en', 'separatorTransformTable', { ',': '.', '.': ',' } );
+ mw.language.setData( 'en', 'minimumGroupingDigits', null );
mw.config.set( 'wgUserLanguage', 'en' );
mw.config.set( 'wgTranslateNumerals', true );
- assert.equal( mw.language.convertNumber( 1800 ), '1.800', 'formatting' );
+ assert.equal( mw.language.convertNumber( 180 ), '180', 'formatting 3-digit' );
+ assert.equal( mw.language.convertNumber( 1800 ), '1.800', 'formatting 4-digit' );
+ assert.equal( mw.language.convertNumber( 18000 ), '18.000', 'formatting 5-digit' );
+
assert.equal( mw.language.convertNumber( '1.800', true ), '1800', 'unformatting' );
+
+ mw.language.setData( 'en', 'minimumGroupingDigits', 2 );
+ assert.equal( mw.language.convertNumber( 180 ), '180', 'formatting 3-digit with minimumGroupingDigits=2' );
+ assert.equal( mw.language.convertNumber( 1800 ), '1800', 'formatting 4-digit with minimumGroupingDigits=2' );
+ assert.equal( mw.language.convertNumber( 18000 ), '18.000', 'formatting 5-digit with minimumGroupingDigits=2' );
} );
QUnit.test( 'mw.language.convertNumber - digitTransformTable', function ( assert ) {
mw.config.set( 'wgTranslateNumerals', true );
mw.language.setData( 'hi', 'digitGroupingPattern', null );
mw.language.setData( 'hi', 'separatorTransformTable', { ',': '.', '.': ',' } );
+ mw.language.setData( 'hi', 'minimumGroupingDigits', null );
// Example from Hindi (MessagesHi.php)
mw.language.setData( 'hi', 'digitTransformTable', {